Compare commits

..

62 Commits

Author SHA1 Message Date
Logan Cusano
776b3d9ac2 Fix active/all token issues
All checks were successful
release-image / release-image (push) Successful in 3m15s
2025-07-06 20:16:56 -04:00
Logan Cusano
1658ea2e83 Fix spacing 2025-07-06 19:55:17 -04:00
Logan Cusano
adadd1c62b Add active client object to status endpoint
All checks were successful
release-image / release-image (push) Successful in 2m7s
2025-06-29 22:18:53 -04:00
Logan Cusano
da173e7f58 Update token CRUD endpoints to use ObjectId for IDs
All checks were successful
release-image / release-image (push) Successful in 2m3s
2025-06-29 21:35:11 -04:00
Logan Cusano
64e3031389 Update find discord IDs with more debugging
All checks were successful
release-image / release-image (push) Successful in 2m14s
2025-06-29 21:06:01 -04:00
Logan Cusano
f2dd714571 Implement fixed node for token
All checks were successful
release-image / release-image (push) Successful in 2m4s
2025-06-29 19:08:57 -04:00
Logan Cusano
df4e7f7d67 Fix bug when checking if token is in active clients
All checks were successful
release-image / release-image (push) Successful in 2m5s
2025-06-29 18:34:20 -04:00
Logan Cusano
f50503cca8 Fix bug in active client to_dict if there's no active_token
All checks were successful
release-image / release-image (push) Successful in 2m4s
2025-06-29 18:23:41 -04:00
Logan Cusano
bcff5a4981 Restructure to activeclient so it always has DiscordId object for active token (if active)
All checks were successful
release-image / release-image (push) Successful in 2m8s
2025-06-29 18:19:56 -04:00
Logan Cusano
7e538b693e Fix not paying attention
All checks were successful
release-image / release-image (push) Successful in 2m4s
2025-06-29 04:25:59 -04:00
Logan Cusano
12b9db9d8b fix active clients endpoint
All checks were successful
release-image / release-image (push) Successful in 2m5s
2025-06-29 04:20:06 -04:00
Logan Cusano
541f6fddec Fix typo in types
All checks were successful
release-image / release-image (push) Successful in 2m18s
2025-06-29 04:15:14 -04:00
Logan Cusano
2e300800bc Send relevant active client info to FE
All checks were successful
release-image / release-image (push) Successful in 2m53s
2025-06-29 03:47:16 -04:00
Logan Cusano
d889f0e8ea Accept active token from node
All checks were successful
release-image / release-image (push) Successful in 2m8s
2025-06-29 02:37:17 -04:00
Logan Cusano
7820e87989 Update JWT to not expire for nodes and expire in 1 hour for users
All checks were successful
release-image / release-image (push) Successful in 2m12s
2025-06-29 01:43:09 -04:00
Logan Cusano
e89e67f33a Fix node auth
All checks were successful
release-image / release-image (push) Successful in 2m7s
2025-06-08 00:18:46 -04:00
Logan Cusano
ac23a5ec84 Fix typo
All checks were successful
release-image / release-image (push) Successful in 2m7s
2025-06-07 23:32:14 -04:00
Logan Cusano
6f64a8390a Implemented client nickname and access token
All checks were successful
release-image / release-image (push) Successful in 2m9s
2025-06-07 23:08:24 -04:00
Logan Cusano
1575d466f2 Add amd64 build
All checks were successful
release-image / release-image (push) Successful in 1m58s
2025-05-26 17:24:55 -04:00
Logan Cusano
54e5c46496 Fix the tag
All checks were successful
release-image / release-image (push) Successful in 2m8s
2025-05-26 03:51:38 -04:00
Logan Cusano
732f0fe684 fix deploy name
All checks were successful
release-image / release-image (push) Successful in 1m56s
2025-05-26 03:49:32 -04:00
Logan Cusano
dfe431f1ba fix name
All checks were successful
release-image / release-image (push) Successful in 1m59s
2025-05-26 03:40:45 -04:00
Logan Cusano
df91fd994d Init build 2025-05-26 03:40:01 -04:00
Logan Cusano
af10851002 Refactored DB handlers 2025-05-26 02:36:21 -04:00
Logan Cusano
a9c1e24ef9 Added missed roles import 2025-05-26 01:27:20 -04:00
Logan Cusano
dab863db89 Fix typo 2025-05-26 01:26:52 -04:00
Logan Cusano
4c5085d98d Added missed imports 2025-05-26 01:26:21 -04:00
Logan Cusano
490b6b3545 Added permissions to the endpoints 2025-05-26 01:25:00 -04:00
Logan Cusano
09ed25dfc0 Fix copy mistake 2025-05-26 01:19:52 -04:00
Logan Cusano
f4195e5e41 Update system delete logic 2025-05-26 01:17:40 -04:00
Logan Cusano
8bfc3939ba Fix copy mistake 2025-05-26 01:15:52 -04:00
Logan Cusano
8a63f11315 Improved update system logic 2025-05-26 01:15:03 -04:00
Logan Cusano
a6c318ecc8 Update create token logic 2025-05-26 01:09:41 -04:00
Logan Cusano
243ae6d15a Fix param name 2025-05-26 00:57:58 -04:00
Logan Cusano
ea361f51a6 Added token/discord ID endpoints 2025-05-26 00:53:50 -04:00
Logan Cusano
f49472c651 Handle when active config is empty 2025-05-26 00:23:37 -04:00
Logan Cusano
ec13c38dea Fixed bug when checking for active tokens 2025-05-26 00:09:23 -04:00
Logan Cusano
6f74a7bea9 Ensured consistent 'None' if system_id not provided 2025-05-25 23:36:21 -04:00
Logan Cusano
dff9371b32 Remove unused body param 2025-05-25 23:04:22 -04:00
Logan Cusano
5d35f0fa77 Fix copy mistake 2025-05-25 23:02:16 -04:00
Logan Cusano
a094027a10 Implement OP25 commands 2025-05-25 22:58:38 -04:00
Logan Cusano
a9ea9a374d Update status response object 2025-05-25 22:05:28 -04:00
Logan Cusano
de6a547f4f Fix read response logic 2025-05-25 22:02:46 -04:00
Logan Cusano
f46f7d6160 Fix status command 2025-05-25 21:45:27 -04:00
Logan Cusano
5b90ebb8f1 Implemented staus check and refactored node endpoints 2025-05-25 21:41:56 -04:00
Logan Cusano
5e6ee765d8 Fix naming when getting all discord tokens 2025-05-25 20:43:32 -04:00
Logan Cusano
3188a10a74 Return a more useful object (insecure for now) 2025-05-25 20:20:38 -04:00
Logan Cusano
2740abfdcb Add endpoint for getting all discord IDs 2025-05-25 19:59:31 -04:00
Logan Cusano
c4a5f0ac1e Add CORS 2025-05-25 18:24:50 -04:00
Logan Cusano
e418de0ac9 Implement basic auth for frontend 2025-05-25 15:59:16 -04:00
Logan Cusano
cb6065a60f Remove Mac meta files 2025-05-25 15:54:47 -04:00
Logan Cusano
8c6cf5683a Added the client ID to the returned object when getting online bots 2025-05-24 23:30:46 -04:00
Logan Cusano
3806c7bcdd Fixed logic of getting the online bots 2025-05-24 23:28:25 -04:00
Logan Cusano
4a61bd195f Added new node route to get online bots 2025-05-24 23:24:18 -04:00
Logan Cusano
fbd0e65019 Minor bugs when creating a system 2025-05-24 22:44:20 -04:00
Logan Cusano
82d7160e5e refactored frequency_khz to frequencies 2025-05-24 18:39:07 -04:00
Logan Cusano
58a95fd501 Reorganize for classes to access each other 2025-05-24 18:28:54 -04:00
Logan Cusano
69b02b363e Fix bug when checking if token is in use and added the token to the active clients when selected 2025-05-24 18:27:09 -04:00
Logan Cusano
8bb8a60ece fix typo 2025-05-24 18:16:12 -04:00
Logan Cusano
679bea5eae Define init function for ActiveClient 2025-05-24 18:15:33 -04:00
Logan Cusano
107ab049ff Implemented bot endpoint with DB requests and other tweaks 2025-05-24 18:12:59 -04:00
Logan Cusano
15b12ecd5f Fix bug to convert system ID to string 2025-05-24 15:58:16 -04:00
14 changed files with 1257 additions and 380 deletions

View File

@@ -0,0 +1,58 @@
name: release-image
on:
push:
branches:
- master
jobs:
release-image:
runs-on: ubuntu-latest
env:
DOCKER_LATEST: nightly
CONTAINER_NAME: drb-core-server
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
with: # replace it with your local IP
config-inline: |
[registry."git.vpn.cusano.net"]
http = false
insecure = false
- name: Login to DockerHub
uses: docker/login-action@v3
with:
registry: git.vpn.cusano.net # replace it with your local IP
username: ${{ secrets.GIT_REPO_USERNAME }}
password: ${{ secrets.GIT_REPO_PASSWORD }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Validate build configuration
uses: docker/build-push-action@v6
with:
call: check
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: |
linux/arm64
linux/amd64
push: true
tags: | # replace it with your local IP and tags
git.vpn.cusano.net/logan/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
git.vpn.cusano.net/logan/${{ env.CONTAINER_NAME }}:${{ env.DOCKER_LATEST }}

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
*.venv *.venv
*__pycache__/ *__pycache__/
*.bat *.bat
.DS_Store

View File

@@ -15,5 +15,7 @@ run: build
--name $(SERVER_CONTAINER_NAME) \ --name $(SERVER_CONTAINER_NAME) \
-e DB_NAME=$(DB_NAME) \ -e DB_NAME=$(DB_NAME) \
-e MONGO_URL=$(MONGO_URL) \ -e MONGO_URL=$(MONGO_URL) \
--network=host \ -e JWT_SECRET_KEY=$(JWT_SECRET_KEY) \
-p 5000:5000 \
-p 8765:8765 \
$(SERVER_IMAGE) $(SERVER_IMAGE)

16
app/config/jwt_config.py Normal file
View File

@@ -0,0 +1,16 @@
import os
from quart_jwt_extended import JWTManager
# Initialize JWTManager outside of any function to ensure it's a singleton
# It will be initialized with the app object later in server.py
jwt = JWTManager()
def configure_jwt(app):
"""Configures JWT settings for the Quart app."""
app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "your-super-secret-key-that-should-be-in-env")
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = 3600 # 1 hour
app.config["JWT_ALGORITHM"] = "HS256"
# You might need to set a custom error handler for unauthorized access
# @app.errorhandler(401)
# def unauthorized(error):
# return jsonify({"msg": "Missing or invalid token"}), 401

View File

