169 lines
5.7 KiB
Python
169 lines
5.7 KiB
Python
# 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])} |