From f57cdbadd871e1cc5e0adc0382c70957cc8120a1 Mon Sep 17 00:00:00 2001 From: Michael Woods Date: Sun, 5 Jan 2025 14:20:23 -0500 Subject: [PATCH] Added ZEO to requirements. Server by default maintains a ZEO storage process in addition to the server process to allow live editing of configuration and DB from other processes. --- requirements.txt | 3 +- src/packetserver/common/util.py | 9 +++ src/packetserver/server/__init__.py | 26 +++++++- src/packetserver/server/bulletin.py | 6 ++ src/packetserver/server/requests.py | 10 +++- src/packetserver/server/users.py | 92 ++++++++++++++++++++++++++--- 6 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 src/packetserver/common/util.py diff --git a/requirements.txt b/requirements.txt index 5fb1257..bee8c22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ msgpack pyham_ax25 ZODB BTrees -transaction \ No newline at end of file +transaction +ZEO \ No newline at end of file diff --git a/src/packetserver/common/util.py b/src/packetserver/common/util.py new file mode 100644 index 0000000..6193330 --- /dev/null +++ b/src/packetserver/common/util.py @@ -0,0 +1,9 @@ +import re + +def email_valid(email: str) -> bool: + """Taken from https://www.geeksforgeeks.org/check-if-email-address-valid-or-not-in-python/""" + regex = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b' + if re.fullmatch(regex, email): + return True + else: + return False diff --git a/src/packetserver/server/__init__.py b/src/packetserver/server/__init__.py index e4738cb..b05a3ed 100644 --- a/src/packetserver/server/__init__.py +++ b/src/packetserver/server/__init__.py @@ -15,6 +15,7 @@ import signal import time from msgpack.exceptions import OutOfData from typing import Callable, Self, Union +from traceback import format_exc def init_bulletins(root: PersistentMapping): @@ -24,13 +25,16 @@ def init_bulletins(root: PersistentMapping): root['bulletin_counter'] = 0 class Server: - def __init__(self, pe_server: str, port: int, server_callsign: str, data_dir: str = None): + def __init__(self, pe_server: str, port: int, server_callsign: str, data_dir: str = None, zeo: bool = True): if not ax25.Address.valid_call(server_callsign): raise ValueError(f"Provided callsign '{server_callsign}' is invalid.") self.callsign = server_callsign self.pe_server = pe_server self.pe_port = port self.handlers = deepcopy(standard_handlers) + self.zeo_addr = None + self.zeo_stop = None + self.zeo = zeo if data_dir: data_path = Path(data_dir) else: @@ -68,6 +72,8 @@ class Server: PacketServerConnection.connection_subscribers.append(lambda x: self.server_connection_bouncer(x)) signal.signal(signal.SIGINT, self.exit_gracefully) signal.signal(signal.SIGTERM, self.exit_gracefully) + self.db.close() + self.storage.close() @property @@ -145,12 +151,25 @@ class Server: def server_receiver(self, conn: PacketServerConnection): logging.debug("running server receiver") - self.process_incoming_data(conn) + try: + self.process_incoming_data(conn) + except Exception: + logging.debug(f"Unhandled exception while processing incoming data:\n{format_exc()}") def register_path_handler(self, path_root: str, fn: Callable): self.handlers[path_root.strip().lower()] = fn def start(self): + if not self.zeo: + self.storage = ZODB.FileStorage.FileStorage(self.data_file) + self.db = ZODB.DB(self.storage) + else: + import ZEO + address, stop = ZEO.server(path=self.data_file) + self.zeo_addr = address + self.zeo_stop = stop + self.db = ZEO.DB(self.zeo_addr) + logging.info(f"Starting ZEO server with address {self.zeo_addr}") self.app.start(self.pe_server, self.pe_port) self.app.register_callsigns(self.callsign) @@ -164,3 +183,6 @@ class Server: self.app.stop() self.storage.close() self.db.close() + if self.zeo: + logging.info("Stopping ZEO.") + self.zeo_stop() diff --git a/src/packetserver/server/bulletin.py b/src/packetserver/server/bulletin.py index f4b86e4..759e541 100644 --- a/src/packetserver/server/bulletin.py +++ b/src/packetserver/server/bulletin.py @@ -7,6 +7,7 @@ from typing import Self,Union,Optional from packetserver.common import PacketServerConnection, Request, Response, Message, send_response, send_blank_response import ZODB import logging +from packetserver.server.users import user_authorized def get_new_bulletin_id(root: PersistentMapping) -> int: if 'bulletin_counter' not in root: @@ -144,6 +145,11 @@ def handle_bulletin_delete(req: Request, conn: PacketServerConnection, db: ZODB. def bulletin_root_handler(req: Request, conn: PacketServerConnection, db: ZODB.DB): logging.debug(f"{req} being processed by bulletin_root_handler") + if not user_authorized(conn, db): + logging.debug(f"user {conn.remote_callsign} not authorized") + send_blank_response(conn, req, status_code=401) + return + logging.debug("user is authorized") if req.method is Request.Method.GET: handle_bulletin_get(req, conn, db) elif req.method is Request.Method.POST: diff --git a/src/packetserver/server/requests.py b/src/packetserver/server/requests.py index 45761d5..ba05fac 100644 --- a/src/packetserver/server/requests.py +++ b/src/packetserver/server/requests.py @@ -3,7 +3,7 @@ from msgpack.exceptions import OutOfData from packetserver.common import Message, Request, Response, PacketServerConnection, send_response, send_blank_response from .bulletin import bulletin_root_handler -from .users import user_root_handler +from .users import user_root_handler, user_authorized import logging from typing import Union import ZODB @@ -21,9 +21,15 @@ def handle_root_get(req: Request, conn: PacketServerConnection, if 'operator' in storage.root.config: operator = storage.root.config['operator'] + if user_authorized(conn, db): + user_message = f"User {conn.remote_callsign} is enabled." + else: + user_message = f"User {conn.remote_callsign} is not enabled." + response.payload = { 'operator': operator, - 'motd': motd + 'motd': motd, + 'user': user_message } send_response(conn, response, req) diff --git a/src/packetserver/server/users.py b/src/packetserver/server/users.py index 573bdae..3e552e9 100644 --- a/src/packetserver/server/users.py +++ b/src/packetserver/server/users.py @@ -11,23 +11,28 @@ import ZODB import logging import uuid from uuid import UUID +from packetserver.common.util import email_valid class User(persistent.Persistent): - def __init__(self, username: str, enabled: bool = True, hidden: bool = False, bio: str = "", status: str = ""): + def __init__(self, username: str, enabled: bool = True, hidden: bool = False, bio: str = "", status: str = "", + email: str = None, location: str = "", socials: list[str] = None): self._username = username.upper().strip() self.enabled = enabled self.hidden = hidden self.created_at = datetime.datetime.now(datetime.UTC) self.last_seen = self.created_at + self._email = "" + if email: + self.email = email + self._location = "" + self.location = location + self._socials = [] + if socials: + self.socials = socials self._uuid = None - if len(bio) > 4000: - self._bio = bio[:4000] - else: - self._bio = bio - if len(status) > 300: - self._status = status[:300] - else: - self._status = status + self.bio = bio + self._status = "" + self.status = status def write_new(self, db_root: PersistentMapping): all_uuids = [db_root['users'][x].uuid for x in db_root['users']] @@ -38,6 +43,47 @@ class User(persistent.Persistent): if self.username not in db_root['users']: db_root['users'][self.username] = self + @property + def location(self) -> str: + return self._location + + @location.setter + def location(self, location: str): + if len(location) > 1000: + self._location = location[:1000] + else: + self._location = location + + @property + def email(self) -> str: + return self._email + + @email.setter + def email(self, email: str): + if email_valid(email.strip().lower()): + self._email = email.strip().lower() + else: + raise ValueError(f"Invalid e-mail given: {email}") + + @property + def socials(self) -> list[str]: + return [] + + @socials.setter + def socials(self, socials: list[str]): + for social in socials: + if len(social) > 300: + social = social[:300] + self._socials.append(social) + + def add_social(self, social: str): + if len(social) > 300: + social = social[:300] + self._socials.append(social) + + def remove_social(self, social: str): + self.socials.remove(social) + @property def uuid(self): return self._uuid @@ -80,6 +126,14 @@ class User(persistent.Persistent): else: return all_users[:limit] + @classmethod + def is_authorized(cls, username: str, db_root: PersistentMapping) -> bool: + user = User.get_user_by_username(username, db_root) + if user: + if user.enabled: + return True + return False + def seen(self): self.last_seen = datetime.datetime.now(datetime.UTC) @@ -114,10 +168,25 @@ class User(persistent.Persistent): "username": self.username, "status": self.status, "bio": self.bio, + "socials": self.socials, + "email": self.email, + "location": self.location, "last_seen": self.last_seen.isoformat(), "created_at": self.created_at.isoformat() } + def __repr__(self): + return f"" + +def user_authorized(conn: PacketServerConnection, db: ZODB.DB) -> bool: + username = ax25.Address(conn.remote_callsign).call + logging.debug(f"Running authcheck for user {username}") + result = False + with db.transaction() as db: + result = User.is_authorized(username, db.root()) + logging.debug(f"User is authorized? {result}") + return result + def handle_user_get(req: Request, conn: PacketServerConnection, db: ZODB.DB): sp = req.path.split("/") logging.debug("handle_user_get working") @@ -161,6 +230,11 @@ def handle_user_update(req: Request, conn: PacketServerConnection, db: ZODB.DB): def user_root_handler(req: Request, conn: PacketServerConnection, db: ZODB.DB): logging.debug(f"{req} being processed by user_root_handler") + if not user_authorized(conn, db): + logging.debug(f"user {conn.remote_callsign} not authorized") + send_blank_response(conn, req, status_code=401) + return + logging.debug("user is authorized") if req.method is Request.Method.GET: handle_user_get(req, conn, db) else: