Bunch of fixes for new database model.

This commit is contained in:
Michael Woods
2025-12-25 20:02:47 -05:00
parent bc8a649ff4
commit bec626678e
10 changed files with 246 additions and 233 deletions

View File

@@ -8,7 +8,7 @@ import time
from persistent.mapping import PersistentMapping
from persistent.list import PersistentList
from packetserver.common.util import is_valid_ax25_callsign
from .database import ConnectionDependency
from .database import DbDependency
ph = PasswordHasher()
@@ -52,43 +52,44 @@ class HttpUser(Persistent):
# rf enabled checks..
#
def is_rf_enabled(self, conn: ConnectionDependency) -> bool:
def is_rf_enabled(self, db: DbDependency) -> bool:
"""
Check if RF gateway is enabled (i.e., callsign NOT in global blacklist).
Requires an open ZODB connection.
"""
root = conn.root()
blacklist = root.get('config', {}).get('blacklist', [])
return self.username not in blacklist
with db.transaction() as conn:
root = conn.root()
blacklist = root.get('config', {}).get('blacklist', [])
return self.username not in blacklist
def set_rf_enabled(self, conn: ConnectionDependency, allow: bool):
def set_rf_enabled(self, db: DbDependency, allow: bool):
"""
Enable/disable RF gateway by adding/removing from global blacklist.
Requires an open ZODB connection (inside a transaction).
Only allows enabling if the username is a valid AX.25 callsign.
"""
from packetserver.common.util import is_valid_ax25_callsign # our validator
with db.transaction() as conn:
root = conn.root()
config = root.setdefault('config', PersistentMapping())
blacklist = config.setdefault('blacklist', PersistentList())
root = conn.root()
config = root.setdefault('config', PersistentMapping())
blacklist = config.setdefault('blacklist', PersistentList())
upper_name = self.username
upper_name = self.username
if allow:
if not is_valid_ax25_callsign(upper_name):
raise ValueError(f"{upper_name} is not a valid AX.25 callsign cannot enable RF access")
if upper_name in blacklist:
blacklist.remove(upper_name)
blacklist._p_changed = True
else:
if upper_name not in blacklist:
blacklist.append(upper_name)
blacklist._p_changed = True
if allow:
if not is_valid_ax25_callsign(upper_name):
raise ValueError(f"{upper_name} is not a valid AX.25 callsign cannot enable RF access")
if upper_name in blacklist:
blacklist.remove(upper_name)
blacklist._p_changed = True
else:
if upper_name not in blacklist:
blacklist.append(upper_name)
blacklist._p_changed = True
config._p_changed = True
root._p_changed = True
transaction.commit()
config._p_changed = True
root._p_changed = True
transaction.commit()
# ------------------------------------------------------------------
# Password handling (unchanged)

View File

@@ -46,16 +46,16 @@ def get_db() -> ZODB.DB:
raise RuntimeError("Database not initialized call init_db() on startup")
return _db
def get_connection() -> Generator[Connection, None, None]:
"""Per-request dependency: yields an open Connection, closes on exit."""
db = get_db()
conn = db.open()
try:
yield conn
finally:
#print("not closing connection")
#conn.close()
pass
#def get_connection() -> Generator[Connection, None, None]:
# """Per-request dependency: yields an open Connection, closes on exit."""
# db = get_db()
# conn = db.open()
# try:
# yield conn
# finally:
# #print("not closing connection")
# #conn.close()
# pass
# Optional: per-request transaction (if you want automatic commit/abort)
def get_transaction_manager():
@@ -63,4 +63,4 @@ def get_transaction_manager():
# Annotated dependencies for routers
DbDependency = Annotated[ZODB.DB, Depends(get_db)]
ConnectionDependency = Annotated[Connection, Depends(get_connection)]
#ConnectionDependency = Annotated[Connection, Depends(get_connection)]

View File

@@ -3,49 +3,49 @@ from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from .auth import HttpUser
from .database import ConnectionDependency
from .database import DbDependency
security = HTTPBasic()
async def get_current_http_user(conn: ConnectionDependency, credentials: HTTPBasicCredentials = Depends(security)):
async def get_current_http_user(db: DbDependency, credentials: HTTPBasicCredentials = Depends(security)):
"""
Authenticate via Basic Auth using HttpUser from ZODB.
Injected by the standalone runner (get_db_connection available).
"""
with db.transaction() as conn:
root = conn.root()
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"},
)
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())
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:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
headers={"WWW-Authenticate": "Basic"},
)
if not user.http_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="HTTP access disabled for this user",
)
if not user.http_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"},
)
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
user.record_login_success()
return user

