Adding message sending. Profile and message get seem to work.
This commit is contained in:
53
packetserver/http/dependencies.py
Normal file
53
packetserver/http/dependencies.py
Normal 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
|
||||||
0
packetserver/http/routers/__init__.py
Normal file
0
packetserver/http/routers/__init__.py
Normal file
65
packetserver/http/routers/messages.py
Normal file
65
packetserver/http/routers/messages.py
Normal 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])}
|
||||||
29
packetserver/http/routers/profile.py
Normal file
29
packetserver/http/routers/profile.py
Normal 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,
|
||||||
|
}
|
||||||
19
packetserver/http/routers/public.py
Normal file
19
packetserver/http/routers/public.py
Normal 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"}
|
||||||
82
packetserver/http/routers/send.py
Normal file
82
packetserver/http/routers/send.py
Normal 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
|
||||||
|
}
|
||||||
@@ -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])}
|
|
||||||
@@ -12,4 +12,5 @@ fastapi
|
|||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
jinja2
|
jinja2
|
||||||
python-multipart
|
python-multipart
|
||||||
argon2-cffi
|
argon2-cffi
|
||||||
|
pydantic
|
||||||
Reference in New Issue
Block a user