Merge branch 'dev' into bugfix/bedrock-build-update

This commit is contained in:
Zedifus
2025-11-23 02:25:26 +00:00
20 changed files with 324 additions and 78 deletions

View File

@@ -52,7 +52,7 @@ persistent=yes
# Minimum Python version to use for version dependent checks. Will default to # Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint. # the version used to run pylint.
py-version=3.9 py-version=3.10
# When enabled, pylint would attempt to guess common misconfiguration and emit # When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages. # user-friendly hints instead of false-positive error messages.

View File

@@ -1,7 +1,7 @@
# Changelog # Changelog
## --- [4.5.6] - 2025/TBD ## --- [4.6.1] - 2025/TBD
### New features ### New features
TBD - Jinja2 Dynamic Variables for Webhook Notifications ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/757))
### Bug fixes ### Bug fixes
- Change hour and minute intervals in APScheudler to fix incorrect triggers ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/910)) - Change hour and minute intervals in APScheudler to fix incorrect triggers ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/910))
- Use asyncio locks to limit upload handler race condition ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/907)) - Use asyncio locks to limit upload handler race condition ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/907))

View File

@@ -1,5 +1,5 @@
[![Crafty Logo](app/frontend/static/assets/images/logo_long.svg)](https://craftycontrol.com) [![Crafty Logo](app/frontend/static/assets/images/logo_long.svg)](https://craftycontrol.com)
# Crafty Controller 4.5.6 # Crafty Controller 4.6.1
> Python based Control Panel for your Minecraft Server > Python based Control Panel for your Minecraft Server
## What is Crafty Controller? ## What is Crafty Controller?
@@ -66,8 +66,6 @@ The image is located at: `registry.gitlab.com/crafty-controller/crafty-4:latest`
$ vim docker-compose.yml $ vim docker-compose.yml
``` ```
```yml ```yml
version: '3'
services: services:
crafty: crafty:
container_name: crafty_container container_name: crafty_container

View File

@@ -69,6 +69,7 @@ MASTER_CONFIG = {
"max_login_attempts": 3, "max_login_attempts": 3,
"superMFA": False, "superMFA": False,
"general_user_log_access": False, "general_user_log_access": False,
"base_url": "127.0.0.1:8443",
} }
CONFIG_CATEGORIES = { CONFIG_CATEGORIES = {
@@ -80,6 +81,7 @@ CONFIG_CATEGORIES = {
"disabled_language_files", "disabled_language_files",
"big_bucket_repo", "big_bucket_repo",
"enable_user_self_delete", "enable_user_self_delete",
"base_url",
], ],
"security": [ "security": [
"allow_nsfw_profile_pictures", "allow_nsfw_profile_pictures",

View File

@@ -27,6 +27,8 @@ logger = logging.getLogger(__name__)
class BackupManager: class BackupManager:
SNAPSHOT_BACKUP_DATE_FORMAT_STRING = "%Y-%m-%d-%H-%M-%S" SNAPSHOT_BACKUP_DATE_FORMAT_STRING = "%Y-%m-%d-%H-%M-%S"
SNAPSHOT_SUFFIX = ".manifest"
ARCHIVE_SUFFIX = ".zip"
def __init__(self, helper, file_helper, management_helper): def __init__(self, helper, file_helper, management_helper):
self.helper = helper self.helper = helper
@@ -94,13 +96,14 @@ class BackupManager:
), ),
) )
def backup_starter(self, backup_config, server): def backup_starter(self, backup_config, server) -> tuple:
"""Notify users of backup starting, and start the backup. """Notify users of backup starting, and start the backup.
Args: Args:
backup_config (_type_): _description_ backup_config (_type_): _description_
server (_type_): Server object to backup server (_type_): Server object to backup
""" """
# Notify users of backup starting # Notify users of backup starting
logger.info(f"Starting server {server.name} (ID {server.server_id}) backup") logger.info(f"Starting server {server.name} (ID {server.server_id}) backup")
server_users = PermissionsServers.get_server_user_list(server.server_id) server_users = PermissionsServers.get_server_user_list(server.server_id)
@@ -114,14 +117,37 @@ class BackupManager:
).format(server.name), ).format(server.name),
) )
time.sleep(3) time.sleep(3)
size = False
# Start the backup # Start the backup
if backup_config.get("backup_type", "zip_vault") == "zip_vault": if backup_config.get("backup_type", "zip_vault") == "zip_vault":
self.zip_vault(backup_config, server) backup_file_name = self.zip_vault(backup_config, server)
if (
backup_file_name
and Path(backup_file_name).suffix != self.ARCHIVE_SUFFIX
):
backup_file_name += self.ARCHIVE_SUFFIX
if backup_file_name:
size = (
Path(
backup_config["backup_location"],
backup_config["backup_id"],
backup_file_name,
)
.stat()
.st_size
)
else: else:
self.snapshot_backup(backup_config, server) backup_file_name = self.snapshot_backup(backup_config, server)
if (
backup_file_name
and Path(backup_file_name).suffix != self.SNAPSHOT_SUFFIX
):
backup_file_name += self.SNAPSHOT_SUFFIX
if backup_file_name:
return (backup_file_name, size)
return (False, "error")
def zip_vault(self, backup_config, server): def zip_vault(self, backup_config, server) -> str | bool:
# Adjust the location to include the backup ID for destination. # Adjust the location to include the backup ID for destination.
backup_location = os.path.join( backup_location = os.path.join(
@@ -131,7 +157,7 @@ class BackupManager:
# Check if the backup location even exists. # Check if the backup location even exists.
if not backup_location: if not backup_location:
Console.critical("No backup path found. Canceling") Console.critical("No backup path found. Canceling")
return None return False
self.helper.ensure_dir_exists(backup_location) self.helper.ensure_dir_exists(backup_location)
@@ -195,8 +221,10 @@ class BackupManager:
{"status": json.dumps({"status": "Standby", "message": ""})}, {"status": json.dumps({"status": "Standby", "message": ""})},
) )
time.sleep(5) time.sleep(5)
return Path(backup_filename).name
except Exception as e: except Exception as e:
self.fail_backup(e, backup_config, server) self.fail_backup(e, backup_config, server)
return False
@staticmethod @staticmethod
def fail_backup(why: Exception, backup_config: dict, server) -> None: def fail_backup(why: Exception, backup_config: dict, server) -> None:
@@ -234,8 +262,7 @@ class BackupManager:
{"status": json.dumps({"status": "Failed", "message": f"{why}"})}, {"status": json.dumps({"status": "Failed", "message": f"{why}"})},
) )
@staticmethod def list_backups(self, backup_config: dict, server_id) -> list:
def list_backups(backup_config: dict, server_id) -> list:
if not backup_config: if not backup_config:
logger.info( logger.info(
f"Error putting backup file list for server with ID: {server_id}" f"Error putting backup file list for server with ID: {server_id}"
@@ -268,7 +295,7 @@ class BackupManager:
"size": "", "size": "",
} }
for f in files for f in files
if f["path"].endswith(".manifest") if f["path"].endswith(self.SNAPSHOT_SUFFIX)
] ]
return [ return [
{ {
@@ -279,7 +306,7 @@ class BackupManager:
"size": f["size"], "size": f["size"],
} }
for f in files for f in files
if f["path"].endswith(".zip") if f["path"].endswith(self.ARCHIVE_SUFFIX)
] ]
def remove_old_backups(self, backup_config, server): def remove_old_backups(self, backup_config, server):
@@ -297,7 +324,7 @@ class BackupManager:
logger.info(f"Removing old backup '{oldfile['path']}'") logger.info(f"Removing old backup '{oldfile['path']}'")
os.remove(Helpers.get_os_understandable_path(oldfile_path)) os.remove(Helpers.get_os_understandable_path(oldfile_path))
def snapshot_backup(self, backup_config, server) -> None: def snapshot_backup(self, backup_config, server) -> str | bool:
""" """
Creates snapshot style backup of server. No file will be saved more than once Creates snapshot style backup of server. No file will be saved more than once
over all backups. Designed to enable encryption of files and s3 compatability in over all backups. Designed to enable encryption of files and s3 compatability in
@@ -339,7 +366,7 @@ class BackupManager:
manifest_file: io.TextIOWrapper = backup_manifest_path.open("w+") manifest_file: io.TextIOWrapper = backup_manifest_path.open("w+")
except OSError as why: except OSError as why:
self.fail_backup(why, backup_config, server) self.fail_backup(why, backup_config, server)
return return False
# Write manifest file version. # Write manifest file version.
manifest_file.write("00\n") manifest_file.write("00\n")
@@ -359,7 +386,7 @@ class BackupManager:
manifest_file.close() manifest_file.close()
backup_manifest_path.unlink(missing_ok=True) backup_manifest_path.unlink(missing_ok=True)
self.fail_backup(why, backup_config, server) self.fail_backup(why, backup_config, server)
return return False
# Write saved file into manifest. # Write saved file into manifest.
manifest_file.write( manifest_file.write(
@@ -373,6 +400,13 @@ class BackupManager:
backup_config["max_backups"], backup_repository_path backup_config["max_backups"], backup_repository_path
) )
HelpersManagement.update_backup_config(
backup_config["backup_id"],
{"status": json.dumps({"status": "Standby", "message": ""})},
)
return Path(backup_manifest_path).name
def snapshot_restore( def snapshot_restore(
self, backup_config: {str}, backup_manifest_filename: str, server self, backup_config: {str}, backup_manifest_filename: str, server
) -> None: ) -> None:

View File

@@ -7,7 +7,7 @@ import time
import datetime import datetime
import base64 import base64
import threading import threading
import logging.config import logging
import subprocess import subprocess
import html import html
import glob import glob
@@ -54,31 +54,72 @@ def callback(called_func):
res = None res = None
logger.debug("Checking for callbacks") logger.debug("Checking for callbacks")
try: try:
res = called_func(*args, **kwargs) res = called_func(*args, **kwargs) # Calls and runs the function
finally: finally:
events = WebhookFactory.get_monitored_events() event_type = called_func.__name__
if called_func.__name__ in events:
# For send_command, Retrieve command from args or kwargs
command = args[1] if len(args) > 1 else kwargs.get("command", "")
if event_type in WebhookFactory.get_monitored_events():
server_webhooks = HelpersWebhooks.get_webhooks_by_server( server_webhooks = HelpersWebhooks.get_webhooks_by_server(
args[0].server_id, True args[0].server_id, True
) )
for swebhook in server_webhooks: for swebhook in server_webhooks:
if called_func.__name__ in str(swebhook.trigger).split(","): if event_type in str(swebhook.trigger).split(","):
logger.info( logger.info(
f"Found callback for event {called_func.__name__}" f"Found callback for event {event_type}"
f" for server {args[0].server_id}" f" for server {args[0].server_id}"
) )
webhook = HelpersWebhooks.get_webhook_by_id(swebhook.id) webhook = HelpersWebhooks.get_webhook_by_id(swebhook.id)
webhook_provider = WebhookFactory.create_provider( webhook_provider = WebhookFactory.create_provider(
webhook["webhook_type"] webhook["webhook_type"]
) )
# Extract source context from kwargs if present
source_type = kwargs.get("source_type", "unknown")
source_id = kwargs.get("source_id", "")
source_name = kwargs.get("source_name", "")
backup_name = ""
backup_size = ""
backup_link = ""
backup_status = ""
backup_error = ""
if isinstance(res, dict):
backup_name = res.get("backup_name")
backup_size = str(res.get("backup_size"))
backup_link = res.get("backup_link")
backup_status = res.get("backup_status")
backup_error = res.get("backup_error")
event_data = {
"server_name": args[0].name,
"server_id": args[0].server_id,
"command": command,
"event_type": event_type,
"source_type": source_type,
"source_id": source_id,
"source_name": source_name,
"backup_name": backup_name,
"backup_size": backup_size,
"backup_link": backup_link,
"backup_status": backup_status,
"backup_error": backup_error,
}
# Add time variables to event_data
event_data = webhook_provider.add_time_variables(event_data)
if res is not False and swebhook.enabled: if res is not False and swebhook.enabled:
webhook_provider.send( webhook_provider.send(
bot_name=webhook["bot_name"],
server_name=args[0].name, server_name=args[0].name,
title=webhook["name"], title=webhook["name"],
url=webhook["url"], url=webhook["url"],
message=webhook["body"], message_template=webhook["body"],
event_data=event_data,
color=webhook["color"], color=webhook["color"],
bot_name=webhook["bot_name"],
) )
return res return res
@@ -1205,7 +1246,7 @@ class ServerInstance:
logger.info(f"Backup Thread started for server {self.settings['server_name']}.") logger.info(f"Backup Thread started for server {self.settings['server_name']}.")
@callback @callback
def backup_server(self, backup_id): def backup_server(self, backup_id) -> dict | bool:
logger.info(f"Starting server {self.name} (ID {self.server_id}) backup") logger.info(f"Starting server {self.name} (ID {self.server_id}) backup")
server_users = PermissionsServers.get_server_user_list(self.server_id) server_users = PermissionsServers.get_server_user_list(self.server_id)
# Alert the start of the backup to the authorized users. # Alert the start of the backup to the authorized users.
@@ -1221,7 +1262,15 @@ class ServerInstance:
# Get the backup config # Get the backup config
if not backup_id: if not backup_id:
return logger.error("No backup ID provided. Exiting backup") logger.error("No backup ID provided. Exiting backup")
last_failed = self.last_backup_status()
if last_failed:
last_backup_status = ""
reason = "No backup ID provided"
return {
"backup_status": last_backup_status,
"backup_error": reason,
}
conf = HelpersManagement.get_backup_config(backup_id) conf = HelpersManagement.get_backup_config(backup_id)
# Adjust the location to include the backup ID for destination. # Adjust the location to include the backup ID for destination.
backup_location = os.path.join(conf["backup_location"], conf["backup_id"]) backup_location = os.path.join(conf["backup_location"], conf["backup_id"])
@@ -1229,7 +1278,16 @@ class ServerInstance:
# Check if the backup location even exists. # Check if the backup location even exists.
if not backup_location: if not backup_location:
Console.critical("No backup path found. Canceling") Console.critical("No backup path found. Canceling")
return None backup_status = json.loads(
HelpersManagement.get_backup_config(backup_id)["status"]
)
if backup_status["status"] == "Failed":
last_backup_status = ""
reason = backup_status["message"]
return {
"backup_status": last_backup_status,
"backup_error": reason,
}
if conf["before"]: if conf["before"]:
logger.debug( logger.debug(
"Found running server and send command option. Sending command" "Found running server and send command option. Sending command"
@@ -1237,7 +1295,7 @@ class ServerInstance:
self.send_command(conf["before"]) self.send_command(conf["before"])
# Pause to let command run # Pause to let command run
time.sleep(5) time.sleep(5)
self.backup_mgr.backup_starter(conf, self) backup_name, backup_size = self.backup_mgr.backup_starter(conf, self)
if conf["after"]: if conf["after"]:
self.send_command(conf["after"]) self.send_command(conf["after"])
if conf["shutdown"] and self.was_running: if conf["shutdown"] and self.was_running:
@@ -1247,6 +1305,46 @@ class ServerInstance:
self.run_threaded_server(HelperUsers.get_user_id_by_name("system")) self.run_threaded_server(HelperUsers.get_user_id_by_name("system"))
self.set_backup_status() self.set_backup_status()
# Return data for webhooks callback
base_url = f"{self.helper.get_setting('base_url')}"
size = backup_size
backup_status = json.loads(
HelpersManagement.get_backup_config(backup_id)["status"]
)
reason = backup_status["message"]
if not backup_name:
return {
"backup_status": "",
"backup_error": reason,
}
if backup_size:
size = self.helper.human_readable_file_size(backup_size)
url = (
f"https://{base_url}/api/v2/servers/{self.server_id}"
f"/backups/backup/{backup_id}/download/{html.escape(backup_name)}"
)
if conf["backup_type"] == "snapshot":
size = 0
url = (
f"https://{base_url}/panel/edit_backup?"
f"id={self.server_id}&backup_id={backup_id}"
)
backup_status = json.loads(
HelpersManagement.get_backup_config(backup_id)["status"]
)
last_backup_status = ""
reason = ""
if backup_status["status"] == "Failed":
last_backup_status = ""
reason = backup_status["message"]
return {
"backup_name": backup_name,
"backup_size": size,
"backup_link": url,
"backup_status": last_backup_status,
"backup_error": reason,
}
def set_backup_status(self): def set_backup_status(self):
backups = HelpersManagement.get_backups_by_server(self.server_id, True) backups = HelpersManagement.get_backups_by_server(self.server_id, True)
alert = False alert = False

View File

@@ -701,7 +701,9 @@ class PanelHandler(BaseHandler):
server_id, model=True server_id, model=True
) )
) )
page_data["triggers"] = WebhookFactory.get_monitored_events() page_data["triggers"] = list(
WebhookFactory.get_monitored_events().keys()
)
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)
@@ -991,7 +993,7 @@ class PanelHandler(BaseHandler):
page_data["webhook"]["enabled"] = True page_data["webhook"]["enabled"] = True
page_data["providers"] = WebhookFactory.get_supported_providers() page_data["providers"] = WebhookFactory.get_supported_providers()
page_data["triggers"] = WebhookFactory.get_monitored_events() page_data["triggers"] = list(WebhookFactory.get_monitored_events().keys())
if not EnumPermissionsServer.CONFIG in page_data["user_permissions"]: if not EnumPermissionsServer.CONFIG in page_data["user_permissions"]:
if not superuser: if not superuser:
@@ -1042,7 +1044,7 @@ class PanelHandler(BaseHandler):
).split(",") ).split(",")
page_data["providers"] = WebhookFactory.get_supported_providers() page_data["providers"] = WebhookFactory.get_supported_providers()
page_data["triggers"] = WebhookFactory.get_monitored_events() page_data["triggers"] = list(WebhookFactory.get_monitored_events().keys())
if not EnumPermissionsServer.CONFIG in page_data["user_permissions"]: if not EnumPermissionsServer.CONFIG in page_data["user_permissions"]:
if not superuser: if not superuser:

View File

@@ -119,6 +119,13 @@ config_json_schema = {
"error": "typeBool", "error": "typeBool",
"fill": True, "fill": True,
}, },
"base_url": {
"type": "string",
"pattern": (
r"^(?:(?:\d{1,3}\.){3}\d{1,3}"
r"|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})(?::\d{1,5})?$"
),
},
"max_login_attempts": {"type": "integer", "error": "typeInt", "fill": True}, "max_login_attempts": {"type": "integer", "error": "typeInt", "fill": True},
"superMFA": {"type": "boolean", "error": "typeBool", "fill": True}, "superMFA": {"type": "boolean", "error": "typeBool", "fill": True},
"general_user_log_access": { "general_user_log_access": {

View File

@@ -1,6 +1,9 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import logging import logging
import datetime
import time
import requests import requests
from jinja2 import Environment, BaseLoader
from app.classes.helpers.helpers import Helpers from app.classes.helpers.helpers import Helpers
@@ -16,6 +19,12 @@ class WebhookProvider(ABC):
ensuring that each provider will have a send method. ensuring that each provider will have a send method.
""" """
def __init__(self):
self.jinja_env = Environment(
loader=BaseLoader(),
autoescape=True,
)
WEBHOOK_USERNAME = "Crafty Webhooks" WEBHOOK_USERNAME = "Crafty Webhooks"
WEBHOOK_PFP_URL = ( WEBHOOK_PFP_URL = (
"https://gitlab.com/crafty-controller/crafty-4/-" "https://gitlab.com/crafty-controller/crafty-4/-"
@@ -34,6 +43,55 @@ class WebhookProvider(ABC):
logger.error(error) logger.error(error)
raise RuntimeError(f"Failed to dispatch notification: {error}") from error raise RuntimeError(f"Failed to dispatch notification: {error}") from error
def render_template(self, template_str, context):
"""
Renders a Jinja2 template with the provided context.
Args:
template_str (str): The Jinja2 template string.
context (dict): A dictionary containing all the variables needed for
rendering the template.
Returns:
str: The rendered message.
"""
try:
template = self.jinja_env.from_string(template_str)
return template.render(context)
except Exception as error:
logger.error(f"Error rendering Jinja2 template: {error}")
raise
def add_time_variables(self, event_data):
"""
Adds various time format variables to the event_data dictionary.
Adds the following time-related variables to event_data:
- time_iso: ISO 8601 formatted datetime (UTC)
- time_unix: UNIX timestamp (seconds since epoch)
- time_day: Day of month (1-31)
- time_month: Month (1-12)
- time_year: Full year (e.g., 2025)
- time_formatted: Human-readable format (YYYY-MM-DD HH:MM:SS UTC)
Args:
event_data (dict): A dictionary containing event information.
Returns:
dict: The event_data dictionary with time variables added.
"""
now_utc = datetime.datetime.now(datetime.timezone.utc)
unix_timestamp = int(time.time())
event_data["time_iso"] = now_utc.isoformat().replace("+00:00", "Z")
event_data["time_unix"] = unix_timestamp
event_data["time_day"] = now_utc.day
event_data["time_month"] = now_utc.month
event_data["time_year"] = now_utc.year
event_data["time_formatted"] = now_utc.strftime("%Y-%m-%d %H:%M:%S UTC")
return event_data
@abstractmethod @abstractmethod
def send(self, server_name, title, url, message, **kwargs): def send(self, server_name, title, url, message_template, event_data, **kwargs):
"""Abstract method that derived classes will implement for sending webhooks.""" """Abstract method that derived classes will implement for sending webhooks."""

View File

@@ -51,7 +51,7 @@ class DiscordWebhook(WebhookProvider):
return payload, headers return payload, headers
def send(self, server_name, title, url, message, **kwargs): def send(self, server_name, title, url, message_template, event_data, **kwargs):
""" """
Sends a Discord webhook notification using the given details. Sends a Discord webhook notification using the given details.
@@ -74,6 +74,7 @@ class DiscordWebhook(WebhookProvider):
Raises: Raises:
Exception: If there's an error in dispatching the webhook. Exception: If there's an error in dispatching the webhook.
""" """
message = self.render_template(message_template, event_data)
color = kwargs.get("color", "#005cd1") # Default to a color if not provided. color = kwargs.get("color", "#005cd1") # Default to a color if not provided.
bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME) bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME)
payload, headers = self._construct_discord_payload( payload, headers = self._construct_discord_payload(

View File

@@ -41,7 +41,7 @@ class MattermostWebhook(WebhookProvider):
return payload, headers return payload, headers
def send(self, server_name, title, url, message, **kwargs): def send(self, server_name, title, url, message_template, event_data, **kwargs):
""" """
Sends a Mattermost webhook notification using the given details. Sends a Mattermost webhook notification using the given details.
@@ -52,7 +52,8 @@ class MattermostWebhook(WebhookProvider):
server_name (str): The name of the server triggering the notification. server_name (str): The name of the server triggering the notification.
title (str): The title for the notification message. title (str): The title for the notification message.
url (str): The webhook URL to send the notification to. url (str): The webhook URL to send the notification to.
message (str): The main content or body of the notification message. message_template (str): The Jinja2 template for the message body.
event_data (dict): A dictionary containing variables for template rendering.
bot_name (str): Override for the Webhook's name set on creation, see note! bot_name (str): Override for the Webhook's name set on creation, see note!
Returns: Returns:
@@ -67,6 +68,7 @@ class MattermostWebhook(WebhookProvider):
- Mattermost's `config.json` setting is `"EnablePostUsernameOverride": true` - Mattermost's `config.json` setting is `"EnablePostUsernameOverride": true`
- Mattermost's `config.json` setting is `"EnablePostIconOverride": true` - Mattermost's `config.json` setting is `"EnablePostIconOverride": true`
""" """
message = self.render_template(message_template, event_data)
bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME) bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME)
payload, headers = self._construct_mattermost_payload( payload, headers = self._construct_mattermost_payload(
server_name, title, message, bot_name server_name, title, message, bot_name

View File

@@ -67,7 +67,7 @@ class SlackWebhook(WebhookProvider):
return payload, headers return payload, headers
def send(self, server_name, title, url, message, **kwargs): def send(self, server_name, title, url, message_template, event_data, **kwargs):
""" """
Sends a Slack webhook notification using the given details. Sends a Slack webhook notification using the given details.
@@ -78,7 +78,8 @@ class SlackWebhook(WebhookProvider):
server_name (str): The name of the server triggering the notification. server_name (str): The name of the server triggering the notification.
title (str): The title for the notification message. title (str): The title for the notification message.
url (str): The webhook URL to send the notification to. url (str): The webhook URL to send the notification to.
message (str): The main content or body of the notification message. message_template (str): The Jinja2 template for the message body.
event_data (dict): A dictionary containing variables for template rendering.
color (str, optional): The color code for the blocks's colour accent. color (str, optional): The color code for the blocks's colour accent.
Defaults to a pretty blue if not provided. Defaults to a pretty blue if not provided.
bot_name (str): Override for the Webhook's name set on creation, (not working). bot_name (str): Override for the Webhook's name set on creation, (not working).
@@ -90,6 +91,7 @@ class SlackWebhook(WebhookProvider):
Raises: Raises:
Exception: If there's an error in dispatching the webhook. Exception: If there's an error in dispatching the webhook.
""" """
message = self.render_template(message_template, event_data)
color = kwargs.get("color", "#005cd1") # Default to a color if not provided. color = kwargs.get("color", "#005cd1") # Default to a color if not provided.
bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME) bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME)
payload, headers = self._construct_slack_payload( payload, headers = self._construct_slack_payload(

View File

@@ -101,19 +101,19 @@ class TeamsWebhook(WebhookProvider):
return payload, headers return payload, headers
def send(self, server_name, title, url, message, **kwargs): def send(self, server_name, title, url, message_template, event_data, **kwargs):
""" """
Sends a Teams Adaptive card notification using the given details. Sends a Teams Adaptive card notification using the given details.
The method constructs and dispatches a payload suitable for The method constructs and dispatches a payload suitable for
Discords's webhook system. Teams's webhook system.
Parameters: Parameters:
server_name (str): The name of the server triggering the notification. server_name (str): The name of the server triggering the notification.
title (str): The title for the notification message. title (str): The title for the notification message.
url (str): The webhook URL to send the notification to. url (str): The webhook URL to send the notification to.
message (str): The main content or body of the notification message. message_template (str): The Jinja2 template for the message body.
Defaults to a pretty blue if not provided. event_data (dict): A dictionary containing variables for template rendering.
Returns: Returns:
str: "Dispatch successful!" if the message is sent successfully, otherwise an str: "Dispatch successful!" if the message is sent successfully, otherwise an
@@ -122,5 +122,6 @@ class TeamsWebhook(WebhookProvider):
Raises: Raises:
Exception: If there's an error in dispatching the webhook. Exception: If there's an error in dispatching the webhook.
""" """
message = self.render_template(message_template, event_data)
payload, headers = self._construct_teams_payload(server_name, title, message) payload, headers = self._construct_teams_payload(server_name, title, message)
return self._send_request(url, payload, headers) return self._send_request(url, payload, headers)

View File

@@ -13,7 +13,7 @@ class WebhookFactory:
to manage the available providers. to manage the available providers.
Attributes: Attributes:
- _registry (dict): A dictionary mapping provider names to their classes. - _registry (dict): A dictionary mapping provider names to their classes.
""" """
_registry = { _registry = {
@@ -32,18 +32,18 @@ class WebhookFactory:
provided arguments. If the provider is not recognized, a ValueError is raised. provided arguments. If the provider is not recognized, a ValueError is raised.
Arguments: Arguments:
- provider_name (str): The name of the desired webhook provider. - provider_name (str): The name of the desired webhook provider.
Additional arguments supported that we may use for if a provider Additional arguments supported that we may use for if a provider
requires initialization: requires initialization:
- *args: Positional arguments to pass to the provider's constructor. - *args: Positional arguments to pass to the provider's constructor.
- **kwargs: Keyword arguments to pass to the provider's constructor. - **kwargs: Keyword arguments to pass to the provider's constructor.
Returns: Returns:
WebhookProvider: An instance of the desired webhook provider. WebhookProvider: An instance of the desired webhook provider.
Raises: Raises:
ValueError: If the specified provider name is not recognized. ValueError: If the specified provider name is not recognized.
""" """
if provider_name not in cls._registry: if provider_name not in cls._registry:
raise ValueError(f"Provider {provider_name} is not supported.") raise ValueError(f"Provider {provider_name} is not supported.")
@@ -58,7 +58,7 @@ class WebhookFactory:
currently registered in the factory's registry. currently registered in the factory's registry.
Returns: Returns:
List[str]: A list of supported provider names. List[str]: A list of supported provider names.
""" """
return list(cls._registry.keys()) return list(cls._registry.keys())
@@ -68,17 +68,45 @@ class WebhookFactory:
Retrieves the list of supported events for monitoring. Retrieves the list of supported events for monitoring.
This method provides a list of common server events that the webhook system can This method provides a list of common server events that the webhook system can
monitor and notify about. monitor and notify about. Along with the available `event_data` vars for use
on the frontend.
Returns: Returns:
List[str]: A list of supported monitored actions. dict: A dictionary where each key is an event name and the value is a
dictionary containing a list of `variables` for that event.
These variables are intended for use in the frontend to show whats
available.
""" """
return [ # Common variables for all events
"start_server", common_vars = [
"stop_server", "server_name",
"crash_detected", "server_id",
"backup_server", "event_type",
"jar_update", "source_type",
"send_command", "source_id",
"kill", "source_name",
"time_iso",
"time_unix",
"time_day",
"time_month",
"time_year",
"time_formatted",
] ]
return {
"start_server": {"variables": common_vars},
"stop_server": {"variables": common_vars},
"crash_detected": {"variables": common_vars},
"backup_server": {
"variables": common_vars
+ [
"backup_name",
"backup_link",
"backup_size",
"backup_status",
"backup_error",
]
},
"jar_update": {"variables": common_vars},
"send_command": {"variables": common_vars + ["command"]},
"kill": {"variables": common_vars + ["reason"]},
}

View File

@@ -1,5 +1,5 @@
{ {
"major": 4, "major": 4,
"minor": 5, "minor": 6,
"sub": 6 "sub": 1
} }

View File

@@ -0,0 +1,9 @@
.jinja2 {
background-color: var(--card-banner-bg);
border-top-left-radius: 10px;
border-top-right-radius: 10px;
outline: 1px solid var(--outline);
padding: 10px;
margin-top: 10px;
margin-bottom: -10px;
}

View File

@@ -7,6 +7,7 @@
{% block content %} {% block content %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-select/1.13.10/css/bootstrap-select.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-select/1.13.10/css/bootstrap-select.min.css">
<link rel="stylesheet" href="/static/assets/css/partial/crafty-webhooks.css">
<div class="content-wrapper"> <div class="content-wrapper">
<!-- Page Title Header Starts--> <!-- Page Title Header Starts-->
@@ -83,10 +84,11 @@
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="body">{{ translate('webhooks', 'webhook_body', data['lang']) }}</label> <label for="body">{{ translate('webhooks', 'webhook_body', data['lang']) }} <br><div class="jinja2"><small>{{translate("webhooks", "jinja2", data['lang'])}} <br>
<textarea id="body-input" name="body" rows="4" cols="50"> <a href="https://docs.craftycontrol.com/pages/user-guide/webhooks/#jinja2-dynamic-variables-461" target="_blank">{{translate("webhooks", "documentation", data['lang'])}}</a></small></div></label>
{{ data["webhook"]["body"] }} <textarea id="body-input" name="body" rows="4" cols="50">
</textarea> {{ data["webhook"]["body"] }}
</textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="bot_name">{{ translate('webhooks', 'color' , data['lang']) }}</label> <label for="bot_name">{{ translate('webhooks', 'color' , data['lang']) }}</label>

View File

@@ -790,9 +790,11 @@
"bot_name": "Bot Name", "bot_name": "Bot Name",
"color": "Select Color Accent", "color": "Select Color Accent",
"crash_detected": "Server Crashed", "crash_detected": "Server Crashed",
"documentation": "Please reference this documentation for information regarding dynamic variables.",
"edit": "Edit", "edit": "Edit",
"enabled": "Enabled", "enabled": "Enabled",
"jar_update": "Server Executable Updated", "jar_update": "Server Executable Updated",
"jinja2": "Crafty's webhook engine takes advantage of Jinja2 for dynamic message rendering.",
"kill": "Server Killed", "kill": "Server Killed",
"name": "Name", "name": "Name",
"new": "New Webhook", "new": "New Webhook",

View File

@@ -1,26 +1,26 @@
aiofiles==24.1.0
anyio==4.9.0
apscheduler==3.10.4 apscheduler==3.10.4
argon2-cffi==23.1.0 argon2-cffi==23.1.0
cached_property==1.5.2 cached_property==1.5.2
colorama==0.4.6 colorama==0.4.6
croniter==1.4.1 croniter==1.4.1
cryptography==44.0.1 cryptography==44.0.1
httpx==0.28.1
jinja2==3.1.6
jsonschema==4.19.1
libgravatar==1.0.4 libgravatar==1.0.4
nh3==0.2.14 nh3==0.2.14
orjson==3.9.15
packaging==23.2 packaging==23.2
peewee==3.13 peewee==3.13
pillow==10.4.0
prometheus-client==0.17.1
psutil==5.9.5 psutil==5.9.5
pyjwt==2.8.0 pyjwt==2.8.0
pyotp==2.9.0
PyYAML==6.0.1 PyYAML==6.0.1
requests==2.32.4 requests==2.32.4
termcolor==1.1 termcolor==1.1
tornado==6.5 tornado==6.5
tzlocal==5.1 tzlocal==5.1
jsonschema==4.19.1
orjson==3.9.15
prometheus-client==0.17.1
pyotp==2.9.0
pillow==10.4.0
httpx==0.28.1
aiofiles==24.1.0
anyio==4.9.0

View File

@@ -3,8 +3,8 @@ sonar.organization=crafty-controller
# This is the name and version displayed in the SonarCloud UI. # This is the name and version displayed in the SonarCloud UI.
sonar.projectName=Crafty 4 sonar.projectName=Crafty 4
sonar.projectVersion=4.5.6 sonar.projectVersion=4.6.1
sonar.python.version=3.9, 3.10, 3.11 sonar.python.version=3.10, 3.11, 3.12
sonar.exclusions=app/migrations/**, app/frontend/static/assets/vendors/** sonar.exclusions=app/migrations/**, app/frontend/static/assets/vendors/**
# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.