View File

@@ -7,7 +7,7 @@ import transaction
from persistent.list import PersistentList
from ZODB.Connection import Connection
from packetserver.http.database import DbDependency, ConnectionDependency, get_db
from packetserver.http.database import DbDependency
from ..dependencies import get_current_http_user
from ..auth import HttpUser
from ..server import templates

View File

@@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser
from packetserver.http.server import templates
from packetserver.http.database import ConnectionDependency
from packetserver.http.database import DbDependency
router = APIRouter(tags=["dashboard"])
@@ -16,41 +16,43 @@ from .bulletins import list_bulletins
@router.get("/dashboard", response_class=HTMLResponse)
async def dashboard(
conn: ConnectionDependency,
db: DbDependency,
request: Request,
current_user: HttpUser = Depends(get_current_http_user)
):
# Internal call pass explicit defaults to avoid Query object injection
messages_resp = await api_get_messages(
conn,
db,
current_user=current_user,
type="all",
limit=100,
since=None # prevents Query wrapper
)
messages = messages_resp["messages"]
with db.transaction() as conn:
# Internal call pass explicit defaults to avoid Query object injection
bulletins_resp = await list_bulletins(conn, limit=10, since=None)
recent_bulletins = bulletins_resp["bulletins"]
messages = messages_resp["messages"]
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"current_user": current_user.username,
"messages": messages,
"bulletins": recent_bulletins
}
)
bulletins_resp = await list_bulletins(conn, limit=10, since=None)
recent_bulletins = bulletins_resp["bulletins"]
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"current_user": current_user.username,
"messages": messages,
"bulletins": recent_bulletins
}
)
@router.get("/dashboard/profile", response_class=HTMLResponse)
async def profile_page(
conn: ConnectionDependency,
db: DbDependency,
request: Request,
current_user: HttpUser = Depends(get_current_http_user)
):
from packetserver.http.routers.profile import profile as api_profile
profile_data = await api_profile(conn, current_user=current_user)
profile_data = await api_profile(db, current_user=current_user)
return templates.TemplateResponse(
"profile.html",

View File

@@ -4,11 +4,13 @@ from fastapi.responses import HTMLResponse
from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser
from packetserver.http.server import templates
from packetserver.http.database import DbDependency
router = APIRouter(tags=["message-detail"])
@router.get("/dashboard/message/{msg_id}", response_class=HTMLResponse)
async def message_detail_page(
db: DbDependency,
request: Request,
msg_id: str = Path(..., description="Message UUID as string"),
current_user: HttpUser = Depends(get_current_http_user)
@@ -18,6 +20,7 @@ async def message_detail_page(
# Call with mark_retrieved=True to auto-mark as read on view (optional—remove if you prefer manual)
message_data = await api_get_message(
db,
msg_id=msg_id,
mark_retrieved=True,
current_user=current_user

View File

@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, validator
from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser
from packetserver.http.database import ConnectionDependency
from packetserver.http.database import DbDependency
html_router = APIRouter(tags=["messages-html"])
@@ -29,7 +29,7 @@ class MarkRetrievedRequest(BaseModel):
@router.get("/messages")
async def get_messages(
conn: ConnectionDependency,
db: DbDependency,
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)"),
@@ -40,128 +40,131 @@ async def get_messages(
limit = 20
username = current_user.username
root = conn.root()
with db.transaction() as conn:
root = conn.root()
if 'messages' not in root:
root['messages'] = PersistentMapping()
if username not in root['messages']:
root['messages'][username] = persistent.list.PersistentList()
if 'messages' not in root:
root['messages'] = PersistentMapping()
if username not in root['messages']:
root['messages'][username] = persistent.list.PersistentList()
mailbox = root['messages'][username]
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")
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 = []
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.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)
messages.sort(key=lambda m: m["sent_at"], reverse=True)
return {"messages": messages[:limit], "total_returned": len(messages[:limit])}
return {"messages": messages[:limit], "total_returned": len(messages[:limit])}
@router.get("/messages/{msg_id}")
async def get_message(
conn: ConnectionDependency,
db: DbDependency,
msg_id: str = Path(..., description="UUID of the message (as string)"),
mark_retrieved: bool = Query(False, description="If true, mark message as retrieved/read"),
current_user: HttpUser = Depends(get_current_http_user)
):
root = conn.root()
with db.transaction() as conn:
root = conn.root()
username = current_user.username
username = current_user.username
messages_root = root.get('messages', {})
mailbox = messages_root.get(username)
if not mailbox:
raise HTTPException(status_code=404, detail="Mailbox not found")
messages_root = root.get('messages', {})
mailbox = messages_root.get(username)
if not mailbox:
raise HTTPException(status_code=404, detail="Mailbox not found")
# Find message by ID
target_msg = None
for msg in mailbox:
if str(msg.msg_id) == msg_id:
target_msg = msg
break
# Find message by ID
target_msg = None
for msg in mailbox:
if str(msg.msg_id) == msg_id:
target_msg = msg
break
if not target_msg:
raise HTTPException(status_code=404, detail="Message not found")
if not target_msg:
raise HTTPException(status_code=404, detail="Message not found")
# Optionally mark as retrieved
if mark_retrieved and not target_msg.retrieved:
target_msg.retrieved = True
target_msg._p_changed = True
mailbox._p_changed = True
# Explicit transaction for the write
transaction.get().commit()
# Optionally mark as retrieved
if mark_retrieved and not target_msg.retrieved:
target_msg.retrieved = True
target_msg._p_changed = True
mailbox._p_changed = True
# Explicit transaction for the write
transaction.get().commit()
return {
"id": str(target_msg.msg_id),
"from": target_msg.msg_from or "UNKNOWN",
"to": list(target_msg.msg_to),
"sent_at": target_msg.sent_at.isoformat() + "Z",
"text": target_msg.text,
"retrieved": target_msg.retrieved,
"has_attachments": len(target_msg.attachments) > 0,
# Future: "attachments": [...] metadata
}
return {
"id": str(target_msg.msg_id),
"from": target_msg.msg_from or "UNKNOWN",
"to": list(target_msg.msg_to),
"sent_at": target_msg.sent_at.isoformat() + "Z",
"text": target_msg.text,
"retrieved": target_msg.retrieved,
"has_attachments": len(target_msg.attachments) > 0,
# Future: "attachments": [...] metadata
}
@router.patch("/messages/{msg_id}")
async def mark_message_retrieved(
conn: ConnectionDependency,
db: DbDependency,
msg_id: str = Path(..., description="Message UUID as string"),
payload: MarkRetrievedRequest = None,
current_user: HttpUser = Depends(get_current_http_user)
):
root = conn.root()
with db.transaction() as conn:
root = conn.root()
username = current_user.username
mailbox = root.get('messages', {}).get(username)
username = current_user.username
mailbox = root.get('messages', {}).get(username)
if not mailbox:
raise HTTPException(status_code=404, detail="Mailbox not found")
if not mailbox:
raise HTTPException(status_code=404, detail="Mailbox not found")
target_msg = None
for msg in mailbox:
if str(msg.msg_id) == msg_id:
target_msg = msg
break
target_msg = None
for msg in mailbox:
if str(msg.msg_id) == msg_id:
target_msg = msg
break
if not target_msg:
raise HTTPException(status_code=404, detail="Message not found")
if not target_msg:
raise HTTPException(status_code=404, detail="Message not found")
if target_msg.retrieved:
# Already marked idempotent success
return {"status": "already_retrieved", "id": msg_id}
if target_msg.retrieved:
# Already marked idempotent success
return {"status": "already_retrieved", "id": msg_id}
target_msg.retrieved = True
target_msg._p_changed = True
mailbox._p_changed = True
transaction.get().commit()
target_msg.retrieved = True
target_msg._p_changed = True
mailbox._p_changed = True
transaction.get().commit()
return {"status": "marked_retrieved", "id": msg_id}
return {"status": "marked_retrieved", "id": msg_id}
@html_router.get("/messages", response_class=HTMLResponse)
async def message_list_page(
conn: ConnectionDependency,
db: DbDependency,
request: Request,
type: str = Query("received", alias="msg_type"), # matches your filter links
limit: Optional[int] = Query(50, le=100),
@@ -169,7 +172,7 @@ async def message_list_page(
):
from packetserver.http.server import templates
# Directly call the existing API endpoint function
api_resp = await get_messages(conn, current_user=current_user, type=type, limit=limit, since=None)
api_resp = await get_messages(db, current_user=current_user, type=type, limit=limit, since=None)
messages = api_resp["messages"]
return templates.TemplateResponse(

View File

@@ -6,7 +6,7 @@ import mimetypes
from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser
from packetserver.http.database import DbDependency, ConnectionDependency
from packetserver.http.database import DbDependency
from packetserver.server.objects import Object
from pydantic import BaseModel

View File

@@ -3,26 +3,28 @@ from fastapi import APIRouter, Depends
from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser
from packetserver.http.database import ConnectionDependency
from packetserver.http.database import DbDependency
router = APIRouter(prefix="/api/v1", tags=["auth"])
@router.get("/profile")
async def profile(conn: ConnectionDependency,current_user: HttpUser = Depends(get_current_http_user)):
async def profile(db: DbDependency, current_user: HttpUser = Depends(get_current_http_user)):
username = current_user.username
root = conn.root()
rf_enabled = current_user.is_rf_enabled(db)
# 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 {}
rf_enabled = current_user.is_rf_enabled(conn)
with db.transaction() as conn:
root = conn.root()
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.http_enabled,
"rf_enabled": rf_enabled,
"http_created_at": current_user.created_at,
"http_last_login": current_user.last_login,
}
return {
**safe_profile,
"http_enabled": current_user.http_enabled,
"rf_enabled": rf_enabled,
"http_created_at": current_user.created_at,
"http_last_login": current_user.last_login,
}

View File

@@ -11,7 +11,7 @@ from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser
from packetserver.server.messages import Message
from packetserver.common.util import is_valid_ax25_callsign
from packetserver.http.database import ConnectionDependency
from packetserver.http.database import DbDependency
router = APIRouter(prefix="/api/v1", tags=["messages"])
@@ -40,55 +40,57 @@ class SendMessageRequest(BaseModel):
@router.post("/messages")
async def send_message(
conn: ConnectionDependency,
db: DbDependency,
payload: SendMessageRequest,
current_user: HttpUser = Depends(get_current_http_user)
):
root = conn.root()
is_rf_enabled = current_user.is_rf_enabled(db)
with db.transaction() as conn:
root = conn.root()
if not current_user.is_rf_enabled(conn):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="RF gateway access required to send messages"
if not is_rf_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="RF gateway access required to send messages"
)
username = current_user.username
# Prepare recipients
to_list = payload.to
to_tuple = tuple(to_list)
if "ALL" in to_list:
to_tuple = ("ALL",)
is_bulletin = "ALL" in to_list
recipients = to_list if not is_bulletin else list(root.get('users', {}).keys())
# Create message using only supported core params
new_msg = Message(
text=payload.text,
msg_from=username,
msg_to=to_tuple,
attachments=()
)
username = current_user.username
# Deliver to recipients + always sender (sent folder)
messages_root = root.setdefault('messages', PersistentMapping())
delivered_to = set()
# Prepare recipients
to_list = payload.to
to_tuple = tuple(to_list)
if "ALL" in to_list:
to_tuple = ("ALL",)
for recip in set(recipients) | {username}:
mailbox = messages_root.setdefault(recip, PersistentList())
mailbox.append(new_msg)
mailbox._p_changed = True
delivered_to.add(recip)
is_bulletin = "ALL" in to_list
recipients = to_list if not is_bulletin else list(root.get('users', {}).keys())
messages_root._p_changed = True
transaction.commit()
# Create message using only supported core params
new_msg = Message(
text=payload.text,
msg_from=username,
msg_to=to_tuple,
attachments=()
)
# Deliver to recipients + always sender (sent folder)
messages_root = root.setdefault('messages', PersistentMapping())
delivered_to = set()
for recip in set(recipients) | {username}:
mailbox = messages_root.setdefault(recip, PersistentList())
mailbox.append(new_msg)
mailbox._p_changed = True
delivered_to.add(recip)
messages_root._p_changed = True
transaction.commit()
return {
"status": "sent",
"message_id": str(new_msg.msg_id),
"from": username,
"to": list(to_tuple),
"sent_at": new_msg.sent_at.isoformat() + "Z",
"recipients_delivered": len(delivered_to)
}
return {
"status": "sent",
"message_id": str(new_msg.msg_id),
"from": username,
"to": list(to_tuple),
"sent_at": new_msg.sent_at.isoformat() + "Z",
"recipients_delivered": len(delivered_to)
}