From c2ed95fe6a7260603f071821bdb5e3820fdc913a Mon Sep 17 00:00:00 2001 From: Michael Woods Date: Sun, 5 Jan 2025 18:59:31 -0500 Subject: [PATCH] Adding object support. Not completed or tested at all. --- src/packetserver/server/__init__.py | 11 +- src/packetserver/server/messages.py | 20 ++ src/packetserver/server/objects.py | 316 ++++++++++++++++++++++++++++ src/packetserver/server/requests.py | 4 +- src/packetserver/server/users.py | 18 ++ 5 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 src/packetserver/server/messages.py create mode 100644 src/packetserver/server/objects.py diff --git a/src/packetserver/server/__init__.py b/src/packetserver/server/__init__.py index b05a3ed..7b56857 100644 --- a/src/packetserver/server/__init__.py +++ b/src/packetserver/server/__init__.py @@ -16,7 +16,7 @@ import time from msgpack.exceptions import OutOfData from typing import Callable, Self, Union from traceback import format_exc - +from os import linesep def init_bulletins(root: PersistentMapping): if 'bulletins' not in root: @@ -66,6 +66,9 @@ class Server: if 'SYSTEM' not in conn.root.users: logging.debug("Creating system user for first time.") User('SYSTEM', hidden=True, enabled=False).write_new(conn.root()) + if 'objects' not in conn.root(): + logging.debug("objects bucket missing, creating") + conn.root.objects = OOBTree() init_bulletins(conn.root()) self.app = pe.app.Application() PacketServerConnection.receive_subscribers.append(lambda x: self.server_receiver(x)) @@ -170,6 +173,12 @@ class Server: self.zeo_stop = stop self.db = ZEO.DB(self.zeo_addr) logging.info(f"Starting ZEO server with address {self.zeo_addr}") + try: + zeo_address_file = str(self.home_dir.joinpath("zeo-address.txt")) + open(zeo_address_file, 'w').write(f"{self.zeo_addr[0]}:{self.zeo_addr[1]}{linesep}") + logging.info(f"Wrote ZEO server info to '{zeo_address_file}'") + except: + logging.warning(f"Couldn't write ZEO server info to '{zeo_address_file}'\n{format_exc()}") self.app.start(self.pe_server, self.pe_port) self.app.register_callsigns(self.callsign) diff --git a/src/packetserver/server/messages.py b/src/packetserver/server/messages.py new file mode 100644 index 0000000..0635119 --- /dev/null +++ b/src/packetserver/server/messages.py @@ -0,0 +1,20 @@ +"""BBS private message system""" +import ax25 +import persistent +import persistent.list +from persistent.mapping import PersistentMapping +import datetime +from typing import Self,Union,Optional +from packetserver.common import PacketServerConnection, Request, Response, Message, send_response, send_blank_response +import ZODB +import logging +import uuid +from uuid import UUID +from packetserver.common.util import email_valid + +# TODO all messages + +class Attachment: + """Name and data that is sent with a message.""" + def __init__(self, name: str, ): + pass \ No newline at end of file diff --git a/src/packetserver/server/objects.py b/src/packetserver/server/objects.py new file mode 100644 index 0000000..7e794d5 --- /dev/null +++ b/src/packetserver/server/objects.py @@ -0,0 +1,316 @@ +"""Server object storage system.""" +from copy import deepcopy + +import persistent +import ax25 +import persistent.list +from persistent.mapping import PersistentMapping +import datetime +from typing import Self,Union,Optional +from packetserver.common import PacketServerConnection, Request, Response, Message, send_response, send_blank_response +import ZODB +import logging +import uuid +from uuid import UUID +from packetserver.server.users import User, user_authorized +from collections import namedtuple + +class Object(persistent.Persistent): + def __init__(self, name: str = "", data: Union[bytes,bytearray,str] = None): + self.private = False + self._binary = False + self._data = b'' + self._name = "" + self._owner = None + if data: + self.data = data + if name: + self._name = name + self._uuid = None + self.created_at = datetime.datetime.now(datetime.UTC) + self.modified_at = datetime.datetime.now(datetime.UTC) + + + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, name: str): + if name.strip() != self._name: + if len(name.strip()) > 300: + raise ValueError("Object names must be no more than 300 characters.") + self._name = name.strip() + self.touch() + + def touch(self): + self.modified_at = datetime.datetime.now(datetime.UTC) + + @property + def size(self) -> int: + return len(self.data) + + @property + def binary(self): + return self._binary + + @property + def data(self) -> Union[str,bytes]: + if self.binary: + return self._data + else: + return self._data.decode() + + @data.setter + def data(self, data: Union[bytes,bytearray,str]): + if type(data) in (bytes,bytearray): + if bytes(data) != self._data: + self._data = bytes(data) + self._binary = True + self.touch() + else: + if str(data).encode() != self._data: + self._data = str(data).encode() + self._binary = False + self.touch() + + @property + def owner(self) -> Optional[UUID]: + return self._owner + + @owner.setter + def owner(self, owner_uuid: UUID): + if owner_uuid: + if type(owner_uuid) is UUID: + self._owner = owner_uuid + self.touch() + else: + raise ValueError("Owner must be a UUID") + else: + self._owner = None + self.touch() + + def chown(self, username: str, db: ZODB.DB): + un = username.strip().upper() + old_owner_uuid = self._owner + with db.transaction() as db: + user = User.get_user_by_username(username, db.root()) + old_owner = User.get_user_by_uuid(old_owner_uuid, db.root()) + if user: + db.root.objects[self.uuid].owner = user.uuid + user.add_obj_uuid(self.uuid) + if old_owner_uuid: + if old_owner: + old_owner.remove_obj_uuid(self.uuid) + else: + raise KeyError(f"User '{un}' not found.") + + @classmethod + def get_object_by_uuid(cls, obj: UUID, db_root: PersistentMapping): + return db_root['objects'].get(obj) + + @classmethod + def get_objects_by_username(cls, username: str, db: ZODB.DB) -> list[Self]: + un = username.strip().upper() + objs = [] + with db.transaction() as db: + user = User.get_user_by_username(username, db.root()) + if user: + uuids = user.object_uuids + for u in uuids: + try: + obj = cls.get_object_by_uuid(u, db) + if obj: + objs.append(obj) + except: + pass + return objs + + @property + def uuid(self) -> Optional[UUID]: + return self._uuid + + def write_new(self, db: ZODB.DB) -> UUID: + if self.uuid: + raise KeyError("Object already has UUID. Manually clear it to write it again.") + self._uuid = uuid.uuid4() + with db.transaction() as db: + while self.uuid in db.root.objects: + self._uuid = uuid.uuid4() + db.root.objects[self.uuid] = self + self.touch() + return self.uuid + + def to_dict(self, include_data: bool = True) -> dict: + data = b'' + if include_data: + data = self.data + return { + "name": self.name, + "uuid_bytes": self.uuid.bytes, + "size_bytes": self.size, + "binary": self.binary, + "private": self.private, + "created_at": self.created_at, + "modified_at": self.modified_at, + "includes_data": include_data, + "data": data + } + + @classmethod + def from_dict(cls, obj: dict) -> Self: + o = Object(name=obj['name']) + o._uuid = UUID(bytes=obj['uuid_bytes']) + o.private = obj['private'] + o.data = obj['data'] + o._binary = obj['binary'] + return o + + def authorized_write(self, username: str, db: ZODB.DB): + un = username.strip().upper() + with db.transaction() as db: + user = User.get_user_by_username(username, db.root()) + if user: + if user.uuid == self.owner: + return True + else: + return False + + def authorized_get(self, username: str, db: ZODB.DB): + if not self.private: + return True + un = username.strip().upper() + with db.transaction() as db: + user = User.get_user_by_username(username, db.root()) + if user: + if user.uuid == self.owner: + return True + else: + return False + +DisplayOptions = namedtuple('DisplayOptions', ['get_data', 'limit', 'sort_by', 'reverse', 'search']) + +def parse_display_options(req: Request) -> DisplayOptions: + limit = req.vars.get('limit') + try: + limit = int(limit) + except: + limit = None + + d = req.vars.get('fetch') + if type(d) is str: + d.lower().strip() + if d in [1, 'y', True, 'yes', 'true', 't']: + get_data = True + else: + get_data = False + + r = req.vars.get('reverse') + if type(r) is str: + r.lower().strip() + if r in [1, 'y', True, 'yes', 'true', 't']: + reverse = True + else: + reverse = False + + sort = req.vars.get('sort') + sort_by = "name" + if type(sort) is str: + sort = sort.lower().strip() + if sort == "date": + sort_by = "date" + elif sort == "size": + sort_by = "size" + + s = req.vars.get('search') + search = None + if type(s) is str: + s = s.lower() + if s: + search = str(s) + + return DisplayOptions(get_data, limit, sort_by, reverse, search) + +def object_display_filter(source: list[Object], opts: DisplayOptions) -> list[dict]: + if opts.search: + objs = [x for x in source if str(opts.search) in x.name.lower()] + else: + objs = deepcopy(source) + + if opts.sort_by == "size": + objs.sort(key=lambda x: x.size, reverse=opts.reverse) + + elif opts.sort_by == "date": + objs.sort(key=lambda x: x.modified_at, reverse=opts.reverse) + else: + objs.sort(key=lambda x: x.name, reverse=opts.reverse) + + if opts.limit: + if len(objs) >= opts.limit: + objs = objs[:opts.limit] + + return [o.to_dict(include_data=opts.get_data) for o in objs] + +def handle_get_no_path(req: Request, conn: PacketServerConnection, db: ZODB.DB): + opts = parse_display_options(req) + response = Response.blank() + response.status_code = 404 + username = ax25.Address(conn.remote_callsign).call.upper().strip() + with db.transaction() as db: + user = User.get_user_by_username(username, db.root()) + if not user: + send_blank_response(conn, req, status_code=500, payload="Unknown user account problem") + return + if 'uuid' in req.vars: + uid = req.vars['uuid'] + if type(uid) is bytes: + obj = Object.get_object_by_uuid(UUID(bytes=uid), db.root()) + if obj: + if not obj.owner == user.uuid: + if not obj.private: + send_blank_response(conn, req, status_code=401) + return + if opts.get_data: + response.payload = obj.to_dict() + response.status_code = 200 + else: + response.payload = obj.to_dict(include_data=False) + response.status_code = 200 + else: + uuids = User.object_uuids + objs = [] + for i in uuids: + obj = Object.get_object_by_uuid(i, db.root()) + if not obj.private: + objs.append(obj) + else: + if obj.uuid == user.uuid: + objs.append(obj) + response.payload = object_display_filter(objs, opts) + response.status_code = 200 + + send_response(conn, response, req) + +def handle_object_get(req: Request, conn: PacketServerConnection, db: ZODB.DB): + # Case: User searching their own objects -> list + # or passes specific UUID as var -> Object + handle_get_no_path(req, conn, db) + + +def handle_object_post(req: Request, conn: PacketServerConnection, db: ZODB.DB): + # TODO + pass + +def object_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_object_get(req, conn, db) + elif req.method is Request.Method.POST: + handle_object_post(req, conn, db) + else: + send_blank_response(conn, req, status_code=404) diff --git a/src/packetserver/server/requests.py b/src/packetserver/server/requests.py index ba05fac..2b032c0 100644 --- a/src/packetserver/server/requests.py +++ b/src/packetserver/server/requests.py @@ -4,6 +4,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, user_authorized +from .objects import object_root_handler import logging from typing import Union import ZODB @@ -46,7 +47,8 @@ def root_root_handler(req: Request, conn: PacketServerConnection, standard_handlers = { "": root_root_handler, "bulletin": bulletin_root_handler, - "user": user_root_handler + "user": user_root_handler, + "object": object_root_handler } diff --git a/src/packetserver/server/users.py b/src/packetserver/server/users.py index 3e552e9..77f2f14 100644 --- a/src/packetserver/server/users.py +++ b/src/packetserver/server/users.py @@ -3,6 +3,7 @@ import ax25 import persistent import persistent.list +from persistent.list import PersistentList from persistent.mapping import PersistentMapping import datetime from typing import Self,Union,Optional @@ -12,6 +13,7 @@ import logging import uuid from uuid import UUID from packetserver.common.util import email_valid +from BTrees.OOBTree import TreeSet class User(persistent.Persistent): def __init__(self, username: str, enabled: bool = True, hidden: bool = False, bio: str = "", status: str = "", @@ -33,6 +35,7 @@ class User(persistent.Persistent): self.bio = bio self._status = "" self.status = status + self._objects = TreeSet() def write_new(self, db_root: PersistentMapping): all_uuids = [db_root['users'][x].uuid for x in db_root['users']] @@ -43,6 +46,21 @@ class User(persistent.Persistent): if self.username not in db_root['users']: db_root['users'][self.username] = self + @property + def object_uuids(self) -> list[UUID]: + return list(self._objects) + + def remove_obj_uuid(self, obj: UUID): + self._objects.remove(obj) + + def add_obj_uuid(self, obj: UUID): + self._objects.add(obj) + + def user_has_obj(self, obj: UUID) -> bool: + if obj in self._objects: + return True + return False + @property def location(self) -> str: return self._location