diff --git a/packetserver/http/auth.py b/packetserver/http/auth.py index 6886508..7cd5bd3 100644 --- a/packetserver/http/auth.py +++ b/packetserver/http/auth.py @@ -8,7 +8,7 @@ import time from persistent.mapping import PersistentMapping from persistent.list import PersistentList from packetserver.common.util import is_valid_ax25_callsign -from .database import ConnectionDependency +from .database import DbDependency ph = PasswordHasher() @@ -52,43 +52,44 @@ class HttpUser(Persistent): # rf enabled checks.. # - def is_rf_enabled(self, conn: ConnectionDependency) -> bool: + def is_rf_enabled(self, db: DbDependency) -> bool: """ Check if RF gateway is enabled (i.e., callsign NOT in global blacklist). Requires an open ZODB connection. """ - root = conn.root() - blacklist = root.get('config', {}).get('blacklist', []) - return self.username not in blacklist + with db.transaction() as conn: + root = conn.root() + blacklist = root.get('config', {}).get('blacklist', []) + return self.username not in blacklist - def set_rf_enabled(self, conn: ConnectionDependency, allow: bool): + def set_rf_enabled(self, db: DbDependency, allow: bool): """ Enable/disable RF gateway by adding/removing from global blacklist. Requires an open ZODB connection (inside a transaction). Only allows enabling if the username is a valid AX.25 callsign. """ from packetserver.common.util import is_valid_ax25_callsign # our validator + with db.transaction() as conn: + root = conn.root() + config = root.setdefault('config', PersistentMapping()) + blacklist = config.setdefault('blacklist', PersistentList()) - root = conn.root() - config = root.setdefault('config', PersistentMapping()) - blacklist = config.setdefault('blacklist', PersistentList()) + upper_name = self.username - upper_name = self.username + if allow: + if not is_valid_ax25_callsign(upper_name): + raise ValueError(f"{upper_name} is not a valid AX.25 callsign – cannot enable RF access") + if upper_name in blacklist: + blacklist.remove(upper_name) + blacklist._p_changed = True + else: + if upper_name not in blacklist: + blacklist.append(upper_name) + blacklist._p_changed = True - if allow: - if not is_valid_ax25_callsign(upper_name): - raise ValueError(f"{upper_name} is not a valid AX.25 callsign – cannot enable RF access") - if upper_name in blacklist: - blacklist.remove(upper_name) - blacklist._p_changed = True - else: - if upper_name not in blacklist: - blacklist.append(upper_name) - blacklist._p_changed = True - - config._p_changed = True - root._p_changed = True - transaction.commit() + config._p_changed = True + root._p_changed = True + transaction.commit() # ------------------------------------------------------------------ # Password handling (unchanged) diff --git a/packetserver/http/database.py b/packetserver/http/database.py index a6a7590..0334bec 100644 --- a/packetserver/http/database.py +++ b/packetserver/http/database.py @@ -46,16 +46,16 @@ def get_db() -> ZODB.DB: raise RuntimeError("Database not initialized – call init_db() on startup") return _db -def get_connection() -> Generator[Connection, None, None]: - """Per-request dependency: yields an open Connection, closes on exit.""" - db = get_db() - conn = db.open() - try: - yield conn - finally: - #print("not closing connection") - #conn.close() - pass +#def get_connection() -> Generator[Connection, None, None]: +# """Per-request dependency: yields an open Connection, closes on exit.""" +# db = get_db() +# conn = db.open() +# try: +# yield conn +# finally: +# #print("not closing connection") +# #conn.close() +# pass # Optional: per-request transaction (if you want automatic commit/abort) def get_transaction_manager(): @@ -63,4 +63,4 @@ def get_transaction_manager(): # Annotated dependencies for routers DbDependency = Annotated[ZODB.DB, Depends(get_db)] -ConnectionDependency = Annotated[Connection, Depends(get_connection)] \ No newline at end of file +#ConnectionDependency = Annotated[Connection, Depends(get_connection)] \ No newline at end of file diff --git a/packetserver/http/dependencies.py b/packetserver/http/dependencies.py index d3670ab..4fc3b99 100644 --- a/packetserver/http/dependencies.py +++ b/packetserver/http/dependencies.py @@ -3,49 +3,49 @@ from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBasic, HTTPBasicCredentials from .auth import HttpUser -from .database import ConnectionDependency +from .database import DbDependency security = HTTPBasic() -async def get_current_http_user(conn: ConnectionDependency, credentials: HTTPBasicCredentials = Depends(security)): +async def get_current_http_user(db: DbDependency, credentials: HTTPBasicCredentials = Depends(security)): """ Authenticate via Basic Auth using HttpUser from ZODB. Injected by the standalone runner (get_db_connection available). """ + with db.transaction() as conn: + root = conn.root() - root = conn.root() + http_users = root.get("httpUsers") + if http_users is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password", + headers={"WWW-Authenticate": "Basic"}, + ) - http_users = root.get("httpUsers") - if http_users is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid username or password", - headers={"WWW-Authenticate": "Basic"}, - ) + user: HttpUser | None = http_users.get(credentials.username.upper()) - user: HttpUser | None = http_users.get(credentials.username.upper()) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password", + headers={"WWW-Authenticate": "Basic"}, + ) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid username or password", - headers={"WWW-Authenticate": "Basic"}, - ) + if not user.http_enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="HTTP access disabled for this user", + ) - if not user.http_enabled: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="HTTP access disabled for this user", - ) + if not user.verify_password(credentials.password): + user.record_login_failure() + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password", + headers={"WWW-Authenticate": "Basic"}, + ) - if not user.verify_password(credentials.password): - user.record_login_failure() - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid username or password", - headers={"WWW-Authenticate": "Basic"}, - ) - - user.record_login_success() - return user \ No newline at end of file + user.record_login_success() + return user \ No newline at end of file diff --git a/packetserver/http/routers/bulletins.py b/packetserver/http/routers/bulletins.py index 2ae3cae..e99c7b2 100644 --- a/packetserver/http/routers/bulletins.py +++ b/packetserver/http/routers/bulletins.py @@ -7,7 +7,7 @@ import transaction from persistent.list import PersistentList from ZODB.Connection import Connection -from packetserver.http.database import DbDependency, ConnectionDependency, get_db +from packetserver.http.database import DbDependency from ..dependencies import get_current_http_user from ..auth import HttpUser from ..server import templates diff --git a/packetserver/http/routers/dashboard.py b/packetserver/http/routers/dashboard.py index e351f88..1e2a07c 100644 --- a/packetserver/http/routers/dashboard.py +++ b/packetserver/http/routers/dashboard.py @@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse from packetserver.http.dependencies import get_current_http_user from packetserver.http.auth import HttpUser from packetserver.http.server import templates -from packetserver.http.database import ConnectionDependency +from packetserver.http.database import DbDependency router = APIRouter(tags=["dashboard"]) @@ -16,41 +16,43 @@ from .bulletins import list_bulletins @router.get("/dashboard", response_class=HTMLResponse) async def dashboard( - conn: ConnectionDependency, + db: DbDependency, request: Request, current_user: HttpUser = Depends(get_current_http_user) ): - # Internal call – pass explicit defaults to avoid Query object injection messages_resp = await api_get_messages( - conn, + db, current_user=current_user, type="all", limit=100, since=None # prevents Query wrapper ) - messages = messages_resp["messages"] + with db.transaction() as conn: + # Internal call – pass explicit defaults to avoid Query object injection - bulletins_resp = await list_bulletins(conn, limit=10, since=None) - recent_bulletins = bulletins_resp["bulletins"] + messages = messages_resp["messages"] - return templates.TemplateResponse( - "dashboard.html", - { - "request": request, - "current_user": current_user.username, - "messages": messages, - "bulletins": recent_bulletins - } - ) + bulletins_resp = await list_bulletins(conn, limit=10, since=None) + recent_bulletins = bulletins_resp["bulletins"] + + return templates.TemplateResponse( + "dashboard.html", + { + "request": request, + "current_user": current_user.username, + "messages": messages, + "bulletins": recent_bulletins + } + ) @router.get("/dashboard/profile", response_class=HTMLResponse) async def profile_page( - conn: ConnectionDependency, + db: DbDependency, request: Request, current_user: HttpUser = Depends(get_current_http_user) ): from packetserver.http.routers.profile import profile as api_profile - profile_data = await api_profile(conn, current_user=current_user) + profile_data = await api_profile(db, current_user=current_user) return templates.TemplateResponse( "profile.html", diff --git a/packetserver/http/routers/message_detail.py b/packetserver/http/routers/message_detail.py index 55d0057..3cb8d61 100644 --- a/packetserver/http/routers/message_detail.py +++ b/packetserver/http/routers/message_detail.py @@ -4,11 +4,13 @@ from fastapi.responses import HTMLResponse from packetserver.http.dependencies import get_current_http_user from packetserver.http.auth import HttpUser from packetserver.http.server import templates +from packetserver.http.database import DbDependency router = APIRouter(tags=["message-detail"]) @router.get("/dashboard/message/{msg_id}", response_class=HTMLResponse) async def message_detail_page( + db: DbDependency, request: Request, msg_id: str = Path(..., description="Message UUID as string"), current_user: HttpUser = Depends(get_current_http_user) @@ -18,6 +20,7 @@ async def message_detail_page( # Call with mark_retrieved=True to auto-mark as read on view (optional—remove if you prefer manual) message_data = await api_get_message( + db, msg_id=msg_id, mark_retrieved=True, current_user=current_user diff --git a/packetserver/http/routers/messages.py b/packetserver/http/routers/messages.py index 6a855bc..c2e1cc1 100644 --- a/packetserver/http/routers/messages.py +++ b/packetserver/http/routers/messages.py @@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, validator from packetserver.http.dependencies import get_current_http_user from packetserver.http.auth import HttpUser -from packetserver.http.database import ConnectionDependency +from packetserver.http.database import DbDependency html_router = APIRouter(tags=["messages-html"]) @@ -29,7 +29,7 @@ class MarkRetrievedRequest(BaseModel): @router.get("/messages") async def get_messages( - conn: ConnectionDependency, + db: DbDependency, current_user: HttpUser = Depends(get_current_http_user), type: str = Query("received", description="received, sent, or all"), limit: Optional[int] = Query(20, le=100, description="Max messages to return (default 20, max 100)"), @@ -40,128 +40,131 @@ async def get_messages( limit = 20 username = current_user.username - root = conn.root() + with db.transaction() as conn: + root = conn.root() - if 'messages' not in root: - root['messages'] = PersistentMapping() - if username not in root['messages']: - root['messages'][username] = persistent.list.PersistentList() + if 'messages' not in root: + root['messages'] = PersistentMapping() + if username not in root['messages']: + root['messages'][username] = persistent.list.PersistentList() - mailbox = root['messages'][username] + mailbox = root['messages'][username] - since_dt = None - if since: - try: - since_dt = datetime.fromisoformat(since.replace("Z", "+00:00")) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid 'since' format") + since_dt = None + if since: + try: + since_dt = datetime.fromisoformat(since.replace("Z", "+00:00")) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid 'since' format") - messages = [] - for msg in mailbox: - if type == "received" and msg.msg_from == username: - continue - if type == "sent" and msg.msg_from != username: - continue - if since_dt and msg.sent_at < since_dt: - continue + messages = [] + for msg in mailbox: + if type == "received" and msg.msg_from == username: + continue + if type == "sent" and msg.msg_from != username: + continue + if since_dt and msg.sent_at < since_dt: + continue - messages.append({ - "id": str(msg.msg_id), - "from": msg.msg_from, - "to": list(msg.msg_to) if isinstance(msg.msg_to, tuple) else [msg.msg_to], - "sent_at": msg.sent_at.isoformat() + "Z", - "text": msg.text, - "has_attachments": len(msg.attachments) > 0, - "retrieved": msg.retrieved, - }) + messages.append({ + "id": str(msg.msg_id), + "from": msg.msg_from, + "to": list(msg.msg_to) if isinstance(msg.msg_to, tuple) else [msg.msg_to], + "sent_at": msg.sent_at.isoformat() + "Z", + "text": msg.text, + "has_attachments": len(msg.attachments) > 0, + "retrieved": msg.retrieved, + }) - messages.sort(key=lambda m: m["sent_at"], reverse=True) + messages.sort(key=lambda m: m["sent_at"], reverse=True) - return {"messages": messages[:limit], "total_returned": len(messages[:limit])} + return {"messages": messages[:limit], "total_returned": len(messages[:limit])} @router.get("/messages/{msg_id}") async def get_message( - conn: ConnectionDependency, + db: DbDependency, msg_id: str = Path(..., description="UUID of the message (as string)"), mark_retrieved: bool = Query(False, description="If true, mark message as retrieved/read"), current_user: HttpUser = Depends(get_current_http_user) ): - root = conn.root() + with db.transaction() as conn: + root = conn.root() - username = current_user.username + username = current_user.username - messages_root = root.get('messages', {}) - mailbox = messages_root.get(username) - if not mailbox: - raise HTTPException(status_code=404, detail="Mailbox not found") + messages_root = root.get('messages', {}) + mailbox = messages_root.get(username) + if not mailbox: + raise HTTPException(status_code=404, detail="Mailbox not found") - # Find message by ID - target_msg = None - for msg in mailbox: - if str(msg.msg_id) == msg_id: - target_msg = msg - break + # Find message by ID + target_msg = None + for msg in mailbox: + if str(msg.msg_id) == msg_id: + target_msg = msg + break - if not target_msg: - raise HTTPException(status_code=404, detail="Message not found") + if not target_msg: + raise HTTPException(status_code=404, detail="Message not found") - # Optionally mark as retrieved - if mark_retrieved and not target_msg.retrieved: - target_msg.retrieved = True - target_msg._p_changed = True - mailbox._p_changed = True - # Explicit transaction for the write - transaction.get().commit() + # Optionally mark as retrieved + if mark_retrieved and not target_msg.retrieved: + target_msg.retrieved = True + target_msg._p_changed = True + mailbox._p_changed = True + # Explicit transaction for the write + transaction.get().commit() - return { - "id": str(target_msg.msg_id), - "from": target_msg.msg_from or "UNKNOWN", - "to": list(target_msg.msg_to), - "sent_at": target_msg.sent_at.isoformat() + "Z", - "text": target_msg.text, - "retrieved": target_msg.retrieved, - "has_attachments": len(target_msg.attachments) > 0, - # Future: "attachments": [...] metadata - } + return { + "id": str(target_msg.msg_id), + "from": target_msg.msg_from or "UNKNOWN", + "to": list(target_msg.msg_to), + "sent_at": target_msg.sent_at.isoformat() + "Z", + "text": target_msg.text, + "retrieved": target_msg.retrieved, + "has_attachments": len(target_msg.attachments) > 0, + # Future: "attachments": [...] metadata + } @router.patch("/messages/{msg_id}") async def mark_message_retrieved( - conn: ConnectionDependency, + db: DbDependency, msg_id: str = Path(..., description="Message UUID as string"), payload: MarkRetrievedRequest = None, current_user: HttpUser = Depends(get_current_http_user) ): - root = conn.root() + with db.transaction() as conn: + root = conn.root() - username = current_user.username - mailbox = root.get('messages', {}).get(username) + username = current_user.username + mailbox = root.get('messages', {}).get(username) - if not mailbox: - raise HTTPException(status_code=404, detail="Mailbox not found") + if not mailbox: + raise HTTPException(status_code=404, detail="Mailbox not found") - target_msg = None - for msg in mailbox: - if str(msg.msg_id) == msg_id: - target_msg = msg - break + target_msg = None + for msg in mailbox: + if str(msg.msg_id) == msg_id: + target_msg = msg + break - if not target_msg: - raise HTTPException(status_code=404, detail="Message not found") + if not target_msg: + raise HTTPException(status_code=404, detail="Message not found") - if target_msg.retrieved: - # Already marked – idempotent success - return {"status": "already_retrieved", "id": msg_id} + if target_msg.retrieved: + # Already marked – idempotent success + return {"status": "already_retrieved", "id": msg_id} - target_msg.retrieved = True - target_msg._p_changed = True - mailbox._p_changed = True - transaction.get().commit() + target_msg.retrieved = True + target_msg._p_changed = True + mailbox._p_changed = True + transaction.get().commit() - return {"status": "marked_retrieved", "id": msg_id} + return {"status": "marked_retrieved", "id": msg_id} @html_router.get("/messages", response_class=HTMLResponse) async def message_list_page( - conn: ConnectionDependency, + db: DbDependency, request: Request, type: str = Query("received", alias="msg_type"), # matches your filter links limit: Optional[int] = Query(50, le=100), @@ -169,7 +172,7 @@ async def message_list_page( ): from packetserver.http.server import templates # Directly call the existing API endpoint function - api_resp = await get_messages(conn, current_user=current_user, type=type, limit=limit, since=None) + api_resp = await get_messages(db, current_user=current_user, type=type, limit=limit, since=None) messages = api_resp["messages"] return templates.TemplateResponse( diff --git a/packetserver/http/routers/objects.py b/packetserver/http/routers/objects.py index 8174e77..c8e2349 100644 --- a/packetserver/http/routers/objects.py +++ b/packetserver/http/routers/objects.py @@ -6,7 +6,7 @@ import mimetypes from packetserver.http.dependencies import get_current_http_user from packetserver.http.auth import HttpUser -from packetserver.http.database import DbDependency, ConnectionDependency +from packetserver.http.database import DbDependency from packetserver.server.objects import Object from pydantic import BaseModel diff --git a/packetserver/http/routers/profile.py b/packetserver/http/routers/profile.py index 76b18c0..96bd6f1 100644 --- a/packetserver/http/routers/profile.py +++ b/packetserver/http/routers/profile.py @@ -3,26 +3,28 @@ from fastapi import APIRouter, Depends from packetserver.http.dependencies import get_current_http_user from packetserver.http.auth import HttpUser -from packetserver.http.database import ConnectionDependency +from packetserver.http.database import DbDependency router = APIRouter(prefix="/api/v1", tags=["auth"]) @router.get("/profile") -async def profile(conn: ConnectionDependency,current_user: HttpUser = Depends(get_current_http_user)): +async def profile(db: DbDependency, current_user: HttpUser = Depends(get_current_http_user)): username = current_user.username - root = conn.root() + rf_enabled = current_user.is_rf_enabled(db) # Get main BBS User and safe dict - main_users = root.get('users', {}) - bbs_user = main_users.get(username) - safe_profile = bbs_user.to_safe_dict() if bbs_user else {} - rf_enabled = current_user.is_rf_enabled(conn) + with db.transaction() as conn: + root = conn.root() + main_users = root.get('users', {}) + bbs_user = main_users.get(username) + safe_profile = bbs_user.to_safe_dict() if bbs_user else {} - return { - **safe_profile, - "http_enabled": current_user.http_enabled, - "rf_enabled": rf_enabled, - "http_created_at": current_user.created_at, - "http_last_login": current_user.last_login, - } \ No newline at end of file + + return { + **safe_profile, + "http_enabled": current_user.http_enabled, + "rf_enabled": rf_enabled, + "http_created_at": current_user.created_at, + "http_last_login": current_user.last_login, + } \ No newline at end of file diff --git a/packetserver/http/routers/send.py b/packetserver/http/routers/send.py index a0d5551..b7e5a96 100644 --- a/packetserver/http/routers/send.py +++ b/packetserver/http/routers/send.py @@ -11,7 +11,7 @@ from packetserver.http.dependencies import get_current_http_user from packetserver.http.auth import HttpUser from packetserver.server.messages import Message from packetserver.common.util import is_valid_ax25_callsign -from packetserver.http.database import ConnectionDependency +from packetserver.http.database import DbDependency router = APIRouter(prefix="/api/v1", tags=["messages"]) @@ -40,55 +40,57 @@ class SendMessageRequest(BaseModel): @router.post("/messages") async def send_message( - conn: ConnectionDependency, + db: DbDependency, payload: SendMessageRequest, current_user: HttpUser = Depends(get_current_http_user) ): - root = conn.root() + is_rf_enabled = current_user.is_rf_enabled(db) + with db.transaction() as conn: + root = conn.root() - if not current_user.is_rf_enabled(conn): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="RF gateway access required to send messages" + if not is_rf_enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="RF gateway access required to send messages" + ) + + username = current_user.username + + # Prepare recipients + to_list = payload.to + to_tuple = tuple(to_list) + if "ALL" in to_list: + to_tuple = ("ALL",) + + is_bulletin = "ALL" in to_list + recipients = to_list if not is_bulletin else list(root.get('users', {}).keys()) + + # Create message using only supported core params + new_msg = Message( + text=payload.text, + msg_from=username, + msg_to=to_tuple, + attachments=() ) - username = current_user.username + # Deliver to recipients + always sender (sent folder) + messages_root = root.setdefault('messages', PersistentMapping()) + delivered_to = set() - # Prepare recipients - to_list = payload.to - to_tuple = tuple(to_list) - if "ALL" in to_list: - to_tuple = ("ALL",) + for recip in set(recipients) | {username}: + mailbox = messages_root.setdefault(recip, PersistentList()) + mailbox.append(new_msg) + mailbox._p_changed = True + delivered_to.add(recip) - is_bulletin = "ALL" in to_list - recipients = to_list if not is_bulletin else list(root.get('users', {}).keys()) + messages_root._p_changed = True + transaction.commit() - # Create message using only supported core params - new_msg = Message( - text=payload.text, - msg_from=username, - msg_to=to_tuple, - attachments=() - ) - - # Deliver to recipients + always sender (sent folder) - messages_root = root.setdefault('messages', PersistentMapping()) - delivered_to = set() - - for recip in set(recipients) | {username}: - mailbox = messages_root.setdefault(recip, PersistentList()) - mailbox.append(new_msg) - mailbox._p_changed = True - delivered_to.add(recip) - - messages_root._p_changed = True - transaction.commit() - - return { - "status": "sent", - "message_id": str(new_msg.msg_id), - "from": username, - "to": list(to_tuple), - "sent_at": new_msg.sent_at.isoformat() + "Z", - "recipients_delivered": len(delivered_to) - } \ No newline at end of file + return { + "status": "sent", + "message_id": str(new_msg.msg_id), + "from": username, + "to": list(to_tuple), + "sent_at": new_msg.sent_at.isoformat() + "Z", + "recipients_delivered": len(delivered_to) + } \ No newline at end of file