Redoing all the database logic entirely. I didn't understand FastAPI going into this and let grok do weird stuff. We did weird stuff together, really.

This commit is contained in:
Michael Woods
2025-12-25 00:06:24 -05:00
parent 1bd6e21cc7
commit 58b366a235
8 changed files with 150 additions and 159 deletions

View File

@@ -7,6 +7,7 @@ import time
from persistent.mapping import PersistentMapping from persistent.mapping import PersistentMapping
from persistent.list import PersistentList from persistent.list import PersistentList
from packetserver.common.util import is_valid_ax25_callsign from packetserver.common.util import is_valid_ax25_callsign
from .database import get_db, get_transaction
ph = PasswordHasher() ph = PasswordHasher()
@@ -50,14 +51,15 @@ class HttpUser(Persistent):
# rf enabled checks.. # rf enabled checks..
# #
def is_rf_enabled(self, connection) -> bool: def is_rf_enabled(self) -> bool:
""" """
Check if RF gateway is enabled (i.e., callsign NOT in global blacklist). Check if RF gateway is enabled (i.e., callsign NOT in global blacklist).
Requires an open ZODB connection. Requires an open ZODB connection.
""" """
root = connection.root() with get_transaction() as storage:
blacklist = root.get('config', {}).get('blacklist', []) root = storage.root()
return self.username not in blacklist blacklist = root.get('config', {}).get('blacklist', [])
return self.username not in blacklist
def set_rf_enabled(self, connection, allow: bool): def set_rf_enabled(self, connection, allow: bool):
""" """
@@ -67,26 +69,26 @@ class HttpUser(Persistent):
""" """
from packetserver.common.util import is_valid_ax25_callsign # our validator from packetserver.common.util import is_valid_ax25_callsign # our validator
root = connection.root() with get_transaction() as storage:
config = root.setdefault('config', PersistentMapping()) root = storage.root()
blacklist = config.setdefault('blacklist', PersistentList()) config = root.setdefault('config', PersistentMapping())
blacklist = config.setdefault('blacklist', PersistentList())
upper_name = self.username upper_name = self.username
if allow: if allow:
if not is_valid_ax25_callsign(upper_name): if not is_valid_ax25_callsign(upper_name):
raise ValueError(f"{upper_name} is not a valid AX.25 callsign cannot enable RF access") raise ValueError(f"{upper_name} is not a valid AX.25 callsign cannot enable RF access")
if upper_name in blacklist: if upper_name in blacklist:
blacklist.remove(upper_name) blacklist.remove(upper_name)
blacklist._p_changed = True blacklist._p_changed = True
else: else:
if upper_name not in blacklist: if upper_name not in blacklist:
blacklist.append(upper_name) blacklist.append(upper_name)
blacklist._p_changed = True blacklist._p_changed = True
config._p_changed = True config._p_changed = True
root._p_changed = True root._p_changed = True
# Caller should commit the transaction
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Password handling (unchanged) # Password handling (unchanged)

View File

@@ -0,0 +1,18 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""
Application settings loaded from environment variables and .env files.
"""
# Define your settings fields with type hints and optional default values
name: str = "PacketServer"
zeo_file: str
operator: str | None = None
debug_mode: bool = False
log_level: str = "info"
# Configure how settings are loaded
model_config = SettingsConfigDict(
case_sensitive=False, # Make environment variable names case-sensitive
env_prefix="PS_APP_" # Use a prefix for environment variables (e.g., MY_APP_DATABASE_URL)
)

View File

@@ -0,0 +1,33 @@
from .config import Settings
from os.path import isfile
import ZEO
import ZODB
from fastapi import Depends
from typing import Annotated, ContextManager
settings = Settings()
def get_zeo_address(zeo_address_file: str) -> tuple[str,int]:
if not isfile(zeo_address_file):
raise FileNotFoundError(f"ZEO address file is not a file: '{zeo_address_file}'")
contents = open(zeo_address_file, 'r').read().strip().split(":")
if len(contents) != 2:
raise ValueError(f"Invalid ZEO address file: {zeo_address_file}")
host = contents[0]
try:
port = int(contents[1])
except ValueError:
raise ValueError(f"Invalid ZEO address file: {zeo_address_file}")
return host,port
def get_db() -> ZODB.DB:
return ZEO.DB(get_zeo_address(settings.zeo_file))
def get_transaction() -> ContextManager:
return ZEO.DB(get_zeo_address(settings.zeo_file)).transaction()
DbDependency = Annotated[ZODB.DB, Depends(get_db)]
TransactionDependency = Annotated[ContextManager, Depends(get_transaction)]

View File

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

View File

@@ -6,6 +6,7 @@ from datetime import datetime
import transaction import transaction
from persistent.list import PersistentList from persistent.list import PersistentList
from ..database import DbDependency, TransactionDependency
from ..dependencies import get_current_http_user from ..dependencies import get_current_http_user
from ..auth import HttpUser from ..auth import HttpUser
from ..server import templates from ..server import templates
@@ -20,31 +21,30 @@ html_router = APIRouter(tags=["bulletins-html"])
# --- API Endpoints --- # --- API Endpoints ---
async def list_bulletins(limit: int = 50, since: Optional[datetime] = None) -> dict: async def list_bulletins(, limit: int = 50, since: Optional[datetime] = None) -> dict:
from packetserver.runners.http_server import get_db_connection with trans as conn:
conn = get_db_connection() root = conn.root()
root = conn.root() bulletins_list: List[Bulletin] = root.get("bulletins", [])
bulletins_list: List[Bulletin] = root.get("bulletins", [])
# Newest first # Newest first
bulletins_list = sorted(bulletins_list, key=lambda b: b.created_at, reverse=True) bulletins_list = sorted(bulletins_list, key=lambda b: b.created_at, reverse=True)
if since: if since:
bulletins_list = [b for b in bulletins_list if b.created_at > since] bulletins_list = [b for b in bulletins_list if b.created_at > since]
bulletins = [ bulletins = [
{ {
"id": b.id, "id": b.id,
"author": b.author, "author": b.author,
"subject": b.subject, "subject": b.subject,
"body": b.body, "body": b.body,
"created_at": b.created_at.isoformat() + "Z", "created_at": b.created_at.isoformat() + "Z",
"updated_at": b.updated_at.isoformat() + "Z", "updated_at": b.updated_at.isoformat() + "Z",
} }
for b in bulletins_list[:limit] for b in bulletins_list[:limit]
] ]
return {"bulletins": bulletins} return {"bulletins": bulletins}
@router.get("/bulletins") @router.get("/bulletins")
async def api_list_bulletins( async def api_list_bulletins(
@@ -55,22 +55,21 @@ async def api_list_bulletins(
return await list_bulletins(limit=limit, since=since) return await list_bulletins(limit=limit, since=since)
async def get_one_bulletin(bid: int) -> dict: async def get_one_bulletin(bid: int) -> dict:
from packetserver.runners.http_server import get_db_connection with get_transaction() as conn:
conn = get_db_connection() root = conn.root()
root = conn.root() bulletins_list: List[Bulletin] = root.get("bulletins", [])
bulletins_list: List[Bulletin] = root.get("bulletins", [])
for b in bulletins_list: for b in bulletins_list:
if b.id == bid: if b.id == bid:
return { return {
"id": b.id, "id": b.id,
"author": b.author, "author": b.author,
"subject": b.subject, "subject": b.subject,
"body": b.body, "body": b.body,
"created_at": b.created_at.isoformat() + "Z", "created_at": b.created_at.isoformat() + "Z",
"updated_at": b.updated_at.isoformat() + "Z", "updated_at": b.updated_at.isoformat() + "Z",
} }
raise HTTPException(status_code=404, detail="Bulletin not found") raise HTTPException(status_code=404, detail="Bulletin not found")
@router.get("/bulletins/{bid}") @router.get("/bulletins/{bid}")
async def api_get_bulletin( async def api_get_bulletin(

View File

@@ -18,50 +18,6 @@ import ZODB.DB
import logging import logging
from packetserver.http.server import app from packetserver.http.server import app
# Global DB and connection for reuse in the FastAPI dependency
_db = None
_connection = None
def open_database(db_arg: str) -> ZODB.DB:
"""
Open a ZODB database from either a local FileStorage path or ZEO address.
"""
if ":" in db_arg:
parts = db_arg.split(":")
if len(parts) == 2 and parts[1].isdigit():
import ZEO
host = parts[0]
port = int(parts[1])
storage = ZEO.client((host, port)) # correct modern ZEO client function
return ZODB.DB(storage)
# Local FileStorage fallback
storage = ZODB.FileStorage.FileStorage(db_arg)
return ZODB.DB(storage)
def get_db_connection():
"""Helper used in http/server.py dependency (get_current_http_user)"""
global _connection
if _connection is None or getattr(_connection, "opened", None) is None:
if _db is None:
raise RuntimeError("Database not opened run the runner properly")
_connection = _db.open()
return _connection
def get_db():
"""Helper used in http/server.py dependency (get_current_http_user)"""
if _db is None:
raise RuntimeError("Database not opened run the runner properly")
return _db
# Monkey-patch the dependency helper so server.py can use it without changes
from packetserver.http import server
server.get_db_connection = get_db_connection # replaces any previous definition
def main(): def main():
parser = argparse.ArgumentParser(description="Run the PacketServer HTTP API server") parser = argparse.ArgumentParser(description="Run the PacketServer HTTP API server")
parser.add_argument("--db", required=True, help="DB path (local /path/to/Data.fs) or ZEO (host:port)") parser.add_argument("--db", required=True, help="DB path (local /path/to/Data.fs) or ZEO (host:port)")
@@ -71,30 +27,12 @@ def main():
args = parser.parse_args() args = parser.parse_args()
global _db uvicorn.run(
try: "packetserver.http.server:app",
_db = open_database(args.db) host=args.host,
print(f"Opened database: {args.db}") port=args.port,
except Exception as e: reload=args.reload,
print(f"Failed to open database {args.db}: {e}") )
sys.exit(1)
# Open initial connection (will be reused/closed on shutdown)
get_db_connection()
try:
uvicorn.run(
"packetserver.http.server:app",
host=args.host,
port=args.port,
reload=args.reload,
)
finally:
if _connection and not _connection.closed:
_connection.close()
if _db:
_db.close()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

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

View File

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