Working on messaging API and database now.

This commit is contained in:
Michael Woods
2025-01-07 20:35:27 -05:00
parent 06b27ba011
commit 5918afdf94
3 changed files with 251 additions and 5 deletions

View File

@@ -63,6 +63,9 @@ class Server:
if 'users' not in conn.root():
logging.debug("users missing, creating bucket")
conn.root.users = PersistentMapping()
if 'messages' not in conn.root():
logging.debug("messages container missing, creating bucket")
conn.root.users = PersistentMapping()
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())

View File

@@ -2,4 +2,5 @@
default_server_config = {
"motd": "Welcome to this PacketServer BBS!",
"operator": "placeholder",
"max_message_length": 2000
}

View File

@@ -4,17 +4,259 @@ 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
from typing import Self,Union,Optional,Iterable,Sequence
from packetserver.common import PacketServerConnection, Request, Response, send_response, send_blank_response
from packetserver.common import Message as PacketMessage
import ZODB
import logging
import uuid
from uuid import UUID
from packetserver.common.util import email_valid
from packetserver.server.objects import Object
from BTrees.OOBTree import TreeSet
from packetserver.server.users import User, user_authorized
from traceback import format_exc
from collections import namedtuple
# TODO all messages
def mailbox_create(username: str, db_root: PersistentMapping):
un = username.upper().strip()
if un not in db_root['messages']:
db_root['messages'][un] = persistent.list.PersistentList()
def global_unique_message_uuid(db_root: PersistentMapping) -> UUID:
if "message_uuids" not in db_root:
db_root['message_uuids'] = TreeSet()
logging.debug("Created message_uuid set for global message ids.")
uid = uuid.uuid4()
while uid in db_root['message_uuids']:
uid = uuid.uuid4()
return uid
class Attachment:
"""Name and data that is sent with a message."""
def __init__(self, name: str, ):
pass
def __init__(self, name: str, data: Union[bytes,bytearray,str]):
self._name = ""
self._data = b""
self._binary = True
self.data = data
self.name = name
@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()
@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
else:
if str(data).encode() != self._data:
self._data = str(data).encode()
self._binary = False
@property
def size(self) -> int:
return len(self.data)
def copy(self):
return Attachment(self.name, self.data)
class ObjectAttachment(Attachment):
def __init__(self, name: str, obj: Object):
self.object = obj
super().__init__(name, "")
@property
def size(self) -> int:
return self.object.size
@property
def data(self) -> Union[str,bytes]:
return self.object.data
@property
def binary(self) -> bool:
return self.object.binary
class MessageTextTooLongError(Exception):
"""Raised when the message text exceeds the length allowed in the server config."""
pass
class MessageAlreadySentError(Exception):
"""Raised when the message text exceeds the length allowed in the server config."""
pass
class Message(persistent.Persistent):
def __init__(self, text: str, msg_to: Optional[Iterable[str],str]= None, msg_from: Optional[str] = None,
attachments: Optional[Iterable[Attachment]] = None):
self.retrieved = False
self.sent_at = datetime.datetime.now(datetime.UTC)
self.text = text
self.attachments = ()
self.msg_to = (None,)
self.msg_from = None
self.msg_id = uuid.uuid4()
self.msg_delivered = False
if msg_to:
if type(msg_to) is str:
msg_to = msg_to.upper().strip()
self.msg_to = (msg_to,)
else:
msg_to_tmp = []
for i in msg_to:
i = str(i).strip().upper()
if i == "ALL":
msg_to_tmp = ["ALL"]
break
else:
msg_to_tmp.append(i)
self.msg_to = tuple(msg_to_tmp)
if msg_from:
self.msg_from = str(msg_from).upper().strip()
if attachments:
attch = []
for i in attachments:
if not isinstance(i,Attachment):
attch.append(Attachment("",str(i)))
else:
attch.append(i)
self.attachments = tuple(attch)
def __repr__(self):
return f"<Message: ID: {self.msg_id}, Sent: {self.msg_delivered}>"
def send(self, db: ZODB.DB) -> tuple:
if self.msg_delivered:
raise MessageAlreadySentError("Cannot send a private message that has already been sent.")
if self.msg_from is None:
raise ValueError("Message sender (message_from) cannot be None.")
new_attachments = []
for i in self.attachments:
if isinstance(i,ObjectAttachment):
new_attachments.append(Attachment(i.name, i.data))
else:
new_attachments.append(i)
send_counter = 0
recipients = []
failed = []
to_all = False
with db.transaction() as db:
for recipient in self.msg_to:
recipient = recipient.upper().strip()
if recipient is None:
continue
if recipient == "ALL":
recipients = [x for x in db.root.users if db.root.users[x].enabled]
to_all = True
break
recipients.append(recipient)
for recipient in recipients:
msg = Message(self.text, recipient, self.msg_from, attachments=[x.copy() for x in new_attachments])
try:
mailbox_create(recipient, db.root())
msg.msg_id = global_unique_message_uuid(db.root())
msg.msg_delivered = True
msg.sent_at = datetime.datetime.now(datetime.UTC)
if to_all:
msg.msg_to = 'ALL'
db.root.messages[recipient].append(msg)
send_counter = send_counter + 1
except:
logging.error(f"Error sending message to {recipient}:\n{format_exc()}")
failed.append(recipient)
return send_counter, failed
DisplayOptions = namedtuple('DisplayOptions', ['get_text', 'limit', 'sort_by', 'reverse', 'search',
'get_attachments', 'sent_unsent'])
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 handle_message_get(req: Request, conn: PacketServerConnection, db: ZODB.DB):
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_message_get(req, conn, db)
else:
send_blank_response(conn, req, status_code=404)