From e3687e3fb511df6154062df5ce6b6956da4ebe46 Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 23 Apr 2026 02:30:52 -0500 Subject: [PATCH] feat(repository-server): add repository server management and HTTP endpoints for file operations --- meshchatx/meshchat.py | 222 +++++++++++++++++++++- meshchatx/repository_http_standalone.py | 61 ++++++ meshchatx/src/backend/identity_context.py | 11 ++ 3 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 meshchatx/repository_http_standalone.py diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 7866772..4ca3e6a 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -93,7 +93,7 @@ from meshchatx.src.backend.lxmf_utils import ( is_user_facing_lxmf_payload, lxmf_fields_are_columba_reaction, ) -from meshchatx.src.backend.map_manager import TRANSPARENT_TILE +from meshchatx.src.backend.map_manager import MAX_EXPORT_TILES, TRANSPARENT_TILE from meshchatx.src.backend.markdown_renderer import MarkdownRenderer from meshchatx.src.backend.meshchat_utils import ( convert_db_favourite_to_dict, @@ -357,6 +357,19 @@ class ReticulumMeshChat: if self.current_context: self.current_context.docs_manager = value + @property + def repository_server_manager(self): + return ( + self.current_context.repository_server_manager + if self.current_context + else None + ) + + @repository_server_manager.setter + def repository_server_manager(self, value): + if self.current_context: + self.current_context.repository_server_manager = value + @property def nomadnet_manager(self): return self.current_context.nomadnet_manager if self.current_context else None @@ -5413,6 +5426,141 @@ class ReticulumMeshChat: return web.json_response(content) + # repository server (wheels + uploads; optional in-process plain HTTP) + @routes.get("/api/v1/repository-server/status") + async def repository_server_status(_request): + mgr = self.repository_server_manager + if not mgr: + return web.json_response({"error": "Unavailable"}, status=503) + return web.json_response(mgr.status()) + + @routes.get("/api/v1/repository-server/list") + async def repository_server_list(_request): + mgr = self.repository_server_manager + if not mgr: + return web.json_response({"error": "Unavailable"}, status=503) + return web.json_response(mgr.list_entries()) + + @routes.post("/api/v1/repository-server/upload") + async def repository_server_upload(request): + mgr = self.repository_server_manager + if not mgr: + return web.json_response({"error": "Unavailable"}, status=503) + try: + reader = await request.multipart() + field = await reader.next() + if not field or field.name != "file": + return web.json_response( + {"error": "No file field in multipart request"}, + status=400, + ) + filename = field.filename or "upload.bin" + data = await field.read() + ok, err = mgr.save_upload(filename, data) + if not ok: + return web.json_response( + {"success": False, "error": err}, status=400 + ) + return web.json_response({"success": True}) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + @routes.delete("/api/v1/repository-server/upload/{name}") + async def repository_server_delete_upload(request): + mgr = self.repository_server_manager + if not mgr: + return web.json_response({"error": "Unavailable"}, status=503) + name = request.match_info.get("name") or "" + ok, err = mgr.delete_upload(name) + if not ok: + code = 404 if err == "not_found" else 400 + return web.json_response({"success": False, "error": err}, status=code) + return web.json_response({"success": True}) + + @routes.post("/api/v1/repository-server/refresh-bundled") + async def repository_server_refresh_bundled(_request): + mgr = self.repository_server_manager + if not mgr: + return web.json_response({"error": "Unavailable"}, status=503) + try: + result = await asyncio.to_thread(mgr.refresh_bundled_wheels) + return web.json_response(result) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + @routes.post("/api/v1/repository-server/http/start") + async def repository_server_http_start(request): + mgr = self.repository_server_manager + if not mgr: + return web.json_response({"error": "Unavailable"}, status=503) + try: + data = await request.json() + except Exception: + data = {} + if not isinstance(data, dict): + data = {} + host = data.get("host") + port = data.get("port") + port_int = None + if port is not None: + try: + port_int = int(port) + except (TypeError, ValueError): + return web.json_response( + {"ok": False, "error": "invalid_port"}, status=400 + ) + try: + result = await asyncio.to_thread( + mgr.start_http_server, + str(host).strip() if host is not None else None, + port_int, + ) + return web.json_response(result) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + @routes.post("/api/v1/repository-server/http/stop") + async def repository_server_http_stop(_request): + mgr = self.repository_server_manager + if not mgr: + return web.json_response({"error": "Unavailable"}, status=503) + try: + result = await asyncio.to_thread(mgr.stop_http_server) + return web.json_response(result) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + @routes.post("/api/v1/repository-server/http/restart") + async def repository_server_http_restart(request): + mgr = self.repository_server_manager + if not mgr: + return web.json_response({"error": "Unavailable"}, status=503) + try: + data = await request.json() + except Exception: + data = {} + if not isinstance(data, dict): + data = {} + host = data.get("host") + port = data.get("port") + port_int = None + if port is not None: + try: + port_int = int(port) + except (TypeError, ValueError): + return web.json_response( + {"ok": False, "error": "invalid_port"}, status=400 + ) + try: + result = await asyncio.to_thread( + mgr.restart_http_server, + str(host).strip() if host is not None else None, + port_int, + ) + return web.json_response(result) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + # export docs @routes.get("/api/v1/docs/export") async def docs_export(request): @@ -11277,6 +11425,23 @@ class ReticulumMeshChat: if not bbox or len(bbox) != 4: return web.json_response({"error": "Invalid bbox"}, status=400) + tile_count = self.map_manager.count_export_tiles( + bbox, + min_zoom, + max_zoom, + ) + if tile_count > MAX_EXPORT_TILES: + return web.json_response( + { + "error": ( + f"Export would download {tile_count} tiles; " + f"maximum allowed is {MAX_EXPORT_TILES}. " + "Shrink the area or lower max zoom." + ), + }, + status=400, + ) + export_id = secrets.token_hex(8) self.map_manager.start_export(export_id, bbox, min_zoom, max_zoom, name) @@ -13074,6 +13239,61 @@ class ReticulumMeshChat: duplicate_signal = "duplicate_lxm" try: + uri_raw = uri.strip() + lu = uri_raw.lower() + if lu.startswith("meshchatx://map") or lu.startswith("meshchat://map"): + from urllib.parse import parse_qsl, urlparse + + parsed = urlparse(uri_raw) + q = dict(parse_qsl(parsed.query, keep_blank_values=True)) + try: + lat = float(q.get("lat", "") or "") + lon = float(q.get("lon", "") or "") + except (TypeError, ValueError): + AsyncUtils.run_async( + client.send_str( + json.dumps( + { + "type": "lxm.ingest_uri.result", + "status": "error", + "message": "Invalid map link: lat and lon must be numbers.", + }, + ), + ), + ) + return + zraw = q.get("z") or q.get("zoom") or "10" + try: + zoom = int(float(zraw)) + except (TypeError, ValueError): + zoom = 10 + zoom = max(0, min(22, zoom)) + layers = (q.get("layers") or "").strip() + label = (q.get("label") or "").strip() + mq = { + "lat": lat, + "lon": lon, + "zoom": zoom, + } + if layers: + mq["layers"] = layers + if label: + mq["label"] = label + AsyncUtils.run_async( + client.send_str( + json.dumps( + { + "type": "lxm.ingest_uri.result", + "status": "success", + "message": "Opening map view.", + "ingest_type": "map_view", + "map_query": mq, + }, + ), + ), + ) + return + # Columba-style contact sharing URI: # lxma://: if uri.lower().startswith("lxma://"): diff --git a/meshchatx/repository_http_standalone.py b/meshchatx/repository_http_standalone.py new file mode 100644 index 0000000..33c58e5 --- /dev/null +++ b/meshchatx/repository_http_standalone.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: 0BSD + +"""Plain HTTP file server for a MeshChatX repository-server directory.""" + +from __future__ import annotations + +import argparse +import os +import socketserver + +from meshchatx.src.backend.repository_server_manager import ( + make_repository_http_request_handler, +) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Serve MeshChatX repository-server files over HTTP without TLS.", + ) + parser.add_argument( + "--directory", + required=True, + help="Path to the identity's repository-server folder (contains uploads/ and bundled/).", + ) + parser.add_argument( + "--host", default="0.0.0.0", help="Bind address (default 0.0.0.0)." + ) + parser.add_argument( + "--port", type=int, default=8787, help="TCP port (default 8787)." + ) + parser.add_argument( + "--public-dir", + default=None, + help="MeshChatX public directory (for repository-server-index.html when not next to source).", + ) + args = parser.parse_args() + root = os.path.abspath(args.directory) + if not os.path.isdir(root): + print(f"Not a directory: {root}", flush=True) + return 2 + + pub = os.path.abspath(args.public_dir) if args.public_dir else None + handler_cls = make_repository_http_request_handler(root, public_dir=pub) + httpd = socketserver.ThreadingTCPServer((args.host, args.port), handler_cls) + httpd.allow_reuse_address = True + print( + f"Serving {root} at http://{args.host}:{args.port}/ (uploads/ and bundled/ subdirs)", + flush=True, + ) + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("Stopping.", flush=True) + return 0 + finally: + httpd.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py index 9e34224..71842e2 100644 --- a/meshchatx/src/backend/identity_context.py +++ b/meshchatx/src/backend/identity_context.py @@ -16,6 +16,7 @@ from meshchatx.src.backend.community_interfaces import CommunityInterfacesManage from meshchatx.src.backend.config_manager import ConfigManager from meshchatx.src.backend.database import Database from meshchatx.src.backend.docs_manager import DocsManager +from meshchatx.src.backend.repository_server_manager import RepositoryServerManager from meshchatx.src.backend.forwarding_manager import ForwardingManager from meshchatx.src.backend.integrity_manager import IntegrityManager from meshchatx.src.backend.map_manager import MapManager @@ -70,6 +71,7 @@ class IdentityContext: self.archiver_manager = None self.map_manager = None self.docs_manager = None + self.repository_server_manager = None self.nomadnet_manager = None self.message_router = None self.telephone_manager = None @@ -178,6 +180,10 @@ class IdentityContext: ), storage_dir=self.storage_path, ) + self.repository_server_manager = RepositoryServerManager( + self.storage_path, + public_dir=self.app.get_public_path(), + ) self.nomadnet_manager = NomadNetworkManager( self.config, self.archiver_manager, @@ -581,6 +587,11 @@ class IdentityContext: if self.docs_manager: self.docs_manager = None + if self.repository_server_manager: + with contextlib.suppress(Exception): + self.repository_server_manager.stop_http_server() + self.repository_server_manager = None + if self.nomadnet_manager: self.nomadnet_manager = None