From 0e88677dad2985d28e94998fc9b11ec1187a29c4 Mon Sep 17 00:00:00 2001 From: Michael Woods Date: Sat, 20 Dec 2025 22:29:27 -0500 Subject: [PATCH] Adding message sending. Profile and message get seem to work. --- packetserver/http/dependencies.py | 53 ++++++++ packetserver/http/routers/__init__.py | 0 packetserver/http/routers/messages.py | 65 ++++++++++ packetserver/http/routers/profile.py | 29 +++++ packetserver/http/routers/public.py | 19 +++ packetserver/http/routers/send.py | 82 +++++++++++++ packetserver/http/server.py | 166 ++------------------------ requirements.txt | 3 +- setup.py | 3 +- 9 files changed, 262 insertions(+), 158 deletions(-) create mode 100644 packetserver/http/dependencies.py create mode 100644 packetserver/http/routers/__init__.py create mode 100644 packetserver/http/routers/messages.py create mode 100644 packetserver/http/routers/profile.py create mode 100644 packetserver/http/routers/public.py create mode 100644 packetserver/http/routers/send.py diff --git a/packetserver/http/dependencies.py b/packetserver/http/dependencies.py new file mode 100644 index 0000000..c59a573 --- /dev/null +++ b/packetserver/http/dependencies.py @@ -0,0 +1,53 @@ +# packetserver/http/dependencies.py +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials + +from .auth import HttpUser + + +security = HTTPBasic() + + +async def get_current_http_user(credentials: HTTPBasicCredentials = Depends(security)): + """ + Authenticate via Basic Auth using HttpUser from ZODB. + Injected by the standalone runner (get_db_connection available). + """ + from packetserver.runners.http_server import get_db_connection # provided by runner + + conn = get_db_connection() + 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"}, + ) + + 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.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"}, + ) + + user.record_login_success() + return user \ No newline at end of file diff --git a/packetserver/http/routers/__init__.py b/packetserver/http/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packetserver/http/routers/messages.py b/packetserver/http/routers/messages.py new file mode 100644 index 0000000..41ecc27 --- /dev/null +++ b/packetserver/http/routers/messages.py @@ -0,0 +1,65 @@ +# packetserver/http/routers/messages.py +from fastapi import APIRouter, Depends, Query, HTTPException +from typing import Optional +from datetime import datetime +from persistent.mapping import PersistentMapping +import persistent.list + +from packetserver.http.dependencies import get_current_http_user +from packetserver.http.auth import HttpUser + +router = APIRouter(prefix="/api/v1", tags=["messages"]) + + +@router.get("/messages") +async def get_messages( + 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)"), + since: Optional[str] = Query(None, description="ISO UTC timestamp filter (e.g. 2025-12-01T00:00:00Z)") +): + if limit is None or limit < 1: + limit = 20 + + username = current_user.username + + from packetserver.runners.http_server import get_db_connection + conn = get_db_connection() + root = conn.root() + + if 'messages' not in root: + root['messages'] = PersistentMapping() + if username not in root['messages']: + root['messages'][username] = persistent.list.PersistentList() + + 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") + + 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.sort(key=lambda m: m["sent_at"], reverse=True) + + return {"messages": messages[:limit], "total_returned": len(messages[:limit])} \ No newline at end of file diff --git a/packetserver/http/routers/profile.py b/packetserver/http/routers/profile.py new file mode 100644 index 0000000..654edea --- /dev/null +++ b/packetserver/http/routers/profile.py @@ -0,0 +1,29 @@ +# packetserver/http/routers/profile.py +from fastapi import APIRouter, Depends + +from packetserver.http.dependencies import get_current_http_user +from packetserver.http.auth import HttpUser + +router = APIRouter(prefix="/api/v1", tags=["auth"]) + + +@router.get("/profile") +async def profile(current_user: HttpUser = Depends(get_current_http_user)): + username = current_user.username + + from packetserver.runners.http_server import get_db_connection + conn = get_db_connection() + root = conn.root() + + # 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 {} + + return { + **safe_profile, + "http_enabled": current_user.enabled, + "rf_enabled": current_user.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/public.py b/packetserver/http/routers/public.py new file mode 100644 index 0000000..2b6ada4 --- /dev/null +++ b/packetserver/http/routers/public.py @@ -0,0 +1,19 @@ +# packetserver/http/routers/public.py +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +router = APIRouter(tags=["public"]) + + +@router.get("/", response_class=HTMLResponse) +async def root(request: Request): + from packetserver.http.server import templates + return templates.TemplateResponse( + "index.html", + {"request": request, "message": "Welcome to PacketServer HTTP Interface"} + ) + + +@router.get("/health") +async def health(): + return {"status": "ok", "service": "packetserver-http"} \ No newline at end of file diff --git a/packetserver/http/routers/send.py b/packetserver/http/routers/send.py new file mode 100644 index 0000000..c2da188 --- /dev/null +++ b/packetserver/http/routers/send.py @@ -0,0 +1,82 @@ +# packetserver/http/routers/send.py +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field, validator +from typing import List +from datetime import datetime +import uuid + +from packetserver.http.dependencies import get_current_http_user +from packetserver.http.auth import HttpUser +from packetserver.server.messages import Message # core Message class + +router = APIRouter(prefix="/api/v1", tags=["messages"]) + + +class SendMessageRequest(BaseModel): + to: List[str] = Field(..., description="List of recipient callsigns (uppercase) or ['ALL'] for bulletin") + subject: str = Field("", description="Optional subject line") + text: str = Field(..., min_length=1, description="Message body text") + + @validator("to") + def validate_to(cls, v): + if not v: + raise ValueError("At least one recipient required") + # Allow 'ALL' only as single bulletin + if len(v) > 1 and "ALL" in [x.upper() for x in v]: + raise ValueError("'ALL' can only be used alone for bulletins") + return [x.upper() for x in v] + + +@router.post("/messages") +async def send_message( + payload: SendMessageRequest, + current_user: HttpUser = Depends(get_current_http_user) +): + if not current_user.rf_enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="RF gateway access required to send messages" + ) + + username = current_user.username + + from packetserver.runners.http_server import get_db_connection + conn = get_db_connection() + root = conn.root() + + # Prepare recipients tuple + to_tuple = tuple(payload.to) + if "ALL" in payload.to: + to_tuple = ("ALL",) + + # Create new Message + new_msg = Message( + msg_from=username, + msg_to=to_tuple, + text=payload.text, + subject=payload.subject, # if Message supports it; otherwise drop + sent_at=datetime.utcnow(), + attachments=() # empty for now + ) + + # Deliver to all recipients (including sender for sent folder) + recipients = payload.to if "ALL" not in payload.to else list(root.get('users', {}).keys()) + + for recip in set(recipients + [username]): # always copy to sender + if 'messages' not in root: + root['messages'] = PersistentMapping() + mailbox = root['messages'].setdefault(recip, persistent.list.PersistentList()) + mailbox.append(new_msg) + + # Persist + conn.root()["messages"]._p_changed = True + # Note: transaction.commit() not needed here—FastAPI/ZODB handles on response + + return { + "status": "sent", + "message_id": str(new_msg.msg_id), + "from": username, + "to": list(to_tuple), + "sent_at": new_msg.sent_at.isoformat() + "Z", + "subject": payload.subject + } \ No newline at end of file diff --git a/packetserver/http/server.py b/packetserver/http/server.py index 7bf8ab4..5a50264 100644 --- a/packetserver/http/server.py +++ b/packetserver/http/server.py @@ -1,19 +1,12 @@ # packetserver/http/server.py -from fastapi import FastAPI, Depends, HTTPException, status, Query -from fastapi.security import HTTPBasic, HTTPBasicCredentials -from fastapi.responses import HTMLResponse +from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from starlette.requests import Request -from typing import Optional -from datetime import datetime -from persistent.mapping import PersistentMapping -import persistent.list +from pathlib import Path -# No database module needed – get_db_connection is provided by the runner -# get_http_user logic inlined directly in the dependency (simple and avoids module) +from .routers import public, profile, messages, send -from .auth import HttpUser +BASE_DIR = Path(__file__).parent.resolve() app = FastAPI( title="PacketServer HTTP API", @@ -21,151 +14,12 @@ app = FastAPI( version="0.1.0", ) -# New static (relative to this file's location – always works) -from pathlib import Path - -BASE_DIR = Path(__file__).parent.resolve() - +# Static and templates app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") templates = Jinja2Templates(directory=BASE_DIR / "templates") -security = HTTPBasic() - -async def get_current_http_user(credentials: HTTPBasicCredentials = Depends(security)): - # get_db_connection is injected by the standalone runner - conn = get_db_connection() - root = conn.root() - - # Inline httpUsers mapping access (replaces get_http_user from nonexistent database.py) - http_users = root.get("httpUsers") - if http_users is None: - # No users yet – auth fails - 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()) - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid username or password", - headers={"WWW-Authenticate": "Basic"}, - ) - - if not user.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"}, - ) - - user.record_login_success() - return user - - -# ------------------------------------------------------------------ -# Public routes (no auth) -# ------------------------------------------------------------------ -@app.get("/", response_class=HTMLResponse) -async def root(request: Request): - return templates.TemplateResponse( - "index.html", - {"request": request, "message": "Welcome to PacketServer HTTP Interface"} - ) - - -@app.get("/health") -async def health(): - return {"status": "ok", "service": "packetserver-http"} - - -# ------------------------------------------------------------------ -# Protected routes (require auth) -# ------------------------------------------------------------------ -@app.get("/api/v1/profile") -async def profile(current_user: HttpUser = Depends(get_current_http_user)): - return { - "username": current_user.username, - "enabled": current_user.enabled, - "rf_enabled": current_user.rf_enabled, - "created_at": current_user.created_at, - "last_login": current_user.last_login, - } - - -@app.get("/api/v1/messages") -async def get_messages( - 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)"), - since: Optional[str] = Query(None, description="ISO UTC timestamp (e.g. 2025-12-01T00:00:00Z) to filter newer messages") -): - """ - List messages for the authenticated user. - - received: incoming (default) - - sent: outgoing (stored in sender's mailbox) - - all: both incoming and outgoing - """ - if limit is None or limit < 1: - limit = 20 - - username = current_user.username # already uppercase - - conn = get_db_connection() - root = conn.root() - - # Ensure mailbox exists (safe, matches server logic) - if 'messages' not in root: - root['messages'] = PersistentMapping() - if username not in root['messages']: - root['messages'][username] = persistent.list.PersistentList() - - mailbox = root['messages'][username] - - # Parse 'since' if provided - 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 – use ISO UTC (e.g. 2025-12-01T00:00:00Z)") - - messages = [] - for msg in mailbox: - # Filter by type - if type == "received" and msg.msg_from == username: - continue # skip own sent messages - if type == "sent" and msg.msg_from != username: - continue # skip received - # 'all' includes everything - - # Filter by since - if since_dt and msg.sent_at < since_dt: - continue - - # Basic dict (no attachments data yet) - 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 # expose if marked read on RF - }) - - # Sort newest first - messages.sort(key=lambda m: m["sent_at"], reverse=True) - - # Apply limit - return {"messages": messages[:limit], "total_returned": len(messages[:limit])} \ No newline at end of file +# Include routers +app.include_router(public.router) +app.include_router(profile.router) +app.include_router(messages.router) +app.include_router(send.router) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index a99eec9..74266ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,5 @@ fastapi uvicorn[standard] jinja2 python-multipart -argon2-cffi \ No newline at end of file +argon2-cffi +pydantic \ No newline at end of file diff --git a/setup.py b/setup.py index 5304833..6f26a74 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,8 @@ setup( 'ZODB', 'ZEO', 'podman', - 'tabulate' + 'tabulate', + 'pydantic' ], entry_points={ 'console_scripts': [