@@ -0,0 +1,77 @@
# internal/auth_wrappers.py
import os
import asyncio
from uuid import uuid4
from typing import Optional, List, Dict, Any
from internal.db_handler import MongoHandler #
from internal.types import User, UserRoles
DB_NAME = os.getenv("DB_NAME", "default_db")
MONGO_URL = os.getenv("MONGO_URL", "mongodb://10.10.202.4:27017/")
USER_DB_COLLECTION_NAME = "users"
class UserDbController:
def __init__(self):
self.db_h = MongoHandler(DB_NAME, USER_DB_COLLECTION_NAME, MONGO_URL) #
async def close_db_connection(self):
"""Closes the underlying MongoDB connection."""
if self.db_h:
await self.db_h.close_client() #
async def create_user(self, user_data: Dict[str, Any]) -> Optional[User]:
try:
if not user_data.get("_id"):
user_data['_id'] = str(uuid4())
inserted_id = None
async with self.db_h as db: #
insert_result = await db.insert_one(user_data) #
inserted_id = insert_result.inserted_id
if inserted_id:
query = {"_id": inserted_id}
inserted_doc = None
async with self.db_h as db: #
inserted_doc = await db.find_one(query) #
if inserted_doc:
return User.from_dict(inserted_doc)
return None
except Exception as e:
print(f"Create user failed: {e}")
return None
async def find_user(self, query: Dict[str, Any]) -> Optional[User]:
try:
found_doc = None
async with self.db_h as db: #
# The 'db' here is self.db_h, which is an instance of MongoHandler.
# MongoHandler.find_one will be called.
found_doc = await db.find_one(query) #
if found_doc:
return User.from_dict(found_doc)
return None
except Exception as e:
print(f"Find user failed: {e}") # This error should be less frequent or indicate actual DB issues now
return None
async def update_user(self, query: Dict[str, Any], update_data: Dict[str, Any]) -> Optional[int]:
try:
update_result = None
async with self.db_h as db: #
update_result = await db.update_one(query, update_data) #
return update_result.modified_count
except Exception as e:
print(f"Update user failed: {e}")
return None
async def delete_user(self, query: Dict[str, Any]) -> Optional[int]:
try:
delete_result = None
async with self.db_h as db: #
delete_result = await db.delete_one(query) #
return delete_result.deleted_count
except Exception as e:
print(f"Delete user failed: {e}")
return None

View File

@@ -4,8 +4,9 @@ from typing import Optional, Dict, Any, List
class MongoHandler: class MongoHandler:
""" """
A basic asynchronous handler for MongoDB operations using motor. An asynchronous handler for MongoDB operations using motor.
Designed to be used with asyncio. The client connection is established on first use (via async with or direct call to connect())
and should be explicitly closed by calling close_client() at application shutdown.
""" """
def __init__(self, db_name: str, collection_name: str, mongo_uri: str = "mongodb://localhost:27017/"): def __init__(self, db_name: str, collection_name: str, mongo_uri: str = "mongodb://localhost:27017/"):
""" """
@@ -15,7 +16,6 @@ class MongoHandler:
db_name (str): The name of the database to connect to. db_name (str): The name of the database to connect to.
collection_name (str): The name of the collection to use. collection_name (str): The name of the collection to use.
mongo_uri (str): The MongoDB connection string URI. mongo_uri (str): The MongoDB connection string URI.
Defaults to the standard local URI.
""" """
self.mongo_uri = mongo_uri self.mongo_uri = mongo_uri
self.db_name = db_name self.db_name = db_name
@@ -23,229 +23,142 @@ class MongoHandler:
self._client: Optional[motor.motor_asyncio.AsyncIOMotorClient] = None self._client: Optional[motor.motor_asyncio.AsyncIOMotorClient] = None
self._db: Optional[motor.motor_asyncio.AsyncIOMotorDatabase] = None self._db: Optional[motor.motor_asyncio.AsyncIOMotorDatabase] = None
self._collection: Optional[motor.motor_asyncio.AsyncIOMotorCollection] = None self._collection: Optional[motor.motor_asyncio.AsyncIOMotorCollection] = None
self._lock = asyncio.Lock() # Lock for serializing client creation
async def connect(self): async def connect(self):
"""Establishes an asynchronous connection to MongoDB.""" """
Establishes an asynchronous connection to MongoDB if not already established.
This method is idempotent.
"""
if self._client is None: if self._client is None:
async with self._lock: # Ensure only one coroutine attempts to initialize the client
if self._client is None: # Double-check after acquiring lock
try: try:
print(f"Initializing MongoDB client for: DB '{self.db_name}', Collection '{self.collection_name}' URI: {self.mongo_uri.split('@')[-1]}") # Avoid logging credentials
self._client = motor.motor_asyncio.AsyncIOMotorClient(self.mongo_uri) self._client = motor.motor_asyncio.AsyncIOMotorClient(self.mongo_uri)
# The ismaster command is cheap and does not require auth. # The ismaster command is cheap and does not require auth.
# It is used to confirm that the client can connect to the deployment.
await self._client.admin.command('ismaster') await self._client.admin.command('ismaster')
self._db = self._client[self.db_name] self._db = self._client[self.db_name]
self._collection = self._db[self.collection_name] self._collection = self._db[self.collection_name]
print(f"Connected to MongoDB: Database '{self.db_name}', Collection '{self.collection_name}'") print(f"MongoDB client initialized and connected: Database '{self.db_name}', Collection '{self.collection_name}'")
except Exception as e: except Exception as e:
print(f"Failed to connect to MongoDB at {self.mongo_uri}: {e}") print(f"Failed to initialize MongoDB client at {self.mongo_uri.split('@')[-1]} for {self.db_name}/{self.collection_name}: {e}")
self._client = None # Ensure client is None if connection fails self._client = None # Ensure client is None if connection fails
self._db = None
self._collection = None
raise # Re-raise the exception after printing raise # Re-raise the exception after printing
async def close(self): if self._collection is None and self._client is not None:
"""Closes the MongoDB connection.""" # This can happen if connect was called, client was set, but then an error occurred before collection was set
# Or if connect logic needs to re-establish db/collection objects without re-creating client (less common with motor)
if self._db is None:
self._db = self._client[self.db_name]
self._collection = self._db[self.collection_name]
if self._collection is None:
raise RuntimeError(f"MongoDB collection '{self.collection_name}' could not be established even though client exists.")
async def close_client(self):
"""Closes the MongoDB client connection. Should be called on application shutdown."""
async with self._lock:
if self._client: if self._client:
print(f"Closing MongoDB client for: Database '{self.db_name}', Collection '{self.collection_name}'")
self._client.close() self._client.close()
self._client = None self._client = None
self._db = None self._db = None
self._collection = None self._collection = None
print("MongoDB connection closed.") print(f"MongoDB client closed for: Database '{self.db_name}', Collection '{self.collection_name}'.")
async def __aenter__(self): async def __aenter__(self):
"""Allows using the handler with async with.""" """Allows using the handler with async with. Ensures connection is active."""
await self.connect() await self.connect()
return self return self
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Ensures the connection is closed when exiting async with.""" """Ensures the connection is NOT closed when exiting async with."""
await self.close() # The connection is managed and closed at the application level by calling close_client()
pass
async def insert_one(self, document: Dict[str, Any]) -> Any: async def insert_one(self, document: Dict[str, Any]) -> Any:
"""
Inserts a single document into the collection.
Args:
document (Dict[str, Any]): The document to insert.
Returns:
Any: The result of the insert operation (InsertOneResult).
"""
if self._collection is None: if self._collection is None:
raise RuntimeError("MongoDB connection not established. Call connect() first or use async with.") await self.connect() # Try to connect if collection is None
if self._collection is None: # Check again after attempting to connect
raise RuntimeError("MongoDB collection not available. Call connect() or use async with.")
print(f"Inserting document into '{self.collection_name}'...") print(f"Inserting document into '{self.collection_name}'...")
result = await self._collection.insert_one(document) result = await self._collection.insert_one(document)
print(f"Inserted document with ID: {result.inserted_id}") print(f"Inserted document with ID: {result.inserted_id}")
return result return result
async def find_one(self, query: Dict[str, Any]) -> Optional[Dict[str, Any]]: async def find_one(self, query: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Finds a single document matching the query.
Args:
query (Dict[str, Any]): The query document.
Returns:
Optional[Dict[str, Any]]: The found document, or None if not found.
"""
if self._collection is None: if self._collection is None:
raise RuntimeError("MongoDB connection not established. Call connect() first or use async with.") await self.connect()
if self._collection is None:
raise RuntimeError("MongoDB collection not available. Call connect() or use async with.")
print(f"Finding one document in '{self.collection_name}' with query: {query}") print(f"Finding one document in '{self.collection_name}' with query: {query}")
document = await self._collection.find_one(query) document = await self._collection.find_one(query)
return document return document
async def find(self, query: Dict[str, Any] = None) -> List[Dict[str, Any]]: async def find(self, query: Dict[str, Any] = None) -> List[Dict[str, Any]]:
"""
Finds multiple documents matching the query.
Args:
query (Dict[str, Any], optional): The query document. Defaults to None (find all).
Returns:
List[Dict[str, Any]]: A list of matching documents.
"""
if self._collection is None: if self._collection is None:
raise RuntimeError("MongoDB connection not established. Call connect() first or use async with.") await self.connect()
if self._collection is None:
raise RuntimeError("MongoDB collection not available. Call connect() or use async with.")
if query is None: if query is None:
query = {} query = {}
print(f"Finding documents in '{self.collection_name}' with query: {query}") print(f"Finding documents in '{self.collection_name}' with query: {query}")
# Use list comprehension to iterate through the cursor asynchronously
documents = [doc async for doc in self._collection.find(query)] documents = [doc async for doc in self._collection.find(query)]
print(f"Found {len(documents)} documents.") print(f"Found {len(documents)} documents.")
return documents return documents
async def update_one(self, query: Dict[str, Any], update: Dict[str, Any], upsert: bool = False) -> Any: async def update_one(self, query: Dict[str, Any], update: Dict[str, Any], upsert: bool = False) -> Any:
"""
Updates a single document matching the query.
Args:
query (Dict[str, Any]): The query document.
update (Dict[str, Any]): The update operations to apply.
upsert (bool): If True, insert a new document if no match is found.
Returns:
Any: The result of the update operation (UpdateResult).
"""
if self._collection is None: if self._collection is None:
raise RuntimeError("MongoDB connection not established. Call connect() first or use async with.") await self.connect()
if self._collection is None:
raise RuntimeError("MongoDB collection not available. Call connect() or use async with.")
print(f"Updating one document in '{self.collection_name}' with query: {query}, update: {update}") print(f"Updating one document in '{self.collection_name}' with query: {query}, update: {update}")
result = await self._collection.update_one(query, update, upsert=upsert) result = await self._collection.update_one(query, update, upsert=upsert)
print(f"Matched {result.matched_count}, Modified {result.modified_count}, Upserted ID: {result.upserted_id}") print(f"Matched {result.matched_count}, Modified {result.modified_count}, Upserted ID: {result.upserted_id}")
return result return result
async def delete_one(self, query: Dict[str, Any]) -> Any: async def delete_one(self, query: Dict[str, Any]) -> Any:
"""
Deletes a single document matching the query.
Args:
query (Dict[str, Any]): The query document.
Returns:
Any: The result of the delete operation (DeleteResult).
"""
if self._collection is None: if self._collection is None:
raise RuntimeError("MongoDB connection not established. Call connect() first or use async with.") await self.connect()
if self._collection is None:
raise RuntimeError("MongoDB collection not available. Call connect() or use async with.")
print(f"Deleting one document from '{self.collection_name}' with query: {query}") print(f"Deleting one document from '{self.collection_name}' with query: {query}")
result = await self._collection.delete_one(query) result = await self._collection.delete_one(query)
print(f"Deleted count: {result.deleted_count}") print(f"Deleted count: {result.deleted_count}")
return result return result
async def delete_many(self, query: Dict[str, Any]) -> Any: async def delete_many(self, query: Dict[str, Any]) -> Any:
"""
Deletes multiple documents matching the query.
Args:
query (Dict[str, Any]): The query document.
Returns:
Any: The result of the delete operation (DeleteResult).
"""
if self._collection is None: if self._collection is None:
raise RuntimeError("MongoDB connection not established. Call connect() first or use async with.") await self.connect()
if self._collection is None:
raise RuntimeError("MongoDB collection not available. Call connect() or use async with.")
print(f"Deleting many documents from '{self.collection_name}' with query: {query}") print(f"Deleting many documents from '{self.collection_name}' with query: {query}")
result = await self._collection.delete_many(query) result = await self._collection.delete_many(query)
print(f"Deleted count: {result.deleted_count}") print(f"Deleted count: {result.deleted_count}")
return result return result
# --- Example Usage (no change needed here, but behavior of MongoHandler is different) ---
# --- Example Usage ---
async def example_mongo_usage(): async def example_mongo_usage():
"""Demonstrates how to use the MongoHandler."""
# Ensure you have a MongoDB server running, default is localhost:27017
db_name = "radio_app_db" db_name = "radio_app_db"
collection_name = "channels" collection_name = "channels_example" # Use a different collection for example
handler = MongoHandler(db_name, collection_name) # MONGO_URL defaults to localhost
# Using async with ensures the connection is closed automatically
async with MongoHandler(db_name, collection_name) as mongo:
# --- Insert Example ---
print("\n--- Inserting a document ---")
channel_data = {
"_id": "channel_3", # You can specify _id or let MongoDB generate one
"name": "Emergency Services",
"frequency_khz": 453000,
"location": "Countywide",
"avail_on_nodes": ["client-xyz987"],
"description": "Monitor for emergency broadcasts."
}
try: try:
insert_result = await mongo.insert_one(channel_data) async with handler: # Connects on enter (if not already connected)
print(f"Insert successful: {insert_result.inserted_id}") print("\n--- Inserting a document ---")
except Exception as e: # ... (rest of example usage)
print(f"Insert failed: {e}") channel_data = { "_id": "example_channel", "name": "Example" }
await handler.insert_one(channel_data)
found = await handler.find_one({"_id": "example_channel"})
print(f"Found: {found}")
await handler.delete_one({"_id": "example_channel"})
print("Example completed.")
finally:
await handler.close_client() # Explicitly close client at the end of usage
# --- Find One Example ---
print("\n--- Finding one document ---")
query = {"_id": "channel_3"}
found_channel = await mongo.find_one(query)
if found_channel:
print("Found document:", found_channel)
else:
print("Document not found.")
# --- Find Many Example ---
print("\n--- Finding all documents ---")
all_channels = await mongo.find() # Empty query finds all
print("All documents:", all_channels)
# --- Update Example ---
print("\n--- Updating a document ---")
update_query = {"_id": "channel_3"}
update_data = {"$set": {"location": "Statewide", "avail_on_nodes": ["client-xyz987", "client-newnode1"]}}
update_result = await mongo.update_one(update_query, update_data)
print(f"Update successful: Matched {update_result.matched_count}, Modified {update_result.modified_count}")
print("\n--- Finding the updated document ---")
updated_channel = await mongo.find_one(update_query)
print("Updated document:", updated_channel)
# --- Delete Example ---
print("\n--- Deleting a document ---")
delete_query = {"_id": "channel_3"}
delete_result = await mongo.delete_one(delete_query)
print(f"Delete successful: Deleted count {delete_result.deleted_count}")
print("\n--- Verifying deletion ---")
deleted_channel = await mongo.find_one(delete_query)
if deleted_channel:
print("Document still found (deletion failed).")
else:
print("Document successfully deleted.")
# --- Insert another for delete_many example ---
temp_doc1 = {"_id": "temp_1", "tag": "temp"}
temp_doc2 = {"_id": "temp_2", "tag": "temp"}
await mongo.insert_one(temp_doc1)
await mongo.insert_one(temp_doc2)
# --- Delete Many Example ---
print("\n--- Deleting many documents ---")
delete_many_query = {"tag": "temp"}
delete_many_result = await mongo.delete_many(delete_many_query)
print(f"Delete many successful: Deleted count {delete_many_result.deleted_count}")
# To run the example usage:
# 1. Ensure you have a MongoDB server running locally on the default port (27017).
# 2. Save the code as mongodb_handler.py.
# 3. Run from your terminal: python -m asyncio mongodb_handler.py
if __name__ == "__main__": if __name__ == "__main__":
# Running the example directly requires running within an asyncio loop
asyncio.run(example_mongo_usage()) asyncio.run(example_mongo_usage())

