Merge branch 'feature/discord-webhooks' into 'dev'

Webhook Functionality

See merge request crafty-controller/crafty-4!594
This commit is contained in:
Iain Powrie 2023-10-06 18:46:05 +00:00
commit 4379ba408b
20 changed files with 1824 additions and 12 deletions

View File

@ -2,6 +2,7 @@
## --- [4.2.0] - 2023/TBD ## --- [4.2.0] - 2023/TBD
### New features ### 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)) - 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 ### 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)) - 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)) - Fix hidden servers appearing visible on public mobile status page ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/612))

View File

@ -1,7 +1,7 @@
import logging import logging
import queue import queue
from app.classes.models.management import HelpersManagement from app.classes.models.management import HelpersManagement, HelpersWebhooks
from app.classes.models.servers import HelperServers from app.classes.models.servers import HelperServers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -206,3 +206,30 @@ class ManagementController:
@staticmethod @staticmethod
def set_master_server_dir(server_dir): def set_master_server_dir(server_dir):
HelpersManagement.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)

View File

@ -79,11 +79,15 @@ class HostStats(BaseModel):
# ********************************************************************************** # **********************************************************************************
class Webhooks(BaseModel): class Webhooks(BaseModel):
id = AutoField() id = AutoField()
name = CharField(max_length=64, unique=True, index=True) server_id = IntegerField(null=True)
method = CharField(default="POST") name = CharField(default="Custom Webhook", max_length=64)
url = CharField(unique=True) url = CharField(default="")
event = CharField(default="") webhook_type = CharField(default="Custom")
send_data = BooleanField(default=True) 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: class Meta:
table_name = "webhooks" table_name = "webhooks"
@ -501,3 +505,82 @@ class HelpersManagement:
f"Not removing {dir_to_del} from excluded directories - " f"Not removing {dir_to_del} from excluded directories - "
f"not in the excluded directory list for server ID {server_id}" 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()

View File

