Improved PageNode class with documentation, error handling, and path management.
Update file and page serving methods to utilize pathlib for modern python path handling
This commit is contained in:
@@ -1,21 +1,21 @@
|
||||
"""Minimal Reticulum Page Node
|
||||
"""Minimal Reticulum Page Node.
|
||||
|
||||
Serves .mu pages and files over RNS.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import RNS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_INDEX = """>Default Home Page
|
||||
|
||||
This node is serving pages using rns-page-node, but the home page file (index.mu) was not found in the pages directory. Please add an index.mu file to customize the home page.
|
||||
This node is serving pages using rns-page-node, but index.mu was not found.
|
||||
Please add an index.mu file to customize the home page.
|
||||
"""
|
||||
|
||||
DEFAULT_NOTALLOWED = """>Request Not Allowed
|
||||
@@ -25,6 +25,8 @@ You are not authorised to carry out the request.
|
||||
|
||||
|
||||
class PageNode:
|
||||
"""A Reticulum page node that serves .mu pages and files over RNS."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identity,
|
||||
@@ -35,15 +37,30 @@ class PageNode:
|
||||
page_refresh_interval=0,
|
||||
file_refresh_interval=0,
|
||||
):
|
||||
"""Initialize the PageNode.
|
||||
|
||||
Args:
|
||||
identity: RNS Identity for the node
|
||||
pagespath: Path to directory containing .mu pages
|
||||
filespath: Path to directory containing files to serve
|
||||
announce_interval: Seconds between announcements (default: 360)
|
||||
name: Display name for the node (optional)
|
||||
page_refresh_interval: Seconds between page rescans (0 = disabled)
|
||||
file_refresh_interval: Seconds between file rescans (0 = disabled)
|
||||
|
||||
"""
|
||||
self._stop_event = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
self.logger = logging.getLogger(f"{__name__}.PageNode")
|
||||
self.identity = identity
|
||||
self.name = name
|
||||
self.pagespath = pagespath
|
||||
self.filespath = filespath
|
||||
self.destination = RNS.Destination(
|
||||
identity, RNS.Destination.IN, RNS.Destination.SINGLE, "nomadnetwork", "node",
|
||||
identity,
|
||||
RNS.Destination.IN,
|
||||
RNS.Destination.SINGLE,
|
||||
"nomadnetwork",
|
||||
"node",
|
||||
)
|
||||
self.announce_interval = announce_interval
|
||||
self.last_announce = 0
|
||||
@@ -58,20 +75,22 @@ class PageNode:
|
||||
self.destination.set_link_established_callback(self.on_connect)
|
||||
|
||||
self._announce_thread = threading.Thread(
|
||||
target=self._announce_loop, daemon=True,
|
||||
target=self._announce_loop,
|
||||
daemon=True,
|
||||
)
|
||||
self._announce_thread.start()
|
||||
self._refresh_thread = threading.Thread(target=self._refresh_loop, daemon=True)
|
||||
self._refresh_thread.start()
|
||||
|
||||
def register_pages(self):
|
||||
"""Scan pages directory and register request handlers for all .mu files."""
|
||||
with self._lock:
|
||||
self.servedpages = []
|
||||
self._scan_pages(self.pagespath)
|
||||
|
||||
pagespath = os.path.join(self.pagespath, "")
|
||||
pagespath = Path(self.pagespath)
|
||||
|
||||
if not os.path.isfile(os.path.join(self.pagespath, "index.mu")):
|
||||
if not (pagespath / "index.mu").is_file():
|
||||
self.destination.register_request_handler(
|
||||
"/page/index.mu",
|
||||
response_generator=self.serve_default_index,
|
||||
@@ -79,7 +98,7 @@ class PageNode:
|
||||
)
|
||||
|
||||
for full_path in self.servedpages:
|
||||
rel = full_path[len(pagespath) :]
|
||||
rel = full_path[len(str(pagespath)) :]
|
||||
if not rel.startswith("/"):
|
||||
rel = "/" + rel
|
||||
request_path = f"/page{rel}"
|
||||
@@ -90,14 +109,15 @@ class PageNode:
|
||||
)
|
||||
|
||||
def register_files(self):
|
||||
"""Scan files directory and register request handlers for all files."""
|
||||
with self._lock:
|
||||
self.servedfiles = []
|
||||
self._scan_files(self.filespath)
|
||||
|
||||
filespath = os.path.join(self.filespath, "")
|
||||
filespath = Path(self.filespath)
|
||||
|
||||
for full_path in self.servedfiles:
|
||||
rel = full_path[len(filespath) :]
|
||||
rel = full_path[len(str(filespath)) :]
|
||||
if not rel.startswith("/"):
|
||||
rel = "/" + rel
|
||||
request_path = f"/file{rel}"
|
||||
@@ -109,89 +129,115 @@ class PageNode:
|
||||
)
|
||||
|
||||
def _scan_pages(self, base):
|
||||
for entry in os.listdir(base):
|
||||
if entry.startswith("."):
|
||||
base_path = Path(base)
|
||||
for entry in base_path.iterdir():
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
path = os.path.join(base, entry)
|
||||
if os.path.isdir(path):
|
||||
self._scan_pages(path)
|
||||
elif os.path.isfile(path) and not entry.endswith(".allowed"):
|
||||
self.servedpages.append(path)
|
||||
if entry.is_dir():
|
||||
self._scan_pages(str(entry))
|
||||
elif entry.is_file() and not entry.name.endswith(".allowed"):
|
||||
self.servedpages.append(str(entry))
|
||||
|
||||
def _scan_files(self, base):
|
||||
for entry in os.listdir(base):
|
||||
if entry.startswith("."):
|
||||
base_path = Path(base)
|
||||
for entry in base_path.iterdir():
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
path = os.path.join(base, entry)
|
||||
if os.path.isdir(path):
|
||||
self._scan_files(path)
|
||||
elif os.path.isfile(path):
|
||||
self.servedfiles.append(path)
|
||||
if entry.is_dir():
|
||||
self._scan_files(str(entry))
|
||||
elif entry.is_file():
|
||||
self.servedfiles.append(str(entry))
|
||||
|
||||
@staticmethod
|
||||
def serve_default_index(
|
||||
path, data, request_id, link_id, remote_identity, requested_at,
|
||||
_path,
|
||||
_data,
|
||||
_request_id,
|
||||
_link_id,
|
||||
_remote_identity,
|
||||
_requested_at,
|
||||
):
|
||||
"""Serve the default index page when no index.mu file exists."""
|
||||
return DEFAULT_INDEX.encode("utf-8")
|
||||
|
||||
def serve_page(
|
||||
self, path, data, request_id, link_id, remote_identity, requested_at,
|
||||
self,
|
||||
path,
|
||||
data,
|
||||
_request_id,
|
||||
_link_id,
|
||||
remote_identity,
|
||||
_requested_at,
|
||||
):
|
||||
pagespath = os.path.join(self.pagespath, "")
|
||||
file_path = pagespath + path[5:]
|
||||
"""Serve a .mu page file, executing it as a script if it has a shebang."""
|
||||
pagespath = Path(self.pagespath)
|
||||
relative_path = path[6:] if path.startswith("/page/") else path[5:]
|
||||
file_path = pagespath / relative_path
|
||||
try:
|
||||
with open(file_path, "rb") as _f:
|
||||
with file_path.open("rb") as _f:
|
||||
first_line = _f.readline()
|
||||
is_script = first_line.startswith(b"#!")
|
||||
except Exception:
|
||||
is_script = False
|
||||
if is_script and os.access(file_path, os.X_OK):
|
||||
# Note: The execution of file_path is intentional here, as some pages are designed to be executable scripts.
|
||||
# This is acknowledged as a potential security risk if untrusted input can control file_path.
|
||||
if is_script and os.access(str(file_path), os.X_OK):
|
||||
try:
|
||||
env = os.environ.copy()
|
||||
if remote_identity:
|
||||
env["remote_identity"] = RNS.hexrep(remote_identity.hash, delimit=False)
|
||||
env["remote_identity"] = RNS.hexrep(
|
||||
remote_identity.hash,
|
||||
delimit=False,
|
||||
)
|
||||
if data and isinstance(data, bytes):
|
||||
try:
|
||||
data_str = data.decode('utf-8')
|
||||
data_str = data.decode("utf-8")
|
||||
if data_str:
|
||||
if '|' in data_str and '&' not in data_str:
|
||||
pairs = data_str.split('|')
|
||||
if "|" in data_str and "&" not in data_str:
|
||||
pairs = data_str.split("|")
|
||||
else:
|
||||
pairs = data_str.split('&')
|
||||
pairs = data_str.split("&")
|
||||
for pair in pairs:
|
||||
if '=' in pair:
|
||||
key, value = pair.split('=', 1)
|
||||
if key.startswith('field_'):
|
||||
if "=" in pair:
|
||||
key, value = pair.split("=", 1)
|
||||
if key.startswith(("field_", "var_")):
|
||||
env[key] = value
|
||||
elif key.startswith('var_'):
|
||||
env[key] = value
|
||||
elif key == 'action':
|
||||
env['var_action'] = value
|
||||
elif key == "action":
|
||||
env["var_action"] = value
|
||||
else:
|
||||
env[f'field_{key}'] = value
|
||||
except Exception:
|
||||
self.logger.exception("Error parsing request data")
|
||||
result = subprocess.run([file_path], stdout=subprocess.PIPE, check=True, env=env) # noqa: S603
|
||||
env[f"field_{key}"] = value
|
||||
except Exception as e:
|
||||
RNS.log(f"Error parsing request data: {e}", RNS.LOG_ERROR)
|
||||
result = subprocess.run( # noqa: S603
|
||||
[str(file_path)],
|
||||
stdout=subprocess.PIPE,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
return result.stdout
|
||||
except Exception:
|
||||
self.logger.exception("Error executing script page")
|
||||
with open(file_path, "rb") as f:
|
||||
except Exception as e:
|
||||
RNS.log(f"Error executing script page: {e}", RNS.LOG_ERROR)
|
||||
with file_path.open("rb") as f:
|
||||
return f.read()
|
||||
|
||||
def serve_file(
|
||||
self, path, data, request_id, link_id, remote_identity, requested_at,
|
||||
self,
|
||||
path,
|
||||
_data,
|
||||
_request_id,
|
||||
_link_id,
|
||||
_remote_identity,
|
||||
_requested_at,
|
||||
):
|
||||
filespath = os.path.join(self.filespath, "")
|
||||
file_path = filespath + path[6:]
|
||||
"""Serve a file from the files directory."""
|
||||
filespath = Path(self.filespath)
|
||||
relative_path = path[6:] if path.startswith("/file/") else path[5:]
|
||||
file_path = filespath / relative_path
|
||||
return [
|
||||
open(file_path, "rb"),
|
||||
{"name": os.path.basename(file_path).encode("utf-8")},
|
||||
file_path.open("rb"),
|
||||
{"name": file_path.name.encode("utf-8")},
|
||||
]
|
||||
|
||||
def on_connect(self, link):
|
||||
pass
|
||||
"""Handle new link connections."""
|
||||
|
||||
def _announce_loop(self):
|
||||
try:
|
||||
@@ -203,8 +249,8 @@ class PageNode:
|
||||
self.destination.announce()
|
||||
self.last_announce = time.time()
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
self.logger.exception("Error in announce loop")
|
||||
except Exception as e:
|
||||
RNS.log(f"Error in announce loop: {e}", RNS.LOG_ERROR)
|
||||
|
||||
def _refresh_loop(self):
|
||||
try:
|
||||
@@ -223,45 +269,55 @@ class PageNode:
|
||||
self.register_files()
|
||||
self.last_file_refresh = now
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
self.logger.exception("Error in refresh loop")
|
||||
except Exception as e:
|
||||
RNS.log(f"Error in refresh loop: {e}", RNS.LOG_ERROR)
|
||||
|
||||
def shutdown(self):
|
||||
self.logger.info("Shutting down PageNode...")
|
||||
"""Gracefully shutdown the PageNode and cleanup resources."""
|
||||
RNS.log("Shutting down PageNode...", RNS.LOG_INFO)
|
||||
self._stop_event.set()
|
||||
try:
|
||||
self._announce_thread.join(timeout=5)
|
||||
self._refresh_thread.join(timeout=5)
|
||||
except Exception:
|
||||
self.logger.exception("Error waiting for threads to shut down")
|
||||
except Exception as e:
|
||||
RNS.log(f"Error waiting for threads to shut down: {e}", RNS.LOG_ERROR)
|
||||
try:
|
||||
if hasattr(self.destination, "close"):
|
||||
self.destination.close()
|
||||
except Exception:
|
||||
self.logger.exception("Error closing RNS destination")
|
||||
except Exception as e:
|
||||
RNS.log(f"Error closing RNS destination: {e}", RNS.LOG_ERROR)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the RNS page node application."""
|
||||
parser = argparse.ArgumentParser(description="Minimal Reticulum Page Node")
|
||||
parser.add_argument(
|
||||
"-c", "--config", dest="configpath", help="Reticulum config path", default=None,
|
||||
"-c",
|
||||
"--config",
|
||||
dest="configpath",
|
||||
help="Reticulum config path",
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--pages-dir",
|
||||
dest="pages_dir",
|
||||
help="Pages directory",
|
||||
default=os.path.join(os.getcwd(), "pages"),
|
||||
default=str(Path.cwd() / "pages"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--files-dir",
|
||||
dest="files_dir",
|
||||
help="Files directory",
|
||||
default=os.path.join(os.getcwd(), "files"),
|
||||
default=str(Path.cwd() / "files"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-n", "--node-name", dest="node_name", help="Node display name", default=None,
|
||||
"-n",
|
||||
"--node-name",
|
||||
dest="node_name",
|
||||
help="Node display name",
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-a",
|
||||
@@ -276,7 +332,7 @@ def main():
|
||||
"--identity-dir",
|
||||
dest="identity_dir",
|
||||
help="Directory to store node identity",
|
||||
default=os.path.join(os.getcwd(), "node-config"),
|
||||
default=str(Path.cwd() / "node-config"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--page-refresh-interval",
|
||||
@@ -310,22 +366,18 @@ def main():
|
||||
identity_dir = args.identity_dir
|
||||
page_refresh_interval = args.page_refresh_interval
|
||||
file_refresh_interval = args.file_refresh_interval
|
||||
numeric_level = getattr(logging, args.log_level.upper(), logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=numeric_level, format="%(asctime)s %(name)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
|
||||
RNS.Reticulum(configpath)
|
||||
os.makedirs(identity_dir, exist_ok=True)
|
||||
identity_file = os.path.join(identity_dir, "identity")
|
||||
if os.path.isfile(identity_file):
|
||||
identity = RNS.Identity.from_file(identity_file)
|
||||
Path(identity_dir).mkdir(parents=True, exist_ok=True)
|
||||
identity_file = Path(identity_dir) / "identity"
|
||||
if identity_file.is_file():
|
||||
identity = RNS.Identity.from_file(str(identity_file))
|
||||
else:
|
||||
identity = RNS.Identity()
|
||||
identity.to_file(identity_file)
|
||||
identity.to_file(str(identity_file))
|
||||
|
||||
os.makedirs(pages_dir, exist_ok=True)
|
||||
os.makedirs(files_dir, exist_ok=True)
|
||||
Path(pages_dir).mkdir(parents=True, exist_ok=True)
|
||||
Path(files_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
node = PageNode(
|
||||
identity,
|
||||
@@ -336,15 +388,14 @@ def main():
|
||||
page_refresh_interval,
|
||||
file_refresh_interval,
|
||||
)
|
||||
logger.info("Page node running. Press Ctrl-C to exit.")
|
||||
logger.info("Node address: %s", RNS.prettyhexrep(node.destination.hash))
|
||||
|
||||
RNS.log("Page node running. Press Ctrl-C to exit.", RNS.LOG_INFO)
|
||||
RNS.log(f"Node address: {RNS.prettyhexrep(node.destination.hash)}", RNS.LOG_INFO)
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Keyboard interrupt received, shutting down...")
|
||||
RNS.log("Keyboard interrupt received, shutting down...", RNS.LOG_INFO)
|
||||
node.shutdown()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user