View File

@@ -3,82 +3,60 @@ import asyncio
from uuid import uuid4 from uuid import uuid4
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from enum import Enum from enum import Enum
from internal.db_handler import MongoHandler from internal.db_handler import MongoHandler #
from internal.types import System from internal.types import System, DiscordId
# Init vars # Init vars
DB_NAME = os.getenv("DB_NAME", "default_db") DB_NAME = os.getenv("DB_NAME", "default_db")
MONGO_URL = os.getenv("MONGO_URL", "mongodb://10.10.202.4:27017/") MONGO_URL = os.getenv("MONGO_URL", "mongodb://10.10.202.4:27017/")
SYSTEM_DB_COLLECTION_NAME = "radio_systems" SYSTEM_DB_COLLECTION_NAME = "radio_systems"
DISCORD_ID_DB_COLLECTION_NAME = "discord_bot_ids"
# --- System class --- # --- System class ---
class SystemDbController(): class SystemDbController():
def __init__(self): def __init__(self):
# Init the handler self.db_h = MongoHandler(DB_NAME, SYSTEM_DB_COLLECTION_NAME, MONGO_URL) #
self.db_h = MongoHandler(DB_NAME, SYSTEM_DB_COLLECTION_NAME, MONGO_URL)
async def close_db_connection(self):
"""Closes the underlying MongoDB connection."""
if self.db_h:
await self.db_h.close_client() #
async def create_system(self, system_data: Dict[str, Any]) -> Optional[System]: async def create_system(self, system_data: Dict[str, Any]) -> Optional[System]:
"""
Creates a new system entry in the database.
Args:
system_data: A dictionary containing the data for the new system.
Returns:
The created System object if successful, None otherwise.
"""
print("\n--- Creating a document ---") print("\n--- Creating a document ---")
try: try:
# Check if the data to be inserted has an ID
if not system_data.get("_id"): if not system_data.get("_id"):
system_data['_id'] = uuid4() system_data['_id'] = str(uuid4())
inserted_result = None
inserted_id = None inserted_id = None
async with self.db_h as db: async with self.db_h as db: #
insert_result = await self.db_h.insert_one(system_data) insert_result = await db.insert_one(system_data) #
inserted_id = insert_result.inserted_id inserted_id = insert_result.inserted_id
if inserted_id: if inserted_id:
print(f"Insert successful with ID: {inserted_id}") print(f"Insert successful with ID: {inserted_id}")
# Fetch the inserted document to get the complete data including the generated _id
query = {"_id": inserted_id} query = {"_id": inserted_id}
inserted_doc = None inserted_doc = None
async with self.db_h as db: async with self.db_h as db: #
inserted_doc = await db.find_one(query) inserted_doc = await db.find_one(query) #
if inserted_doc: if inserted_doc:
# Convert the fetched dictionary back to a System object
return System.from_dict(inserted_doc) return System.from_dict(inserted_doc)
else: else:
print("Insert acknowledged but no ID returned.") print("Insert acknowledged but no ID returned.")
return None return None
except Exception as e: except Exception as e:
print(f"Create failed: {e}") print(f"Create failed: {e}")
return None return None
async def find_system(self, query: Dict[str, Any]) -> Optional[System]: async def find_system(self, query: Dict[str, Any]) -> Optional[System]:
"""
Finds a single system entry in the database.
Args:
query: A dictionary representing the query criteria.
Returns:
A System object if found, None otherwise.
"""
print("\n--- Finding one document ---") print("\n--- Finding one document ---")
try: try:
found_doc = None found_doc = None
async with self.db_h as db: async with self.db_h as db: #
found_doc = await db.find_one(query) found_doc = await db.find_one(query) #
if found_doc: if found_doc:
print("Found document (raw dict):", found_doc) print("Found document (raw dict):", found_doc)
# Convert the dictionary result to a System object
return System.from_dict(found_doc) return System.from_dict(found_doc)
else: else:
print("Document not found.") print("Document not found.")
@@ -88,30 +66,15 @@ class SystemDbController():
return None return None
async def find_systems(self, query: Dict[str, Any]) -> Optional[List[System]]: async def find_systems(self, query: Dict[str, Any]) -> Optional[List[System]]:
"""
Finds one or more system entries in the database.
Args:
query: A dictionary representing the query criteria.
Returns:
A list of System object(s) if found, None otherwise.
"""
print("\n--- Finding documents ---") print("\n--- Finding documents ---")
try: try:
found_docs = None found_docs = None
async with self.db_h as db: async with self.db_h as db: #
found_docs = await db.find(query) found_docs = await db.find(query) #
if found_docs: if found_docs:
print("Found document (raw dict):", found_docs) print("Found document (raw dict):", found_docs)
# Convert the dictionary results to a System object converted_systems = [System.from_dict(doc) for doc in found_docs]
converted_systems = [] print("Found document (converted):", converted_systems)
for doc in found_docs:
converted_systems.append(System.from_dict(doc))
print("YURB", found_docs, converted_systems)
return converted_systems if len(converted_systems) > 0 else None return converted_systems if len(converted_systems) > 0 else None
else: else:
print("Document not found.") print("Document not found.")
@@ -120,25 +83,19 @@ class SystemDbController():
print(f"Find failed: {e}") print(f"Find failed: {e}")
return None return None
async def find_all_systems(self, query: Dict[str, Any] = {}) -> List[System]: async def find_all_systems(self, query: Optional[Dict[str, Any]] = None) -> List[System]:
"""
Finds multiple system entries in the database.
Args:
query: A dictionary representing the query criteria (default is empty to find all).
Returns:
A list of System objects.
"""
print("\n--- Finding multiple documents ---") print("\n--- Finding multiple documents ---")
try: try:
# Initialize an empty dictionary if no query is provided
if query is None:
query = {}
found_docs = None found_docs = None
async with self.db_h as db: async with self.db_h as db: #
found_docs = await db.find(query) found_docs = await db.find(query) #
if found_docs: if found_docs:
print(f"Found {len(found_docs)} documents (raw dicts).") print(f"Found {len(found_docs)} documents (raw dicts).")
# Convert the list of dictionaries to a list of System objects
return [System.from_dict(doc) for doc in found_docs] return [System.from_dict(doc) for doc in found_docs]
else: else:
print("No documents found.") print("No documents found.")
@@ -148,22 +105,11 @@ class SystemDbController():
return [] return []
async def update_system(self, query: Dict[str, Any], update_data: Dict[str, Any]) -> Optional[int]: async def update_system(self, query: Dict[str, Any], update_data: Dict[str, Any]) -> Optional[int]:
"""
Updates a single system entry in the database.
Args:
query: A dictionary representing the query criteria to find the document.
update_data: A dictionary representing the update operations (e.g., using $set).
Returns:
The number of modified documents if successful, None otherwise.
"""
print("\n--- Updating a document ---") print("\n--- Updating a document ---")
try: try:
update_result = None update_result = None
async with self.db_h as db: async with self.db_h as db: #
update_result = await db.update_one(query, update_data) update_result = await db.update_one(query, update_data) #
print(f"Update result: Matched {update_result.matched_count}, Modified {update_result.modified_count}") print(f"Update result: Matched {update_result.matched_count}, Modified {update_result.modified_count}")
return update_result.modified_count return update_result.modified_count
except Exception as e: except Exception as e:
@@ -171,21 +117,117 @@ class SystemDbController():
return None return None
async def delete_system(self, query: Dict[str, Any]) -> Optional[int]: async def delete_system(self, query: Dict[str, Any]) -> Optional[int]:
"""
Deletes a single system entry from the database.
Args:
query: A dictionary representing the query criteria to find the document to delete.
Returns:
The number of deleted documents if successful, None otherwise.
"""
print("\n--- Deleting a document ---") print("\n--- Deleting a document ---")
try: try:
delete_result = None delete_result = None
async with self.db_h as db: async with self.db_h as db: #
delete_result = await self.db_h.delete_one(query) delete_result = await db.delete_one(query) #
print(f"Delete result: Deleted count {delete_result.deleted_count}") print(f"Delete result: Deleted count {delete_result.deleted_count}")
return delete_result.deleted_count return delete_result.deleted_count
except Exception as e: except Exception as e:
print(f"Delete failed: {e}") print(f"Delete failed: {e}")
return None return None
# --- DiscordIdDbController class ---
class DiscordIdDbController():
def __init__(self):
self.db_h = MongoHandler(DB_NAME, DISCORD_ID_DB_COLLECTION_NAME, MONGO_URL) #
async def close_db_connection(self):
"""Closes the underlying MongoDB connection."""
if self.db_h:
await self.db_h.close_client() #
async def create_discord_id(self, discord_id_data: Dict[str, Any]) -> Optional[DiscordId]:
print("\n--- Creating a Discord ID document ---")
try:
if not discord_id_data.get("_id"):
discord_id_data['_id'] = str(uuid4())
inserted_id = None
async with self.db_h as db: #
insert_result = await db.insert_one(discord_id_data) #
inserted_id = insert_result.inserted_id
if inserted_id:
print(f"Discord ID insert successful with ID: {inserted_id}")
query = {"_id": inserted_id}
inserted_doc = None
async with self.db_h as db: #
inserted_doc = await db.find_one(query) #
if inserted_doc:
return DiscordId.from_dict(inserted_doc)
else:
print("Discord ID insert acknowledged but no ID returned.")
return None
except Exception as e:
print(f"Discord ID create failed: {e}")
return None
async def find_discord_id(self, query: Dict[str, Any]) -> Optional[DiscordId]:
print("\n--- Finding one Discord ID document ---")
try:
found_doc = None
async with self.db_h as db: #
found_doc = await db.find_one(query) #
if found_doc:
print("Found Discord ID document (raw dict):", found_doc)
return DiscordId.from_dict(found_doc)
else:
print("Discord ID document not found.")
return None
except Exception as e:
print(f"Discord ID find failed: {e}")
return None
async def find_discord_ids(self, query: Optional[Dict[str, Any]] = None, guild_id: Optional[str] = None, active_only: bool = False) -> Optional[List[DiscordId]]:
print("\n--- Finding multiple Discord ID documents ---")
try:
if query is None:
query = {}
if active_only == True:
print("Searching active IDs")
query["active"] = True
if guild_id:
print(f"Searching for IDs in {guild_id}")
query["guild_ids"] = {"$in": [guild_id]}
found_docs = None
async with self.db_h as db: #
print(f"Query: {query}")
found_docs = await db.find(query) #
if found_docs:
print(f"Found {len(found_docs)} Discord ID documents (raw dicts).")
converted_discord_ids = [DiscordId.from_dict(doc) for doc in found_docs]
return converted_discord_ids if len(converted_discord_ids) > 0 else None
else:
print("Discord ID documents not found.")
return None
except Exception as e:
print(f"Discord ID find failed: {e}")
return None
async def update_discord_id(self, query: Dict[str, Any], update_data: Dict[str, Any]) -> Optional[int]:
print("\n--- Updating a Discord ID document ---")
try:
update_result = None
async with self.db_h as db: #
update_result = await db.update_one(query, update_data) #
print(f"Discord ID update result: Matched {update_result.matched_count}, Modified {update_result.modified_count}")
return update_result.modified_count
except Exception as e:
print(f"Discord ID update failed: {e}")
return None
async def delete_discord_id(self, query: Dict[str, Any]) -> Optional[int]:
print("\n--- Deleting a Discord ID document ---")
try:
delete_result = None
async with self.db_h as db: #
delete_result = await db.delete_one(query) #
print(f"Discord ID delete result: Deleted count {delete_result.deleted_count}")
return delete_result.deleted_count
except Exception as e:
print(f"Discord ID delete failed: {e}")
return None

