zerochat 9.3.0__tar.gz → 9.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {zerochat-9.3.0/zerochat.egg-info → zerochat-9.5.0}/PKG-INFO +1 -1
- {zerochat-9.3.0 → zerochat-9.5.0}/pyproject.toml +1 -1
- {zerochat-9.3.0 → zerochat-9.5.0/zerochat.egg-info}/PKG-INFO +1 -1
- {zerochat-9.3.0 → zerochat-9.5.0}/zerochat.py +267 -122
- {zerochat-9.3.0 → zerochat-9.5.0}/LICENSE +0 -0
- {zerochat-9.3.0 → zerochat-9.5.0}/README.md +0 -0
- {zerochat-9.3.0 → zerochat-9.5.0}/setup.cfg +0 -0
- {zerochat-9.3.0 → zerochat-9.5.0}/zerochat.egg-info/SOURCES.txt +0 -0
- {zerochat-9.3.0 → zerochat-9.5.0}/zerochat.egg-info/dependency_links.txt +0 -0
- {zerochat-9.3.0 → zerochat-9.5.0}/zerochat.egg-info/entry_points.txt +0 -0
- {zerochat-9.3.0 → zerochat-9.5.0}/zerochat.egg-info/top_level.txt +0 -0
|
@@ -17,7 +17,6 @@ import base64
|
|
|
17
17
|
import datetime
|
|
18
18
|
import fnmatch
|
|
19
19
|
import functools
|
|
20
|
-
import gzip
|
|
21
20
|
import hashlib
|
|
22
21
|
import hmac
|
|
23
22
|
import importlib.metadata
|
|
@@ -30,6 +29,7 @@ import secrets
|
|
|
30
29
|
import shutil
|
|
31
30
|
import shlex
|
|
32
31
|
import signal
|
|
32
|
+
import socket
|
|
33
33
|
import subprocess
|
|
34
34
|
import sys
|
|
35
35
|
import tempfile
|
|
@@ -46,7 +46,7 @@ import zlib
|
|
|
46
46
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
47
47
|
from pathlib import Path
|
|
48
48
|
|
|
49
|
-
SOURCE_BACKEND_VERSION = "9.
|
|
49
|
+
SOURCE_BACKEND_VERSION = "9.5.0"
|
|
50
50
|
|
|
51
51
|
def _read_source_version(filename: str) -> str | None:
|
|
52
52
|
"""Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
|
|
@@ -1344,6 +1344,169 @@ def set_project_directory(path) -> dict:
|
|
|
1344
1344
|
return {"cwd": str(target), "previous_cwd": previous}
|
|
1345
1345
|
|
|
1346
1346
|
|
|
1347
|
+
def _configured_gui_diff_tool(git_bin: str, repo_root: Path) -> str:
|
|
1348
|
+
"""Herramienta gráfica que usaría `git difftool --gui`, o '' si no hay ninguna configurada.
|
|
1349
|
+
|
|
1350
|
+
Sin diff.guitool ni merge.guitool, git recurre a diff.tool o adivina una, que puede ser de
|
|
1351
|
+
terminal (vimdiff): lanzada sin terminal quedaría invisible y colgada.
|
|
1352
|
+
"""
|
|
1353
|
+
for key in ("diff.guitool", "merge.guitool"):
|
|
1354
|
+
res = subprocess.run(
|
|
1355
|
+
[git_bin, "config", "--get", key],
|
|
1356
|
+
cwd=str(repo_root),
|
|
1357
|
+
capture_output=True,
|
|
1358
|
+
text=True,
|
|
1359
|
+
timeout=5,
|
|
1360
|
+
check=False
|
|
1361
|
+
)
|
|
1362
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
1363
|
+
return res.stdout.strip()
|
|
1364
|
+
return ""
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def open_file_diff(path: str, commit_fallback: bool = False) -> dict:
|
|
1368
|
+
"""Abre un visor externo de diffs (git difftool) para comparar el fichero actual frente a Git HEAD, su último commit o fichero vacío."""
|
|
1369
|
+
if not isinstance(path, str) or not path.strip():
|
|
1370
|
+
raise ValueError("La ruta del archivo está vacía.")
|
|
1371
|
+
if len(path) > MAX_PATH_LENGTH:
|
|
1372
|
+
raise ValueError("La ruta del archivo excede el tamaño máximo permitido.")
|
|
1373
|
+
|
|
1374
|
+
target = Path(path.strip()).expanduser().resolve()
|
|
1375
|
+
if not target.exists():
|
|
1376
|
+
raise ValueError(f"El archivo '{path}' no existe.")
|
|
1377
|
+
if not target.is_file():
|
|
1378
|
+
raise ValueError(f"'{path}' no es un archivo regular.")
|
|
1379
|
+
|
|
1380
|
+
git_bin = shutil.which("git")
|
|
1381
|
+
if not git_bin:
|
|
1382
|
+
raise RuntimeError("Git no está instalado o no se encuentra en el PATH del sistema.")
|
|
1383
|
+
|
|
1384
|
+
try:
|
|
1385
|
+
res = subprocess.run(
|
|
1386
|
+
[git_bin, "rev-parse", "--show-toplevel"],
|
|
1387
|
+
cwd=str(target.parent),
|
|
1388
|
+
capture_output=True,
|
|
1389
|
+
text=True,
|
|
1390
|
+
timeout=5,
|
|
1391
|
+
check=False
|
|
1392
|
+
)
|
|
1393
|
+
except Exception as err:
|
|
1394
|
+
raise RuntimeError(f"Error comprobando repositorio Git: {err}")
|
|
1395
|
+
|
|
1396
|
+
if res.returncode != 0:
|
|
1397
|
+
raise ValueError(f"El archivo '{target.name}' no pertenece a ningún repositorio Git.")
|
|
1398
|
+
|
|
1399
|
+
repo_root = Path(res.stdout.strip()).resolve()
|
|
1400
|
+
if not _configured_gui_diff_tool(git_bin, repo_root):
|
|
1401
|
+
raise ValueError(
|
|
1402
|
+
"No hay un visor gráfico de diffs configurado en Git. Configura uno, por ejemplo: "
|
|
1403
|
+
"git config --global diff.guitool meld"
|
|
1404
|
+
)
|
|
1405
|
+
|
|
1406
|
+
has_head = subprocess.run(
|
|
1407
|
+
[git_bin, "rev-parse", "--verify", "HEAD"],
|
|
1408
|
+
cwd=str(repo_root),
|
|
1409
|
+
stdout=subprocess.DEVNULL,
|
|
1410
|
+
stderr=subprocess.DEVNULL,
|
|
1411
|
+
timeout=5,
|
|
1412
|
+
check=False
|
|
1413
|
+
).returncode == 0
|
|
1414
|
+
|
|
1415
|
+
in_head = False
|
|
1416
|
+
has_working_tree_diff = True
|
|
1417
|
+
if has_head:
|
|
1418
|
+
try:
|
|
1419
|
+
rel_path = target.relative_to(repo_root).as_posix()
|
|
1420
|
+
in_head = subprocess.run(
|
|
1421
|
+
[git_bin, "cat-file", "-e", f"HEAD:{rel_path}"],
|
|
1422
|
+
cwd=str(repo_root),
|
|
1423
|
+
stdout=subprocess.DEVNULL,
|
|
1424
|
+
stderr=subprocess.DEVNULL,
|
|
1425
|
+
timeout=5,
|
|
1426
|
+
check=False
|
|
1427
|
+
).returncode == 0
|
|
1428
|
+
if in_head:
|
|
1429
|
+
diff_check = subprocess.run(
|
|
1430
|
+
[git_bin, "diff", "--quiet", "HEAD", "--", str(target)],
|
|
1431
|
+
cwd=str(repo_root),
|
|
1432
|
+
timeout=5,
|
|
1433
|
+
check=False
|
|
1434
|
+
)
|
|
1435
|
+
has_working_tree_diff = (diff_check.returncode != 0)
|
|
1436
|
+
except ValueError:
|
|
1437
|
+
in_head = False
|
|
1438
|
+
|
|
1439
|
+
cmd = [git_bin, "difftool", "--no-prompt", "--gui"]
|
|
1440
|
+
|
|
1441
|
+
mode = "working_tree"
|
|
1442
|
+
last_commit = ""
|
|
1443
|
+
if has_working_tree_diff:
|
|
1444
|
+
if in_head:
|
|
1445
|
+
cmd.extend(["HEAD", "--", str(target)])
|
|
1446
|
+
else:
|
|
1447
|
+
cmd.extend(["--no-index", "--", os.devnull, str(target)])
|
|
1448
|
+
else:
|
|
1449
|
+
# No hay cambios pendientes en el árbol de trabajo. Comprobar historial del archivo.
|
|
1450
|
+
rel_path = target.relative_to(repo_root).as_posix()
|
|
1451
|
+
log_res = subprocess.run(
|
|
1452
|
+
[git_bin, "log", "-n", "1", "--format=%H", "--", rel_path],
|
|
1453
|
+
cwd=str(repo_root),
|
|
1454
|
+
capture_output=True,
|
|
1455
|
+
text=True,
|
|
1456
|
+
timeout=5,
|
|
1457
|
+
check=False
|
|
1458
|
+
)
|
|
1459
|
+
last_commit = log_res.stdout.strip() if log_res.returncode == 0 else ""
|
|
1460
|
+
if not commit_fallback or not last_commit:
|
|
1461
|
+
return {
|
|
1462
|
+
"success": True,
|
|
1463
|
+
"has_diff": False,
|
|
1464
|
+
"in_head": in_head,
|
|
1465
|
+
"has_commit_history": bool(last_commit),
|
|
1466
|
+
"last_commit": last_commit or None,
|
|
1467
|
+
"path": str(target)
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
mode = "commit"
|
|
1471
|
+
parent_check = subprocess.run(
|
|
1472
|
+
[git_bin, "rev-parse", "--verify", f"{last_commit}~1"],
|
|
1473
|
+
cwd=str(repo_root),
|
|
1474
|
+
stdout=subprocess.DEVNULL,
|
|
1475
|
+
stderr=subprocess.DEVNULL,
|
|
1476
|
+
timeout=5,
|
|
1477
|
+
check=False
|
|
1478
|
+
)
|
|
1479
|
+
if parent_check.returncode == 0:
|
|
1480
|
+
cmd.extend([f"{last_commit}~1", last_commit, "--", str(target)])
|
|
1481
|
+
else:
|
|
1482
|
+
cmd.extend(["4b825dc642cb6eb9a060e54bf8d69288fbee4904", last_commit, "--", str(target)])
|
|
1483
|
+
|
|
1484
|
+
popen_kwargs = {
|
|
1485
|
+
"cwd": str(repo_root),
|
|
1486
|
+
"stdin": subprocess.DEVNULL,
|
|
1487
|
+
"stdout": subprocess.DEVNULL,
|
|
1488
|
+
"stderr": subprocess.DEVNULL,
|
|
1489
|
+
}
|
|
1490
|
+
if os.name == "nt":
|
|
1491
|
+
popen_kwargs["creationflags"] = (
|
|
1492
|
+
getattr(subprocess, "DETACHED_PROCESS", 0) |
|
|
1493
|
+
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
1494
|
+
)
|
|
1495
|
+
else:
|
|
1496
|
+
popen_kwargs["start_new_session"] = True
|
|
1497
|
+
|
|
1498
|
+
proc = subprocess.Popen(cmd, **popen_kwargs)
|
|
1499
|
+
return {
|
|
1500
|
+
"success": True,
|
|
1501
|
+
"has_diff": True,
|
|
1502
|
+
"mode": mode,
|
|
1503
|
+
"pid": proc.pid,
|
|
1504
|
+
"path": str(target),
|
|
1505
|
+
"in_head": in_head,
|
|
1506
|
+
"commit": last_commit or None
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
|
|
1347
1510
|
def search_files(query: str, path: str = ".", file_pattern: str = None, max_results: int = 100) -> str:
|
|
1348
1511
|
"""Recursively search for plain text or regular expressions across project files."""
|
|
1349
1512
|
try:
|
|
@@ -2716,59 +2879,83 @@ METADATA_HOSTS = frozenset({
|
|
|
2716
2879
|
"100.100.100.200",
|
|
2717
2880
|
})
|
|
2718
2881
|
DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2882
|
+
WEB_FETCH_MAX_BYTES = 10 * 1024 * 1024
|
|
2883
|
+
WEB_FETCH_MAX_REDIRECTS = 5
|
|
2884
|
+
WEB_FETCH_READ_CHUNK = 64 * 1024
|
|
2885
|
+
TEXT_CONTENT_TYPE_RE = re.compile(
|
|
2886
|
+
r"^(text/|application/(json|xml|xhtml\+xml|javascript|x-javascript|ecmascript)$|[a-z]+/[^;]*\+(json|xml)$)"
|
|
2887
|
+
)
|
|
2888
|
+
|
|
2889
|
+
|
|
2890
|
+
def _is_public_ip(address: str) -> bool:
|
|
2891
|
+
"""Una dirección es pública si es global y no multicast; las IPv4 mapeadas en IPv6 se evalúan como IPv4."""
|
|
2892
|
+
ip = ipaddress.ip_address(address.split("%", 1)[0])
|
|
2893
|
+
if ip.version == 6 and ip.ipv4_mapped:
|
|
2894
|
+
ip = ip.ipv4_mapped
|
|
2895
|
+
return ip.is_global and not ip.is_multicast
|
|
2896
|
+
|
|
2897
|
+
|
|
2898
|
+
def is_dangerous_host(hostname: str) -> bool:
|
|
2899
|
+
"""Riesgo de SSRF: metadatos cloud, nombres locales o cualquier dirección resuelta no pública."""
|
|
2900
|
+
clean_host = (hostname or "").strip().lower().strip("[]").rstrip(".")
|
|
2901
|
+
if not clean_host or clean_host in METADATA_HOSTS or clean_host == "localhost":
|
|
2724
2902
|
return True
|
|
2725
|
-
clean_host
|
|
2726
|
-
if clean_host in METADATA_HOSTS:
|
|
2903
|
+
if clean_host.endswith((".localhost", ".local", ".internal")):
|
|
2727
2904
|
return True
|
|
2728
|
-
|
|
2729
2905
|
try:
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
|
|
2734
|
-
return True
|
|
2906
|
+
infos = socket.getaddrinfo(clean_host, None, proto=socket.IPPROTO_TCP)
|
|
2907
|
+
except (socket.gaierror, UnicodeError):
|
|
2908
|
+
# Sin resolución no hay conexión posible: la petición fallará por sí misma.
|
|
2735
2909
|
return False
|
|
2736
|
-
|
|
2737
|
-
pass
|
|
2910
|
+
return any(not _is_public_ip(info[4][0]) for info in infos)
|
|
2738
2911
|
|
|
2739
|
-
if clean_host.isdigit():
|
|
2740
|
-
try:
|
|
2741
|
-
num = int(clean_host)
|
|
2742
|
-
if 0 <= num <= 4294967295:
|
|
2743
|
-
ip = ipaddress.IPv4Address(num)
|
|
2744
|
-
if ip.is_link_local:
|
|
2745
|
-
return True
|
|
2746
|
-
if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
|
|
2747
|
-
return True
|
|
2748
|
-
return False
|
|
2749
|
-
except ValueError:
|
|
2750
|
-
pass
|
|
2751
2912
|
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
except ValueError:
|
|
2763
|
-
pass
|
|
2913
|
+
def _validate_web_url(url: str) -> str:
|
|
2914
|
+
"""Valida esquema y host de una URL de descarga (también de cada redirección)."""
|
|
2915
|
+
parsed = urllib.parse.urlparse(url)
|
|
2916
|
+
if parsed.scheme not in ("http", "https"):
|
|
2917
|
+
raise ValueError(f"Protocolo no permitido: '{parsed.scheme}'. Solo se admite http y https.")
|
|
2918
|
+
if not parsed.hostname:
|
|
2919
|
+
raise ValueError("URL sin nombre de host válido.")
|
|
2920
|
+
if is_dangerous_host(parsed.hostname):
|
|
2921
|
+
raise PermissionError("Acceso bloqueado: intento de acceso a servicios internos o metadatos de infraestructura (SSRF).")
|
|
2922
|
+
return url
|
|
2764
2923
|
|
|
2765
|
-
if not allow_local:
|
|
2766
|
-
if clean_host in ("localhost", "0.0.0.0", "127.0.0.1", "::1"):
|
|
2767
|
-
return True
|
|
2768
|
-
if clean_host.endswith(".local") or clean_host.endswith(".internal"):
|
|
2769
|
-
return True
|
|
2770
2924
|
|
|
2771
|
-
|
|
2925
|
+
class _ValidatingRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
2926
|
+
"""Sigue redirecciones solo si el destino supera la misma validación anti-SSRF que la URL pedida."""
|
|
2927
|
+
max_redirections = WEB_FETCH_MAX_REDIRECTS
|
|
2928
|
+
|
|
2929
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
2930
|
+
_validate_web_url(newurl)
|
|
2931
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
2932
|
+
|
|
2933
|
+
|
|
2934
|
+
_WEB_OPENER = urllib.request.build_opener(_ValidatingRedirectHandler)
|
|
2935
|
+
|
|
2936
|
+
|
|
2937
|
+
def _read_limited_body(stream, content_encoding: str, max_bytes: int) -> tuple[bytes, bool]:
|
|
2938
|
+
"""Lee el cuerpo ya descomprimido hasta `max_bytes`; indica si había más (truncado)."""
|
|
2939
|
+
encoding = (content_encoding or "").strip().lower()
|
|
2940
|
+
# MAX_WBITS | 32 detecta la cabecera gzip o zlib.
|
|
2941
|
+
decompressor = zlib.decompressobj(zlib.MAX_WBITS | 32) if encoding in ("gzip", "x-gzip", "deflate") else None
|
|
2942
|
+
data = bytearray()
|
|
2943
|
+
while len(data) <= max_bytes:
|
|
2944
|
+
chunk = stream.read(WEB_FETCH_READ_CHUNK)
|
|
2945
|
+
if not chunk:
|
|
2946
|
+
break
|
|
2947
|
+
if decompressor:
|
|
2948
|
+
# La salida se limita al hueco restante: un cuerpo comprimido no puede crecer sin tope.
|
|
2949
|
+
chunk = decompressor.decompress(chunk, max_bytes + 1 - len(data))
|
|
2950
|
+
data += chunk
|
|
2951
|
+
truncated = len(data) > max_bytes
|
|
2952
|
+
return bytes(data[:max_bytes]), truncated
|
|
2953
|
+
|
|
2954
|
+
|
|
2955
|
+
def is_text_content_type(content_type: str) -> bool:
|
|
2956
|
+
"""Tipos que se devuelven como texto; el resto viaja en base64."""
|
|
2957
|
+
media_type = (content_type or "").split(";", 1)[0].strip().lower()
|
|
2958
|
+
return not media_type or bool(TEXT_CONTENT_TYPE_RE.match(media_type))
|
|
2772
2959
|
|
|
2773
2960
|
|
|
2774
2961
|
def fetch_web_resource(params: dict) -> dict:
|
|
@@ -2776,97 +2963,48 @@ def fetch_web_resource(params: dict) -> dict:
|
|
|
2776
2963
|
url = params.get("url")
|
|
2777
2964
|
if not isinstance(url, str) or not url.strip():
|
|
2778
2965
|
raise ValueError("URL no válida o ausente.")
|
|
2779
|
-
url = url.strip()
|
|
2780
|
-
parsed = urllib.parse.urlparse(url)
|
|
2781
|
-
if parsed.scheme not in ("http", "https"):
|
|
2782
|
-
raise ValueError(f"Protocolo no permitido: '{parsed.scheme}'. Solo se admite http y https.")
|
|
2783
|
-
hostname = (parsed.hostname or "").lower().strip("[]")
|
|
2784
|
-
if not hostname:
|
|
2785
|
-
raise ValueError("URL sin nombre de host válido.")
|
|
2966
|
+
url = _validate_web_url(url.strip())
|
|
2786
2967
|
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
raise PermissionError("Acceso bloqueado: intento de acceso a servicios internos o metadatos de infraestructura (SSRF).")
|
|
2790
|
-
|
|
2791
|
-
timeout_s = float(params.get("timeout_s", 10.0))
|
|
2792
|
-
timeout_s = max(0.5, min(timeout_s, 30.0))
|
|
2793
|
-
max_bytes = int(params.get("max_bytes", 2 * 1024 * 1024))
|
|
2794
|
-
max_bytes = max(1024, min(max_bytes, 10 * 1024 * 1024))
|
|
2968
|
+
timeout_s = max(0.5, min(float(params.get("timeout_s", 10.0)), 30.0))
|
|
2969
|
+
max_bytes = max(1024, min(int(params.get("max_bytes", 2 * 1024 * 1024)), WEB_FETCH_MAX_BYTES))
|
|
2795
2970
|
as_binary = bool(params.get("binary", False))
|
|
2796
|
-
|
|
2971
|
+
accept = params.get("accept")
|
|
2797
2972
|
req_headers = {
|
|
2798
2973
|
"User-Agent": DEFAULT_USER_AGENT,
|
|
2799
|
-
"Accept":
|
|
2974
|
+
"Accept": accept if isinstance(accept, str) and accept else "*/*",
|
|
2800
2975
|
"Accept-Encoding": "gzip, deflate",
|
|
2801
2976
|
}
|
|
2802
|
-
custom_headers = params.get("headers")
|
|
2803
|
-
if isinstance(custom_headers, dict):
|
|
2804
|
-
for k, v in custom_headers.items():
|
|
2805
|
-
if isinstance(k, str) and isinstance(v, str) and k.lower() != "host":
|
|
2806
|
-
req_headers[k] = v
|
|
2807
2977
|
|
|
2808
2978
|
req = urllib.request.Request(url, headers=req_headers)
|
|
2809
2979
|
try:
|
|
2810
|
-
with
|
|
2811
|
-
status = resp.status
|
|
2980
|
+
with _WEB_OPENER.open(req, timeout=timeout_s) as resp:
|
|
2812
2981
|
content_type = resp.headers.get("Content-Type", "")
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
pass
|
|
2829
|
-
|
|
2830
|
-
resp_headers = {k: v for k, v in resp.getheaders()}
|
|
2831
|
-
|
|
2832
|
-
if as_binary:
|
|
2833
|
-
return {
|
|
2834
|
-
"ok": True,
|
|
2835
|
-
"status": status,
|
|
2836
|
-
"content_type": content_type,
|
|
2837
|
-
"headers": resp_headers,
|
|
2838
|
-
"base64": base64.b64encode(raw_data).decode("ascii"),
|
|
2839
|
-
"byte_size": len(raw_data),
|
|
2840
|
-
"truncated": truncated
|
|
2841
|
-
}
|
|
2842
|
-
|
|
2982
|
+
raw_data, truncated = _read_limited_body(resp, resp.headers.get("Content-Encoding", ""), max_bytes)
|
|
2983
|
+
# Las cabeceras de transporte ya no describen el cuerpo descomprimido.
|
|
2984
|
+
resp_headers = {k: v for k, v in resp.getheaders() if k.lower() not in ("content-encoding", "content-length")}
|
|
2985
|
+
result = {
|
|
2986
|
+
"ok": True,
|
|
2987
|
+
"status": resp.status,
|
|
2988
|
+
"url": resp.geturl(),
|
|
2989
|
+
"content_type": content_type,
|
|
2990
|
+
"headers": resp_headers,
|
|
2991
|
+
"byte_size": len(raw_data),
|
|
2992
|
+
"truncated": truncated
|
|
2993
|
+
}
|
|
2994
|
+
if as_binary or not is_text_content_type(content_type):
|
|
2995
|
+
result["base64"] = base64.b64encode(raw_data).decode("ascii")
|
|
2996
|
+
return result
|
|
2843
2997
|
charset = resp.headers.get_content_charset() or "utf-8"
|
|
2844
2998
|
try:
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
"content_type": content_type,
|
|
2850
|
-
"headers": resp_headers,
|
|
2851
|
-
"text": text_content,
|
|
2852
|
-
"byte_size": len(raw_data),
|
|
2853
|
-
"truncated": truncated
|
|
2854
|
-
}
|
|
2855
|
-
except (UnicodeDecodeError, LookupError):
|
|
2856
|
-
return {
|
|
2857
|
-
"ok": True,
|
|
2858
|
-
"status": status,
|
|
2859
|
-
"content_type": content_type,
|
|
2860
|
-
"headers": resp_headers,
|
|
2861
|
-
"base64": base64.b64encode(raw_data).decode("ascii"),
|
|
2862
|
-
"byte_size": len(raw_data),
|
|
2863
|
-
"truncated": truncated
|
|
2864
|
-
}
|
|
2999
|
+
result["text"] = raw_data.decode(charset, errors="replace")
|
|
3000
|
+
except LookupError:
|
|
3001
|
+
result["text"] = raw_data.decode("utf-8", errors="replace")
|
|
3002
|
+
return result
|
|
2865
3003
|
except urllib.error.HTTPError as he:
|
|
2866
3004
|
err_body = ""
|
|
2867
3005
|
try:
|
|
2868
3006
|
err_body = he.read(2048).decode("utf-8", errors="replace")
|
|
2869
|
-
except
|
|
3007
|
+
except OSError:
|
|
2870
3008
|
pass
|
|
2871
3009
|
return {
|
|
2872
3010
|
"ok": False,
|
|
@@ -3286,6 +3424,13 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
3286
3424
|
return set_project_directory(params.get("path")), None, ""
|
|
3287
3425
|
except (ValueError, OSError) as exc:
|
|
3288
3426
|
return None, {"code": -32602, "message": str(exc)}, ""
|
|
3427
|
+
if method == "zerochat/diff/open":
|
|
3428
|
+
try:
|
|
3429
|
+
return open_file_diff(params.get("path"), bool(params.get("commit_fallback"))), None, ""
|
|
3430
|
+
except (ValueError, RuntimeError) as exc:
|
|
3431
|
+
return None, {"code": -32602, "message": str(exc)}, ""
|
|
3432
|
+
except Exception as exc:
|
|
3433
|
+
return None, {"code": -32603, "message": f"Error al abrir el visor de diffs: {exc}"}, str(exc)
|
|
3289
3434
|
if method == "zerochat/web/fetch":
|
|
3290
3435
|
try:
|
|
3291
3436
|
return fetch_web_resource(params), None, ""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|