Merge branch 'dev' into tweak/logging

This commit is contained in:
Zedifus 2023-10-07 15:42:15 +01:00
commit ecba6f1bf0
20 changed files with 1824 additions and 12 deletions

View File

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

View File

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

View File

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

View File

@ -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:
@ -886,6 +928,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}"')
@ -898,6 +941,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}")
@ -918,6 +962,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(
@ -930,6 +975,7 @@ class ServerInstance:
)
return False
@callback
def kill(self):
logger.info(f"Terminating server {self.server_id} and all child processes")
try:
@ -1018,6 +1064,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!")
@ -1232,6 +1279,7 @@ class ServerInstance:
if f["path"].endswith(".zip")
]
@callback
def jar_update(self):
self.stats_helper.set_update(True)
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.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:

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 (
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,

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>
{% 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>
{% 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>

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">
<i class="fa-solid fa-chart-line"></i>{{ translate('serverDetails', 'metrics', data['lang']) }}</a>
</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>

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)",
"manager": "Manager",
"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"
}
}