View File

@@ -1,3 +1,4 @@
# internal/types.py
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from enum import Enum from enum import Enum
@@ -6,6 +7,16 @@ class DemodTypes(str, Enum):
DMR = "DMR" DMR = "DMR"
ANALOG = "NBFM" ANALOG = "NBFM"
class NodeCommands(str, Enum):
JOIN = "join_server"
LEAVE = "leave_server"
STATUS = "get_status"
OP25_START = "op25_start"
OP25_STOP = "op25_stop"
OP25_SET = "op25_set"
class TalkgroupTag: class TalkgroupTag:
"""Represents a talkgroup tag.""" """Represents a talkgroup tag."""
def __init__(self, talkgroup: str, tagDec: int): def __init__(self, talkgroup: str, tagDec: int):
@@ -14,7 +25,77 @@ class TalkgroupTag:
# Add a method to convert to a dictionary, useful for sending as JSON # Add a method to convert to a dictionary, useful for sending as JSON
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return {"talkgroup": self.talkgroup, "tagDec": self.tagDec} return {"talkgroup": self.talkgroup, "tagDec": self.talkgroup}
class DiscordId:
"""
A data model for a Discord ID entry.
"""
def __init__(self,
_id: str,
discord_id: str,
name: str,
token: str,
active: bool,
guild_ids: List[str],
fixed_node: Optional[str]=None):
"""
Initializes a DiscordId object.
Args:
_id: A unique identifier for the entry (e.g., MongoDB ObjectId string).
discord_id: The Discord user ID.
name: The name associated with the Discord ID.
token: The authentication token.
active: Boolean indicating if the ID is active.
guild_ids: A list of guild IDs the Discord user is part of.
fixed_node: The node ID this DiscordId must use.
"""
self._id: str = str(_id)
self.discord_id: str = discord_id
self.name: str = name
self.token: str = token
self.active: bool = active
self.guild_ids: List[str] = guild_ids
self.fixed_node: Optional[str] = fixed_node
def __repr__(self) -> str:
"""
Provides a developer-friendly string representation of the object.
"""
return (f"DiscordId(_id='{self._id}', discord_id='{self.discord_id}', name='{self.name}', "
f"token='{self.token}', active={self.active}, guild_ids={self.guild_ids})")
def to_dict(self) -> Dict[str, Any]:
"""
Converts the DiscordId object to a dictionary suitable for MongoDB.
"""
return {
"_id": self._id,
"discord_id": self.discord_id,
"name": self.name,
"token": self.token,
"active": self.active,
"guild_ids": self.guild_ids,
"fixed_node": self.fixed_node,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "DiscordId":
"""
Creates a DiscordId object from a dictionary (e.g., from MongoDB).
"""
return cls(
_id=data.get("_id"),
discord_id=data.get("discord_id", ""),
name=data.get("name", ""),
token=data.get("token", ""),
active=data.get("active", False), # Default to False if not present
guild_ids=data.get("guild_ids", []), # Default to empty list if not present
fixed_node=data.get("fixed_node", None), # Default to empty if not present
)
class System: class System:
""" """
@@ -25,7 +106,7 @@ class System:
_id: str, _id: str,
_type: DemodTypes, _type: DemodTypes,
name: str, name: str,
frequency_khz: List[str], frequencies: List[str],
location: str, location: str,
avail_on_nodes: List[str], avail_on_nodes: List[str],
description: Optional[str] = "", description: Optional[str] = "",
@@ -38,15 +119,15 @@ class System:
_id: A unique identifier for the entry (e.g., MongoDB ObjectId string). _id: A unique identifier for the entry (e.g., MongoDB ObjectId string).
_type: The demodulation type (P25, NBFM, etc.). _type: The demodulation type (P25, NBFM, etc.).
name: The name of the channel/system. name: The name of the channel/system.
frequency_khz: The frequency in kilohertz. frequencies: The frequency in kilohertz.
location: The geographical location or coverage area. location: The geographical location or coverage area.
avail_on_nodes: A list of node identifiers where this is available. avail_on_nodes: A list of node identifiers where this is available.
description: A brief description. description: A brief description.
""" """
self._id: str = _id self._id: str = str(_id)
self.type: DemodTypes = _type self.type: DemodTypes = _type
self.name: str = name self.name: str = name
self.frequency_khz: List[int] = frequency_khz self.frequencies: List[int] = frequencies
self.location: str = location self.location: str = location
self.avail_on_nodes: List[str] = avail_on_nodes self.avail_on_nodes: List[str] = avail_on_nodes
self.description: str = description or "" self.description: str = description or ""
@@ -59,7 +140,7 @@ class System:
""" """
# Use self.type.value for string representation of the enum # Use self.type.value for string representation of the enum
return (f"System(_id='{self._id}', type='{self.type.value}', name='{self.name}', " return (f"System(_id='{self._id}', type='{self.type.value}', name='{self.name}', "
f"frequency_khz={self.frequency_khz}, location='{self.location}', " f"frequencies={self.frequencies}, location='{self.location}', "
f"avail_on_nodes={self.avail_on_nodes}, description='{self.description}'," f"avail_on_nodes={self.avail_on_nodes}, description='{self.description}',"
f" tags='{self.tags}', whitelist='{self.whitelist}')") f" tags='{self.tags}', whitelist='{self.whitelist}')")
@@ -72,7 +153,7 @@ class System:
"_id": self._id, "_id": self._id,
"type": self.type.value, # Store the enum value (string) "type": self.type.value, # Store the enum value (string)
"name": self.name, "name": self.name,
"frequency_khz": self.frequency_khz, "frequencies": self.frequencies,
"location": self.location, "location": self.location,
"avail_on_nodes": self.avail_on_nodes, "avail_on_nodes": self.avail_on_nodes,
"description": self.description, "description": self.description,
@@ -100,10 +181,91 @@ class System:
_id=data.get("_id"), _id=data.get("_id"),
_type=system_type, _type=system_type,
name=data.get("name", ""), # Provide default empty string if name is missing name=data.get("name", ""), # Provide default empty string if name is missing
frequency_khz=data.get("frequency_khz", 0), # Provide default 0 if missing frequencies=data.get("frequencies", 0), # Provide default 0 if missing
location=data.get("location", ""), location=data.get("location", ""),
avail_on_nodes=data.get("avail_on_nodes", []), # Provide default empty list avail_on_nodes=data.get("avail_on_nodes", []), # Provide default empty list
description=data.get("description", ""), description=data.get("description", ""),
tags=data.get("tags", None), tags=data.get("tags", None),
whitelist=data.get("whitelist", None) whitelist=data.get("whitelist", None)
) )
class ActiveClient:
"""
The active client model in memory for quicker access
"""
client_id: str = None
websocket = None
active_token: DiscordId = None
nickname: str = None
access_token: str = None
def __init__(self, client_id: str=None, websocket=None, active_token: DiscordId = None, nickname: str = None, access_token: str = None):
self.client_id = client_id
self.active_token = active_token
self.websocket = websocket
self.nickname = nickname
self.access_token = access_token
def to_dict(self):
return {
"client_id": self.client_id,
"nickname": self.nickname,
"active_token": self.active_token.to_dict() if self.active_token else None
}
def __str__(self):
"""
Returns a neatly formatted string representation of the ActiveClient object.
"""
return (f"--- Active Client ---\n"
f"Active Token: {self.active_token if self.active_token else 'N/A'}\n"
f"Nickname: {self.nickname if self.nickname else 'N/A'}\n"
f"Nickname: {self.client_id if self.client_id else 'N/A'}\n"
f"Access Token: {'[REDACTED]' if self.access_token else 'N/A'}\n"
f"Websocket Connected: {'Yes' if self.websocket else 'No'}")
class UserRoles(str, Enum):
ADMIN = "admin"
MOD = "mod"
USER = "user"
class User:
"""
A data model for a User entry.
"""
def __init__(self,
_id: str,
username: str,
password_hash: str,
role: UserRoles,
api_key: Optional[str] = None):
self._id: str = str(_id)
self.username: str = username
self.password_hash: str = password_hash
self.role: UserRoles = role
self.api_key: Optional[str] = api_key
def __repr__(self) -> str:
return (f"User(_id='{self._id}', username='{self.username}', role='{self.role.value}', "
f"api_key={'<hidden>' if self.api_key else 'None'})")
def to_dict(self) -> Dict[str, Any]:
return {
"_id": self._id,
"username": self.username,
"password_hash": self.password_hash,
"role": self.role.value,
"api_key": self.api_key,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "User":
return cls(
_id=data.get("_id"),
username=data.get("username", ""),
password_hash=data.get("password_hash", ""),
role=UserRoles(data.get("role", UserRoles.USER.value)),
api_key=data.get("api_key", None),
)

141
app/routers/auth.py Normal file
View File

@@ -0,0 +1,141 @@
import functools
from quart import Blueprint, jsonify, request, current_app, abort
from werkzeug.security import generate_password_hash, check_password_hash
from quart_jwt_extended import create_access_token, jwt_required, get_jwt_identity
from internal.auth_wrappers import UserDbController
from internal.types import UserRoles
from uuid import uuid4
from datetime import timedelta
# Import the centralized JWTManager instance
from config.jwt_config import jwt as jwt_manager_instance # Renamed to avoid confusion with jwt_required
auth_bp = Blueprint('auth', __name__)
# Decorator for role-based access control
def role_required(required_role: UserRoles):
def wrapper(fn):
@functools.wraps(fn)
@jwt_required
async def decorated_view(*args, **kwargs):
current_user_identity = get_jwt_identity()
user_id = current_user_identity['id']
auth_type = current_user_identity['type']
if auth_type == "node":
node = current_app.active_clients.get(user_id)
print("Node", node)
if not node:
abort(401, "Node not found or invalid token.")
if auth_type == "user":
# Make a DB call to get the user and their role
user = await current_app.user_db_h.find_user({"_id": user_id})
if not user:
abort(401, "User not found or invalid token.") # User corresponding to token not found
user_role = user.role # Get the role from the fetched user object
role_order = {UserRoles.USER: 0, UserRoles.MOD: 1, UserRoles.ADMIN: 2}
if role_order[user_role] < role_order[required_role]:
abort(403, "Permission denied: Insufficient role.")
return await fn(*args, **kwargs) # Directly await the original async function
return decorated_view
return wrapper
@auth_bp.route('/register', methods=['POST'])
async def register_user():
data = await request.get_json()
username = data.get('username')
password = data.get('password')
role = data.get('role', UserRoles.USER.value) # Default to 'user' role
if not username or not password:
abort(400, "Username and password are required")
existing_user = await current_app.user_db_h.find_user({"username": username})
if existing_user:
abort(409, "Username already exists")
hashed_password = generate_password_hash(password)
try:
user_role = UserRoles(role)
except ValueError:
abort(400, f"Invalid role: {role}. Must be one of {list(UserRoles)}")
user_data = {
"username": username,
"password_hash": hashed_password,
"role": user_role.value
}
new_user = await current_app.user_db_h.create_user(user_data)
if new_user:
return jsonify({"message": "User registered successfully", "user_id": new_user._id}), 201
else:
abort(500, "Failed to register user")
@auth_bp.route('/login', methods=['POST'])
async def login_user():
data = await request.get_json()
username = data.get('username')
password = data.get('password')
if not username or not password:
abort(400, "Username and password are required")
user = await current_app.user_db_h.find_user({"username": username})
if not user or not check_password_hash(user.password_hash, password):
abort(401, "Invalid credentials")
access_token = create_access_token(identity={"id": user._id, "username": user.username, "type": "user"}, expires_delta=timedelta(hours=1))
return jsonify({"access_token": access_token, "role": user.role, "username": user.username, "user_id": user._id }), 200
# DEPRECATED
@auth_bp.route('/generate_api_key', methods=['POST'])
@jwt_required
async def generate_api_key():
current_user_identity = get_jwt_identity()
user_id = current_user_identity['id']
user_role = current_user_identity['role']
if user_role not in [UserRoles.ADMIN.value, UserRoles.MOD.value]:
target_user_id = (await request.get_json()).get('user_id', user_id)
if target_user_id != user_id:
abort(403, "Permission denied: You can only generate an API key for your own account.")
else:
target_user_id = (await request.get_json()).get('user_id', user_id)
new_api_key = str(uuid4())
updated_count = await current_app.user_db_h.update_user(
{"_id": target_user_id},
{"$set": {"api_key": new_api_key}}
)
if updated_count:
return jsonify({"message": f"API key generated for user {target_user_id}", "api_key": new_api_key}), 200
else:
abort(404, f"User {target_user_id} not found or unable to update API key.")
@auth_bp.route('/admin_only', methods=['GET'])
@jwt_required
@role_required(UserRoles.ADMIN)
async def admin_only_route():
return jsonify({"message": "Welcome, Admin!"}), 200
@auth_bp.route('/mod_or_admin_only', methods=['GET'])
@jwt_required
@role_required(UserRoles.MOD)
async def mod_or_admin_only_route():
return jsonify({"message": "Welcome, Mod or Admin!"}), 200

210
app/routers/bot.py Normal file
View File

@@ -0,0 +1,210 @@
import json
from quart import Blueprint, jsonify, request, abort, current_app
from werkzeug.exceptions import HTTPException
from internal.types import ActiveClient, DiscordId, UserRoles
from internal.db_wrappers import DiscordIdDbController
from quart_jwt_extended import jwt_required
from routers.auth import role_required
from bson.objectid import ObjectId
bot_bp = Blueprint('bot', __name__)
# ------- Discord Token Functions
@bot_bp.route('/request_token', methods=['POST'])
@jwt_required
@role_required(UserRoles.MOD)
async def request_token_route():
"""
API endpoint to request a token for a client.
Expects 'client_id' in the JSON request body.
"""
try:
request_data = await request.get_json()
if not request_data or 'client_id' not in request_data:
abort(400, "Missing 'client_id' in request body.")
client_id = request_data['client_id']
print(f"Request received for client_id: {client_id}")
# get the available IDs
active_d_ids = await current_app.d_id_db_h.find_discord_ids(active_only=True)
# init available IDs list
avail_ids = []
# Init the selected ID
selected_id = None
# Check which IDs are currently in use by other bots
for d_id in active_d_ids:
if not find_token_in_active_clients(d_id.token):
avail_ids.append(d_id)
if not avail_ids:
abort(404, "No available active Discord IDs found.")
selected_id = None
# Check for any fixed IDs
for avail_id in avail_ids:
if avail_id.fixed_node and avail_id.fixed_node == client_id:
selected_id = avail_id
if not selected_id:
selected_id = avail_ids[0]
print("Selected Discord ID: ", selected_id)
current_app.active_clients[client_id].active_token = selected_id
print(current_app.active_clients[client_id])
return jsonify(selected_id.to_dict())
except Exception as e:
print(f"Error in request_token_route: {e}")
abort(500, f"An internal error occurred: {e}")
@bot_bp.route('/tokens/', methods=['GET'])
@jwt_required
@role_required(UserRoles.USER)
async def get_all_discord_tokens():
"""
API endpoint to return all discord IDs
"""
try:
# get the available IDs
d_ids = await current_app.d_id_db_h.find_discord_ids(active_only=False)
return jsonify([d_id.to_dict() for d_id in d_ids])
except Exception as e:
print(f"Error in request_token_route: {e}")
abort(500, f"An internal error occurred: {e}")
@bot_bp.route('/token/<string:discord_id_param>', methods=['GET'])
@jwt_required
@role_required(UserRoles.MOD)
async def get_discord_token_by_id(discord_id_param: str):
"""
API endpoint to get a single Discord ID by its _id.
"""
try:
query = {"_id": ObjectId(discord_id_param)}
d_id = await current_app.d_id_db_h.find_discord_id(query)
if d_id:
return jsonify(d_id.to_dict())
else:
abort(404, "Discord ID not found.")
except Exception as e:
print(f"Error in get_discord_token_by_id: {e}")
abort(500, f"An internal error occurred: {e}")
@bot_bp.route('/token', methods=["POST"])
@jwt_required
@role_required(UserRoles.MOD)
async def create_discord_token():
"""
API Endpoint to create a discord token (adding a new bot)
"""
data = await request.get_json()
if '_id' in data:
id_search_result = await current_app.sys_db_h.find_system({"_id": ObjectId(data["_id"])})
if id_search_result:
# If _id is provided and exists, return conflict
abort(409, f"System with ID '{data['_id']}' already exists")
temp_name = data.get("name")
temp_discord_id = data.get("discord_id")
temp_token = data.get("token")
temp_active = bool(data.get("active"))
temp_guilds = data.get("guild_ids")
# Create the discord ID object
temp_d_id = {
"discord_id": temp_discord_id,
"name": temp_name,
"token": temp_token,
"active": temp_active,
"guild_ids": temp_guilds
}
new_d_id = await current_app.d_id_db_h.create_discord_id(temp_d_id)
if new_d_id:
return jsonify(new_d_id.to_dict()), 201
else:
abort(500, "Failed to create Discord ID.")
@bot_bp.route('/token/<string:discord_id_param>', methods=['PUT'])
@jwt_required
@role_required(UserRoles.MOD)
async def update_discord_token(discord_id_param: str):
"""
API endpoint to update a Discord ID by its _id.
"""
try:
data = await request.get_json()
if not data:
abort(400, "No update data provided.")
query = {"_id": ObjectId(discord_id_param)}
update_result = await current_app.d_id_db_h.update_discord_id(query, {"$set": data})
if update_result is not None:
if update_result > 0:
# Optionally, fetch the updated document to return it
updated_d_id = await current_app.d_id_db_h.find_discord_id(query)
return jsonify(updated_d_id.to_dict()), 200
else:
abort(404, "Discord ID not found or no changes applied.")
else:
abort(500, "Failed to update Discord ID.")
except Exception as e:
print(f"Error in update_discord_token: {e}")
abort(500, f"An internal error occurred: {e}")
@bot_bp.route('/token/<string:discord_id_param>', methods=['DELETE'])
@jwt_required
@role_required(UserRoles.MOD)
async def delete_discord_token(discord_id_param: str):
"""
API endpoint to delete a Discord ID by its _id.
"""
try:
query = {"_id": ObjectId(discord_id_param)}
delete_count = await current_app.d_id_db_h.delete_discord_id(query)
if delete_count is not None:
if delete_count > 0:
return jsonify({"message": f"Successfully deleted {delete_count} Discord ID(s)."}), 200
else:
abort(404, "Discord ID not found.")
else:
abort(500, "Failed to delete Discord ID.")
except Exception as e:
print(f"Error in delete_discord_token: {e}")
abort(500, f"An internal error occurred: {e}")
# ------- Util Functions
def find_token_in_active_clients(target_token: str) -> bool:
"""
Checks if a target_token exists in the active_token of any online ActiveClient object.
Args:
target_token: The token string to search for.
Returns:
True if the token is found in any ActiveClient, False otherwise.
"""
for client_id in current_app.active_clients:
try:
if current_app.active_clients[client_id].active_token.token == target_token:
return True
except Exception as e:
pass
return False

View File

@@ -1,41 +1,135 @@
import json import json
import asyncio # Import asyncio
import websockets
import uuid
from quart import Blueprint, jsonify, request, abort, current_app from quart import Blueprint, jsonify, request, abort, current_app
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from enum import Enum from enum import Enum
from internal.types import ActiveClient, NodeCommands, UserRoles
from quart_jwt_extended import create_access_token
from quart_jwt_extended import jwt_required
from routers.auth import role_required
nodes_bp = Blueprint('nodes', __name__) nodes_bp = Blueprint('nodes', __name__)
class NodeCommands(str, Enum): # Dictionary to store pending requests: {request_id: asyncio.Future}
JOIN = "join_server" pending_requests = {}
LEAVE = "leave_server"
async def register_client(websocket, client_id): async def register_client(websocket, client_id, client_nickname, active_token):
"""Registers a new client connection.""" """Registers a new client connection."""
current_app.active_clients[client_id] = websocket current_app.active_clients[client_id] = ActiveClient()
current_app.active_clients[client_id].websocket = websocket
current_app.active_clients[client_id].nickname = client_nickname
current_app.active_clients[client_id].active_token = active_token
current_app.active_clients[client_id].client_id = client_id
print(f"Client {client_id} connected.") print(f"Client {client_id} connected.")
# Create a JWT for the client
current_app.active_clients[client_id].access_token = create_access_token(identity={"id": client_id, "username": client_nickname, "type": "node"}, expires_delta=False)
print(current_app.active_clients[client_id])
# Start a task to listen for messages from this client
asyncio.create_task(listen_to_client(websocket, client_id))
async def unregister_client(client_id): async def unregister_client(client_id):
"""Unregisters a disconnected client.""" """Unregisters a disconnected client."""
if client_id in current_app.active_clients: if client_id in current_app.active_clients:
# Also clean up any pending requests for this client
for req_id, req_obj in list(pending_requests.items()):
if req_obj['client_id'] == client_id:
if not req_obj['future'].done():
req_obj['future'].cancel()
del current_app.active_clients[client_id] del current_app.active_clients[client_id]
print(f"Client {client_id} disconnected.") print(f"Client {client_id} disconnected.")
async def send_command_to_client(client_id, command_name, *args): async def listen_to_client(websocket, client_id):
"""Sends a command to a specific client.""" """Listens for messages from a specific client."""
if client_id in current_app.active_clients: try:
websocket = current_app.active_clients[client_id] while True:
message = json.dumps({"type": "command", "name": command_name, "args": args}) message = await websocket.recv()
data = json.loads(message)
message_type = data.get("type")
request_id = data.get("request_id")
if message_type == "response" and request_id in pending_requests:
# Retrieve the dictionary containing the future and client_id
request_info = pending_requests.pop(request_id)
# Extract the actual asyncio.Future object
future = request_info["future"]
if not future.done(): # This will now correctly call .done() on the Future
future.set_result(data.get("payload"))
# Add other message types handling here if needed (e.g., unsolicited messages)
except websockets.exceptions.ConnectionClosedError:
print(f"Client {client_id} connection closed while listening.")
except json.JSONDecodeError:
print(f"Received invalid JSON from client {client_id}.")
except Exception as e:
print(f"Error listening to client {client_id}: {e}")
finally:
await unregister_client(client_id)
async def send_command_to_client(client_id, command_name, *args, wait_for_response=False, timeout=10):
"""Sends a command to a specific client and optionally waits for a response."""
if client_id not in current_app.active_clients:
print(f"Client {client_id} not found.")
raise ValueError(f"Client {client_id} not found.")
websocket = current_app.active_clients[client_id].websocket
request_id = str(uuid.uuid4()) if wait_for_response else None
message_payload = {"type": "command", "name": command_name, "args": args}
if request_id:
message_payload["request_id"] = request_id
message = json.dumps(message_payload)
future = None
if wait_for_response:
future = asyncio.Future()
pending_requests[request_id] = {
"future": future,
"client_id": client_id
}
try: try:
await websocket.send(message) await websocket.send(message)
print(f"Sent command '{command_name}' to client {client_id}") print(f"Sent command '{command_name}' to client {client_id} (Request ID: {request_id})")
if wait_for_response:
try:
response = await asyncio.wait_for(future, timeout)
print(f"Received response for Request ID {request_id} from client {client_id}")
return response
except asyncio.TimeoutError:
print(f"Timeout waiting for response from client {client_id} for Request ID {request_id}")
raise TimeoutError("Client response timed out.")
except asyncio.CancelledError:
print(f"Waiting for response for Request ID {request_id} was cancelled.")
raise ConnectionClosedError("Client disconnected before response was received.")
finally:
# Clean up the future if it's still there
pending_requests.pop(request_id, None)
except websockets.exceptions.ConnectionClosedError: except websockets.exceptions.ConnectionClosedError:
print(f"Failed to send to client {client_id}: connection closed.") print(f"Failed to send to client {client_id}: connection closed.")
# If the connection closed, and we were waiting for a response, mark the future as cancelled
if future and not future.done():
future.cancel()
await unregister_client(client_id) await unregister_client(client_id)
else: raise
print(f"Client {client_id} not found.") except Exception as e:
print(f"An error occurred sending command to client {client_id}: {e}")
if future and not future.done():
future.cancel()
raise
async def send_command_to_all_clients(command_name, *args): async def send_command_to_all_clients(command_name, *args):
@@ -43,31 +137,71 @@ async def send_command_to_all_clients(command_name, *args):
message = json.dumps({"type": "command", "name": command_name, "args": args}) message = json.dumps({"type": "command", "name": command_name, "args": args})
# Use a list of items to avoid issues if clients disconnect during iteration # Use a list of items to avoid issues if clients disconnect during iteration
clients_to_send = list(current_app.active_clients.items()) clients_to_send = list(current_app.active_clients.items())
for client_id, websocket in clients_to_send: for client_id, active_client in clients_to_send:
try: try:
await websocket.send(message) await active_client.websocket.send(message)
print(f"Sent command '{command_name}' to client {client_id}") print(f"Sent command '{command_name}' to client {client_id}")
except websockets.exceptions.ConnectionClosedError: except websockets.exceptions.ConnectionClosedError:
print(f"Failed to send to client {client_id}: connection closed.") print(f"Failed to send to client {client_id}: connection closed.")
await unregister_client(client_id) await unregister_client(client_id)
@nodes_bp.route("/", methods=['GET']) @nodes_bp.route("/", methods=['GET'])
@jwt_required
@role_required(UserRoles.USER)
async def get_nodes(): async def get_nodes():
"""API endpoint to list currently connected client IDs.""" """API endpoint to list currently connected client IDs."""
return jsonify(list(current_app.active_clients.keys())) return jsonify([current_app.active_clients[client_id].to_dict() for client_id in current_app.active_clients])
@nodes_bp.route("/join", methods=['POST']) @nodes_bp.route("/online", methods=['GET'])
async def join(): @jwt_required
@role_required(UserRoles.USER)
async def get_online_bots():
active_bots = []
for client_id, active_client in current_app.active_clients.items():
if active_client.active_token:
active_bots.append({client_id: active_client.active_token.to_dict()})
return jsonify(active_bots)
@nodes_bp.route("/<client_id>/status", methods=["GET"])
@jwt_required
@role_required(UserRoles.USER)
async def status(client_id):
"""
Get the status from a given client
"""
# Check to make sure the client is online
if client_id not in current_app.active_clients:
return jsonify({"error": f"Client {client_id} not found, it might be offline"}), 404
try:
# Send the command and wait for a response
status_data = await send_command_to_client(client_id, NodeCommands.STATUS, wait_for_response=True, timeout=5)
return jsonify({
"active_client": current_app.active_clients[client_id].to_dict(),
"status": status_data['status']
}), 200
except TimeoutError:
return jsonify({"error": f"Client {client_id} did not respond within the timeout period."}), 504
except ConnectionClosedError:
return jsonify({"error": f"Client {client_id} disconnected before status could be retrieved."}), 503
except Exception as e:
return jsonify({"error": f"Failed to get status from client: {e}"}), 500
@nodes_bp.route("/<client_id>/join", methods=['POST'])
@jwt_required
@role_required(UserRoles.MOD)
async def join(client_id):
""" """
Send a join command to the specific system specified Send a join command to the specific system specified
""" """
data = await request.get_json() data = await request.get_json()
client_id = data.get("client_id") system_id = data.get("system_id", None)
system_id = data.get("system_id")
guild_id = data.get("guild_id") guild_id = data.get("guild_id")
channel_id = data.get("channel_id") channel_id = data.get("channel_id")
@@ -90,14 +224,15 @@ async def join():
return jsonify({"error": f"Failed to send command: {e}"}), 500 return jsonify({"error": f"Failed to send command: {e}"}), 500
@nodes_bp.route("/leave", methods=['POST']) @nodes_bp.route("/<client_id>/leave", methods=['POST'])
async def leave(): @jwt_required
@role_required(UserRoles.MOD)
async def leave(client_id):
""" """
Send a join command to the specific system specified Send a leave command to the specific node
""" """
data = await request.get_json() data = await request.get_json()
client_id = data.get("client_id")
guild_id = data.get("guild_id") guild_id = data.get("guild_id")
# Check to make sure the client is online # Check to make sure the client is online
@@ -117,3 +252,78 @@ async def leave():
except Exception as e: except Exception as e:
return jsonify({"error": f"Failed to send command: {e}"}), 500 return jsonify({"error": f"Failed to send command: {e}"}), 500
@nodes_bp.route("/<client_id>/op25_start", methods=['POST'])
@jwt_required
@role_required(UserRoles.MOD)
async def op25_start(client_id):
"""
Send an OP25 start command to the specific node
"""
# Check to make sure the client is online
if client_id not in current_app.active_clients:
return jsonify({"error": f"Client {client_id} not found, it might be offline"}), 404
try:
# Send the command asynchronously
await send_command_to_client(client_id, NodeCommands.OP25_START)
return jsonify({"status": "command sent", "client_id": client_id, "command": NodeCommands.OP25_START}), 200
except Exception as e:
return jsonify({"error": f"Failed to send command: {e}"}), 500
@nodes_bp.route("/<client_id>/op25_stop", methods=['POST'])
@jwt_required
@role_required(UserRoles.MOD)
async def op25_stop(client_id):
"""
Send an OP25 stop command to the specific node
"""
# Check to make sure the client is online
if client_id not in current_app.active_clients:
return jsonify({"error": f"Client {client_id} not found, it might be offline"}), 404
try:
# Send the command asynchronously
await send_command_to_client(client_id, NodeCommands.OP25_STOP)
return jsonify({"status": "command sent", "client_id": client_id, "command": NodeCommands.OP25_STOP}), 200
except Exception as e:
return jsonify({"error": f"Failed to send command: {e}"}), 500
@nodes_bp.route("/<client_id>/op25_set", methods=['POST'])
@jwt_required
@role_required(UserRoles.MOD)
async def op25_set(client_id):
"""
Send an OP25 set config command to the specific node
"""
data = await request.get_json()
system_id = data.get("system_id")
# Check to make sure the client is online
if client_id not in current_app.active_clients:
return jsonify({"error": f"Client {client_id} not found, it might be offline"}), 404
if not system_id:
return jsonify({"error":"No System ID supplied"}), 400
try:
args = [system_id]
if not isinstance(args, list):
return jsonify({"error": "'args' must be a list"}), 400
# Send the command asynchronously
await send_command_to_client(client_id, NodeCommands.OP25_SET, *args)
return jsonify({"status": "command sent", "client_id": client_id, "command": NodeCommands.OP25_SET}), 200
except Exception as e:
return jsonify({"error": f"Failed to send command: {e}"}), 500

View File

@@ -1,13 +1,15 @@
from quart import Blueprint, jsonify, request, abort from quart import Blueprint, jsonify, request, abort, current_app
from internal.db_wrappers import SystemDbController
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from internal.types import System from internal.types import System, UserRoles
from quart_jwt_extended import jwt_required
from routers.auth import role_required
systems_bp = Blueprint('systems', __name__) systems_bp = Blueprint('systems', __name__)
db_h = SystemDbController()
@systems_bp.route("/", methods=['POST']) @systems_bp.route("/", methods=['POST'])
@jwt_required
@role_required(UserRoles.MOD)
async def create_system_route(): async def create_system_route():
"""API endpoint to create a new system.""" """API endpoint to create a new system."""
print("\n--- Handling POST /systems ---") print("\n--- Handling POST /systems ---")
@@ -19,28 +21,28 @@ async def create_system_route():
abort(400, "Request body must be JSON") # Bad Request abort(400, "Request body must be JSON") # Bad Request
if '_id' in request_data: if '_id' in request_data:
id_search_result = await db_h.find_system({"_id": request_data["_id"]}) id_search_result = await current_app.sys_db_h.find_system({"_id": request_data["_id"]})
if id_search_result: if id_search_result:
# If _id is provided and exists, return conflict # If _id is provided and exists, return conflict
abort(409, f"System with ID '{request_data['_id']}' already exists") abort(409, f"System with ID '{request_data['_id']}' already exists")
# Check if name exists (optional, depending on requirements) # Check if name exists (optional, depending on requirements)
if 'name' in request_data: if 'name' in request_data:
name_search_result = await db_h.find_system({"name": request_data["name"]}) name_search_result = await current_app.sys_db_h.find_system({"name": request_data["name"]})
if name_search_result: if name_search_result:
abort(409, f"System with name '{request_data['name']}' already exists") abort(409, f"System with name '{request_data['name']}' already exists")
# Check if frequency_khz exists (optional, depending on requirements) # Check if frequencies exists (optional, depending on requirements)
if 'frequency_khz' in request_data: if 'frequencies' in request_data:
freq_search_result = await db_h.find_system({"frequency_khz": request_data["frequency_khz"]}) freq_search_result = await current_app.sys_db_h.find_system({"frequencies": request_data["frequencies"]})
if freq_search_result: if freq_search_result:
abort(409, f"System with frequency '{request_data['frequency_khz']}' already exists") abort(409, f"System with frequency '{request_data['frequencies']}' already exists")
created_system = await db_h.create_system(request_data) created_system = await current_app.sys_db_h.create_system(request_data)
if created_system: if created_system:
print("Created new system:", created_system) print("Created new system:", created_system)
return jsonify(created_system), 201 return jsonify(created_system.to_dict()), 201
else: else:
abort(500, "Failed to create system in the database.") abort(500, "Failed to create system in the database.")
except HTTPException: except HTTPException:
@@ -52,11 +54,13 @@ async def create_system_route():
@systems_bp.route('/', methods=['GET']) @systems_bp.route('/', methods=['GET'])
@jwt_required
@role_required(UserRoles.USER)
async def list_systems_route(): async def list_systems_route():
"""API endpoint to get a list of all systems.""" """API endpoint to get a list of all systems."""
print("\n--- Handling GET /systems ---") print("\n--- Handling GET /systems ---")
try: try:
all_systems = await db_h.find_all_systems() all_systems = await current_app.sys_db_h.find_all_systems()
return jsonify([system.to_dict() for system in all_systems]), 200 # 200 OK status code return jsonify([system.to_dict() for system in all_systems]), 200 # 200 OK status code
except HTTPException: except HTTPException:
@@ -67,12 +71,14 @@ async def list_systems_route():
@systems_bp.route('/<string:system_id>', methods=['GET']) @systems_bp.route('/<string:system_id>', methods=['GET'])
@jwt_required
@role_required(UserRoles.USER)
async def get_system_route(system_id: str): async def get_system_route(system_id: str):
"""API endpoint to get details for a specific system by ID.""" """API endpoint to get details for a specific system by ID."""
print(f"\n--- Handling GET /systems/{system_id} ---") print(f"\n--- Handling GET /systems/{system_id} ---")
try: try:
# Fix the query dictionary syntax # Fix the query dictionary syntax
system = await db_h.find_system({'_id': system_id}) system = await current_app.sys_db_h.find_system({'_id': system_id})
if system: if system:
# system is a System object, jsonify will convert it # system is a System object, jsonify will convert it
@@ -88,12 +94,14 @@ async def get_system_route(system_id: str):
@systems_bp.route('/client/<string:client_id>', methods=['GET']) @systems_bp.route('/client/<string:client_id>', methods=['GET'])
@jwt_required
@role_required(UserRoles.USER)
async def get_system_by_client_route(client_id: str): async def get_system_by_client_route(client_id: str):
"""API endpoint to get details for a specific system by ID.""" """API endpoint to get details for a specific system by ID."""
print(f"\n--- Handling GET /systems/client/{client_id} ---") print(f"\n--- Handling GET /systems/client/{client_id} ---")
try: try:
# Fix the query dictionary syntax # Fix the query dictionary syntax
systems = await db_h.find_systems({'avail_on_nodes': client_id}) systems = await current_app.sys_db_h.find_systems({'avail_on_nodes': client_id})
if systems: if systems:
# system is a System object, jsonify will convert it # system is a System object, jsonify will convert it
@@ -109,9 +117,17 @@ async def get_system_by_client_route(client_id: str):
@systems_bp.route('/<string:system_id>', methods=['PUT']) @systems_bp.route('/<string:system_id>', methods=['PUT'])
async def update_system_route(system_id: str, updated_system_data): @jwt_required
@role_required(UserRoles.MOD)
async def update_system_route(system_id: str):
try: try:
update_system = await db_h.update_system({"_id", system_id}, updated_system_data) updated_system_data = await request.get_json()
if not updated_system_data:
abort(400, "No update data provided.")
query = {"_id": system_id}
update_system = await current_app.sys_db_h.update_system(query, {"$set": updated_system_data})
if update_system: if update_system:
print("Updated system:", update_system) print("Updated system:", update_system)
@@ -128,25 +144,28 @@ async def update_system_route(system_id: str, updated_system_data):
@systems_bp.route('/<string:system_id>', methods=['DELETE']) @systems_bp.route('/<string:system_id>', methods=['DELETE'])
@jwt_required
@role_required(UserRoles.MOD)
async def delete_system_route(system_id: str): async def delete_system_route(system_id: str):
try: try:
deleted_system = await db_h.delete_system({"_id", system_id}) query = {"_id": system_id}
delete_count = await current_app.sys_db_h.delete_system(query)
if deleted_system: if delete_count is not None:
print("Deleted system:", deleted_system) if delete_count > 0:
return jsonify(deleted_system), 201 return jsonify({"message": f"Successfully deleted {delete_count} systems(s)."}), 200
else: else:
abort(500, "Failed to delete system in the database.") abort(404, "System not found.")
else:
except HTTPException: abort(500, "Failed to delete System.")
raise
except Exception as e: except Exception as e:
print(f"Error deleting system: {e}") print(f"Error in delete_system_route: {e}")
# Catch any other unexpected errors abort(500, f"An internal error occurred: {e}")
abort(500, f"Internal server error: {e}")
@systems_bp.route('/<string:system_id>/assign', methods=['POST']) @systems_bp.route('/<string:system_id>/assign', methods=['POST'])
@jwt_required
@role_required(UserRoles.MOD)
async def assign_client_to_system_route(system_id: str): async def assign_client_to_system_route(system_id: str):
""" """
API endpoint to assign a client ID to a system's available_on_nodes list. API endpoint to assign a client ID to a system's available_on_nodes list.
@@ -166,7 +185,7 @@ async def assign_client_to_system_route(system_id: str):
abort(400, "'client_id' must be a non-empty string") abort(400, "'client_id' must be a non-empty string")
# First, check if the system exists # First, check if the system exists
existing_system = await db_h.find_system({"_id": system_id}) existing_system = await current_app.sys_db_h.find_system({"_id": system_id})
if existing_system is None: if existing_system is None:
abort(404, f"System with ID '{system_id}' not found") abort(404, f"System with ID '{system_id}' not found")
@@ -175,7 +194,7 @@ async def assign_client_to_system_route(system_id: str):
update_query = {"_id": system_id} update_query = {"_id": system_id}
update_data = {"$addToSet": {"avail_on_nodes": client_id}} update_data = {"$addToSet": {"avail_on_nodes": client_id}}
update_result = await db_h.update_system(update_query, update_data) update_result = await current_app.sys_db_h.update_system(update_query, update_data)
if update_result > 0: if update_result > 0:
print(f"Client '{client_id}' assigned to system '{system_id}'.") print(f"Client '{client_id}' assigned to system '{system_id}'.")
@@ -184,7 +203,7 @@ async def assign_client_to_system_route(system_id: str):
print(f"Client '{client_id}' was already assigned to system '{system_id}'.") print(f"Client '{client_id}' was already assigned to system '{system_id}'.")
status = "already_assigned" status = "already_assigned"
updated_system = await db_h.find_system({"_id": system_id}) updated_system = await current_app.sys_db_h.find_system({"_id": system_id})
if updated_system: if updated_system:
return jsonify({ return jsonify({
"status": status, "status": status,
@@ -203,6 +222,8 @@ async def assign_client_to_system_route(system_id: str):
@systems_bp.route('/<string:system_id>/dismiss', methods=['POST']) @systems_bp.route('/<string:system_id>/dismiss', methods=['POST'])
@jwt_required
@role_required(UserRoles.MOD)
async def dismiss_client_from_system_route(system_id: str): async def dismiss_client_from_system_route(system_id: str):
""" """
API endpoint to dismiss (remove) a client ID from a system's available_on_nodes list. API endpoint to dismiss (remove) a client ID from a system's available_on_nodes list.
@@ -222,7 +243,7 @@ async def dismiss_client_from_system_route(system_id: str):
abort(400, "'client_id' must be a non-empty string") abort(400, "'client_id' must be a non-empty string")
# First, check if the system exists # First, check if the system exists
existing_system = await db_h.find_system({"_id": system_id}) existing_system = await current_app.sys_db_h.find_system({"_id": system_id})
if existing_system is None: if existing_system is None:
abort(404, f"System with ID '{system_id}' not found") abort(404, f"System with ID '{system_id}' not found")
@@ -231,7 +252,7 @@ async def dismiss_client_from_system_route(system_id: str):
update_query = {"_id": system_id} update_query = {"_id": system_id}
update_data = {"$pull": {"avail_on_nodes": client_id}} update_data = {"$pull": {"avail_on_nodes": client_id}}
update_result = await db_h.update_system(update_query, update_data) update_result = await current_app.sys_db_h.update_system(update_query, update_data)
if update_result > 0: if update_result > 0:
print(f"Client '{client_id}' dismissed from system '{system_id}'.") print(f"Client '{client_id}' dismissed from system '{system_id}'.")
@@ -242,7 +263,7 @@ async def dismiss_client_from_system_route(system_id: str):
# Note: update_result.matched_count will be 1 even if modified_count is 0 # Note: update_result.matched_count will be 1 even if modified_count is 0
# Optionally fetch the updated document to return its current state # Optionally fetch the updated document to return its current state
updated_system = await db_h.find_system({"_id": system_id}) updated_system = await current_app.sys_db_h.find_system({"_id": system_id})
if updated_system: if updated_system:
return jsonify({ return jsonify({
"status": status, "status": status,
@@ -261,17 +282,21 @@ async def dismiss_client_from_system_route(system_id: str):
@systems_bp.route('/search', methods=['GET']) @systems_bp.route('/search', methods=['GET'])
@jwt_required
@role_required(UserRoles.MOD)
async def search_systems_route(): async def search_systems_route():
""" """
API endpoint to search for systems based on query parameters. API endpoint to search for systems based on query parameters.
Allows searching by 'name', 'frequency_khz', or any other field present in the System model. Allows searching by 'name', 'frequencies', or any other field present in the System model.
Example: /systems/search?name=MySystem&frequency_khz=1000 Example: /systems/search?name=MySystem&frequencies=1000
""" """
print("\n--- Handling GET /systems/search ---") print("\n--- Handling GET /systems/search ---")
try: try:
query_params = dict(request.args) query_params = dict(request.args)
systems = await db_h.find_systems(query_params) systems = await current_app.sys_db_h.find_systems(query_params)
print("Found systems", systems)
if systems: if systems:
# If systems are found, return them as a list of dictionaries # If systems are found, return them as a list of dictionaries

View File

@@ -1,38 +1,61 @@
# server.py
import asyncio import asyncio
import websockets import websockets
import json import json
import uuid import uuid
from quart import Quart, jsonify, request from quart import Quart, jsonify, request, abort
from quart_cors import cors
from routers.systems import systems_bp from routers.systems import systems_bp
from routers.nodes import nodes_bp, register_client, unregister_client from routers.nodes import nodes_bp, register_client, unregister_client
from routers.bot import bot_bp
from routers.auth import auth_bp
from internal.db_wrappers import SystemDbController, DiscordIdDbController #
from internal.auth_wrappers import UserDbController #
from config.jwt_config import jwt, configure_jwt
# --- WebSocket Server Components --- # --- WebSocket Server Components ---
# Dictionary to store active clients: {client_id: websocket}
active_clients = {} active_clients = {}
# --- Quart API Components ---
app = Quart(__name__)
app = cors(app, allow_origin="*")
websocket_server_instance = None
app.active_clients = active_clients
# Create and attach the DB wrappers
app.sys_db_h = SystemDbController() #
app.d_id_db_h = DiscordIdDbController() #
app.user_db_h = UserDbController() #
configure_jwt(app)
jwt.init_app(app)
async def websocket_server_handler(websocket): async def websocket_server_handler(websocket):
"""Handles incoming WebSocket connections and messages from clients."""
client_id = None client_id = None
try: try:
# Handshake: Receive the first message which should contain the client ID
handshake_message = await websocket.recv() handshake_message = await websocket.recv()
handshake_data = json.loads(handshake_message) handshake_data = json.loads(handshake_message)
if handshake_data.get("type") == "handshake" and "id" in handshake_data: if handshake_data.get("type") == "handshake" and "id" in handshake_data:
client_id = handshake_data["id"] client_id = handshake_data["id"]
await register_client(websocket, client_id) client_nickname = handshake_data.get("nickname")
await websocket.send(json.dumps({"type": "handshake_ack", "status": "success"})) # Acknowledge handshake client_active_token = handshake_data.get("active_token")
# Get the DiscordId for the passed token
# Keep the connection alive and listen for potential messages from the client if client_active_token:
# (Though in this server-commanded model, clients might not send much) active_discord_id = await app.d_id_db_h.find_discord_id({"token":client_active_token})
# We primarily wait for the client to close the connection if active_discord_id:
client_active_token = active_discord_id
await register_client(websocket, client_id, client_nickname, client_active_token)
if not app.active_clients[client_id].access_token:
abort(500, "Error retrieving access token")
await websocket.send(json.dumps({"type": "handshake_ack", "status": "success", "access_token": app.active_clients[client_id].access_token}))
await websocket.wait_closed() await websocket.wait_closed()
else: else:
print(f"Received invalid handshake from {websocket.remote_address}. Closing connection.") print(f"Received invalid handshake from {websocket.remote_address}. Closing connection.")
await websocket.close() await websocket.close()
except websockets.exceptions.ConnectionClosedError: except websockets.exceptions.ConnectionClosedError:
print(f"Client connection closed unexpectedly for {client_id}.") print(f"Client connection closed unexpectedly for {client_id}.")
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -43,65 +66,59 @@ async def websocket_server_handler(websocket):
if client_id: if client_id:
await unregister_client(client_id) await unregister_client(client_id)
# --- Quart API Components ---
app = Quart(__name__)
# Store the websocket server instance
websocket_server_instance = None
# Make active_clients accessible via the app instance.
app.active_clients = active_clients
@app.before_serving @app.before_serving
async def startup_websocket_server(): async def startup_tasks(): # Combined startup logic
"""Starts the WebSocket server when the Quart app starts.""" """Starts the WebSocket server and prepares other resources."""
global websocket_server_instance global websocket_server_instance
websocket_server_address = "0.0.0.0" websocket_server_address = "0.0.0.0"
websocket_server_port = 8765 websocket_server_port = 8765
# Start the WebSocket server task
websocket_server_instance = await websockets.serve( websocket_server_instance = await websockets.serve(
websocket_server_handler, websocket_server_handler,
websocket_server_address, websocket_server_address,
websocket_server_port websocket_server_port
) )
print(f"WebSocket server started on ws://{websocket_server_address}:{websocket_server_port}") print(f"WebSocket server started on ws://{websocket_server_address}:{websocket_server_port}")
# Database connections are now established on first use by MongoHandler's __aenter__/connect
# No explicit connect calls needed here unless desired for early failure detection.
print("Application startup complete. DB connections will be initialized on first use.")
@app.after_serving @app.after_serving
async def shutdown_websocket_server(): async def shutdown_tasks(): # Combined shutdown logic
"""Shuts down the WebSocket server when the Quart app stops.""" """Shuts down services and closes connections."""
global websocket_server_instance global websocket_server_instance
if websocket_server_instance: if websocket_server_instance:
websocket_server_instance.close() websocket_server_instance.close()
await websocket_server_instance.wait_closed() await websocket_server_instance.wait_closed()
print("WebSocket server shut down.") print("WebSocket server shut down.")
# Close database connections
if hasattr(app, 'user_db_h') and app.user_db_h:
print("Closing User DB connection...")
await app.user_db_h.close_db_connection() #
if hasattr(app, 'sys_db_h') and app.sys_db_h:
print("Closing System DB connection...")
await app.sys_db_h.close_db_connection() #
if hasattr(app, 'd_id_db_h') and app.d_id_db_h:
print("Closing Discord ID DB connection...")
await app.d_id_db_h.close_db_connection() #
print("All database connections have been signaled to close.")
app.register_blueprint(systems_bp, url_prefix="/systems") app.register_blueprint(systems_bp, url_prefix="/systems")
app.register_blueprint(nodes_bp, url_prefix="/nodes") app.register_blueprint(nodes_bp, url_prefix="/nodes")
app.register_blueprint(bot_bp, url_prefix="/bots")
app.register_blueprint(auth_bp, url_prefix="/auth")
@app.route('/') @app.route('/')
async def index(): async def index():
return "Welcome to the Radio App Server API!" return "Welcome to the Radio App Server API!"
@app.route('/request_token', methods=['POST'])
async def request_token():
"""API endpoint to list currently connected client IDs."""
# TODO - Add DB logic
return jsonify({
"token": "MTE5NjAwNTM2ODYzNjExMjk3Nw.GuCMXg.24iNNofNNumq46FIj68zMe9RmQgugAgfrvelEA"
})
# --- Main Execution ---
if __name__ == "__main__": if __name__ == "__main__":
# Quart's app.run() will start the asyncio event loop and manage it.
# The @app.before_serving decorator ensures the websocket server starts within that loop.
# We removed asyncio.run(main()) and the main() function itself.
print("Starting Quart API server...") print("Starting Quart API server...")
app.run( app.run(
host="0.0.0.0", host="0.0.0.0",
port=5000, port=5000,
debug=False # Set to True for development debug=False
) )
print("Quart API server stopped.") print("Quart API server stopped.")

View File

@@ -1,4 +1,7 @@
websockets websockets
quart quart
quart-cors
motor motor
fastapi fastapi
quart-jwt-extended
passlib[bcrypt]