feat(repository-server): add repository server management and HTTP endpoints for file operations

This commit is contained in:
Ivan
2026-04-23 02:30:52 -05:00
parent b05bf1bb17
commit e3687e3fb5
3 changed files with 293 additions and 1 deletions
+221 -1
View File
@@ -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://<destination_hash_hex>:<public_key_hex>
if uri.lower().startswith("lxma://"):
+61
View File
@@ -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())
+11
View File
@@ -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