diff --git a/CHANGELOG.md b/CHANGELOG.md index 266ec6bf..ce01d1b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## --- [4.2.0] - 2023/TBD ### New features - Finish and Activate Arcadia notification backend ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/621) | [Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/626) | [Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/632)) +- Add initial Webhook Notification (Discord, Mattermost, Slack, Teams) ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/594)) ### Bug fixes - PWA: Removed the custom offline page in favour of browser default ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/607)) - Fix hidden servers appearing visible on public mobile status page ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/612)) diff --git a/app/classes/controllers/management_controller.py b/app/classes/controllers/management_controller.py index cda74fea..25c2a7f2 100644 --- a/app/classes/controllers/management_controller.py +++ b/app/classes/controllers/management_controller.py @@ -1,7 +1,7 @@ import logging import queue -from app.classes.models.management import HelpersManagement +from app.classes.models.management import HelpersManagement, HelpersWebhooks from app.classes.models.servers import HelperServers logger = logging.getLogger(__name__) @@ -206,3 +206,30 @@ class ManagementController: @staticmethod def set_master_server_dir(server_dir): HelpersManagement.set_master_server_dir(server_dir) + + # ********************************************************************************** + # Webhooks Methods + # ********************************************************************************** + @staticmethod + def create_webhook(data): + return HelpersWebhooks.create_webhook(data) + + @staticmethod + def modify_webhook(webhook_id, data): + HelpersWebhooks.modify_webhook(webhook_id, data) + + @staticmethod + def get_webhook_by_id(webhook_id): + return HelpersWebhooks.get_webhook_by_id(webhook_id) + + @staticmethod + def get_webhooks_by_server(server_id, model=False): + return HelpersWebhooks.get_webhooks_by_server(server_id, model) + + @staticmethod + def delete_webhook(webhook_id): + HelpersWebhooks.delete_webhook(webhook_id) + + @staticmethod + def delete_webhook_by_server(server_id): + HelpersWebhooks.delete_webhooks_by_server(server_id) diff --git a/app/classes/models/management.py b/app/classes/models/management.py index fc0a1eb6..e86e3209 100644 --- a/app/classes/models/management.py +++ b/app/classes/models/management.py @@ -79,11 +79,15 @@ class HostStats(BaseModel): # ********************************************************************************** class Webhooks(BaseModel): id = AutoField() - name = CharField(max_length=64, unique=True, index=True) - method = CharField(default="POST") - url = CharField(unique=True) - event = CharField(default="") - send_data = BooleanField(default=True) + server_id = IntegerField(null=True) + name = CharField(default="Custom Webhook", max_length=64) + url = CharField(default="") + webhook_type = CharField(default="Custom") + bot_name = CharField(default="Crafty Controller") + trigger = CharField(default="server_start,server_stop") + body = CharField(default="") + color = CharField(default="#005cd1") + enabled = BooleanField(default=True) class Meta: table_name = "webhooks" @@ -501,3 +505,82 @@ class HelpersManagement: f"Not removing {dir_to_del} from excluded directories - " f"not in the excluded directory list for server ID {server_id}" ) + + +# ********************************************************************************** +# Webhooks Class +# ********************************************************************************** +class HelpersWebhooks: + def __init__(self, database): + self.database = database + + @staticmethod + def create_webhook(create_data) -> int: + """Create a webhook in the database + + Args: + server_id: ID of a server this webhook will be married to + name: The name of the webhook + url: URL to the webhook + webhook_type: The provider this webhook will be sent to + bot name: The name that will appear when the webhook is sent + triggers: Server actions that will trigger this webhook + body: The message body of the webhook + enabled: Should Crafty trigger the webhook + + Returns: + int: The new webhooks's id + + Raises: + PeeweeException: If the webhook already exists + """ + return Webhooks.insert( + { + Webhooks.server_id: create_data["server_id"], + Webhooks.name: create_data["name"], + Webhooks.webhook_type: create_data["webhook_type"], + Webhooks.url: create_data["url"], + Webhooks.bot_name: create_data["bot_name"], + Webhooks.body: create_data["body"], + Webhooks.color: create_data["color"], + Webhooks.trigger: create_data["trigger"], + Webhooks.enabled: create_data["enabled"], + } + ).execute() + + @staticmethod + def modify_webhook(webhook_id, updata): + Webhooks.update(updata).where(Webhooks.id == webhook_id).execute() + + @staticmethod + def get_webhook_by_id(webhook_id): + return model_to_dict(Webhooks.get(Webhooks.id == webhook_id)) + + @staticmethod + def get_webhooks_by_server(server_id, model): + if not model: + data = {} + for webhook in ( + Webhooks.select().where(Webhooks.server_id == server_id).execute() + ): + data[str(webhook.id)] = { + "webhook_type": webhook.webhook_type, + "name": webhook.name, + "url": webhook.url, + "bot_name": webhook.bot_name, + "trigger": webhook.trigger, + "body": webhook.body, + "color": webhook.color, + "enabled": webhook.enabled, + } + else: + data = Webhooks.select().where(Webhooks.server_id == server_id).execute() + return data + + @staticmethod + def delete_webhook(webhook_id): + Webhooks.delete().where(Webhooks.id == webhook_id).execute() + + @staticmethod + def delete_webhooks_by_server(server_id): + Webhooks.delete().where(Webhooks.server_id == server_id).execute() diff --git a/app/classes/shared/server.py b/app/classes/shared/server.py index 2f62dc68..30d9adb0 100644 --- a/app/classes/shared/server.py +++ b/app/classes/shared/server.py @@ -19,13 +19,13 @@ from zoneinfo import ZoneInfo from tzlocal import get_localzone from tzlocal.utils import ZoneInfoNotFoundError from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.jobstores.base import JobLookupError +from apscheduler.jobstores.base import JobLookupError, ConflictingIdError from app.classes.minecraft.stats import Stats from app.classes.minecraft.mc_ping import ping, ping_bedrock from app.classes.models.servers import HelperServers, Servers from app.classes.models.server_stats import HelperServerStats -from app.classes.models.management import HelpersManagement +from app.classes.models.management import HelpersManagement, HelpersWebhooks from app.classes.models.users import HelperUsers from app.classes.models.server_permissions import PermissionsServers from app.classes.shared.console import Console @@ -33,6 +33,7 @@ from app.classes.shared.helpers import Helpers from app.classes.shared.file_helpers import FileHelpers from app.classes.shared.null_writer import NullWriter from app.classes.shared.websocket_manager import WebSocketManager +from app.classes.web.webhooks.webhook_factory import WebhookFactory with redirect_stderr(NullWriter()): import psutil @@ -165,6 +166,45 @@ class ServerInstance: self.stats_helper.server_crash_reset() self.stats_helper.set_update(False) + @staticmethod + def callback(called_func): + # Usage of @callback on method + # definition to run a webhook check + # on method completion + def wrapper(*args, **kwargs): + res = None + logger.debug("Checking for callbacks") + try: + res = called_func(*args, **kwargs) + finally: + events = WebhookFactory.get_monitored_events() + if called_func.__name__ in events: + server_webhooks = HelpersWebhooks.get_webhooks_by_server( + args[0].server_id, True + ) + for swebhook in server_webhooks: + if called_func.__name__ in str(swebhook.trigger).split(","): + logger.info( + f"Found callback for event {called_func.__name__}" + f" for server {args[0].server_id}" + ) + webhook = HelpersWebhooks.get_webhook_by_id(swebhook.id) + webhook_provider = WebhookFactory.create_provider( + webhook["webhook_type"] + ) + if res is not False and swebhook.enabled: + webhook_provider.send( + bot_name=webhook["bot_name"], + server_name=args[0].name, + title=webhook["name"], + url=webhook["url"], + message=webhook["body"], + color=webhook["color"], + ) + return res + + return wrapper + # ********************************************************************************** # Minecraft Server Management # ********************************************************************************** @@ -262,13 +302,13 @@ class ServerInstance: seconds=30, id="save_stats_" + str(self.server_id), ) - except: - self.server_scheduler.remove_job("save_" + str(self.server_id)) + except ConflictingIdError: + self.server_scheduler.remove_job("save_stats_" + str(self.server_id)) self.server_scheduler.add_job( self.record_server_stats, "interval", seconds=30, - id="save_" + str(self.server_id), + id="save_stats_" + str(self.server_id), ) def setup_server_run_command(self): @@ -332,6 +372,7 @@ class ServerInstance: logger.critical(f"Unable to write/access {self.server_path}") Console.critical(f"Unable to write/access {self.server_path}") + @callback def start_server(self, user_id, forge_install=False): if not user_id: user_lang = self.helper.get_setting("language") @@ -775,6 +816,7 @@ class ServerInstance: if self.server_thread: self.server_thread.join() + @callback def stop_server(self): running = self.check_running() if not running: @@ -882,6 +924,7 @@ class ServerInstance: self.last_rc = poll return False + @callback def send_command(self, command): if not self.check_running() and command.lower() != "start": logger.warning(f'Server not running, unable to send command "{command}"') @@ -894,6 +937,7 @@ class ServerInstance: self.process.stdin.flush() return True + @callback def crash_detected(self, name): # clear the old scheduled watcher task self.server_scheduler.remove_job(f"c_{self.server_id}") @@ -914,6 +958,7 @@ class ServerInstance: f"The server {name} has crashed and will be restarted. " f"Restarting server" ) + self.run_threaded_server(None) return True logger.critical( @@ -926,6 +971,7 @@ class ServerInstance: ) return False + @callback def kill(self): logger.info(f"Terminating server {self.server_id} and all child processes") try: @@ -1014,6 +1060,7 @@ class ServerInstance: f.write("eula=true") self.run_threaded_server(user_id) + @callback def backup_server(self): if self.settings["backup_path"] == "": logger.critical("Backup path is None. Canceling Backup!") @@ -1228,6 +1275,7 @@ class ServerInstance: if f["path"].endswith(".zip") ] + @callback def jar_update(self): self.stats_helper.set_update(True) update_thread = threading.Thread( diff --git a/app/classes/web/panel_handler.py b/app/classes/web/panel_handler.py index 05250a6d..58b6056c 100644 --- a/app/classes/web/panel_handler.py +++ b/app/classes/web/panel_handler.py @@ -25,6 +25,7 @@ from app.classes.controllers.roles_controller import RolesController from app.classes.shared.helpers import Helpers from app.classes.shared.main_models import DatabaseShortcuts from app.classes.web.base_handler import BaseHandler +from app.classes.web.webhooks.webhook_factory import WebhookFactory logger = logging.getLogger(__name__) @@ -344,7 +345,9 @@ class PanelHandler(BaseHandler): ) as credits_default_local: try: remote = requests.get( - "https://craftycontrol.com/credits-v2", allow_redirects=True + "https://craftycontrol.com/credits-v2", + allow_redirects=True, + timeout=10, ) credits_dict: dict = remote.json() if not credits_dict["staff"]: @@ -745,6 +748,22 @@ class PanelHandler(BaseHandler): page_data["history_stats"] = self.controller.servers.get_history_stats( server_id, hours=(days * 24) ) + if subpage == "webhooks": + if ( + not page_data["permissions"]["Config"] + in page_data["user_permissions"] + ): + if not superuser: + self.redirect( + "/panel/error?error=Unauthorized access to Webhooks Config" + ) + return + page_data[ + "webhooks" + ] = self.controller.management.get_webhooks_by_server( + server_id, model=True + ) + page_data["triggers"] = WebhookFactory.get_monitored_events() def get_banned_players_html(): banned_players = self.controller.servers.get_banned_players(server_id) @@ -1012,6 +1031,110 @@ class PanelHandler(BaseHandler): template = "panel/panel_edit_user.html" + elif page == "add_webhook": + server_id = self.get_argument("id", None) + if server_id is None: + return self.redirect("/panel/error?error=Invalid Server ID") + server_obj = self.controller.servers.get_server_instance_by_id(server_id) + page_data["backup_failed"] = server_obj.last_backup_status() + server_obj = None + page_data["active_link"] = "webhooks" + page_data["server_data"] = self.controller.servers.get_server_data_by_id( + server_id + ) + page_data[ + "user_permissions" + ] = self.controller.server_perms.get_user_id_permissions_list( + exec_user["user_id"], server_id + ) + page_data["permissions"] = { + "Commands": EnumPermissionsServer.COMMANDS, + "Terminal": EnumPermissionsServer.TERMINAL, + "Logs": EnumPermissionsServer.LOGS, + "Schedule": EnumPermissionsServer.SCHEDULE, + "Backup": EnumPermissionsServer.BACKUP, + "Files": EnumPermissionsServer.FILES, + "Config": EnumPermissionsServer.CONFIG, + "Players": EnumPermissionsServer.PLAYERS, + } + page_data["server_stats"] = self.controller.servers.get_server_stats_by_id( + server_id + ) + page_data["server_stats"][ + "server_type" + ] = self.controller.servers.get_server_type_by_id(server_id) + page_data["new_webhook"] = True + page_data["webhook"] = {} + page_data["webhook"]["webhook_type"] = "Custom" + page_data["webhook"]["name"] = "" + page_data["webhook"]["url"] = "" + page_data["webhook"]["bot_name"] = "Crafty Controller" + page_data["webhook"]["trigger"] = [] + page_data["webhook"]["body"] = "" + page_data["webhook"]["color"] = "#005cd1" + page_data["webhook"]["enabled"] = True + + page_data["providers"] = WebhookFactory.get_supported_providers() + page_data["triggers"] = WebhookFactory.get_monitored_events() + + if not EnumPermissionsServer.CONFIG in page_data["user_permissions"]: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access To Webhooks") + return + + template = "panel/server_webhook_edit.html" + + elif page == "webhook_edit": + server_id = self.get_argument("id", None) + webhook_id = self.get_argument("webhook_id", None) + if server_id is None: + return self.redirect("/panel/error?error=Invalid Server ID") + server_obj = self.controller.servers.get_server_instance_by_id(server_id) + page_data["backup_failed"] = server_obj.last_backup_status() + server_obj = None + page_data["active_link"] = "webhooks" + page_data["server_data"] = self.controller.servers.get_server_data_by_id( + server_id + ) + page_data[ + "user_permissions" + ] = self.controller.server_perms.get_user_id_permissions_list( + exec_user["user_id"], server_id + ) + page_data["permissions"] = { + "Commands": EnumPermissionsServer.COMMANDS, + "Terminal": EnumPermissionsServer.TERMINAL, + "Logs": EnumPermissionsServer.LOGS, + "Schedule": EnumPermissionsServer.SCHEDULE, + "Backup": EnumPermissionsServer.BACKUP, + "Files": EnumPermissionsServer.FILES, + "Config": EnumPermissionsServer.CONFIG, + "Players": EnumPermissionsServer.PLAYERS, + } + page_data["server_stats"] = self.controller.servers.get_server_stats_by_id( + server_id + ) + page_data["server_stats"][ + "server_type" + ] = self.controller.servers.get_server_type_by_id(server_id) + page_data["new_webhook"] = False + page_data["webhook"] = self.controller.management.get_webhook_by_id( + webhook_id + ) + page_data["webhook"]["trigger"] = str( + page_data["webhook"]["trigger"] + ).split(",") + + page_data["providers"] = WebhookFactory.get_supported_providers() + page_data["triggers"] = WebhookFactory.get_monitored_events() + + if not EnumPermissionsServer.CONFIG in page_data["user_permissions"]: + if not superuser: + self.redirect("/panel/error?error=Unauthorized access To Webhooks") + return + + template = "panel/server_webhook_edit.html" + elif page == "add_schedule": server_id = self.get_argument("id", None) if server_id is None: diff --git a/app/classes/web/routes/api/api_handlers.py b/app/classes/web/routes/api/api_handlers.py index 49dcb9de..706c346f 100644 --- a/app/classes/web/routes/api/api_handlers.py +++ b/app/classes/web/routes/api/api_handlers.py @@ -50,6 +50,12 @@ from app.classes.web.routes.api.servers.server.tasks.task.children import ( from app.classes.web.routes.api.servers.server.tasks.task.index import ( ApiServersServerTasksTaskIndexHandler, ) +from app.classes.web.routes.api.servers.server.webhooks.index import ( + ApiServersServerWebhooksIndexHandler, +) +from app.classes.web.routes.api.servers.server.webhooks.webhook.index import ( + ApiServersServerWebhooksManagementIndexHandler, +) from app.classes.web.routes.api.servers.server.users import ApiServersServerUsersHandler from app.classes.web.routes.api.users.index import ApiUsersIndexHandler from app.classes.web.routes.api.users.user.index import ApiUsersUserIndexHandler @@ -250,6 +256,16 @@ def api_handlers(handler_args): ApiServersServerHistoryHandler, handler_args, ), + ( + r"/api/v2/servers/([0-9]+)/webhook/([0-9]+)/?", + ApiServersServerWebhooksManagementIndexHandler, + handler_args, + ), + ( + r"/api/v2/servers/([0-9]+)/webhook/?", + ApiServersServerWebhooksIndexHandler, + handler_args, + ), ( r"/api/v2/servers/([0-9]+)/action/([a-z_]+)/?", ApiServersServerActionHandler, diff --git a/app/classes/web/routes/api/servers/server/webhooks/index.py b/app/classes/web/routes/api/servers/server/webhooks/index.py new file mode 100644 index 00000000..223171c8 --- /dev/null +++ b/app/classes/web/routes/api/servers/server/webhooks/index.py @@ -0,0 +1,108 @@ +# TODO: create and read + +import json +import logging + +from jsonschema import ValidationError, validate +from app.classes.models.server_permissions import EnumPermissionsServer +from app.classes.web.base_api_handler import BaseApiHandler +from app.classes.web.webhooks.webhook_factory import WebhookFactory + + +logger = logging.getLogger(__name__) +new_webhook_schema = { + "type": "object", + "properties": { + "webhook_type": { + "type": "string", + "enum": WebhookFactory.get_supported_providers(), + }, + "name": {"type": "string"}, + "url": {"type": "string"}, + "bot_name": {"type": "string"}, + "trigger": {"type": "array"}, + "body": {"type": "string"}, + "color": {"type": "string", "default": "#005cd1"}, + "enabled": { + "type": "boolean", + "default": True, + }, + }, + "additionalProperties": False, + "minProperties": 7, +} + + +class ApiServersServerWebhooksIndexHandler(BaseApiHandler): + def get(self, server_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + if ( + EnumPermissionsServer.CONFIG + not in self.controller.server_perms.get_user_id_permissions_list( + auth_data[4]["user_id"], server_id + ) + ): + # if the user doesn't have Schedule permission, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + self.finish_json( + 200, + { + "status": "ok", + "data": self.controller.management.get_webhooks_by_server(server_id), + }, + ) + + def post(self, server_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + try: + data = json.loads(self.request.body) + except json.decoder.JSONDecodeError as e: + return self.finish_json( + 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)} + ) + + try: + validate(data, new_webhook_schema) + except ValidationError as e: + return self.finish_json( + 400, + { + "status": "error", + "error": "INVALID_JSON_SCHEMA", + "error_data": str(e), + }, + ) + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if ( + EnumPermissionsServer.CONFIG + not in self.controller.server_perms.get_user_id_permissions_list( + auth_data[4]["user_id"], server_id + ) + ): + # if the user doesn't have Schedule permission, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + data["server_id"] = server_id + + self.controller.management.add_to_audit_log( + auth_data[4]["user_id"], + f"Edited server {server_id}: added webhook", + server_id, + self.get_remote_ip(), + ) + triggers = "" + for item in data["trigger"]: + string = item + "," + triggers += string + data["trigger"] = triggers + webhook_id = self.controller.management.create_webhook(data) + + self.finish_json(200, {"status": "ok", "data": {"webhook_id": webhook_id}}) diff --git a/app/classes/web/routes/api/servers/server/webhooks/webhook/index.py b/app/classes/web/routes/api/servers/server/webhooks/webhook/index.py new file mode 100644 index 00000000..4b58011e --- /dev/null +++ b/app/classes/web/routes/api/servers/server/webhooks/webhook/index.py @@ -0,0 +1,187 @@ +# TODO: read and delete + +import json +import logging + +from jsonschema import ValidationError, validate +from app.classes.models.server_permissions import EnumPermissionsServer +from app.classes.web.webhooks.webhook_factory import WebhookFactory +from app.classes.web.base_api_handler import BaseApiHandler + + +logger = logging.getLogger(__name__) + +webhook_patch_schema = { + "type": "object", + "properties": { + "webhook_type": { + "type": "string", + "enum": WebhookFactory.get_supported_providers(), + }, + "name": {"type": "string"}, + "url": {"type": "string"}, + "bot_name": {"type": "string"}, + "trigger": {"type": "array"}, + "body": {"type": "string"}, + "color": {"type": "string", "default": "#005cd1"}, + "enabled": { + "type": "boolean", + "default": True, + }, + }, + "additionalProperties": False, + "minProperties": 1, +} + + +class ApiServersServerWebhooksManagementIndexHandler(BaseApiHandler): + def get(self, server_id: str, webhook_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + if ( + EnumPermissionsServer.CONFIG + not in self.controller.server_perms.get_user_id_permissions_list( + auth_data[4]["user_id"], server_id + ) + ): + # if the user doesn't have Schedule permission, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + if ( + not str(webhook_id) + in self.controller.management.get_webhooks_by_server(server_id).keys() + ): + return self.finish_json( + 400, {"status": "error", "error": "NO WEBHOOK FOUND"} + ) + self.finish_json( + 200, + { + "status": "ok", + "data": self.controller.management.get_webhook_by_id(webhook_id), + }, + ) + + def delete(self, server_id: str, webhook_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + if ( + EnumPermissionsServer.CONFIG + not in self.controller.server_perms.get_user_id_permissions_list( + auth_data[4]["user_id"], server_id + ) + ): + # if the user doesn't have Schedule permission, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + try: + self.controller.management.delete_webhook(webhook_id) + except Exception: + return self.finish_json( + 400, {"status": "error", "error": "NO WEBHOOK FOUND"} + ) + self.controller.management.add_to_audit_log( + auth_data[4]["user_id"], + f"Edited server {server_id}: removed webhook", + server_id, + self.get_remote_ip(), + ) + + return self.finish_json(200, {"status": "ok"}) + + def patch(self, server_id: str, webhook_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + try: + data = json.loads(self.request.body) + except json.decoder.JSONDecodeError as e: + return self.finish_json( + 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)} + ) + + try: + validate(data, webhook_patch_schema) + except ValidationError as e: + return self.finish_json( + 400, + { + "status": "error", + "error": "INVALID_JSON_SCHEMA", + "error_data": str(e), + }, + ) + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if ( + EnumPermissionsServer.CONFIG + not in self.controller.server_perms.get_user_id_permissions_list( + auth_data[4]["user_id"], server_id + ) + ): + # if the user doesn't have Schedule permission, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + data["server_id"] = server_id + if "trigger" in data.keys(): + triggers = "" + for item in data["trigger"]: + string = item + "," + triggers += string + data["trigger"] = triggers + self.controller.management.modify_webhook(webhook_id, data) + + self.controller.management.add_to_audit_log( + auth_data[4]["user_id"], + f"Edited server {server_id}: updated webhook", + server_id, + self.get_remote_ip(), + ) + + self.finish_json(200, {"status": "ok"}) + + def post(self, server_id: str, webhook_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + self.controller.management.add_to_audit_log( + auth_data[4]["user_id"], + "Tested webhook", + server_id, + self.get_remote_ip(), + ) + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if ( + EnumPermissionsServer.CONFIG + not in self.controller.server_perms.get_user_id_permissions_list( + auth_data[4]["user_id"], server_id + ) + ): + # if the user doesn't have Schedule permission, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + webhook = self.controller.management.get_webhook_by_id(webhook_id) + try: + webhook_provider = WebhookFactory.create_provider(webhook["webhook_type"]) + webhook_provider.send( + server_name=self.controller.servers.get_server_data_by_id(server_id)[ + "server_name" + ], + title=f"Test Webhook: {webhook['name']}", + url=webhook["url"], + message=webhook["body"], + color=webhook["color"], # Prestigious purple! + bot_name="Crafty Webhooks Tester", + ) + except Exception as e: + self.finish_json(500, {"status": "error", "error": str(e)}) + + self.finish_json(200, {"status": "ok"}) diff --git a/app/classes/web/webhooks/base_webhook.py b/app/classes/web/webhooks/base_webhook.py new file mode 100644 index 00000000..75e485fc --- /dev/null +++ b/app/classes/web/webhooks/base_webhook.py @@ -0,0 +1,39 @@ +from abc import ABC, abstractmethod +import logging +import requests + +from app.classes.shared.helpers import Helpers + +logger = logging.getLogger(__name__) +helper = Helpers() + + +class WebhookProvider(ABC): + """ + Base class for all webhook providers. + + Provides a common interface for all webhook provider implementations, + ensuring that each provider will have a send method. + """ + + WEBHOOK_USERNAME = "Crafty Webhooks" + WEBHOOK_PFP_URL = ( + "https://gitlab.com/crafty-controller/crafty-4/-" + + "/raw/master/app/frontend/static/assets/images/" + + "Crafty_4-0.png" + ) + CRAFTY_VERSION = helper.get_version_string() + + def _send_request(self, url, payload, headers=None): + """Send a POST request to the given URL with the provided payload.""" + try: + response = requests.post(url, json=payload, headers=headers, timeout=10) + response.raise_for_status() + return "Dispatch successful" + except requests.RequestException as error: + logger.error(error) + raise RuntimeError(f"Failed to dispatch notification: {error}") from error + + @abstractmethod + def send(self, server_name, title, url, message, **kwargs): + """Abstract method that derived classes will implement for sending webhooks.""" diff --git a/app/classes/web/webhooks/discord_webhook.py b/app/classes/web/webhooks/discord_webhook.py new file mode 100644 index 00000000..eebe38aa --- /dev/null +++ b/app/classes/web/webhooks/discord_webhook.py @@ -0,0 +1,82 @@ +from datetime import datetime +from app.classes.web.webhooks.base_webhook import WebhookProvider + + +class DiscordWebhook(WebhookProvider): + def _construct_discord_payload(self, server_name, title, message, color, bot_name): + """ + Constructs the payload required for sending a Discord webhook notification. + + This method prepares a payload for the Discord webhook API using the provided + message content, the Crafty Controller version, and the current UTC datetime. + + Parameters: + server_name (str): The name of the server triggering the notification. + title (str): The title for the notification message. + message (str): The main content of the notification message. + color (int): The color code for the side stripe in the Discord embed message. + bot_name (str): Override for the Webhook's name set on creation + + Returns: + tuple: A tuple containing the constructed payload (dict) incl headers (dict). + + Note: + - Discord embed designer + - https://discohook.org/ + """ + current_datetime = datetime.utcnow() + formatted_datetime = ( + current_datetime.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" + ) + + # Convert the hex to an integer + sanitized_hex = color[1:] if color.startswith("#") else color + color_int = int(sanitized_hex, 16) + + headers = {"Content-type": "application/json"} + payload = { + "username": bot_name, + "avatar_url": self.WEBHOOK_PFP_URL, + "embeds": [ + { + "title": title, + "description": message, + "color": color_int, + "author": {"name": server_name}, + "footer": {"text": f"Crafty Controller v.{self.CRAFTY_VERSION}"}, + "timestamp": formatted_datetime, + } + ], + } + + return payload, headers + + def send(self, server_name, title, url, message, **kwargs): + """ + Sends a Discord webhook notification using the given details. + + The method constructs and dispatches a payload suitable for + Discords's webhook system. + + Parameters: + server_name (str): The name of the server triggering the notification. + title (str): The title for the notification message. + url (str): The webhook URL to send the notification to. + message (str): The main content or body of the notification message. + color (str, optional): The color code for the embed's side stripe. + Defaults to a pretty blue if not provided. + bot_name (str): Override for the Webhook's name set on creation + + Returns: + str: "Dispatch successful!" if the message is sent successfully, otherwise an + exception is raised. + + Raises: + Exception: If there's an error in dispatching the webhook. + """ + color = kwargs.get("color", "#005cd1") # Default to a color if not provided. + bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME) + payload, headers = self._construct_discord_payload( + server_name, title, message, color, bot_name + ) + return self._send_request(url, payload, headers) diff --git a/app/classes/web/webhooks/mattermost_webhook.py b/app/classes/web/webhooks/mattermost_webhook.py new file mode 100644 index 00000000..3dc97c05 --- /dev/null +++ b/app/classes/web/webhooks/mattermost_webhook.py @@ -0,0 +1,74 @@ +from app.classes.web.webhooks.base_webhook import WebhookProvider + + +class MattermostWebhook(WebhookProvider): + def _construct_mattermost_payload(self, server_name, title, message, bot_name): + """ + Constructs the payload required for sending a Mattermost webhook notification. + + The method formats the given information into a Markdown-styled message for MM, + including an information card containing the Crafty version. + + Parameters: + server_name (str): The name of the server triggering the notification. + title (str): The title for the notification message. + message (str): The main content of the notification message. + bot_name (str): Override for the Webhook's name set on creation. + + Returns: + tuple: A tuple containing the constructed payload (dict) incl headers (dict). + """ + formatted_text = ( + f"-----\n\n" + f"#### {title}\n" + f"##### Server: ```{server_name}```\n\n" + f"```\n{message}\n```\n\n" + f"-----" + ) + + headers = {"Content-Type": "application/json"} + payload = { + "text": formatted_text, + "username": bot_name, + "icon_url": self.WEBHOOK_PFP_URL, + "props": { + "card": ( + f"[Crafty Controller " + f"v.{self.CRAFTY_VERSION}](https://craftycontrol.com)" + ) + }, + } + + return payload, headers + + def send(self, server_name, title, url, message, **kwargs): + """ + Sends a Mattermost webhook notification using the given details. + + The method constructs and dispatches a payload suitable for + Mattermost's webhook system. + + Parameters: + server_name (str): The name of the server triggering the notification. + title (str): The title for the notification message. + url (str): The webhook URL to send the notification to. + message (str): The main content or body of the notification message. + bot_name (str): Override for the Webhook's name set on creation, see note! + + Returns: + str: "Dispatch successful!" if the message is sent successfully, otherwise an + exception is raised. + + Raises: + Exception: If there's an error in dispatching the webhook. + + Note: + - To set webhook username & pfp Mattermost needs to be configured to allow this! + - Mattermost's `config.json` setting is `"EnablePostUsernameOverride": true` + - Mattermost's `config.json` setting is `"EnablePostIconOverride": true` + """ + bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME) + payload, headers = self._construct_mattermost_payload( + server_name, title, message, bot_name + ) + return self._send_request(url, payload, headers) diff --git a/app/classes/web/webhooks/slack_webhook.py b/app/classes/web/webhooks/slack_webhook.py new file mode 100644 index 00000000..cd7c71bf --- /dev/null +++ b/app/classes/web/webhooks/slack_webhook.py @@ -0,0 +1,98 @@ +from app.classes.web.webhooks.base_webhook import WebhookProvider + + +class SlackWebhook(WebhookProvider): + def _construct_slack_payload(self, server_name, title, message, color, bot_name): + """ + Constructs the payload required for sending a Slack webhook notification. + + The method formats the given information into a Markdown-styled message for MM, + including an information card containing the Crafty version. + + Parameters: + server_name (str): The name of the server triggering the notification. + title (str): The title for the notification message. + message (str): The main content of the notification message. + color (int): The color code for the side stripe in the Slack block. + bot_name (str): Override for the Webhook's name set on creation, (not working). + + Returns: + tuple: A tuple containing the constructed payload (dict) incl headers (dict). + + Note: + - Block Builder/designer + - https://app.slack.com/block-kit-builder/ + """ + headers = {"Content-Type": "application/json"} + payload = { + "username": bot_name, + "attachments": [ + { + "color": color, + "blocks": [ + { + "type": "section", + "text": {"type": "plain_text", "text": server_name}, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{title}*\n{message}", + }, + "accessory": { + "type": "image", + "image_url": self.WEBHOOK_PFP_URL, + "alt_text": "Crafty Controller Logo", + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": ( + f"*Crafty Controller " + f"v{self.CRAFTY_VERSION}*" + ), + } + ], + }, + {"type": "divider"}, + ], + } + ], + } + + return payload, headers + + def send(self, server_name, title, url, message, **kwargs): + """ + Sends a Slack webhook notification using the given details. + + The method constructs and dispatches a payload suitable for + Slack's webhook system. + + Parameters: + server_name (str): The name of the server triggering the notification. + title (str): The title for the notification message. + url (str): The webhook URL to send the notification to. + message (str): The main content or body of the notification message. + color (str, optional): The color code for the blocks's colour accent. + Defaults to a pretty blue if not provided. + bot_name (str): Override for the Webhook's name set on creation, (not working). + + Returns: + str: "Dispatch successful!" if the message is sent successfully, otherwise an + exception is raised. + + Raises: + Exception: If there's an error in dispatching the webhook. + """ + color = kwargs.get("color", "#005cd1") # Default to a color if not provided. + bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME) + payload, headers = self._construct_slack_payload( + server_name, title, message, color, bot_name + ) + return self._send_request(url, payload, headers) diff --git a/app/classes/web/webhooks/teams_adaptive_webhook.py b/app/classes/web/webhooks/teams_adaptive_webhook.py new file mode 100644 index 00000000..1e4f442d --- /dev/null +++ b/app/classes/web/webhooks/teams_adaptive_webhook.py @@ -0,0 +1,124 @@ +from datetime import datetime +from app.classes.web.webhooks.base_webhook import WebhookProvider + + +class TeamsWebhook(WebhookProvider): + def _construct_teams_payload(self, server_name, title, message): + """ + Constructs the payload required for sending a Teams Adaptive card notification. + + This method prepares a payload for the Teams webhook API using the provided + message content, the Crafty Controller version, and the current UTC datetime. + + Parameters: + server_name (str): The name of the server triggering the notification. + title (str): The title for the notification message. + message (str): The main content of the notification message. + + Returns: + tuple: A tuple containing the constructed payload (dict) incl headers (dict). + + Note: + - Adaptive Card Designer + - https://www.adaptivecards.io/designer/ + """ + current_datetime = datetime.utcnow() + formatted_datetime = current_datetime.strftime("%Y-%m-%dT%H:%M:%SZ") + + headers = {"Content-type": "application/json"} + payload = { + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { + "body": [ + { + "type": "TextBlock", + "size": "Medium", + "weight": "Bolder", + "text": f"{title}", + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "items": [ + { + "type": "Image", + "style": "Person", + "url": f"{self.WEBHOOK_PFP_URL}", + "size": "Small", + } + ], + "width": "auto", + }, + { + "type": "Column", + "items": [ + { + "type": "TextBlock", + "weight": "Bolder", + "text": f"{server_name}", + "wrap": True, + }, + { + "type": "TextBlock", + "spacing": "None", + "text": "{{DATE(" + + f"{formatted_datetime}" + + ",SHORT)}}", + "isSubtle": True, + "wrap": True, + }, + ], + "width": "stretch", + }, + ], + }, + { + "type": "TextBlock", + "text": f"{message}", + "wrap": True, + }, + { + "type": "TextBlock", + "text": f"Crafty Controller v{self.CRAFTY_VERSION}", + "wrap": True, + "separator": True, + "isSubtle": True, + }, + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.6", + }, + } + ], + } + + return payload, headers + + def send(self, server_name, title, url, message, **kwargs): + """ + Sends a Teams Adaptive card notification using the given details. + + The method constructs and dispatches a payload suitable for + Discords's webhook system. + + Parameters: + server_name (str): The name of the server triggering the notification. + title (str): The title for the notification message. + url (str): The webhook URL to send the notification to. + message (str): The main content or body of the notification message. + Defaults to a pretty blue if not provided. + + Returns: + str: "Dispatch successful!" if the message is sent successfully, otherwise an + exception is raised. + + Raises: + Exception: If there's an error in dispatching the webhook. + """ + payload, headers = self._construct_teams_payload(server_name, title, message) + return self._send_request(url, payload, headers) diff --git a/app/classes/web/webhooks/webhook_factory.py b/app/classes/web/webhooks/webhook_factory.py new file mode 100644 index 00000000..608bf4e5 --- /dev/null +++ b/app/classes/web/webhooks/webhook_factory.py @@ -0,0 +1,85 @@ +from app.classes.web.webhooks.discord_webhook import DiscordWebhook +from app.classes.web.webhooks.mattermost_webhook import MattermostWebhook +from app.classes.web.webhooks.slack_webhook import SlackWebhook +from app.classes.web.webhooks.teams_adaptive_webhook import TeamsWebhook + + +class WebhookFactory: + """ + A factory class responsible for the creation and management of webhook providers. + + This class provides methods to instantiate specific webhook providers based on + their name and to retrieve a list of supported providers. It uses a registry pattern + to manage the available providers. + + Attributes: + - _registry (dict): A dictionary mapping provider names to their classes. + """ + + _registry = { + "Discord": DiscordWebhook, + "Mattermost": MattermostWebhook, + "Slack": SlackWebhook, + "Teams": TeamsWebhook, + # "Custom", + } + + @classmethod + def create_provider(cls, provider_name, *args, **kwargs): + """ + Creates and returns an instance of the specified webhook provider. + + This method looks up the provider in the registry, then instantiates it w/ the + provided arguments. If the provider is not recognized, a ValueError is raised. + + Arguments: + - provider_name (str): The name of the desired webhook provider. + + Additional arguments supported that we may use for if a provider + requires initialization: + - *args: Positional arguments to pass to the provider's constructor. + - **kwargs: Keyword arguments to pass to the provider's constructor. + + Returns: + WebhookProvider: An instance of the desired webhook provider. + + Raises: + ValueError: If the specified provider name is not recognized. + """ + if provider_name not in cls._registry: + raise ValueError(f"Provider {provider_name} is not supported.") + return cls._registry[provider_name](*args, **kwargs) + + @classmethod + def get_supported_providers(cls): + """ + Retrieves the names of all supported webhook providers. + + This method returns a list containing the names of all providers + currently registered in the factory's registry. + + Returns: + List[str]: A list of supported provider names. + """ + return list(cls._registry.keys()) + + @staticmethod + def get_monitored_events(): + """ + Retrieves the list of supported events for monitoring. + + This method provides a list of common server events that the webhook system can + monitor and notify about. + + Returns: + List[str]: A list of supported monitored actions. + """ + return [ + "start_server", + "stop_server", + "crash_detected", + "backup_server", + "jar_update", + "send_command", + "kill", + ] diff --git a/app/frontend/templates/panel/parts/m_server_controls_list.html b/app/frontend/templates/panel/parts/m_server_controls_list.html index 63c6b98a..f311ef10 100644 --- a/app/frontend/templates/panel/parts/m_server_controls_list.html +++ b/app/frontend/templates/panel/parts/m_server_controls_list.html @@ -31,6 +31,9 @@ {{ translate('serverDetails', 'playerControls', data['lang']) }} {% end %} {{ translate('serverDetails', 'metrics', data['lang']) }} + {% if data['permissions']['Config'] in data['user_permissions'] %} + {{ translate('webhooks', 'webhooks', data['lang']) }} + {% end %} \ No newline at end of file diff --git a/app/frontend/templates/panel/parts/server_controls_list.html b/app/frontend/templates/panel/parts/server_controls_list.html index 4f5bb6b5..df63f13d 100644 --- a/app/frontend/templates/panel/parts/server_controls_list.html +++ b/app/frontend/templates/panel/parts/server_controls_list.html @@ -53,4 +53,10 @@ {{ translate('serverDetails', 'metrics', data['lang']) }} + {% if data['permissions']['Config'] in data['user_permissions'] %} +
{{ translate('webhooks', 'name', data['lang']) }} + | +{{ translate('webhooks', 'type', data['lang']) }} | +{{ translate('webhooks', 'trigger', data['lang']) }} | +{{ translate('webhooks', 'enabled', + data['lang']) }} | +{{ translate('webhooks', 'edit', data['lang']) + }} | +
---|---|---|---|---|
+ {{webhook.name}} + |
+
+ {{webhook.webhook_type}} + |
+
+
|
+ + + | +
+
+ + + + + |
+
Name + | +{{ translate('webhooks', 'enabled', + data['lang']) }} | +{{ translate('webhooks', 'edit', data['lang']) + }} | +
---|---|---|
+ {{webhook.name}} + |
+ + + | +
+
+ + + + + |
+