@ -19,13 +19,13 @@ from zoneinfo import ZoneInfo
from tzlocal import get_localzone from tzlocal import get_localzone
from tzlocal.utils import ZoneInfoNotFoundError from tzlocal.utils import ZoneInfoNotFoundError
from apscheduler.schedulers.background import BackgroundScheduler 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.stats import Stats
from app.classes.minecraft.mc_ping import ping, ping_bedrock from app.classes.minecraft.mc_ping import ping, ping_bedrock
from app.classes.models.servers import HelperServers, Servers from app.classes.models.servers import HelperServers, Servers
from app.classes.models.server_stats import HelperServerStats 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.users import HelperUsers
from app.classes.models.server_permissions import PermissionsServers from app.classes.models.server_permissions import PermissionsServers
from app.classes.shared.console import Console 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.file_helpers import FileHelpers
from app.classes.shared.null_writer import NullWriter from app.classes.shared.null_writer import NullWriter
from app.classes.shared.websocket_manager import WebSocketManager from app.classes.shared.websocket_manager import WebSocketManager
from app.classes.web.webhooks.webhook_factory import WebhookFactory
with redirect_stderr(NullWriter()): with redirect_stderr(NullWriter()):
import psutil import psutil
@ -165,6 +166,45 @@ class ServerInstance:
self.stats_helper.server_crash_reset() self.stats_helper.server_crash_reset()
self.stats_helper.set_update(False) 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 # Minecraft Server Management
# ********************************************************************************** # **********************************************************************************
@ -262,13 +302,13 @@ class ServerInstance:
seconds=30, seconds=30,
id="save_stats_" + str(self.server_id), id="save_stats_" + str(self.server_id),
) )
except: except ConflictingIdError:
self.server_scheduler.remove_job("save_" + str(self.server_id)) self.server_scheduler.remove_job("save_stats_" + str(self.server_id))
self.server_scheduler.add_job( self.server_scheduler.add_job(
self.record_server_stats, self.record_server_stats,
"interval", "interval",
seconds=30, seconds=30,
id="save_" + str(self.server_id), id="save_stats_" + str(self.server_id),
) )
def setup_server_run_command(self): def setup_server_run_command(self):
@ -332,6 +372,7 @@ class ServerInstance:
logger.critical(f"Unable to write/access {self.server_path}") logger.critical(f"Unable to write/access {self.server_path}")
Console.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): def start_server(self, user_id, forge_install=False):
if not user_id: if not user_id:
user_lang = self.helper.get_setting("language") user_lang = self.helper.get_setting("language")
@ -775,6 +816,7 @@ class ServerInstance:
if self.server_thread: if self.server_thread:
self.server_thread.join() self.server_thread.join()
@callback
def stop_server(self): def stop_server(self):
running = self.check_running() running = self.check_running()
if not running: if not running:
@ -882,6 +924,7 @@ class ServerInstance:
self.last_rc = poll self.last_rc = poll
return False return False
@callback
def send_command(self, command): def send_command(self, command):
if not self.check_running() and command.lower() != "start": if not self.check_running() and command.lower() != "start":
logger.warning(f'Server not running, unable to send command "{command}"') logger.warning(f'Server not running, unable to send command "{command}"')
@ -894,6 +937,7 @@ class ServerInstance:
self.process.stdin.flush() self.process.stdin.flush()
return True return True
@callback
def crash_detected(self, name): def crash_detected(self, name):
# clear the old scheduled watcher task # clear the old scheduled watcher task
self.server_scheduler.remove_job(f"c_{self.server_id}") 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"The server {name} has crashed and will be restarted. "
f"Restarting server" f"Restarting server"
) )
self.run_threaded_server(None) self.run_threaded_server(None)
return True return True
logger.critical( logger.critical(
@ -926,6 +971,7 @@ class ServerInstance:
) )
return False return False
@callback
def kill(self): def kill(self):
logger.info(f"Terminating server {self.server_id} and all child processes") logger.info(f"Terminating server {self.server_id} and all child processes")
try: try:
@ -1014,6 +1060,7 @@ class ServerInstance:
f.write("eula=true") f.write("eula=true")
self.run_threaded_server(user_id) self.run_threaded_server(user_id)
@callback
def backup_server(self): def backup_server(self):
if self.settings["backup_path"] == "": if self.settings["backup_path"] == "":
logger.critical("Backup path is None. Canceling Backup!") logger.critical("Backup path is None. Canceling Backup!")
@ -1228,6 +1275,7 @@ class ServerInstance:
if f["path"].endswith(".zip") if f["path"].endswith(".zip")
] ]
@callback
def jar_update(self): def jar_update(self):
self.stats_helper.set_update(True) self.stats_helper.set_update(True)
update_thread = threading.Thread( update_thread = threading.Thread(

View File

@ -25,6 +25,7 @@ from app.classes.controllers.roles_controller import RolesController
from app.classes.shared.helpers import Helpers from app.classes.shared.helpers import Helpers
from app.classes.shared.main_models import DatabaseShortcuts from app.classes.shared.main_models import DatabaseShortcuts
from app.classes.web.base_handler import BaseHandler from app.classes.web.base_handler import BaseHandler
from app.classes.web.webhooks.webhook_factory import WebhookFactory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -344,7 +345,9 @@ class PanelHandler(BaseHandler):
) as credits_default_local: ) as credits_default_local:
try: try:
remote = requests.get( 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() credits_dict: dict = remote.json()
if not credits_dict["staff"]: if not credits_dict["staff"]:
@ -745,6 +748,22 @@ class PanelHandler(BaseHandler):
page_data["history_stats"] = self.controller.servers.get_history_stats( page_data["history_stats"] = self.controller.servers.get_history_stats(
server_id, hours=(days * 24) 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(): def get_banned_players_html():
banned_players = self.controller.servers.get_banned_players(server_id) banned_players = self.controller.servers.get_banned_players(server_id)
@ -1012,6 +1031,110 @@ class PanelHandler(BaseHandler):
template = "panel/panel_edit_user.html" 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": elif page == "add_schedule":
server_id = self.get_argument("id", None) server_id = self.get_argument("id", None)
if server_id is None: if server_id is None:

View File

@ -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 ( from app.classes.web.routes.api.servers.server.tasks.task.index import (
ApiServersServerTasksTaskIndexHandler, 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.servers.server.users import ApiServersServerUsersHandler
from app.classes.web.routes.api.users.index import ApiUsersIndexHandler from app.classes.web.routes.api.users.index import ApiUsersIndexHandler
from app.classes.web.routes.api.users.user.index import ApiUsersUserIndexHandler from app.classes.web.routes.api.users.user.index import ApiUsersUserIndexHandler
@ -250,6 +256,16 @@ def api_handlers(handler_args):
ApiServersServerHistoryHandler, ApiServersServerHistoryHandler,
handler_args, 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_]+)/?", r"/api/v2/servers/([0-9]+)/action/([a-z_]+)/?",
ApiServersServerActionHandler, ApiServersServerActionHandler,

View File

@ -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}})

