Files
packetserver/packetserver/http/server.py
Michael Woods f58f669cef True up.
2025-12-20 21:59:25 -05:00

169 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.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
# 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 .auth import HttpUser
app = FastAPI(
title="PacketServer HTTP API",
description="RESTful interface to the AX.25 packet radio BBS",
version="0.1.0",
)
# Mount static files
app.mount("/static", StaticFiles(directory="packetserver/http/static"), name="static")
# Templates
templates = Jinja2Templates(directory="packetserver/http/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])}