Adding message sending. Profile and message get seem to work.

This commit is contained in:
Michael Woods
2025-12-20 22:29:27 -05:00
parent ae52188bcc
commit 0e88677dad
9 changed files with 262 additions and 158 deletions

View File

@@ -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

View File

View File

@@ -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])}

View File

@@ -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,
}

View File

@@ -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"}

View File

@@ -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
}

View File

@@ -1,19 +1,12 @@
# packetserver/http/server.py # packetserver/http/server.py
from fastapi import FastAPI, Depends, HTTPException, status, Query from fastapi import FastAPI
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.requests import Request from pathlib import Path
from typing import Optional
from datetime import datetime
from persistent.mapping import PersistentMapping
import persistent.list
# No database module needed get_db_connection is provided by the runner from .routers import public, profile, messages, send
# get_http_user logic inlined directly in the dependency (simple and avoids module)
from .auth import HttpUser BASE_DIR = Path(__file__).parent.resolve()
app = FastAPI( app = FastAPI(
title="PacketServer HTTP API", title="PacketServer HTTP API",
@@ -21,151 +14,12 @@ app = FastAPI(
version="0.1.0", version="0.1.0",
) )
# New static (relative to this file's location always works) # Static and templates
from pathlib import Path
BASE_DIR = Path(__file__).parent.resolve()
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
templates = Jinja2Templates(directory=BASE_DIR / "templates") templates = Jinja2Templates(directory=BASE_DIR / "templates")
security = HTTPBasic() # Include routers
app.include_router(public.router)
async def get_current_http_user(credentials: HTTPBasicCredentials = Depends(security)): app.include_router(profile.router)
# get_db_connection is injected by the standalone runner app.include_router(messages.router)
conn = get_db_connection() app.include_router(send.router)
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])}

View File

@@ -13,3 +13,4 @@ uvicorn[standard]
jinja2 jinja2
python-multipart python-multipart
argon2-cffi argon2-cffi
pydantic

View File

@@ -19,7 +19,8 @@ setup(
'ZODB', 'ZODB',
'ZEO', 'ZEO',
'podman', 'podman',
'tabulate' 'tabulate',
'pydantic'
], ],
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [