Fixed user manager script after db changes from a while back. Fixed some object router code that was broken.

This commit is contained in:
Michael Woods
2025-12-27 21:14:19 -05:00
parent 13eac22741
commit e77b08fd0b
3 changed files with 230 additions and 151 deletions

View File

@@ -9,6 +9,8 @@ from persistent.mapping import PersistentMapping
from persistent.list import PersistentList
from packetserver.common.util import is_valid_ax25_callsign
from .database import DbDependency
from typing import Union
from ZODB.Connection import Connection
ph = PasswordHasher()
@@ -52,11 +54,15 @@ class HttpUser(Persistent):
# rf enabled checks..
#
def is_rf_enabled(self, db: DbDependency) -> bool:
def is_rf_enabled(self, db: Union[DbDependency,Connection]) -> bool:
"""
Check if RF gateway is enabled (i.e., callsign NOT in global blacklist).
Requires an open ZODB connection.
"""
if type(db) is Connection:
root = db.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', [])

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Header
from fastapi.responses import PlainTextResponse, Response, JSONResponse, StreamingResponse
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Header, Request
from fastapi.responses import PlainTextResponse, Response, JSONResponse, StreamingResponse, RedirectResponse
from typing import List, Optional
from datetime import datetime
from uuid import UUID
@@ -137,23 +137,54 @@ class TextObjectCreate(BaseModel):
name: Optional[str] = None
private: bool = True
@router.post("/objects/text", response_model=ObjectSummary)
@router.post("/objects/text", response_model=None) # Remove response_model to allow mixed returns
async def create_text_object(
payload: TextObjectCreate,
request: Request,
db: DbDependency,
current_user: HttpUser = Depends(get_current_http_user)
):
username = current_user.username
if not payload.text:
# Determine content type and parse accordingly
content_type = request.headers.get("content-type", "").lower()
if "application/x-www-form-urlencoded" in content_type or "multipart/form-data" in content_type:
form = await request.form()
name = form.get("name")
text = form.get("text")
private_str = form.get("private") # "on" if checked, None otherwise
is_form = True
elif "application/json" in content_type:
try:
json_data = await request.json()
name = json_data.get("name")
text = json_data.get("text")
private_str = json_data.get("private")
is_form = False
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON")
else:
raise HTTPException(status_code=415, detail="Unsupported Media Type")
# Validate text
if not text:
raise HTTPException(status_code=400, detail="Text content cannot be empty")
obj_name = (payload.name or "text_object.txt").strip()
# Normalize name (optional, default like original)
obj_name = (name or "text_object.txt").strip()
if len(obj_name) > 300:
raise HTTPException(status_code=400, detail="Object name too long (max 300 chars)")
if not obj_name:
raise HTTPException(status_code=400, detail="Invalid object name")
# Normalize private to bool (handles form "on"/None, JSON bool, or string)
if isinstance(private_str, bool):
private = private_str
elif isinstance(private_str, str):
private = private_str.lower() in ("true", "on", "1", "yes")
else:
private = False # Default to False if invalid/missing
try:
with db.transaction() as conn:
root = conn.root()
@@ -161,13 +192,13 @@ async def create_text_object(
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Create object with str data → forces binary=False
new_object = Object(name=obj_name, data=payload.text)
new_object.private = payload.private
# Create object with str data → forces binary=False
new_object = Object(name=obj_name, data=text)
new_object.private = private
obj_uuid = new_object.write_new(db, username=username)
obj_uuid = new_object.write_new(db, username=username)
logging.info(f"User {username} created text object {obj_uuid} ({obj_name}, {len(payload.text)} chars)")
logging.info(f"User {username} created text object {obj_uuid} ({obj_name}, {len(text)} chars)")
except HTTPException:
raise
@@ -175,22 +206,28 @@ async def create_text_object(
logging.error(f"Text object creation failed for {username}: {e}\n{format_exc()}")
raise HTTPException(status_code=500, detail="Failed to create text object")
# Build summary
content_type, _ = mimetypes.guess_type(new_object.name)
if content_type is None:
content_type = "text/plain" # always text here
# Build summary (for JSON responses)
content_type_guess, _ = mimetypes.guess_type(new_object.name)
if content_type_guess is None:
content_type_guess = "text/plain" # always text here
return ObjectSummary(
summary = ObjectSummary(
uuid=obj_uuid,
name=new_object.name,
binary=new_object.binary, # should be False
size=new_object.size,
content_type=content_type,
content_type=content_type_guess,
private=new_object.private,
created_at=new_object.created_at,
modified_at=new_object.modified_at
)
# Return based on input type
if is_form:
return RedirectResponse(url="/objects", status_code=303) # Back to HTML list
else:
return JSONResponse(content=summary.model_dump(), status_code=201)
class BinaryObjectCreate(BaseModel):
data_base64: str
name: Optional[str] = None

View File

@@ -14,12 +14,16 @@ import sys
import time
from getpass import getpass
import ax25
import os.path
import os
import ZODB.FileStorage
import ZODB.DB
import transaction
from persistent.mapping import PersistentMapping
os.environ['PS_APP_ZEO_FILE'] = "N/A"
# Import our HTTP package internals
from packetserver.http.auth import HttpUser, ph # ph = PasswordHasher
@@ -42,6 +46,13 @@ def open_database(db_arg: str) -> ZODB.DB:
return ZODB.DB(storage)
def open_database_zeo_file(filename: str) -> ZODB.DB:
if os.path.isfile(filename):
return open_database(open(filename,'r').read().strip())
else:
raise FileNotFoundError("Must provide a filename to a zeo address.")
def get_or_create_http_users(root):
if HTTP_USERS_KEY not in root:
root[HTTP_USERS_KEY] = PersistentMapping()
@@ -55,7 +66,8 @@ def confirm(prompt: str) -> bool:
def main():
parser = argparse.ArgumentParser(description="Manage PacketServer HTTP API users")
parser.add_argument("--db", required=True, help="DB path (local /path/to/Data.fs) or ZEO (host:port)")
parser.add_argument("--db", required=False, help="DB path (local /path/to/Data.fs) or ZEO (host:port)")
parser.add_argument("--zeo-file", required=False, help="zeo address file")
subparsers = parser.add_subparsers(dest="command", required=True)
# add
@@ -99,12 +111,16 @@ def main():
args = parser.parse_args()
# Open the database
db = open_database(args.db)
connection = db.open()
root = connection.root()
if args.db:
db = open_database(args.db)
else:
db = open_database_zeo_file(args.zeo_file)
try:
users_mapping = get_or_create_http_users(root)
with db.transaction() as conn:
root = conn.root()
http_users_list = list(get_or_create_http_users(root).keys())
upper_callsign = lambda c: c.upper()
@@ -117,7 +133,7 @@ def main():
print(f"Error: Trying to add valid callsign + ssid. Remove -<num> and add again.")
sys.exit(1)
if callsign in users_mapping:
if callsign in http_users_list:
print(f"Error: HTTP user {callsign} already exists")
sys.exit(1)
@@ -127,79 +143,90 @@ def main():
sys.exit(1)
# Create the HTTP-specific user
http_user = HttpUser(args.callsign, password)
users_mapping[callsign] = http_user
with db.transaction() as conn:
root = conn.root()
http_user = HttpUser(args.callsign, password)
users_mapping = get_or_create_http_users(conn.root())
users_mapping[callsign] = http_user
# Sync: create corresponding regular BBS user using proper write_new for UUID/uniqueness
from packetserver.server.users import User
# Sync: create corresponding regular BBS user using proper write_new for UUID/uniqueness
from packetserver.server.users import User
main_users = root.setdefault('users', PersistentMapping())
if callsign not in main_users:
User.write_new(main_users, args.callsign) # correct: pass mapping + callsign
print(f"Also created regular BBS user {callsign} (with UUID)")
else:
print(f" → Regular BBS user {callsign} already exists")
transaction.commit()
print(f"Created HTTP user {callsign}")
main_users = root.setdefault('users', PersistentMapping())
if callsign not in main_users:
new_user = User(args.callsign)
new_user.write_new(conn.root())
print(f" → Also created regular BBS user {callsign}")
else:
print(f"Regular BBS user {callsign} already exists")
print(f"Created HTTP user {callsign}")
elif args.command == "delete":
callsign = upper_callsign(args.callsign)
if callsign not in users_mapping:
if callsign not in http_users_list:
print(f"Error: User {callsign} not found")
sys.exit(1)
if not confirm(f"Delete HTTP user {callsign}?"):
sys.exit(0)
del users_mapping[callsign]
transaction.commit()
print(f"Deleted HTTP user {callsign}")
with db.transaction() as conn:
root = conn.root()
users_mapping = get_or_create_http_users(root)
del users_mapping[callsign]
print(f"Deleted HTTP user {callsign}")
elif args.command == "set-password":
callsign = upper_callsign(args.callsign)
user = users_mapping.get(callsign)
if not user:
print(f"Error: User {callsign} not found")
sys.exit(1)
newpass = args.newpassword or getpass("New password: ")
if not newpass:
print("Error: No password provided")
sys.exit(1)
user.password_hash = ph.hash(newpass)
user._p_changed = True
transaction.commit()
print(f"Password updated for {callsign}")
with db.transaction() as conn:
root = conn.root()
users_mapping = get_or_create_http_users(root)
user = users_mapping.get(callsign)
if not user:
print(f"Error: User {callsign} not found")
sys.exit(1)
newpass = args.newpassword or getpass("New password: ")
if not newpass:
print("Error: No password provided")
sys.exit(1)
user.password_hash = ph.hash(newpass)
user._p_changed = True
print(f"Password updated for {callsign}")
elif args.command == "enable":
callsign = upper_callsign(args.callsign)
user = users_mapping.get(callsign)
if not user:
print(f"Error: User {callsign} not found")
sys.exit(1)
user.enabled = True
user._p_changed = True
transaction.commit()
print(f"HTTP access enabled for {callsign}")
with db.transaction() as conn:
root = conn.root()
users_mapping = get_or_create_http_users(root)
callsign = upper_callsign(args.callsign)
user = users_mapping.get(callsign)
if not user:
print(f"Error: User {callsign} not found")
sys.exit(1)
user.http_enabled = True
user._p_changed = True
print(f"HTTP access enabled for {callsign}")
elif args.command == "disable":
callsign = upper_callsign(args.callsign)
user = users_mapping.get(callsign)
if not user:
print(f"Error: User {callsign} not found")
sys.exit(1)
user.enabled = False
user._p_changed = True
transaction.commit()
print(f"HTTP access disabled for {callsign}")
with db.transaction() as conn:
root = conn.root()
users_mapping = get_or_create_http_users(root)
user = users_mapping.get(callsign)
if not user:
print(f"Error: User {callsign} not found")
sys.exit(1)
user.http_enabled = False
user._p_changed = True
print(f"HTTP access disabled for {callsign}")
elif args.command == "rf-enable":
callsign = upper_callsign(args.callsign)
user = users_mapping.get(callsign)
if not user:
print(f"Error: User {callsign} not found")
sys.exit(1)
with db.transaction() as conn:
root = conn.root()
users_mapping = get_or_create_http_users(root)
user = users_mapping.get(callsign)
if not user:
print(f"Error: User {callsign} not found")
sys.exit(1)
try:
user.set_rf_enabled(connection, True)
transaction.commit()
user.set_rf_enabled(db, True)
print(f"RF gateway enabled for {callsign}")
except ValueError as e:
print(f"Error: {e}")
@@ -207,67 +234,75 @@ def main():
elif args.command == "rf-disable":
callsign = upper_callsign(args.callsign)
user = users_mapping.get(callsign)
if not user:
print(f"Error: User {callsign} not found")
sys.exit(1)
user.set_rf_enabled(connection, False)
transaction.commit()
with db.transaction() as conn:
root = conn.root()
users_mapping = get_or_create_http_users(root)
user = users_mapping.get(callsign)
if not user:
print(f"Error: User {callsign} not found")
sys.exit(1)
user.set_rf_enabled(db, False)
print(f"RF gateway disabled for {callsign}")
elif args.command == "list":
if not users_mapping:
if not http_users_list:
print("No HTTP users configured")
else:
print(f"{'Callsign':<12} {'HTTP Enabled':<13} {'RF Enabled':<11} {'Created':<20} Last Login")
print("-" * 75)
for user in sorted(users_mapping.values(), key=lambda u: u.username):
created = time.strftime("%Y-%m-%d %H:%M", time.localtime(user.created_at))
last = (time.strftime("%Y-%m-%d %H:%M", time.localtime(user.last_login))
if user.last_login else "-")
rf_status = "True" if user.is_rf_enabled(connection) else "False"
print(f"{user.username:<12} {str(user.http_enabled):<13} {rf_status:<11} {created:<20} {last}")
with db.transaction() as conn:
root = conn.root()
users_mapping = get_or_create_http_users(root)
print(f"{'Callsign':<12} {'HTTP Enabled':<13} {'RF Enabled':<11} {'Created':<20} Last Login")
print("-" * 75)
for user in sorted(users_mapping.values(), key=lambda u: u.username):
created = time.strftime("%Y-%m-%d %H:%M", time.localtime(user.created_at))
last = (time.strftime("%Y-%m-%d %H:%M", time.localtime(user.last_login))
if user.last_login else "-")
rf_status = "True" if user.is_rf_enabled(conn) else "False"
print(f"{user.username:<12} {str(user.http_enabled):<13} {rf_status:<11} {created:<20} {last}")
elif args.command == "dump":
import json
callsign = upper_callsign(args.callsign)
http_user = users_mapping.get(callsign)
if not http_user:
print(f"Error: No HTTP user {callsign} found")
sys.exit(1)
with db.transaction() as conn:
root = conn.root()
users_mapping = get_or_create_http_users(root)
http_user = users_mapping.get(callsign)
if not http_user:
print(f"Error: No HTTP user {callsign} found")
sys.exit(1)
main_users = root.get('users', {})
bbs_user = main_users.get(callsign)
if not bbs_user:
print(f"Error: No corresponding BBS user {callsign} found")
sys.exit(1)
main_users = root.get('users', {})
bbs_user = main_users.get(callsign)
if not bbs_user:
print(f"Error: No corresponding BBS user {callsign} found")
sys.exit(1)
dump_data = {
"http_user": {
"username": http_user.username,
"http_enabled": http_user.http_enabled,
"rf_enabled": http_user.is_rf_enabled(connection),
"blacklisted": not http_user.is_rf_enabled(connection), # explicit inverse
"created_at": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(http_user.created_at)),
"failed_attempts": http_user.failed_attempts,
},
"bbs_user": {
"username": bbs_user.username,
"uuid": str(bbs_user.uuid) if hasattr(bbs_user, 'uuid') and bbs_user.uuid else None,
"hidden": bbs_user.hidden,
"enabled": bbs_user.enabled, # BBS enabled flag
"created_at": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(bbs_user.created_at.timestamp())) if hasattr(bbs_user.created_at, "timestamp") else str(bbs_user.created_at),
"last_seen": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(bbs_user.last_seen.timestamp())) if hasattr(bbs_user.last_seen, "timestamp") else str(bbs_user.last_seen),
"bio": bbs_user.bio.strip() or None,
"status": bbs_user.status.strip() or None,
"email": bbs_user.email.strip() if bbs_user.email != " " else None,
"location": bbs_user.location.strip() if bbs_user.location != " " else None,
"socials": bbs_user.socials,
dump_data = {
"http_user": {
"username": http_user.username,
"http_enabled": http_user.http_enabled,
"rf_enabled": http_user.is_rf_enabled(conn),
"blacklisted": not http_user.is_rf_enabled(conn), # explicit inverse
"created_at": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(http_user.created_at)),
"failed_attempts": http_user.failed_attempts,
},
"bbs_user": {
"username": bbs_user.username,
"uuid": str(bbs_user.uuid) if hasattr(bbs_user, 'uuid') and bbs_user.uuid else None,
"hidden": bbs_user.hidden,
"enabled": bbs_user.enabled, # BBS enabled flag
"created_at": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(bbs_user.created_at.timestamp())) if hasattr(bbs_user.created_at, "timestamp") else str(bbs_user.created_at),
"last_seen": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(bbs_user.last_seen.timestamp())) if hasattr(bbs_user.last_seen, "timestamp") else str(bbs_user.last_seen),
"bio": bbs_user.bio.strip() or None,
"status": bbs_user.status.strip() or None,
"email": bbs_user.email.strip() if bbs_user.email != " " else None,
"location": bbs_user.location.strip() if bbs_user.location != " " else None,
"socials": bbs_user.socials,
}
}
}
print(json.dumps(dump_data, indent=4))
print(json.dumps(dump_data, indent=4))
elif args.command == "sync-missing":
import secrets
@@ -277,38 +312,39 @@ def main():
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
return ''.join(secrets.choice(alphabet) for _ in range(length))
bbs_users = root.get('users', {})
http_users = get_or_create_http_users(root)
with db.transaction() as conn:
root = conn.root()
bbs_users = root.get('users', {})
http_users = get_or_create_http_users(root)
missing = [call for call in bbs_users if call not in http_users and call != "SYSTEM"]
if not missing:
print("No missing HTTP users—all BBS users have HttpUser objects")
else:
print(f"Found {len(missing)} BBS users without HTTP accounts:")
for call in sorted(missing):
print(f" - {call}")
if args.dry_run:
print("\n--dry-run: No changes made")
missing = [call for call in bbs_users if call not in http_users and call != "SYSTEM"]
if not missing:
print("No missing HTTP users—all BBS users have HttpUser objects")
else:
confirm_msg = f"Create {len(missing)} new HttpUser objects (http_enabled={'True' if args.enable else 'False'})?"
if not confirm(confirm_msg):
print("Aborted")
else:
created_count = 0
for call in missing:
password = generate_password() # strong random, not printed
new_http = HttpUser(call, password)
new_http.http_enabled = args.enable
http_users[call] = new_http
created_count += 1
print(f"Found {len(missing)} BBS users without HTTP accounts:")
for call in sorted(missing):
print(f" - {call}")
transaction.commit()
print(f"\nSync complete: {created_count} HTTP users added (passwords random & hidden)")
print("Use 'set-password <call>' to set a known password before enabling login")
if args.dry_run:
print("\n--dry-run: No changes made")
else:
confirm_msg = f"Create {len(missing)} new HttpUser objects (http_enabled={'True' if args.enable else 'False'})?"
if not confirm(confirm_msg):
print("Aborted")
else:
created_count = 0
for call in missing:
password = generate_password() # strong random, not printed
new_http = HttpUser(call, password)
new_http.http_enabled = args.enable
http_users[call] = new_http
created_count += 1
transaction.commit()
print(f"\nSync complete: {created_count} HTTP users added (passwords random & hidden)")
print("Use 'set-password <call>' to set a known password before enabling login")
finally:
connection.close()
db.close()