mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-04-30 12:26:27 +00:00
feat(repository-server): add repository server management and HTTP endpoints for file operations
This commit is contained in:
+221
-1
@@ -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://"):
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user