View File

@ -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"})

View File

@ -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."""

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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",
]

View File

@ -31,6 +31,9 @@
<a class="dropdown-item {% if data['active_link'] == 'admin_controls' %}active{% end %}" href="/panel/server_detail?id={{ data['server_stats']['server_id']['server_id'] }}&subpage=admin_controls" role="tab" aria-selected="true"><i class="fas fa-users"></i> {{ translate('serverDetails', 'playerControls', data['lang']) }}</a> <a class="dropdown-item {% if data['active_link'] == 'admin_controls' %}active{% end %}" href="/panel/server_detail?id={{ data['server_stats']['server_id']['server_id'] }}&subpage=admin_controls" role="tab" aria-selected="true"><i class="fas fa-users"></i> {{ translate('serverDetails', 'playerControls', data['lang']) }}</a>
{% end %} {% end %}
<a class="dropdown-item {% if data['active_link'] == 'metrics' %}active{% end %}" href="/panel/server_detail?id={{ data['server_stats']['server_id']['server_id'] }}&subpage=metrics" role="tab" aria-selected="true"><i class="fa-solid fa-chart-line"></i> {{ translate('serverDetails', 'metrics', data['lang']) }}</a> <a class="dropdown-item {% if data['active_link'] == 'metrics' %}active{% end %}" href="/panel/server_detail?id={{ data['server_stats']['server_id']['server_id'] }}&subpage=metrics" role="tab" aria-selected="true"><i class="fa-solid fa-chart-line"></i> {{ translate('serverDetails', 'metrics', data['lang']) }}</a>
{% if data['permissions']['Config'] in data['user_permissions'] %}
<a class="dropdown-item {% if data['active_link'] == 'webhooks' %}active{% end %}" href="/panel/server_detail?id={{ data['server_stats']['server_id']['server_id'] }}&subpage=webhooks" role="tab" aria-selected="true"><i class="fa-regular fa-bell"></i>{{ translate('webhooks', 'webhooks', data['lang']) }}</a>
{% end %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -53,4 +53,10 @@
<a class="nav-link {% if data['active_link'] == 'metrics' %}active{% end %}" href="/panel/server_detail?id={{ data['server_stats']['server_id']['server_id'] }}&subpage=metrics" role="tab" aria-selected="true"> <a class="nav-link {% if data['active_link'] == 'metrics' %}active{% end %}" href="/panel/server_detail?id={{ data['server_stats']['server_id']['server_id'] }}&subpage=metrics" role="tab" aria-selected="true">
<i class="fa-solid fa-chart-line"></i>{{ translate('serverDetails', 'metrics', data['lang']) }}</a> <i class="fa-solid fa-chart-line"></i>{{ translate('serverDetails', 'metrics', data['lang']) }}</a>
</li> </li>
{% if data['permissions']['Config'] in data['user_permissions'] %}
<li class="nav-item term-nav-item">
<a class="nav-link {% if data['active_link'] == 'webhooks' %}active{% end %}" href="/panel/server_detail?id={{ data['server_stats']['server_id']['server_id'] }}&subpage=webhooks" role="tab" aria-selected="true">
<i class="fa-regular fa-bell"></i>{{ translate('webhooks', 'webhooks', data['lang']) }}</a>
</li>
{% end %}
</ul> </ul>

View File

@ -0,0 +1,278 @@
{% extends ../base.html %}
{% block meta %}
{% end %}
{% block title %}Crafty Controller - {{ translate('serverDetails', 'serverDetails', data['lang']) }}{% end %}
{% block content %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-select/1.13.10/css/bootstrap-select.min.css">
<div class="content-wrapper">
<!-- Page Title Header Starts-->
<div class="row page-title-header">
<div class="col-12">
<div class="page-header">
<h4 class="page-title">
{{ translate('serverDetails', 'serverDetails', data['lang']) }} - {{
data['server_stats']['server_id']['server_name'] }}
<br />
<small>UUID: {{ data['server_stats']['server_id']['server_uuid'] }}</small>
</h4>
</div>
</div>
</div>
<!-- Page Title Header Ends-->
{% include "parts/details_stats.html" %}
<div class="row">
<div class="col-sm-12 grid-margin">
<div class="card">
<div class="card-body pt-0">
{% include "parts/server_controls_list.html" %}
<div class="row">
<div class="col-md-8 col-sm-8">
{% if data['new_webhook'] == True %}
<form class="forms-sample" method="post" id="new_webhook_form"
action="/panel/new_webhook?id={{ data['server_stats']['server_id']['server_id'] }}">
{% else %}
<form class="forms-sample" method="post" id="webhook_form"
action="/panel/edit_webhook?id={{ data['server_stats']['server_id']['server_id'] }}&sch_id={{ data['webhook']['id'] }}">
{% end %}
<select class="form-select form-control form-control-lg select-css" id="webhook_type" name="webhook_type">
<option value="{{data['webhook']['webhook_type']}}">{{data['webhook']['webhook_type']}}</option>
{% for type in data['providers'] %}
{% if type != data['webhook']['webhook_type'] %}
<option value="{{type}}">{{type}}</option>
{%end%}
{% end %}
</select>
<br>
<br>
<div class="form-group">
<label for="name">{{ translate('webhooks', 'name' , data['lang']) }}</label>
<input type="input" class="form-control" name="name" id="name_input"
value="{{ data['webhook']['name']}}" maxlength="30" placeholder="Name" required>
</div>
<div class="form-group">
<label for="url">{{ translate('webhooks', 'url', data['lang']) }}</label>
<input type="input" class="form-control" name="url" id="url"
value="{{ data['webhook']['url']}}" placeholder="https://webhooks.craftycontrol.com/fakeurl" required>
</div>
<div class="form-group">
<label for="bot_name">{{ translate('webhooks', 'bot_name' , data['lang']) }}</label>
<input type="input" class="form-control" name="bot_name" id="bot_name_input"
value="{{ data['webhook']['bot_name']}}" maxlength="30" placeholder="Crafty Controller" required>
</div>
<div class="form-group">
<label for="trigger">{{ translate('webhooks', 'trigger', data['lang']) }}</label>
<select class="form-control selectpicker show-tick" name="trigger" id="trigger-select" data-icon-base="fas" data-tick-icon="fa-check" multiple data-style="custom-picker">
{% for trigger in data['triggers'] %}
{% if trigger in data["webhook"]["trigger"] %}
<option value="{{trigger}}" selected>{{translate('webhooks', trigger , data['lang'])}}</option>
{% else %}
<option value="{{trigger}}">{{translate('webhooks', trigger , data['lang'])}}</option>
{% end %}
{% end %}
</select>
</div>
<div class="form-group">
<label for="body">{{ translate('webhooks', 'webhook_body', data['lang']) }}</label>
<textarea id="body-input" name="body" rows="4" cols="50">
{{ data["webhook"]["body"] }}
</textarea>
</div>
<div class="form-group">
<label for="bot_name">{{ translate('webhooks', 'color' , data['lang']) }}</label>
<input type="color" class="form-control" name="color" id="color" value='{{data["webhook"]["color"]}}'>
</div>
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="enabled" name="enabled" {% if data['webhook']['enabled'] %}checked{%end%}
value="1">
<label for="enabled" class="custom-control-label">{{ translate('webhooks', 'enabled', data['lang']) }}</label>
</div>
</div>
<button type="submit" class="btn btn-success mr-2"><i class="fas fa-save"></i> {{
translate('serverConfig', 'save', data['lang']) }}</button>
<button type="reset"
onclick="location.href=`/panel/server_detail?id={{ data['server_stats']['server_id']['server_id'] }}&subpage=webhooks`"
class="btn btn-light"><i class="fas fa-times"></i> {{ translate('serverConfig', 'cancel',
data['lang']) }}</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.custom-control-input:checked~.custom-control-label::before {
color: black !important;
background-color: blueviolet !important;
border-color: var(--outline) !important;
}
.custom-control-label::before {
background-color: white !important;
top: calc(-0.2rem);
}
.custom-switch .custom-control-label::after {
top: calc(-0.125rem + 1px);
}
#body-input {
background-color: var(--card-banner-bg);
outline-color: var(--outline);
color: var(--base-text);
width: 100%;
}
</style>
<!-- content-wrapper ends -->
{% end %}
{% block js %}
<script>
$(function () {
$('.form-check-input').bootstrapToggle({
on: '',
off: ''
});
})
//used to get cookies from browser - this is part of tornados xsrf protection - it's for extra security
function getCookie(name) {
var r = document.cookie.match("\\b" + name + "=([^;]*)\\b");
return r ? r[1] : undefined;
}
function replacer(key, value) {
if (key != "start_time" && key != "cron_string" && key != "interval_type") {
if (typeof value == "boolean") {
return value
}
console.log(key)
if (key === "interval" && value === ""){
return 0;
}
if (key === "command" && typeof(value === "integer")){
return value.toString();
}else {
return (isNaN(value) ? value : +value);
}
} else {
if (value === "" && key == "start_time"){
return "00:00";
}else{
return value;
}
}
}
const serverId = new URLSearchParams(document.location.search).get('id');
const webhookId = new URLSearchParams(document.location.search).get('webhook_id');
$(document).ready(function () {
console.log("ready!");
console.log('ready for JS!');
$('.selectpicker').selectpicker("refresh");
$("#new_webhook_form").on("submit", async function (e) {
e.preventDefault();
var token = getCookie("_xsrf")
let webhookForm = document.getElementById("new_webhook_form");
let select_val = JSON.stringify($('#trigger-select').val());
select_val = JSON.parse(select_val);
let formData = new FormData(webhookForm);
//Create an object from the form data entries
let formDataObject = Object.fromEntries(formData.entries());
//We need to make sure these are sent regardless of whether or not they're checked
formDataObject.enabled = $("#enabled").prop('checked');
formDataObject.trigger = select_val;
console.log(formDataObject);
// Format the plain form data as JSON
let formDataJsonString = JSON.stringify(formDataObject, replacer);
let res = await fetch(`/api/v2/servers/${serverId}/webhook/`, {
method: 'POST',
headers: {
'X-XSRFToken': token,
"Content-Type": "application/json",
},
body: formDataJsonString,
});
let responseData = await res.json();
if (responseData.status === "ok") {
window.location.href = `/panel/server_detail?id=${serverId}&subpage=webhooks`;
} else {
bootbox.alert({
title: responseData.status,
message: responseData.error
});
}
});
$("#webhook_form").on("submit", async function (e) {
e.preventDefault();
var token = getCookie("_xsrf");
let webhookForm = document.getElementById("webhook_form");
let select_val = JSON.stringify($('#trigger-select').val());
select_val = JSON.parse(select_val);
let formData = new FormData(webhookForm);
//Create an object from the form data entries
let formDataObject = Object.fromEntries(formData.entries());
//We need to make sure these are sent regardless of whether or not they're checked
formDataObject.enabled = $("#enabled").prop('checked');
formDataObject.trigger = select_val;
if(formDataObject.webhook_type != "Discord"){
delete formDataObject.color
}
console.log(formDataObject);
// Format the plain form data as JSON
let formDataJsonString = JSON.stringify(formDataObject, replacer);
let res = await fetch(`/api/v2/servers/${serverId}/webhook/${webhookId}`, {
method: 'PATCH',
headers: {
'X-XSRFToken': token,
"Content-Type": "application/json",
},
body: formDataJsonString,
});
let responseData = await res.json();
if (responseData.status === "ok") {
window.location.href = `/panel/server_detail?id=${serverId}&subpage=webhooks`;
} else {
bootbox.alert({
title: responseData.status,
message: responseData.error
});
}
});
});
function hexToDiscordInt(hexColor) {
// Remove the hash at the start if it's there
const sanitizedHex = hexColor.startsWith('#') ? hexColor.slice(1) : hexColor;
// Convert the hex to an integer
return parseInt(sanitizedHex, 16);
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-select/1.13.10/js/bootstrap-select.min.js"></script>
{% end %}

View File

@ -0,0 +1,380 @@
{% extends ../base.html %}
{% block meta %}
{% end %}
{% block title %}Crafty Controller - {{ translate('serverDetails', 'serverDetails', data['lang']) }}{% end %}
{% block content %}
<div class="content-wrapper">
<!-- Page Title Header Starts-->
<div class="row page-title-header">
<div class="col-12">
<div class="page-header">
<h4 class="page-title">
{{ translate('serverDetails', 'serverDetails', data['lang']) }} - {{
data['server_stats']['server_id']['server_name'] }}
<br />
<small>UUID: {{ data['server_stats']['server_id']['server_uuid'] }}</small>
</h4>
</div>
</div>
</div>
<!-- Page Title Header Ends-->
{% include "parts/details_stats.html" %}
<div class="row">
<div class="col-sm-12 grid-margin">
<div class="card">
<div class="card-body pt-0">
<span class="d-none d-sm-block">
{% include "parts/server_controls_list.html" %}
</span>
<span class="d-block d-sm-none">
{% include "parts/m_server_controls_list.html" %}
</span>
<div class="row">
<div class="col-md-12 col-sm-12" style="overflow-x:auto;">
<div class="card">
<div class="card-header header-sm d-flex justify-content-between align-items-center">
<h4 class="card-title"><i class="fa-regular fa-bell"></i> {{ translate('webhooks', 'webhooks', data['lang']) }} </h4>
{% if data['user_data']['hints'] %}
<span class="too_small" title="{{ translate('serverSchedules', 'cannotSee', data['lang']) }}" ,
data-content="{{ translate('serverSchedules', 'cannotSeeOnMobile', data['lang']) }}" ,
data-placement="bottom"></span>
{% end %}
<div><button
onclick="location.href=`/panel/add_webhook?id={{ data['server_stats']['server_id']['server_id'] }}`"
class="btn btn-info">{{ translate('webhooks', 'new', data['lang']) }}<i
class="fas fa-pencil-alt"></i></button></div>
</div>
<div class="card-body">
<table class="table table-hover d-none d-lg-block responsive-table" id="webhook_table" width="100%" style="table-layout:fixed;">
<thead>
<tr class="rounded">
<th style="width: 10%; min-width: 10px;">{{ translate('webhooks', 'name', data['lang']) }}
</th>
<th style="width: 20%; min-width: 50px;">{{ translate('webhooks', 'type', data['lang']) }}</th>
<th style="width: 50%; min-width: 50px;">{{ translate('webhooks', 'trigger', data['lang']) }}</th>
<th style="width: 10%; min-width: 50px;">{{ translate('webhooks', 'enabled',
data['lang']) }}</th>
<th style="width: 10%; min-width: 50px;">{{ translate('webhooks', 'edit', data['lang'])
}}</th>
</tr>
</thead>
<tbody>
{% for webhook in data['webhooks'] %}
<tr>
<td id="{{webhook.name}}" class="id">
<p>{{webhook.name}}</p>
</td>
<td id="{{webhook.webhook_type}}" class="type">
<p>{{webhook.webhook_type}}</p>
</td>
<td id="{{webhook.trigger}}" class="trigger" style="overflow: scroll; max-width: 30px;">
<ul>
{% for trigger in webhook.trigger.split(",") %}
{% if trigger in data["triggers"] %}
<li>{{translate('webhooks', trigger , data['lang'])}}</li>
{%end%}
{%end%}
</ul>
</td>
<td id="{{webhook.enabled}}" class="enabled">
<input style="width: 10px !important;" type="checkbox" class="webhook-enabled-toggle" data-webhook-id="{{webhook.id}}" data-webhook-enabled="{{ 'true' if webhook.enabled else 'false' }}">
</td>
<td id="webhook_edit" class="action">
<button onclick="window.location.href='/panel/webhook_edit?id={{ data['server_stats']['server_id']['server_id'] }}&webhook_id={{webhook.id}}'" class="btn btn-info">
<i class="fas fa-pencil-alt"></i>
</button>
<br>
<br>
<button data-webhook={{ webhook.id }} class="btn btn-danger del_button">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
<button data-webhook={{ webhook.id }} data-toggle="tooltip" title="{{ translate('webhooks', 'run', data['lang']) }}" class="btn btn-outline-warning test-socket">
<i class="fa-solid fa-vial"></i>
</button>
</td>
</tr>
{% end %}
</tbody>
</table>
<table class="table table-hover d-block d-lg-none responsive-table" id="webhook_table_mini" width="100%" style="table-layout:fixed;">
<thead>
<tr class="rounded">
<th style="width: 33.33%; min-width: 10px;">Name
</th>
<th style="width: 33.33%; min-width: 50px;">{{ translate('webhooks', 'enabled',
data['lang']) }}</th>
<th style="width: 33.33%; min-width: 50px;">{{ translate('webhooks', 'edit', data['lang'])
}}</th>
</tr>
</thead>
<tbody>
{% for webhook in data['webhooks'] %}
<tr>
<td id="{{webhook.name}}" class="id">
<p>{{webhook.name}}</p>
</td>
<td id="{{webhook.enabled}}" class="enabled">
<input style="width: 10px !important;" type="checkbox" class="webhook-enabled-toggle" data-webhook-id="{{webhook.id}}" data-webhook-enabled="{{ 'true' if webhook.enabled else 'false' }}">
</td>
<td id="webhook_edit" class="action">
<button onclick="window.location.href='/panel/webhook_edit?id={{ data['server_stats']['server_id']['server_id'] }}&webhook_id={{webhook.id}}'" class="btn btn-info">
<i class="fas fa-pencil-alt"></i>
</button>
<br>
<br>
<button data-webhook={{ webhook.id }} class="btn btn-danger del_button">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
<button data-webhook={{ webhook.id }} data-toggle="tooltip" title="{{ translate('webhooks', 'run', data['lang']) }}" class="btn btn-outline-warning test-socket">
<i class="fa-solid fa-vial"></i>
</button>
</td>
</tr>
{% end %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.popover-body {
color: white !important;
;
}
.toggle-handle {
background-color: white !important;
}
.toggle-on {
color: black !important;
}
.toggle {
height: 0px !important;
}
</style>
</div>
<style>
/* Hide scrollbar for Chrome, Safari and Opera */
td::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
td {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
</style>
<!-- content-wrapper ends -->
{% end %}
{% block js %}
<script>
$(document).ready(function () {
console.log('ready for JS!')
$('#webhook_table').DataTable({
'order': [4, 'asc'],
}
);
});
$(document).ready(function () {
console.log('ready for JS!')
$('#webhook_table_mini').DataTable({
'order': [2, 'asc']
}
);
document.getElementById('webhook_table_mini_wrapper').hidden = true;
});
$(document).ready(function () {
$('[data-toggle="popover"]').popover();
if ($(window).width() < 1000) {
$('.too_small').popover("show");
document.getElementById('webhook_table_wrapper').hidden = true;
document.getElementById('webhook_table_mini_wrapper').hidden = false;
}
});
$(window).ready(function () {
$('body').click(function () {
$('.too_small').popover("hide");
});
});
$(window).resize(function () {
// This will execute whenever the window is resized
if ($(window).width() < 1000) {
$('.too_small').popover("show");
document.getElementById('webhook_table_wrapper').hidden = true;
document.getElementById('webhook_table_mini_wrapper').hidden = false;
}
else {
$('.too_small').popover("hide");
document.getElementById('webhook_table_wrapper').hidden = false;
document.getElementById('webhook_table_mini_wrapper').hidden = true;
} // New width
});
function debounce(func, timeout = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => { func.apply(this, args); }, timeout);
};
}
$(() => {
$('.webhook-enabled-toggle').bootstrapToggle({
on: 'Yes',
off: 'No',
onstyle: 'success',
offstyle: 'danger',
})
$('.webhook-enabled-toggle').each(function () {
const enabled = JSON.parse(this.getAttribute('data-webhook-enabled'));
$(this).bootstrapToggle(enabled ? 'on' : 'off')
})
$('.webhook-enabled-toggle').change(function () {
const id = this.getAttribute('data-webhook-id');
const enabled = this.checked;
fetch(`/api/v2/servers/{{data['server_id']}}/webhook/${id}`, {
method: 'PATCH',
body: JSON.stringify({ enabled }),
headers: {
'Content-Type': 'application/json',
},
})
});
})
const serverId = new URLSearchParams(document.location.search).get('id')
</script>
<script>
//used to get cookies from browser - this is part of tornados xsrf protection - it's for extra security
function getCookie(name) {
var r = document.cookie.match("\\b" + name + "=([^;]*)\\b");
return r ? r[1] : undefined;
}
$(document).ready(function () {
console.log("ready!");
});
$(".del_button").click(function () {
var webhook_id = $(this).data('webhook');
bootbox.confirm({
message: "{{ translate('webhooks', 'areYouSureDel', data['lang']) }}",
buttons: {
cancel: {
label: '<i class="fas fa-times"></i> {{ translate("serverSchedules", "cancel", data['lang']) }}'
},
confirm: {
className: 'btn-outline-danger',
label: '<i class="fas fa-check"></i> {{ translate("serverSchedules", "confirm", data['lang']) }}'
}
},
callback: function (result) {
console.log(result);
if (result == true) {
del_hook(webhook_id, serverId);
}
}
});
});
$(".test-socket").click(function () {
var webhook_id = $(this).data('webhook');
bootbox.confirm({
message: "{{ translate('webhooks', 'areYouSureRun', data['lang']) }}",
buttons: {
cancel: {
label: '<i class="fas fa-times"></i> {{ translate("serverSchedules", "cancel", data['lang']) }}'
},
confirm: {
className: 'btn-outline-danger',
label: '<i class="fas fa-check"></i> {{ translate("serverSchedules", "confirm", data['lang']) }}'
}
},
callback: function (result) {
console.log(result);
if (result == true) {
test_hook(webhook_id, serverId);
}
}
});
});
async function test_hook(webhook_id, id) {
var token = getCookie("_xsrf")
let res = await fetch(`/api/v2/servers/${id}/webhook/${webhook_id}/`, {
method: 'POST',
headers: {
'token': token,
},
});
let responseData = await res.json();
if (responseData.status === "ok") {
bootbox.alert("Webhook Sent!")
}else{
console.log(responseData);
bootbox.alert({
title: responseData.status,
message: responseData.error
});
}
}
async function del_hook(webhook_id, id) {
var token = getCookie("_xsrf")
let res = await fetch(`/api/v2/servers/${id}/webhook/${webhook_id}`, {
method: 'DELETE',
headers: {
'token': token,
},
});
let responseData = await res.json();
if (responseData.status === "ok") {
window.location.reload();
}else{
bootbox.alert({
title: responseData.status,
message: responseData.error
});
}
}
</script>
{% end %}

View File

@ -0,0 +1,27 @@
# Generated by database migrator
import peewee
def migrate(migrator, database, **kwargs):
migrator.drop_columns("webhooks", ["name", "method", "url", "event", "send_data"])
migrator.add_columns(
"webhooks",
server_id=peewee.IntegerField(null=True),
webhook_type=peewee.CharField(default="Custom"),
name=peewee.CharField(default="Custom Webhook", max_length=64),
url=peewee.CharField(default=""),
bot_name=peewee.CharField(default="Crafty Controller"),
trigger=peewee.CharField(default="server_start,server_stop"),
body=peewee.CharField(default=""),
color=peewee.CharField(default=""),
enabled=peewee.BooleanField(default=True),
)
"""
Write your migrations here.
"""
def rollback(migrator, database, **kwargs):
"""
Write your rollback migrations here.
"""

View File

@ -627,5 +627,28 @@
"uses": "Number of uses allowed (-1==No Limit)", "uses": "Number of uses allowed (-1==No Limit)",
"manager": "Manager", "manager": "Manager",
"selectManager": "Select Manager for User" "selectManager": "Select Manager for User"
},
"webhooks": {
"webhooks": "Webhooks",
"name": "Name",
"type": "Webhook Type",
"trigger": "Trigger",
"enabled": "Enabled",
"url": "Webhook URL",
"bot_name": "Bot Name",
"webhook_body": "Webhook Body",
"color": "Select Color Accent",
"areYouSureDel": "Are you sure you want to delete this webhook?",
"areYouSureRun": "Are you sure you want to test this webhook?",
"edit": "Edit",
"run": "Test Run Webhook",
"new": "New Webhook",
"start_server": "Server Started",
"stop_server": "Server Stopped",
"crash_detected": "Server Crashed",
"jar_update": "Server Executable Updated",
"backup_server": "Server Backup Completed",
"send_command": "Server Command Received",
"kill": "Server Killed"
} }
} }