diff --git a/.dockerignore b/.dockerignore
index 5ab07afd..fd5c9add 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -11,6 +11,7 @@ docker-compose.yml.example
.gitlab/
.gitignore
.gitlab-ci.yml
+lang_sort_log.txt
# root
.editorconfig
diff --git a/.gitignore b/.gitignore
index 7f3ad858..132a2a26 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@ env.bak/
venv.bak/
.idea/
+/import/
/imports/
/servers/
/app/frontend/static/assets/images/auth/custom/
@@ -35,3 +36,4 @@ default.json
app/config/
docker/*
!docker/docker-compose.yml
+lang_sort_log.txt
diff --git a/.gitlab/lint.yml b/.gitlab/lint.yml
index 85a09c39..f335dea8 100644
--- a/.gitlab/lint.yml
+++ b/.gitlab/lint.yml
@@ -81,3 +81,26 @@ sonarcloud-check:
- .sonar/cache
script:
- sonar-scanner
+
+# Lang file checking
+lang-check:
+ stage: lint
+ image: alpine:latest
+ tags:
+ - docker
+ rules:
+ - if: "$CODE_QUALITY_DISABLED"
+ when: never
+ - if: "$CI_COMMIT_TAG || $CI_COMMIT_BRANCH"
+ allow_failure: true
+ before_script:
+ - apk add --no-cache jq bash
+ script:
+ - chmod +x .gitlab/scripts/lang_sort.sh
+ - bash .gitlab/scripts/lang_sort.sh ./app/translations/
+ after_script:
+ - if [ -f .gitlab/scripts/lang_sort_log.txt ]; then cat .gitlab/scripts/lang_sort_log.txt; fi
+ artifacts:
+ paths:
+ - .gitlab/scripts/lang_sort_log.txt
+ expire_in: 1 week
diff --git a/.gitlab/scripts/lang_sort.sh b/.gitlab/scripts/lang_sort.sh
new file mode 100644
index 00000000..5710ce1b
--- /dev/null
+++ b/.gitlab/scripts/lang_sort.sh
@@ -0,0 +1,95 @@
+#!/bin/bash
+
+# Ensure locale is set to C for predictable sorting
+export LC_ALL=C
+export LC_COLLATE=C
+
+# Get the script's own path
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# Directory containing the JSON files to sort
+DIR="$1"
+found_missing_keys=false
+
+
+##### Log Setup #####
+# Log file path
+LOGFILE="${SCRIPT_DIR}/lang_sort_log.txt"
+
+# Redirect stdout and stderr to the logfile
+exec > "${LOGFILE}" 2>&1
+#####################
+
+
+##### Exit Gates #####
+# Check if jq is installed
+if ! command -v jq &> /dev/null
+then
+ echo "jq could not be found, please install jq first."
+ exit
+fi
+
+# Check for directory argument
+if [ "$#" -ne 1 ]; then
+ echo "Usage: $0 /path/to/translations"
+ exit
+fi
+
+# Check if en_EN.json exists in the directory
+if [[ ! -f "${DIR}/en_EN.json" ]]; then
+ echo "The file en_EN.json does not exist in ${DIR}.Ensure you have the right directory, Exiting."
+ exit
+fi
+######################
+
+
+# Sort keys of the en_EN.json file with 4-space indentation and overwrite it
+jq -S --indent 4 '.' "${DIR}/en_EN.json" > "${DIR}/en_EN.json.tmp" && mv "${DIR}/en_EN.json.tmp" "${DIR}/en_EN.json"
+
+# Function to recursively find all keys in a JSON object
+function get_keys {
+ jq -r 'paths(scalars) | join("/")' "$1"
+}
+
+# Get keys and subkeys from en_EN.json
+ref_keys=$(mktemp)
+get_keys "${DIR}/en_EN.json" | sort > "${ref_keys}"
+
+# Iterate over each .json file in the directory
+for file in "${DIR}"/*.json; do
+ # Check if file is a regular file and not en_EN.json, and does not contain "_incomplete" in its name
+ if [[ -f "${file}" && "${file}" != "${DIR}/en_EN.json" && ! "${file}" =~ _incomplete ]]; then
+
+ # Get keys and subkeys from the current file
+ current_keys=$(mktemp)
+ get_keys "${file}" | sort > "${current_keys}"
+
+ # Display keys present in en_EN.json but not in the current file
+ missing_keys=$(comm -23 "${ref_keys}" "${current_keys}")
+ if [[ -n "${missing_keys}" ]]; then
+ found_missing_keys=true
+ echo -e "\nKeys/subkeys present in en_EN.json but missing in $(basename "${file}"): "
+ echo "${missing_keys}"
+ fi
+
+ # Sort keys of the JSON file and overwrite the original file
+ jq -S --indent 4 '.' "${file}" > "${file}.tmp" && mv "${file}.tmp" "${file}"
+
+ # Remove the temporary file
+ rm -f "${current_keys}"
+ fi
+done
+
+# Remove the temporary file
+rm -f "${ref_keys}"
+
+if ${found_missing_keys}; then
+ echo -e "\n\nSorting complete!"
+ echo "Comparison found missing keys, Please Review!"
+ echo "-------------------------------------------------------------------"
+ echo "If there are stale translations, you can exclude with '_incomplete'"
+ echo " e.g. lol_EN_incomplete.json"
+ echo "-------------------------------------------------------------------"
+ exit 1
+else
+ echo -e "\n\nComparison and Sorting complete!"
+fi
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bbb39d7f..627014a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,49 @@
# Changelog
+## --- [4.2.0] - 2023/10/18
+### 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))
+- Implementation of OpenMetrics endpoints, for use with services such as Prometheus ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/624))
+### 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))
+- Correctly handle if a server returns a string instead of json data on socket ping ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/614))
+- Bump tornado to resolve #269 ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/623))
+- Bump crypto to resolve #267 & #268 ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/622))
+- Fix select installs failing to start, returning missing python package `packaging` ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/629))
+- Fix public status page not updating #255 ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/615))
+- Fix service worker vulrn and CQ raised by SonarQ ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/631))
+- Fix Backup Restore/Schedules, Backup button function on `remote-comms2` ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/634))
+- Add a wait to the call for the directory so we can make sure the wait dialogue has time to show up first ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/637))
+- Fix bug where a reaction loop could be created, but would be cut short by an error when the loop occurred ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/636))
+- Use controller on update user call ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/640))
+- Move `imports` to `import/upload` in bind mount to better serve users on unraid with limited vdisk storage ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/642))
+- Fix bug where everytime a page was loaded user settings would be reset #286 ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/643))
+- Fix tooltip info icon on server config page ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/647))
+- Fix quick disable toggle on schedules list ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/649))
+### Refactor
+- Consolidate remaining frontend functions into API V2, and remove ajax internal API ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/585))
+- Replace bleach with nh3 ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/628))
+- Add API route for historical server stats ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/615))
+- Add API route for host stats ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/615))
+### Tweaks
+- Polish/Enhance display for InApp Documentation ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/613))
+- Add `get_users` command to Crafty's console ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/620))
+- Make files hover cursor pointer ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/627))
+- Use `Jar` class naming for jar refresh to make room for steamCMD naming in the future ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/630))
+- Improve ui visibility of Build Wizard selection tabs ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/633))
+- Add additional logging for server bootstrap & moves unnecessary logging to `debug` for improved log clarity ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/635))
+- Bump orjson to `3.9.7` for python `3.12` support ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/638))
+- Bump all Crafty required python dependancies, maintaining minimum `3.9` support ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/639)) Revert peewee bump ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/651))
+- Better optimize and refactor docker launcher sh ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/642))
+- Improve pop-up notifications with Toasts ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/641))
+- Move username and password settings to buttons on panel config ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/643))
+- Remove external references from front end deps ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/648))
+### Lang
+- `fr_FR` Translation Updated to latest en_EN ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/646))
+- `de_DE`, `fr_FR`, `lol_EN`, `lv_LV`, `nl_BE`, `pl_PL` Translations Updated to latest `en_EN` ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/645))
+
+
## --- [4.1.3] - 2023/07/18
### Bug fixes
- Include tzdata in Docker image ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/604))
diff --git a/README.md b/README.md
index 2d422611..5d2379c8 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
[![Crafty Logo](app/frontend/static/assets/images/logo_long.svg)](https://craftycontrol.com)
-# Crafty Controller 4.1.3
+# Crafty Controller 4.2.0
> Python based Control Panel for your Minecraft Server
## What is Crafty Controller?
diff --git a/app/classes/controllers/management_controller.py b/app/classes/controllers/management_controller.py
index 7c423da9..7085b503 100644
--- a/app/classes/controllers/management_controller.py
+++ b/app/classes/controllers/management_controller.py
@@ -1,7 +1,9 @@
import logging
import queue
-from app.classes.models.management import HelpersManagement
+from prometheus_client import CollectorRegistry, Gauge
+
+from app.classes.models.management import HelpersManagement, HelpersWebhooks
from app.classes.models.servers import HelperServers
logger = logging.getLogger(__name__)
@@ -11,6 +13,8 @@ class ManagementController:
def __init__(self, management_helper):
self.management_helper = management_helper
self.command_queue = queue.Queue()
+ self.host_registry = CollectorRegistry()
+ self.init_host_registries()
# **********************************************************************************
# Config Methods
@@ -54,6 +58,19 @@ class ManagementController:
def add_crafty_row():
HelpersManagement.create_crafty_row()
+ def init_host_registries(self):
+ # REGISTRY Entries for Server Stats functions
+ self.cpu_usage = Gauge(
+ name="CPU_Usage",
+ documentation="The CPU usage of the server",
+ registry=self.host_registry,
+ )
+ self.mem_usage_percent = Gauge(
+ name="Mem_Usage",
+ documentation="The Memory usage of the server",
+ registry=self.host_registry,
+ )
+
# **********************************************************************************
# Commands Methods
# **********************************************************************************
@@ -79,8 +96,8 @@ class ManagementController:
# Audit_Log Methods
# **********************************************************************************
@staticmethod
- def get_actity_log():
- return HelpersManagement.get_actity_log()
+ def get_activity_log():
+ return HelpersManagement.get_activity_log()
def add_to_audit_log(self, user_id, log_msg, server_id=None, source_ip=None):
return self.management_helper.add_to_audit_log(
@@ -206,3 +223,30 @@ class ManagementController:
@staticmethod
def set_master_server_dir(server_dir):
HelpersManagement.set_master_server_dir(server_dir)
+
+ # **********************************************************************************
+ # Webhooks Methods
+ # **********************************************************************************
+ @staticmethod
+ def create_webhook(data):
+ return HelpersWebhooks.create_webhook(data)
+
+ @staticmethod
+ def modify_webhook(webhook_id, data):
+ HelpersWebhooks.modify_webhook(webhook_id, data)
+
+ @staticmethod
+ def get_webhook_by_id(webhook_id):
+ return HelpersWebhooks.get_webhook_by_id(webhook_id)
+
+ @staticmethod
+ def get_webhooks_by_server(server_id, model=False):
+ return HelpersWebhooks.get_webhooks_by_server(server_id, model)
+
+ @staticmethod
+ def delete_webhook(webhook_id):
+ HelpersWebhooks.delete_webhook(webhook_id)
+
+ @staticmethod
+ def delete_webhook_by_server(server_id):
+ HelpersWebhooks.delete_webhooks_by_server(server_id)
diff --git a/app/classes/controllers/servers_controller.py b/app/classes/controllers/servers_controller.py
index ca6c8d22..c0bae7b0 100644
--- a/app/classes/controllers/servers_controller.py
+++ b/app/classes/controllers/servers_controller.py
@@ -105,9 +105,9 @@ class ServersController(metaclass=Singleton):
return ret
- def get_history_stats(self, server_id, days):
+ def get_history_stats(self, server_id, hours):
srv = ServersController().get_server_instance_by_id(server_id)
- return srv.stats_helper.get_history_stats(server_id, days)
+ return srv.stats_helper.get_history_stats(server_id, hours)
@staticmethod
def update_unloaded_server(server_obj):
diff --git a/app/classes/controllers/users_controller.py b/app/classes/controllers/users_controller.py
index 667e01b4..ed53ad61 100644
--- a/app/classes/controllers/users_controller.py
+++ b/app/classes/controllers/users_controller.py
@@ -31,7 +31,7 @@ class UsersController:
for permission in PermissionsCrafty.get_permissions_list()
],
},
- "quantity": {"type": "number", "minimum": 0},
+ "quantity": {"type": "number", "minimum": -1},
"enabled": {"type": "boolean"},
}
self.user_jsonschema_props: t.Final = {
@@ -46,7 +46,7 @@ class UsersController:
"password": {
"type": "string",
"maxLength": 20,
- "minLength": 4,
+ "minLength": 6,
"examples": ["crafty"],
"title": "Password",
},
@@ -73,6 +73,8 @@ class UsersController:
"examples": [False],
"title": "Superuser",
},
+ "manager": {"type": ["integer", "null"]},
+ "theme": {"type": "string"},
"permissions": {
"type": "array",
"items": {
@@ -84,7 +86,7 @@ class UsersController:
"roles": {
"type": "array",
"items": {
- "type": "string",
+ "type": "integer",
"minLength": 1,
},
},
@@ -212,14 +214,14 @@ class UsersController:
limit_server_creation = 0
limit_user_creation = 0
limit_role_creation = 0
-
- PermissionsCrafty.add_or_update_user(
- user_id,
- permissions_mask,
- limit_server_creation,
- limit_user_creation,
- limit_role_creation,
- )
+ if user_crafty_data:
+ PermissionsCrafty.add_or_update_user(
+ user_id,
+ permissions_mask,
+ limit_server_creation,
+ limit_user_creation,
+ limit_role_creation,
+ )
self.users_helper.delete_user_roles(user_id, removed_roles)
diff --git a/app/classes/minecraft/mc_ping.py b/app/classes/minecraft/mc_ping.py
index 51fcaa2c..c5cb9916 100644
--- a/app/classes/minecraft/mc_ping.py
+++ b/app/classes/minecraft/mc_ping.py
@@ -16,6 +16,12 @@ logger = logging.getLogger(__name__)
class Server:
def __init__(self, data):
+ if isinstance(data, str):
+ logger.error(
+ "Failed to calculate stats. Expected object. "
+ f"Server returned string: {data}"
+ )
+ return
self.description = data.get("description")
# print(self.description)
if isinstance(self.description, dict):
diff --git a/app/classes/minecraft/serverjars.py b/app/classes/minecraft/serverjars.py
index faa12a7d..447cf80b 100644
--- a/app/classes/minecraft/serverjars.py
+++ b/app/classes/minecraft/serverjars.py
@@ -8,6 +8,7 @@ import requests
from app.classes.controllers.servers_controller import ServersController
from app.classes.models.server_permissions import PermissionsServers
+from app.classes.shared.websocket_manager import WebSocketManager
logger = logging.getLogger(__name__)
@@ -179,9 +180,7 @@ class ServerJars:
try:
ServersController.set_import(server_id)
for user in server_users:
- self.helper.websocket_helper.broadcast_user(
- user, "send_start_reload", {}
- )
+ WebSocketManager().broadcast_user(user, "send_start_reload", {})
break
except Exception as ex:
@@ -206,11 +205,9 @@ class ServerJars:
server_users = PermissionsServers.get_server_user_list(server_id)
for user in server_users:
- self.helper.websocket_helper.broadcast_user(
+ WebSocketManager().broadcast_user(
user, "notification", "Executable download finished"
)
time.sleep(3)
- self.helper.websocket_helper.broadcast_user(
- user, "send_start_reload", {}
- )
+ WebSocketManager().broadcast_user(user, "send_start_reload", {})
return success
diff --git a/app/classes/minecraft/stats.py b/app/classes/minecraft/stats.py
index c336612a..a3f85c05 100644
--- a/app/classes/minecraft/stats.py
+++ b/app/classes/minecraft/stats.py
@@ -226,7 +226,7 @@ class Stats:
def get_server_players(self, server_id):
server = HelperServers.get_server_data_by_id(server_id)
- logger.info(f"Getting players for server {server}")
+ logger.debug(f"Getting players for server {server['server_name']}")
internal_ip = server["server_ip"]
server_port = server["server_port"]
diff --git a/app/classes/models/management.py b/app/classes/models/management.py
index 2c64a8ff..e86e3209 100644
--- a/app/classes/models/management.py
+++ b/app/classes/models/management.py
@@ -17,6 +17,7 @@ from app.classes.models.users import HelperUsers
from app.classes.models.servers import Servers
from app.classes.models.server_permissions import PermissionsServers
from app.classes.shared.main_models import DatabaseShortcuts
+from app.classes.shared.websocket_manager import WebSocketManager
logger = logging.getLogger(__name__)
@@ -78,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"
@@ -145,7 +150,7 @@ class HelpersManagement:
# Audit_Log Methods
# **********************************************************************************
@staticmethod
- def get_actity_log():
+ def get_activity_log():
query = AuditLog.select()
return DatabaseShortcuts.return_db_rows(query)
@@ -158,9 +163,7 @@ class HelpersManagement:
server_users = PermissionsServers.get_server_user_list(server_id)
for user in server_users:
try:
- self.helper.websocket_helper.broadcast_user(
- user, "notification", audit_msg
- )
+ WebSocketManager().broadcast_user(user, "notification", audit_msg)
except Exception as e:
logger.error(f"Error broadcasting to user {user} - {e}")
@@ -502,3 +505,82 @@ class HelpersManagement:
f"Not removing {dir_to_del} from excluded directories - "
f"not in the excluded directory list for server ID {server_id}"
)
+
+
+# **********************************************************************************
+# Webhooks Class
+# **********************************************************************************
+class HelpersWebhooks:
+ def __init__(self, database):
+ self.database = database
+
+ @staticmethod
+ def create_webhook(create_data) -> int:
+ """Create a webhook in the database
+
+ Args:
+ server_id: ID of a server this webhook will be married to
+ name: The name of the webhook
+ url: URL to the webhook
+ webhook_type: The provider this webhook will be sent to
+ bot name: The name that will appear when the webhook is sent
+ triggers: Server actions that will trigger this webhook
+ body: The message body of the webhook
+ enabled: Should Crafty trigger the webhook
+
+ Returns:
+ int: The new webhooks's id
+
+ Raises:
+ PeeweeException: If the webhook already exists
+ """
+ return Webhooks.insert(
+ {
+ Webhooks.server_id: create_data["server_id"],
+ Webhooks.name: create_data["name"],
+ Webhooks.webhook_type: create_data["webhook_type"],
+ Webhooks.url: create_data["url"],
+ Webhooks.bot_name: create_data["bot_name"],
+ Webhooks.body: create_data["body"],
+ Webhooks.color: create_data["color"],
+ Webhooks.trigger: create_data["trigger"],
+ Webhooks.enabled: create_data["enabled"],
+ }
+ ).execute()
+
+ @staticmethod
+ def modify_webhook(webhook_id, updata):
+ Webhooks.update(updata).where(Webhooks.id == webhook_id).execute()
+
+ @staticmethod
+ def get_webhook_by_id(webhook_id):
+ return model_to_dict(Webhooks.get(Webhooks.id == webhook_id))
+
+ @staticmethod
+ def get_webhooks_by_server(server_id, model):
+ if not model:
+ data = {}
+ for webhook in (
+ Webhooks.select().where(Webhooks.server_id == server_id).execute()
+ ):
+ data[str(webhook.id)] = {
+ "webhook_type": webhook.webhook_type,
+ "name": webhook.name,
+ "url": webhook.url,
+ "bot_name": webhook.bot_name,
+ "trigger": webhook.trigger,
+ "body": webhook.body,
+ "color": webhook.color,
+ "enabled": webhook.enabled,
+ }
+ else:
+ data = Webhooks.select().where(Webhooks.server_id == server_id).execute()
+ return data
+
+ @staticmethod
+ def delete_webhook(webhook_id):
+ Webhooks.delete().where(Webhooks.id == webhook_id).execute()
+
+ @staticmethod
+ def delete_webhooks_by_server(server_id):
+ Webhooks.delete().where(Webhooks.server_id == server_id).execute()
diff --git a/app/classes/models/server_stats.py b/app/classes/models/server_stats.py
index 14f85ad3..8473ed12 100644
--- a/app/classes/models/server_stats.py
+++ b/app/classes/models/server_stats.py
@@ -8,6 +8,7 @@ from app.classes.shared.helpers import Helpers
from app.classes.shared.main_models import DatabaseShortcuts
from app.classes.shared.migration import MigrationManager
+
try:
from peewee import (
SqliteDatabase,
@@ -50,6 +51,7 @@ class ServerStats(Model):
max = IntegerField(default=0)
players = CharField(default="")
desc = CharField(default="Unable to Connect")
+ icon = CharField(default="")
version = CharField(default="")
updating = BooleanField(default=False)
waiting_start = BooleanField(default=False)
@@ -141,16 +143,20 @@ class HelperServerStats:
self.database.close()
return server_data
- def get_history_stats(self, server_id, num_days):
+ def get_history_stats(self, server_id, num_hours):
self.database.connect(reuse_if_open=True)
- max_age = datetime.datetime.now() - timedelta(days=num_days)
- server_stats = (
+ max_age = datetime.datetime.now() - timedelta(hours=num_hours)
+ query_stats = (
ServerStats.select()
.where(ServerStats.created > max_age)
.where(ServerStats.server_id == server_id)
+ # .order_by(ServerStats.created.desc())
.execute(self.database)
)
- self.database.connect(reuse_if_open=True)
+ server_stats = []
+ for stat in query_stats:
+ server_stats.append(DatabaseShortcuts.get_data_obj(stat))
+ self.database.close()
return server_stats
def insert_server_stats(self, server_stats):
@@ -179,6 +185,7 @@ class HelperServerStats:
ServerStats.max: server_stats.get("max", False),
ServerStats.players: server_stats.get("players", False),
ServerStats.desc: server_stats.get("desc", False),
+ ServerStats.icon: server_stats.get("icon", None),
ServerStats.version: server_stats.get("version", False),
}
).execute(self.database)
diff --git a/app/classes/models/users.py b/app/classes/models/users.py
index b0612017..ccd8f1b0 100644
--- a/app/classes/models/users.py
+++ b/app/classes/models/users.py
@@ -45,6 +45,7 @@ class Users(BaseModel):
manager = IntegerField(default=None, null=True)
pfp = CharField(default="/static/assets/images/faces-clipart/pic-3.png")
theme = CharField(default="default")
+ cleared_notifs = CharField(default="default")
class Meta:
table_name = "users"
@@ -171,6 +172,7 @@ class HelperUsers:
"roles": [],
"servers": [],
"support_logs": "",
+ "cleared_notifs": "",
}
user = model_to_dict(Users.get(Users.user_id == user_id))
diff --git a/app/classes/shared/command.py b/app/classes/shared/command.py
index 26fdd2f0..155fe083 100644
--- a/app/classes/shared/command.py
+++ b/app/classes/shared/command.py
@@ -11,6 +11,7 @@ from app.classes.shared.helpers import Helpers
from app.classes.shared.tasks import TasksManager
from app.classes.shared.migration import MigrationManager
from app.classes.shared.main_controller import Controller
+from app.classes.shared.websocket_manager import WebSocketManager
logger = logging.getLogger(__name__)
@@ -92,6 +93,9 @@ class MainPrompt(cmd.Cmd):
self.controller.users.update_user(user_id, {"password": new_pass})
+ def do_get_users(self, _line):
+ Console.info(self.controller.users.get_all_usernames())
+
@staticmethod
def do_threads(_line):
for thread in threading.enumerate():
@@ -115,7 +119,7 @@ class MainPrompt(cmd.Cmd):
Console.info(
"Stopping all server daemons / threads - This may take a few seconds"
)
- self.helper.websocket_helper.disconnect_all()
+ WebSocketManager().disconnect_all()
Console.info("Waiting for main thread to stop")
while True:
if self.tasks_manager.get_main_thread_run_status():
diff --git a/app/classes/shared/file_helpers.py b/app/classes/shared/file_helpers.py
index 4005e965..cc09dc4f 100644
--- a/app/classes/shared/file_helpers.py
+++ b/app/classes/shared/file_helpers.py
@@ -8,6 +8,7 @@ from zipfile import ZipFile, ZIP_DEFLATED
from app.classes.shared.helpers import Helpers
from app.classes.shared.console import Console
+from app.classes.shared.websocket_manager import WebSocketManager
logger = logging.getLogger(__name__)
@@ -149,7 +150,7 @@ class FileHelpers:
"percent": 0,
"total_files": self.helper.human_readable_file_size(dir_bytes),
}
- self.helper.websocket_helper.broadcast_page_params(
+ WebSocketManager().broadcast_page_params(
"/panel/server_detail",
{"id": str(server_id)},
"backup_status",
@@ -194,7 +195,7 @@ class FileHelpers:
"percent": percent,
"total_files": self.helper.human_readable_file_size(dir_bytes),
}
- self.helper.websocket_helper.broadcast_page_params(
+ WebSocketManager().broadcast_page_params(
"/panel/server_detail",
{"id": str(server_id)},
"backup_status",
@@ -215,7 +216,7 @@ class FileHelpers:
"percent": 0,
"total_files": self.helper.human_readable_file_size(dir_bytes),
}
- self.helper.websocket_helper.broadcast_page_params(
+ WebSocketManager().broadcast_page_params(
"/panel/server_detail",
{"id": str(server_id)},
"backup_status",
@@ -274,7 +275,7 @@ class FileHelpers:
"total_files": self.helper.human_readable_file_size(dir_bytes),
}
# send status results to page.
- self.helper.websocket_helper.broadcast_page_params(
+ WebSocketManager().broadcast_page_params(
"/panel/server_detail",
{"id": str(server_id)},
"backup_status",
@@ -325,3 +326,12 @@ class FileHelpers:
else:
return "false"
return
+
+ def unzip_server(self, zip_path, user_id):
+ if Helpers.check_file_perms(zip_path):
+ temp_dir = tempfile.mkdtemp()
+ with zipfile.ZipFile(zip_path, "r") as zip_ref:
+ # extracts archive to temp directory
+ zip_ref.extractall(temp_dir)
+ if user_id:
+ return temp_dir
diff --git a/app/classes/shared/helpers.py b/app/classes/shared/helpers.py
index 489115ae..ba9c5a28 100644
--- a/app/classes/shared/helpers.py
+++ b/app/classes/shared/helpers.py
@@ -29,7 +29,6 @@ from app.classes.shared.null_writer import NullWriter
from app.classes.shared.console import Console
from app.classes.shared.installer import installer
from app.classes.shared.translation import Translation
-from app.classes.web.websocket_helper import WebSocketHelper
with redirect_stderr(NullWriter()):
import psutil
@@ -78,7 +77,6 @@ class Helpers:
self.passhasher = PasswordHasher()
self.exiting = False
- self.websocket_helper = WebSocketHelper(self)
self.translation = Translation(self)
self.update_available = False
self.ignored_names = ["crafty_managed.txt", "db_stats"]
@@ -579,20 +577,16 @@ class Helpers:
return version_data
- @staticmethod
- def get_announcements():
- data = (
- '[{"id":"1","date":"Unknown",'
- '"title":"Error getting Announcements",'
- '"desc":"Error getting Announcements","link":""}]'
- )
-
+ def get_announcements(self):
+ data = []
try:
- response = requests.get("https://craftycontrol.com/notify.json", timeout=2)
+ response = requests.get("https://craftycontrol.com/notify", timeout=2)
data = json.loads(response.content)
except Exception as e:
logger.error(f"Failed to fetch notifications with error: {e}")
+ if self.update_available:
+ data.append(self.update_available)
return data
def get_version_string(self):
@@ -1092,87 +1086,6 @@ class Helpers:
return data
- def generate_tree(self, folder, output=""):
- dir_list = []
- unsorted_files = []
- file_list = os.listdir(folder)
- for item in file_list:
- if os.path.isdir(os.path.join(folder, item)):
- dir_list.append(item)
- elif str(item) != self.ignored_names:
- unsorted_files.append(item)
- file_list = sorted(dir_list, key=str.casefold) + sorted(
- unsorted_files, key=str.casefold
- )
- for raw_filename in file_list:
- filename = html.escape(raw_filename)
- rel = os.path.join(folder, raw_filename)
- dpath = os.path.join(folder, filename)
- if os.path.isdir(rel):
- if filename not in self.ignored_names:
- output += f"""
- \n
-
-
-
- {filename}
-
-
- \n"""
- else:
- if filename not in self.ignored_names:
- output += f"""
"""
-
- self.write(Helpers.get_os_understandable_path(folder) + "\n" + output)
- self.finish()
-
- elif page == "get_dir":
- server_id = self.get_argument("id", None)
- path = self.get_argument("path", None)
-
- if not self.check_server_id(server_id, "get_tree"):
- return
- server_id = bleach.clean(server_id)
-
- if Helpers.validate_traversal(
- self.controller.servers.get_server_data_by_id(server_id)["path"], path
- ):
- self.write(
- Helpers.get_os_understandable_path(path)
- + "\n"
- + Helpers.generate_dir(path)
- )
- self.finish()
-
- @tornado.web.authenticated
- def post(self, page):
- api_key, _, exec_user = self.current_user
- superuser = exec_user["superuser"]
- if api_key is not None:
- superuser = superuser and api_key.superuser
-
- server_id = self.get_argument("id", None)
-
- 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,
- }
- user_perms = self.controller.server_perms.get_user_id_permissions_list(
- exec_user["user_id"], server_id
- )
-
- if page == "send_command":
- command = self.get_body_argument("command", default=None, strip=True)
- server_id = self.get_argument("id", None)
-
- if server_id is None:
- logger.warning("Server ID not found in send_command ajax call")
- Console.warning("Server ID not found in send_command ajax call")
-
- svr_obj = self.controller.servers.get_server_instance_by_id(server_id)
-
- if command == svr_obj.settings["stop_command"]:
- logger.info(
- "Stop command detected as terminal input - intercepting."
- + f"Starting Crafty's stop process for server with id: {server_id}"
- )
- self.controller.management.send_command(
- exec_user["user_id"], server_id, self.get_remote_ip(), "stop_server"
- )
- command = None
- elif command == "restart":
- logger.info(
- "Restart command detected as terminal input - intercepting."
- + f"Starting Crafty's stop process for server with id: {server_id}"
- )
- self.controller.management.send_command(
- exec_user["user_id"],
- server_id,
- self.get_remote_ip(),
- "restart_server",
- )
- command = None
- if command:
- if svr_obj.check_running():
- svr_obj.send_command(command)
-
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f"Sent command to "
- f"{self.controller.servers.get_server_friendly_name(server_id)} "
- f"terminal: {command}",
- server_id,
- self.get_remote_ip(),
- )
-
- elif page == "send_order":
- self.controller.users.update_server_order(
- exec_user["user_id"], bleach.clean(self.get_argument("order"))
- )
- return
-
- elif page == "backup_now":
- server_id = self.get_argument("id", None)
- if server_id is None:
- logger.error("Server ID is none. Canceling backup!")
- return
-
- server = self.controller.servers.get_server_instance_by_id(server_id)
- self.controller.management.add_to_audit_log_raw(
- self.controller.users.get_user_by_id(exec_user["user_id"])["username"],
- exec_user["user_id"],
- server_id,
- f"Backup now executed for server {server_id} ",
- source_ip=self.get_remote_ip(),
- )
-
- server.backup_server()
-
- elif page == "select_photo":
- if exec_user["superuser"]:
- photo = urllib.parse.unquote(self.get_argument("photo", ""))
- opacity = self.get_argument("opacity", 100)
- self.controller.management.set_login_opacity(int(opacity))
- if photo == "login_1.jpg":
- self.controller.management.set_login_image("login_1.jpg")
- self.controller.cached_login = f"{photo}"
- else:
- self.controller.management.set_login_image(f"custom/{photo}")
- self.controller.cached_login = f"custom/{photo}"
- return
-
- elif page == "delete_photo":
- if exec_user["superuser"]:
- photo = urllib.parse.unquote(self.get_argument("photo", None))
- if photo and photo != "login_1.jpg":
- os.remove(
- os.path.join(
- self.controller.project_root,
- f"app/frontend/static/assets/images/auth/custom/{photo}",
- )
- )
- current = self.controller.cached_login
- split = current.split("/")
- if len(split) == 1:
- current_photo = current
- else:
- current_photo = split[1]
- if current_photo == photo:
- self.controller.management.set_login_image("login_1.jpg")
- self.controller.cached_login = "login_1.jpg"
- return
-
- elif page == "eula":
- server_id = self.get_argument("id", None)
- svr = self.controller.servers.get_server_instance_by_id(server_id)
- svr.agree_eula(exec_user["user_id"])
-
- elif page == "restore_backup":
- if not permissions["Backup"] in user_perms:
- if not superuser:
- self.redirect("/panel/error?error=Unauthorized access to Backups")
- return
- server_id = bleach.clean(self.get_argument("id", None))
- zip_name = bleach.clean(self.get_argument("zip_file", None))
- svr_obj = self.controller.servers.get_server_obj(server_id)
- server_data = self.controller.servers.get_server_data_by_id(server_id)
-
- # import the server again based on zipfile
- if server_data["type"] == "minecraft-java":
- backup_path = svr_obj.backup_path
- if Helpers.validate_traversal(backup_path, zip_name):
- temp_dir = Helpers.unzip_backup_archive(backup_path, zip_name)
- new_server = self.controller.import_zip_server(
- svr_obj.server_name,
- temp_dir,
- server_data["executable"],
- "1",
- "2",
- server_data["server_port"],
- server_data["created_by"],
- )
- new_server_id = new_server
- new_server = self.controller.servers.get_server_data(new_server)
- self.controller.rename_backup_dir(
- server_id, new_server_id, new_server["server_uuid"]
- )
- # preserve current schedules
- for schedule in self.controller.management.get_schedules_by_server(
- server_id
- ):
- self.tasks_manager.update_job(
- schedule.schedule_id, {"server_id": new_server_id}
- )
- # preserve execution command
- new_server_obj = self.controller.servers.get_server_obj(
- new_server_id
- )
- new_server_obj.execution_command = server_data["execution_command"]
- # reset executable path
- if svr_obj.path in svr_obj.executable:
- new_server_obj.executable = str(svr_obj.executable).replace(
- svr_obj.path, new_server_obj.path
- )
- # reset run command path
- if svr_obj.path in svr_obj.execution_command:
- new_server_obj.execution_command = str(
- svr_obj.execution_command
- ).replace(svr_obj.path, new_server_obj.path)
- # reset log path
- if svr_obj.path in svr_obj.log_path:
- new_server_obj.log_path = str(svr_obj.log_path).replace(
- svr_obj.path, new_server_obj.path
- )
- self.controller.servers.update_server(new_server_obj)
-
- # preserve backup config
- backup_config = self.controller.management.get_backup_config(
- server_id
- )
- excluded_dirs = []
- server_obj = self.controller.servers.get_server_obj(server_id)
- loop_backup_path = self.helper.wtol_path(server_obj.path)
- for item in self.controller.management.get_excluded_backup_dirs(
- server_id
- ):
- item_path = self.helper.wtol_path(item)
- bu_path = os.path.relpath(item_path, loop_backup_path)
- bu_path = os.path.join(new_server_obj.path, bu_path)
- excluded_dirs.append(bu_path)
- self.controller.management.set_backup_config(
- new_server_id,
- new_server_obj.backup_path,
- backup_config["max_backups"],
- excluded_dirs,
- backup_config["compress"],
- backup_config["shutdown"],
- )
- # remove old server's tasks
- try:
- self.tasks_manager.remove_all_server_tasks(server_id)
- except:
- logger.info("No active tasks found for server")
- self.controller.remove_server(server_id, True)
- self.redirect("/panel/dashboard")
-
- else:
- backup_path = svr_obj.backup_path
- if Helpers.validate_traversal(backup_path, zip_name):
- temp_dir = Helpers.unzip_backup_archive(backup_path, zip_name)
- new_server = self.controller.import_bedrock_zip_server(
- svr_obj.server_name,
- temp_dir,
- server_data["executable"],
- server_data["server_port"],
- server_data["created_by"],
- )
- new_server_id = new_server
- new_server = self.controller.servers.get_server_data(new_server)
- self.controller.rename_backup_dir(
- server_id, new_server_id, new_server["server_uuid"]
- )
- # preserve current schedules
- for schedule in self.controller.management.get_schedules_by_server(
- server_id
- ):
- self.tasks_manager.update_job(
- schedule.schedule_id, {"server_id": new_server_id}
- )
- # preserve execution command
- new_server_obj = self.controller.servers.get_server_obj(
- new_server_id
- )
- new_server_obj.execution_command = server_data["execution_command"]
- # reset executable path
- if server_obj.path in server_obj.executable:
- new_server_obj.executable = str(server_obj.executable).replace(
- server_obj.path, new_server_obj.path
- )
- # reset run command path
- if server_obj.path in server_obj.execution_command:
- new_server_obj.execution_command = str(
- server_obj.execution_command
- ).replace(server_obj.path, new_server_obj.path)
- # reset log path
- if server_obj.path in server_obj.log_path:
- new_server_obj.log_path = str(server_obj.log_path).replace(
- server_obj.path, new_server_obj.path
- )
- self.controller.servers.update_server(new_server_obj)
-
- # preserve backup config
- backup_config = self.controller.management.get_backup_config(
- server_id
- )
- excluded_dirs = []
- server_obj = self.controller.servers.get_server_obj(server_id)
- loop_backup_path = self.helper.wtol_path(server_obj.path)
- for item in self.controller.management.get_excluded_backup_dirs(
- server_id
- ):
- item_path = self.helper.wtol_path(item)
- bu_path = os.path.relpath(item_path, loop_backup_path)
- bu_path = os.path.join(new_server_obj.path, bu_path)
- excluded_dirs.append(bu_path)
- self.controller.management.set_backup_config(
- new_server_id,
- new_server_obj.backup_path,
- backup_config["max_backups"],
- excluded_dirs,
- backup_config["compress"],
- backup_config["shutdown"],
- )
- try:
- self.tasks_manager.remove_all_server_tasks(server_id)
- except:
- logger.info("No active tasks found for server")
- self.controller.remove_server(server_id, True)
- self.redirect("/panel/dashboard")
-
- elif page == "unzip_server":
- path = urllib.parse.unquote(self.get_argument("path", ""))
- if not path:
- path = os.path.join(
- self.controller.project_root,
- "imports",
- urllib.parse.unquote(self.get_argument("file", "")),
- )
- if Helpers.check_file_exists(path):
- self.helper.unzip_server(path, exec_user["user_id"])
- else:
- user_id = exec_user["user_id"]
- if user_id:
- time.sleep(5)
- user_lang = self.controller.users.get_user_lang_by_id(user_id)
- self.helper.websocket_helper.broadcast_user(
- user_id,
- "send_start_error",
- {
- "error": self.helper.translation.translate(
- "error", "no-file", user_lang
- )
- },
- )
- return
-
- elif page == "backup_select":
- path = self.get_argument("path", None)
- self.helper.backup_select(path, exec_user["user_id"])
- return
-
- elif page == "jar_cache":
- if not superuser:
- self.redirect("/panel/error?error=Not a super user")
- return
-
- self.controller.server_jars.manual_refresh_cache()
- return
-
- elif page == "update_server_dir":
- if self.helper.dir_migration:
- return
- for server in self.controller.servers.get_all_servers_stats():
- if server["stats"]["running"]:
- self.helper.websocket_helper.broadcast_user(
- exec_user["user_id"],
- "send_start_error",
- {
- "error": "You must stop all servers before "
- "starting a storage migration."
- },
- )
- return
- if not superuser:
- self.redirect("/panel/error?error=Not a super user")
- return
- if self.helper.is_env_docker():
- self.redirect(
- "/panel/error?error=This feature is not"
- " supported on docker environments"
- )
- return
- new_dir = urllib.parse.unquote(self.get_argument("server_dir"))
- self.controller.update_master_server_dir(new_dir, exec_user["user_id"])
- return
-
- @tornado.web.authenticated
- def delete(self, page):
- api_key, _, exec_user = self.current_user
- superuser = exec_user["superuser"]
- if api_key is not None:
- superuser = superuser and api_key.superuser
-
- server_id = self.get_argument("id", None)
-
- 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,
- }
- user_perms = self.controller.server_perms.get_user_id_permissions_list(
- exec_user["user_id"], server_id
- )
-
- if page == "del_backup":
- if not permissions["Backup"] in user_perms:
- if not superuser:
- self.redirect("/panel/error?error=Unauthorized access to Backups")
- return
- file_path = Helpers.get_os_understandable_path(
- self.get_body_argument("file_path", default=None, strip=True)
- )
- server_id = self.get_argument("id", None)
-
- Console.warning(f"Delete {file_path} for server {server_id}")
-
- if not self.check_server_id(server_id, "del_backup"):
- return
- server_id = bleach.clean(server_id)
-
- server_info = self.controller.servers.get_server_data_by_id(server_id)
- if not (
- self.helper.is_subdir(
- file_path, Helpers.get_os_understandable_path(server_info["path"])
- )
- or self.helper.is_subdir(
- file_path,
- Helpers.get_os_understandable_path(server_info["backup_path"]),
- )
- ) or not Helpers.check_file_exists(os.path.abspath(file_path)):
- logger.warning(f"Invalid path in del_backup ajax call ({file_path})")
- Console.warning(f"Invalid path in del_backup ajax call ({file_path})")
- return
-
- # Delete the file
- if Helpers.validate_traversal(
- Helpers.get_os_understandable_path(server_info["backup_path"]),
- file_path,
- ):
- os.remove(file_path)
-
- def check_server_id(self, server_id, page_name):
- if server_id is None:
- logger.warning(
- f"Server ID not defined in {page_name} ajax call ({server_id})"
- )
- Console.warning(
- f"Server ID not defined in {page_name} ajax call ({server_id})"
- )
- return
- server_id = bleach.clean(server_id)
-
- # does this server id exist?
- if not self.controller.servers.server_id_exists(server_id):
- logger.warning(
- f"Server ID not found in {page_name} ajax call ({server_id})"
- )
- Console.warning(
- f"Server ID not found in {page_name} ajax call ({server_id})"
- )
- return
- return True
diff --git a/app/classes/web/base_handler.py b/app/classes/web/base_handler.py
index e772d633..2504bc13 100644
--- a/app/classes/web/base_handler.py
+++ b/app/classes/web/base_handler.py
@@ -2,15 +2,16 @@ import logging
import re
import typing as t
import orjson
-import bleach
+import nh3
import tornado.web
from app.classes.models.crafty_permissions import EnumPermissionsCrafty
from app.classes.models.users import ApiKeys
from app.classes.shared.helpers import Helpers
+from app.classes.shared.file_helpers import FileHelpers
from app.classes.shared.main_controller import Controller
from app.classes.shared.translation import Translation
-from app.classes.models.management import DatabaseShortcuts
+from app.classes.shared.main_models import DatabaseShortcuts
logger = logging.getLogger(__name__)
@@ -24,15 +25,22 @@ class BaseHandler(tornado.web.RequestHandler):
helper: Helpers
controller: Controller
translator: Translation
+ file_helper: FileHelpers
# noinspection PyAttributeOutsideInit
def initialize(
- self, helper=None, controller=None, tasks_manager=None, translator=None
+ self,
+ helper=None,
+ controller=None,
+ tasks_manager=None,
+ translator=None,
+ file_helper=None,
):
self.helper = helper
self.controller = controller
self.tasks_manager = tasks_manager
self.translator = translator
+ self.file_helper = file_helper
def set_default_headers(self) -> None:
"""
@@ -93,7 +101,7 @@ class BaseHandler(tornado.web.RequestHandler):
if type(text) in self.nobleach:
logger.debug("Auto-bleaching - bypass type")
return text
- return bleach.clean(text)
+ return nh3.clean(text)
def get_argument(
self,
diff --git a/app/classes/web/file_handler.py b/app/classes/web/file_handler.py
deleted file mode 100644
index e2d07476..00000000
--- a/app/classes/web/file_handler.py
+++ /dev/null
@@ -1,464 +0,0 @@
-import os
-import logging
-import bleach
-import tornado.web
-import tornado.escape
-
-from app.classes.models.server_permissions import EnumPermissionsServer
-from app.classes.shared.console import Console
-from app.classes.shared.helpers import Helpers
-from app.classes.shared.file_helpers import FileHelpers
-from app.classes.web.base_handler import BaseHandler
-
-logger = logging.getLogger(__name__)
-
-
-class FileHandler(BaseHandler):
- def render_page(self, template, page_data):
- self.render(
- template,
- data=page_data,
- translate=self.translator.translate,
- )
-
- @tornado.web.authenticated
- def get(self, page):
- api_key, _, exec_user = self.current_user
- superuser = exec_user["superuser"]
- if api_key is not None:
- superuser = superuser and api_key.superuser
-
- server_id = self.get_argument("id", None)
-
- 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,
- }
- user_perms = self.controller.server_perms.get_user_id_permissions_list(
- exec_user["user_id"], server_id
- )
-
- if page == "get_file":
- if not permissions["Files"] in user_perms:
- if not superuser:
- self.redirect("/panel/error?error=Unauthorized access to Files")
- return
- file_path = Helpers.get_os_understandable_path(
- self.get_argument("file_path", None)
- )
-
- if not self.check_server_id(server_id, "get_file"):
- return
- server_id = bleach.clean(server_id)
-
- if not self.helper.is_subdir(
- file_path,
- Helpers.get_os_understandable_path(
- self.controller.servers.get_server_data_by_id(server_id)["path"]
- ),
- ) or not Helpers.check_file_exists(os.path.abspath(file_path)):
- logger.warning(
- f"Invalid path in get_file file file ajax call ({file_path})"
- )
- Console.warning(
- f"Invalid path in get_file file file ajax call ({file_path})"
- )
- return
-
- error = None
-
- try:
- with open(file_path, encoding="utf-8") as file:
- file_contents = file.read()
- except UnicodeDecodeError:
- file_contents = ""
- error = "UnicodeDecodeError"
-
- self.write({"content": file_contents, "error": error})
- self.finish()
-
- elif page == "get_tree":
- if not permissions["Files"] in user_perms:
- if not superuser:
- self.redirect("/panel/error?error=Unauthorized access to Files")
- return
- path = self.get_argument("path", None)
-
- if not self.check_server_id(server_id, "get_tree"):
- return
- server_id = bleach.clean(server_id)
-
- if Helpers.validate_traversal(
- self.controller.servers.get_server_data_by_id(server_id)["path"], path
- ):
- self.write(
- Helpers.get_os_understandable_path(path)
- + "\n"
- + self.helper.generate_tree(path)
- )
- self.finish()
-
- elif page == "get_dir":
- if not permissions["Files"] in user_perms:
- if not superuser:
- self.redirect("/panel/error?error=Unauthorized access to Files")
- return
- path = self.get_argument("path", None)
-
- if not self.check_server_id(server_id, "get_tree"):
- return
- server_id = bleach.clean(server_id)
-
- if Helpers.validate_traversal(
- self.controller.servers.get_server_data_by_id(server_id)["path"], path
- ):
- self.write(
- Helpers.get_os_understandable_path(path)
- + "\n"
- + self.helper.generate_dir(path)
- )
- self.finish()
-
- @tornado.web.authenticated
- def post(self, page):
- api_key, _, exec_user = self.current_user
- superuser = exec_user["superuser"]
- if api_key is not None:
- superuser = superuser and api_key.superuser
-
- server_id = self.get_argument("id", None)
-
- 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,
- }
- user_perms = self.controller.server_perms.get_user_id_permissions_list(
- exec_user["user_id"], server_id
- )
-
- if page == "create_file":
- if not permissions["Files"] in user_perms:
- if not superuser:
- self.redirect("/panel/error?error=Unauthorized access to Files")
- return
- file_parent = Helpers.get_os_understandable_path(
- self.get_body_argument("file_parent", default=None, strip=True)
- )
- file_name = self.get_body_argument("file_name", default=None, strip=True)
- file_path = os.path.join(file_parent, file_name)
-
- if not self.check_server_id(server_id, "create_file"):
- return
- server_id = bleach.clean(server_id)
-
- if not self.helper.is_subdir(
- file_path,
- Helpers.get_os_understandable_path(
- self.controller.servers.get_server_data_by_id(server_id)["path"]
- ),
- ) or Helpers.check_file_exists(os.path.abspath(file_path)):
- logger.warning(
- f"Invalid path in create_file file ajax call ({file_path})"
- )
- Console.warning(
- f"Invalid path in create_file file ajax call ({file_path})"
- )
- return
-
- # Create the file by opening it
- with open(file_path, "w", encoding="utf-8") as file_object:
- file_object.close()
-
- elif page == "create_dir":
- if not permissions["Files"] in user_perms:
- if not superuser:
- self.redirect("/panel/error?error=Unauthorized access to Files")
- return
- dir_parent = Helpers.get_os_understandable_path(
- self.get_body_argument("dir_parent", default=None, strip=True)
- )
- dir_name = self.get_body_argument("dir_name", default=None, strip=True)
- dir_path = os.path.join(dir_parent, dir_name)
-
- if not self.check_server_id(server_id, "create_dir"):
- return
- server_id = bleach.clean(server_id)
-
- if not self.helper.is_subdir(
- dir_path,
- Helpers.get_os_understandable_path(
- self.controller.servers.get_server_data_by_id(server_id)["path"]
- ),
- ) or Helpers.check_path_exists(os.path.abspath(dir_path)):
- logger.warning(
- f"Invalid path in create_dir file ajax call ({dir_path})"
- )
- Console.warning(
- f"Invalid path in create_dir file ajax call ({dir_path})"
- )
- return
- # Create the directory
- os.mkdir(dir_path)
-
- elif page == "unzip_file":
- if not permissions["Files"] in user_perms:
- if not superuser:
- self.redirect("/panel/error?error=Unauthorized access to Files")
- return
- path = Helpers.get_os_understandable_path(self.get_argument("path", None))
- if Helpers.is_os_windows():
- path = Helpers.wtol_path(path)
- FileHelpers.unzip_file(path)
- self.redirect(f"/panel/server_detail?id={server_id}&subpage=files")
- return
-
- @tornado.web.authenticated
- def delete(self, page):
- api_key, _, exec_user = self.current_user
- superuser = exec_user["superuser"]
- if api_key is not None:
- superuser = superuser and api_key.superuser
-
- server_id = self.get_argument("id", None)
-
- 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,
- }
- user_perms = self.controller.server_perms.get_user_id_permissions_list(
- exec_user["user_id"], server_id
- )
- if page == "del_file":
- if not permissions["Files"] in user_perms:
- if not superuser:
- self.redirect("/panel/error?error=Unauthorized access to Files")
- return
- file_path = Helpers.get_os_understandable_path(
- self.get_body_argument("file_path", default=None, strip=True)
- )
-
- Console.warning(f"Delete {file_path} for server {server_id}")
-
- if not self.check_server_id(server_id, "del_file"):
- return
- server_id = bleach.clean(server_id)
-
- server_info = self.controller.servers.get_server_data_by_id(server_id)
- if not (
- self.helper.is_subdir(
- file_path, Helpers.get_os_understandable_path(server_info["path"])
- )
- or self.helper.is_subdir(
- file_path,
- Helpers.get_os_understandable_path(server_info["backup_path"]),
- )
- ) or not Helpers.check_file_exists(os.path.abspath(file_path)):
- logger.warning(f"Invalid path in del_file file ajax call ({file_path})")
- Console.warning(
- f"Invalid path in del_file file ajax call ({file_path})"
- )
- return
-
- # Delete the file
- FileHelpers.del_file(file_path)
-
- elif page == "del_dir":
- if not permissions["Files"] in user_perms:
- if not superuser:
- self.redirect("/panel/error?error=Unauthorized access to Files")
- return
- dir_path = Helpers.get_os_understandable_path(
- self.get_body_argument("dir_path", default=None, strip=True)
- )
-
- Console.warning(f"Delete {dir_path} for server {server_id}")
-
- if not self.check_server_id(server_id, "del_dir"):
- return
- server_id = bleach.clean(server_id)
-
- server_info = self.controller.servers.get_server_data_by_id(server_id)
- if not self.helper.is_subdir(
- dir_path, Helpers.get_os_understandable_path(server_info["path"])
- ) or not Helpers.check_path_exists(os.path.abspath(dir_path)):
- logger.warning(f"Invalid path in del_file file ajax call ({dir_path})")
- Console.warning(f"Invalid path in del_file file ajax call ({dir_path})")
- return
-
- # Delete the directory
- # os.rmdir(dir_path) # Would only remove empty directories
- if Helpers.validate_traversal(
- Helpers.get_os_understandable_path(server_info["path"]), dir_path
- ):
- # Removes also when there are contents
- FileHelpers.del_dirs(dir_path)
-
- @tornado.web.authenticated
- def put(self, page):
- api_key, _, exec_user = self.current_user
- superuser = exec_user["superuser"]
- if api_key is not None:
- superuser = superuser and api_key.superuser
-
- server_id = self.get_argument("id", None)
- 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,
- }
- user_perms = self.controller.server_perms.get_user_id_permissions_list(
- exec_user["user_id"], server_id
- )
- if page == "save_file":
- if not permissions["Files"] in user_perms:
- if not superuser:
- self.redirect("/panel/error?error=Unauthorized access to Files")
- return
- file_contents = self.get_body_argument(
- "file_contents", default=None, strip=True
- )
- file_path = Helpers.get_os_understandable_path(
- self.get_body_argument("file_path", default=None, strip=True)
- )
-
- if not self.check_server_id(server_id, "save_file"):
- return
- server_id = bleach.clean(server_id)
-
- if not self.helper.is_subdir(
- file_path,
- Helpers.get_os_understandable_path(
- self.controller.servers.get_server_data_by_id(server_id)["path"]
- ),
- ) or not Helpers.check_file_exists(os.path.abspath(file_path)):
- logger.warning(
- f"Invalid path in save_file file ajax call ({file_path})"
- )
- Console.warning(
- f"Invalid path in save_file file ajax call ({file_path})"
- )
- return
-
- # Open the file in write mode and store the content in file_object
- with open(file_path, "w", encoding="utf-8") as file_object:
- file_object.write(file_contents)
-
- @tornado.web.authenticated
- def patch(self, page):
- api_key, _, exec_user = self.current_user
- superuser = exec_user["superuser"]
- if api_key is not None:
- superuser = superuser and api_key.superuser
-
- server_id = self.get_argument("id", None)
- 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,
- }
- user_perms = self.controller.server_perms.get_user_id_permissions_list(
- exec_user["user_id"], server_id
- )
- if page == "rename_file":
- if not permissions["Files"] in user_perms:
- if not superuser:
- self.redirect("/panel/error?error=Unauthorized access to Files")
- return
- item_path = Helpers.get_os_understandable_path(
- self.get_body_argument("item_path", default=None, strip=True)
- )
- new_item_name = self.get_body_argument(
- "new_item_name", default=None, strip=True
- )
-
- if not self.check_server_id(server_id, "rename_file"):
- return
- server_id = bleach.clean(server_id)
-
- if item_path is None or new_item_name is None:
- logger.warning("Invalid path(s) in rename_file file ajax call")
- Console.warning("Invalid path(s) in rename_file file ajax call")
- return
-
- if not self.helper.is_subdir(
- item_path,
- Helpers.get_os_understandable_path(
- self.controller.servers.get_server_data_by_id(server_id)["path"]
- ),
- ) or not Helpers.check_path_exists(os.path.abspath(item_path)):
- logger.warning(
- f"Invalid old name path in rename_file file ajax call ({server_id})"
- )
- Console.warning(
- f"Invalid old name path in rename_file file ajax call ({server_id})"
- )
- return
-
- new_item_path = os.path.join(os.path.split(item_path)[0], new_item_name)
-
- if not self.helper.is_subdir(
- new_item_path,
- Helpers.get_os_understandable_path(
- self.controller.servers.get_server_data_by_id(server_id)["path"]
- ),
- ) or Helpers.check_path_exists(os.path.abspath(new_item_path)):
- logger.warning(
- f"Invalid new name path in rename_file file ajax call ({server_id})"
- )
- Console.warning(
- f"Invalid new name path in rename_file file ajax call ({server_id})"
- )
- return
-
- # RENAME
- os.rename(item_path, new_item_path)
-
- def check_server_id(self, server_id, page_name):
- if server_id is None:
- logger.warning(
- f"Server ID not defined in {page_name} file ajax call ({server_id})"
- )
- Console.warning(
- f"Server ID not defined in {page_name} file ajax call ({server_id})"
- )
- return
- server_id = bleach.clean(server_id)
-
- # does this server id exist?
- if not self.controller.servers.server_id_exists(server_id):
- logger.warning(
- f"Server ID not found in {page_name} file ajax call ({server_id})"
- )
- Console.warning(
- f"Server ID not found in {page_name} file ajax call ({server_id})"
- )
- return
- return True
diff --git a/app/classes/web/metrics_handler.py b/app/classes/web/metrics_handler.py
new file mode 100644
index 00000000..869a6931
--- /dev/null
+++ b/app/classes/web/metrics_handler.py
@@ -0,0 +1,53 @@
+import logging
+import typing as t
+
+from prometheus_client import REGISTRY, CollectorRegistry
+from prometheus_client.exposition import _bake_output
+from prometheus_client.exposition import parse_qs, urlparse
+
+from app.classes.web.base_api_handler import BaseApiHandler
+
+logger = logging.getLogger(__name__)
+
+
+class BaseMetricsHandler(BaseApiHandler):
+ """HTTP handler that gives metrics from ``REGISTRY``."""
+
+ registry: CollectorRegistry = REGISTRY
+ # registry.unregister(GC_COLLECTOR)
+ # registry.unregister(PLATFORM_COLLECTOR)
+ # registry.unregister(PROCESS_COLLECTOR)
+
+ def get_registry(self) -> None:
+ # Prepare parameters
+ registry = self.registry
+ accept_header = self.request.headers.get("Accept")
+ accept_encoding_header = self.request.headers.get("Accept-Encoding")
+ params = parse_qs(urlparse(self.request.path).query)
+ # Bake output
+ status, headers, output = _bake_output(
+ registry, accept_header, accept_encoding_header, params, False
+ )
+ # Return output
+ self.finish_metrics(int(status.split(" ", maxsplit=1)[0]), headers, output)
+
+ @classmethod
+ def factory(cls, registry: CollectorRegistry) -> type:
+ """Returns a dynamic MetricsHandler class tied
+ to the passed registry.
+ """
+ # This implementation relies on MetricsHandler.registry
+ # (defined above and defaulted to REGISTRY).
+
+ # As we have unicode_literals, we need to create a str()
+ # object for type().
+ cls_name = str(cls.__name__)
+ MyMetricsHandler = type(cls_name, (cls, object), {"registry": registry})
+ return MyMetricsHandler
+
+ def finish_metrics(self, status: int, headers, data: t.Dict[str, t.Any]):
+ self.set_status(status)
+ self.set_header("Content-Type", "text/plain")
+ for header in headers:
+ self.set_header(*header)
+ self.finish(data)
diff --git a/app/classes/web/panel_handler.py b/app/classes/web/panel_handler.py
index 20c76c1a..e1d21f03 100644
--- a/app/classes/web/panel_handler.py
+++ b/app/classes/web/panel_handler.py
@@ -7,7 +7,8 @@ import json
import logging
import threading
import urllib.parse
-import bleach
+from zoneinfo import ZoneInfoNotFoundError
+import nh3
import requests
import tornado.web
import tornado.escape
@@ -15,7 +16,6 @@ from tornado import iostream
# TZLocal is set as a hidden import on win pipeline
from tzlocal import get_localzone
-from tzlocal.utils import ZoneInfoNotFoundError
from app.classes.models.servers import Servers
from app.classes.models.server_permissions import EnumPermissionsServer
@@ -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__)
@@ -67,9 +68,7 @@ class PanelHandler(BaseHandler):
) in self.controller.crafty_perms.list_defined_crafty_permissions():
argument = int(
float(
- bleach.clean(
- self.get_argument(f"permission_{permission.name}", "0")
- )
+ nh3.clean(self.get_argument(f"permission_{permission.name}", "0"))
)
)
if argument:
@@ -78,9 +77,7 @@ class PanelHandler(BaseHandler):
)
q_argument = int(
- float(
- bleach.clean(self.get_argument(f"quantity_{permission.name}", "0"))
- )
+ float(nh3.clean(self.get_argument(f"quantity_{permission.name}", "0")))
)
if q_argument:
server_quantity[permission.name] = q_argument
@@ -348,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"]:
@@ -479,7 +478,7 @@ class PanelHandler(BaseHandler):
template = "panel/dashboard.html"
elif page == "server_detail":
- subpage = bleach.clean(self.get_argument("subpage", ""))
+ subpage = nh3.clean(self.get_argument("subpage", ""))
server_id = self.check_server_id()
if server_id is None:
@@ -747,8 +746,24 @@ class PanelHandler(BaseHandler):
0, page_data["options"].pop(page_data["options"].index(days))
)
page_data["history_stats"] = self.controller.servers.get_history_stats(
- server_id, days
+ 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)
@@ -1016,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:
@@ -1284,7 +1403,7 @@ class PanelHandler(BaseHandler):
template = "panel/panel_edit_user_apikeys.html"
elif page == "remove_user":
- user_id = bleach.clean(self.get_argument("id", None))
+ user_id = nh3.clean(self.get_argument("id", None))
if (
not superuser
@@ -1415,40 +1534,8 @@ class PanelHandler(BaseHandler):
template = "panel/panel_edit_role.html"
- elif page == "remove_role":
- role_id = bleach.clean(self.get_argument("id", None))
-
- if (
- not superuser
- and self.controller.roles.get_role(role_id)["manager"]
- != exec_user["user_id"]
- ):
- self.redirect(
- "/panel/error?error=Unauthorized access: not superuser not"
- " role manager"
- )
- return
- if role_id is None:
- self.redirect("/panel/error?error=Invalid Role ID")
- return
- # does this user id exist?
- target_role = self.controller.roles.get_role(role_id)
- if not target_role:
- self.redirect("/panel/error?error=Invalid Role ID")
- return
-
- self.controller.roles.remove_role(role_id)
-
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f"Removed role {target_role['role_name']} (RID:{role_id})",
- server_id=0,
- source_ip=self.get_remote_ip(),
- )
- self.redirect("/panel/panel_config")
-
elif page == "activity_logs":
- page_data["audit_logs"] = self.controller.management.get_actity_log()
+ page_data["audit_logs"] = self.controller.management.get_activity_log()
template = "panel/activity_logs.html"
@@ -1530,606 +1617,3 @@ class PanelHandler(BaseHandler):
utc_offset=(time.timezone * -1 / 60 / 60),
translate=self.translator.translate,
)
-
- @tornado.web.authenticated
- def post(self, page):
- api_key, _token_data, exec_user = self.current_user
- superuser = exec_user["superuser"]
- if api_key is not None:
- superuser = superuser and api_key.superuser
-
- server_id = self.get_argument("id", None)
- 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,
- }
- if superuser:
- # defined_servers = self.controller.servers.list_defined_servers()
- exec_user_role = {"Super User"}
- exec_user_crafty_permissions = (
- self.controller.crafty_perms.list_defined_crafty_permissions()
- )
- else:
- exec_user_crafty_permissions = (
- self.controller.crafty_perms.get_crafty_permissions_list(
- exec_user["user_id"]
- )
- )
- # defined_servers =
- # self.controller.servers.get_authorized_servers(exec_user["user_id"])
- exec_user_role = set()
- for r in exec_user["roles"]:
- role = self.controller.roles.get_role(r)
- exec_user_role.add(role["role_name"])
-
- if page == "server_backup":
- logger.debug(self.request.arguments)
-
- server_id = self.check_server_id()
- if not server_id:
- return
-
- if (
- not permissions["Backup"]
- in self.controller.server_perms.get_user_id_permissions_list(
- exec_user["user_id"], server_id
- )
- and not superuser
- ):
- self.redirect(
- "/panel/error?error=Unauthorized access: User not authorized"
- )
- return
-
- server_obj = self.controller.servers.get_server_obj(server_id)
- compress = self.get_argument("compress", False)
- shutdown = self.get_argument("shutdown", False)
- check_changed = self.get_argument("changed")
- before = self.get_argument("backup_before", "")
- after = self.get_argument("backup_after", "")
- if str(check_changed) == str(1):
- checked = self.get_body_arguments("root_path")
- else:
- checked = self.controller.management.get_excluded_backup_dirs(server_id)
- if superuser:
- backup_path = self.get_argument("backup_path", None)
- if Helpers.is_os_windows():
- backup_path.replace(" ", "^ ")
- backup_path = Helpers.wtol_path(backup_path)
- else:
- backup_path = server_obj.backup_path
- max_backups = bleach.clean(self.get_argument("max_backups", None))
-
- server_obj = self.controller.servers.get_server_obj(server_id)
-
- server_obj.backup_path = backup_path
- self.controller.servers.update_server(server_obj)
- self.controller.management.set_backup_config(
- server_id,
- max_backups=max_backups,
- excluded_dirs=checked,
- compress=bool(compress),
- shutdown=bool(shutdown),
- before=before,
- after=after,
- )
-
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f"Edited server {server_id}: updated backups",
- server_id,
- self.get_remote_ip(),
- )
- self.tasks_manager.reload_schedule_from_db()
- self.redirect(f"/panel/server_detail?id={server_id}&subpage=backup")
-
- elif page == "config_json":
- try:
- data = {}
- with open(self.helper.settings_file, "r", encoding="utf-8") as f:
- keys = json.load(f).keys()
- this_uuid = self.get_argument("uuid")
- for key in keys:
- arg_data = self.get_argument(key)
- if arg_data.startswith(this_uuid):
- arg_data = arg_data.split(",")
- arg_data.pop(0)
- data[key] = arg_data
- else:
- try:
- data[key] = int(arg_data)
- except:
- if arg_data == "True":
- data[key] = True
- elif arg_data == "False":
- data[key] = False
- else:
- data[key] = arg_data
- keys = list(data.keys())
- keys.sort()
- sorted_data = {i: data[i] for i in keys}
- with open(self.helper.settings_file, "w", encoding="utf-8") as f:
- json.dump(sorted_data, f, indent=4)
- except Exception as e:
- logger.critical(
- "Config File Error: Unable to read "
- f"{self.helper.settings_file} due to {e}"
- )
-
- self.redirect("/panel/config_json")
-
- elif page == "edit_user":
- if bleach.clean(self.get_argument("username", None)).lower() == "system":
- self.redirect(
- "/panel/error?error=Unauthorized access: "
- "system user is not editable"
- )
- user_id = bleach.clean(self.get_argument("id", None))
- user = self.controller.users.get_user_by_id(user_id)
- username = bleach.clean(self.get_argument("username", None).lower())
- theme = bleach.clean(self.get_argument("theme", "default"))
- if (
- username != self.controller.users.get_user_by_id(user_id)["username"]
- and username in self.controller.users.get_all_usernames()
- ):
- self.redirect(
- "/panel/error?error=Duplicate User: Useranme already exists."
- )
- password0 = bleach.clean(self.get_argument("password0", None))
- password1 = bleach.clean(self.get_argument("password1", None))
- email = bleach.clean(self.get_argument("email", "default@example.com"))
- enabled = int(float(self.get_argument("enabled", "0")))
- try:
- hints = int(bleach.clean(self.get_argument("hints")))
- hints = True
- except:
- hints = False
- lang = bleach.clean(
- self.get_argument("language"), self.helper.get_setting("language")
- )
-
- if superuser:
- # Checks if user is trying to change super user status of self.
- # We don't want that. Automatically make them stay super user
- # since we know they are.
- if str(exec_user["user_id"]) != str(user_id):
- superuser = int(bleach.clean(self.get_argument("superuser", "0")))
- else:
- superuser = 1
- else:
- superuser = 0
-
- if exec_user["superuser"]:
- manager = self.get_argument("manager")
- if manager == "":
- manager = None
- else:
- manager = int(manager)
- else:
- manager = user["manager"]
-
- if (
- not exec_user["superuser"]
- and int(exec_user["user_id"]) != user["manager"]
- ):
- if username is None or username == "":
- self.redirect("/panel/error?error=Invalid username")
- return
- if user_id is None:
- self.redirect("/panel/error?error=Invalid User ID")
- return
- if (
- EnumPermissionsCrafty.USER_CONFIG
- not in exec_user_crafty_permissions
- ):
- if str(user_id) != str(exec_user["user_id"]):
- self.redirect(
- "/panel/error?error=Unauthorized access: not a user editor"
- )
- return
-
- user_data = {
- "username": username,
- "password": password0,
- "email": email,
- "lang": lang,
- "hints": hints,
- "theme": theme,
- }
- self.controller.users.update_user(user_id, user_data=user_data)
-
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f"Edited user {username} (UID:{user_id}) password",
- server_id=0,
- source_ip=self.get_remote_ip(),
- )
- self.redirect("/panel/panel_config")
- return
- # does this user id exist?
- if not self.controller.users.user_id_exists(user_id):
- self.redirect("/panel/error?error=Invalid User ID")
- return
- else:
- if password0 != password1:
- self.redirect("/panel/error?error=Passwords must match")
- return
-
- roles = self.get_user_role_memberships()
- permissions_mask, server_quantity = self.get_perms_quantity()
-
- # if email is None or "":
- # email = "default@example.com"
-
- user_data = {
- "username": username,
- "manager": manager,
- "password": password0,
- "email": email,
- "enabled": enabled,
- "roles": roles,
- "lang": lang,
- "superuser": superuser,
- "hints": hints,
- "theme": theme,
- }
- user_crafty_data = {
- "permissions_mask": permissions_mask,
- "server_quantity": server_quantity,
- }
- self.controller.users.update_user(
- user_id, user_data=user_data, user_crafty_data=user_crafty_data
- )
-
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f"Edited user {username} (UID:{user_id}) with roles {roles} "
- f"and permissions {permissions_mask}",
- server_id=0,
- source_ip=self.get_remote_ip(),
- )
- self.redirect("/panel/panel_config")
-
- elif page == "edit_user_apikeys":
- user_id = self.get_argument("id", None)
- name = self.get_argument("name", None)
- superuser = self.get_argument("superuser", None) == "1"
-
- if name is None or name == "":
- self.redirect("/panel/error?error=Invalid API key name")
- return
- if user_id is None:
- self.redirect("/panel/error?error=Invalid User ID")
- return
- # does this user id exist?
- if not self.controller.users.user_id_exists(user_id):
- self.redirect("/panel/error?error=Invalid User ID")
- return
-
- if str(user_id) != str(exec_user["user_id"]) and not exec_user["superuser"]:
- self.redirect(
- "/panel/error?error=You do not have access to change"
- + "this user's api key."
- )
- return
-
- crafty_permissions_mask = self.get_perms()
- server_permissions_mask = self.get_perms_server()
-
- self.controller.users.add_user_api_key(
- name,
- user_id,
- superuser,
- server_permissions_mask,
- crafty_permissions_mask,
- )
-
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f"Added API key {name} with crafty permissions "
- f"{crafty_permissions_mask}"
- f" and {server_permissions_mask} for user with UID: {user_id}",
- server_id=0,
- source_ip=self.get_remote_ip(),
- )
- self.redirect(f"/panel/edit_user_apikeys?id={user_id}")
-
- elif page == "get_token":
- key_id = self.get_argument("id", None)
-
- if key_id is None:
- self.redirect("/panel/error?error=Invalid Key ID")
- return
- key = self.controller.users.get_user_api_key(key_id)
- # does this user id exist?
- if key is None:
- self.redirect("/panel/error?error=Invalid Key ID")
- return
-
- if (
- str(key.user_id) != str(exec_user["user_id"])
- and not exec_user["superuser"]
- ):
- self.redirect(
- "/panel/error?error=You are not authorized to access this key."
- )
- return
-
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f"Generated a new API token for the key {key.name} "
- f"from user with UID: {key.user_id}",
- server_id=0,
- source_ip=self.get_remote_ip(),
- )
-
- self.write(
- self.controller.authentication.generate(
- key.user_id_id, {"token_id": key.token_id}
- )
- )
- self.finish()
-
- elif page == "add_user":
- username = bleach.clean(self.get_argument("username", None).lower())
- if username.lower() == "system":
- self.redirect(
- "/panel/error?error=Unauthorized access: "
- "username system is reserved for the Crafty system."
- " Please choose a different username."
- )
- return
- password0 = bleach.clean(self.get_argument("password0", None))
- password1 = bleach.clean(self.get_argument("password1", None))
- email = bleach.clean(self.get_argument("email", "default@example.com"))
- enabled = int(float(self.get_argument("enabled", "0")))
- theme = bleach.clean(self.get_argument("theme"), "default")
- hints = True
- lang = bleach.clean(
- self.get_argument("lang", self.helper.get_setting("language"))
- )
- # We don't want a non-super user to be able to create a super user.
- if superuser:
- new_superuser = int(bleach.clean(self.get_argument("superuser", "0")))
- else:
- new_superuser = 0
-
- if EnumPermissionsCrafty.USER_CONFIG not in exec_user_crafty_permissions:
- self.redirect(
- "/panel/error?error=Unauthorized access: not a user editor"
- )
- return
-
- if (
- not self.controller.crafty_perms.can_add_user(exec_user["user_id"])
- and not exec_user["superuser"]
- ):
- self.redirect(
- "/panel/error?error=Unauthorized access: quantity limit reached"
- )
- return
- if username is None or username == "":
- self.redirect("/panel/error?error=Invalid username")
- return
-
- if exec_user["superuser"]:
- manager = self.get_argument("manager")
- if manager == "":
- manager = None
- else:
- manager = int(manager)
- else:
- manager = int(exec_user["user_id"])
- # does this user id exist?
- if self.controller.users.get_id_by_name(username) is not None:
- self.redirect("/panel/error?error=User exists")
- return
-
- if password0 != password1:
- self.redirect("/panel/error?error=Passwords must match")
- return
-
- roles = self.get_user_role_memberships()
- permissions_mask, server_quantity = self.get_perms_quantity()
-
- user_id = self.controller.users.add_user(
- username,
- manager=manager,
- password=password0,
- email=email,
- enabled=enabled,
- superuser=new_superuser,
- theme=theme,
- )
- user_data = {"roles": roles, "lang": lang, "hints": True}
- user_crafty_data = {
- "permissions_mask": permissions_mask,
- "server_quantity": server_quantity,
- }
- self.controller.users.update_user(
- user_id, user_data=user_data, user_crafty_data=user_crafty_data
- )
-
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f"Added user {username} (UID:{user_id})",
- server_id=0,
- source_ip=self.get_remote_ip(),
- )
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f"Edited user {username} (UID:{user_id}) with roles {roles}",
- server_id=0,
- source_ip=self.get_remote_ip(),
- )
- self.redirect("/panel/panel_config")
-
- elif page == "edit_role":
- role_id = bleach.clean(self.get_argument("id", None))
- role_name = bleach.clean(self.get_argument("role_name", None))
-
- role = self.controller.roles.get_role(role_id)
-
- if (
- EnumPermissionsCrafty.ROLES_CONFIG not in exec_user_crafty_permissions
- and exec_user["user_id"] != role["manager"]
- and not exec_user["superuser"]
- ):
- self.redirect(
- "/panel/error?error=Unauthorized access: not a role editor"
- )
- return
- if role_name is None or role_name == "":
- self.redirect("/panel/error?error=Invalid username")
- return
- if role_id is None:
- self.redirect("/panel/error?error=Invalid Role ID")
- return
- # does this user id exist?
- if not self.controller.roles.role_id_exists(role_id):
- self.redirect("/panel/error?error=Invalid Role ID")
- return
-
- if exec_user["superuser"]:
- manager = self.get_argument("manager", None)
- if manager == "":
- manager = None
- else:
- manager = role["manager"]
-
- servers = self.get_role_servers()
-
- self.controller.roles.update_role_advanced(
- role_id, role_name, servers, manager
- )
-
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f"edited role {role_name} (RID:{role_id}) with servers {servers}",
- server_id=0,
- source_ip=self.get_remote_ip(),
- )
- self.redirect("/panel/panel_config")
-
- elif page == "add_role":
- role_name = bleach.clean(self.get_argument("role_name", None))
- if exec_user["superuser"]:
- manager = self.get_argument("manager", None)
- if manager == "":
- manager = None
- else:
- manager = exec_user["user_id"]
-
- if EnumPermissionsCrafty.ROLES_CONFIG not in exec_user_crafty_permissions:
- self.redirect(
- "/panel/error?error=Unauthorized access: not a role editor"
- )
- return
- if (
- not self.controller.crafty_perms.can_add_role(exec_user["user_id"])
- and not exec_user["superuser"]
- ):
- self.redirect(
- "/panel/error?error=Unauthorized access: quantity limit reached"
- )
- return
- if role_name is None or role_name == "":
- self.redirect("/panel/error?error=Invalid role name")
- return
- # does this user id exist?
- if self.controller.roles.get_roleid_by_name(role_name) is not None:
- self.redirect("/panel/error?error=Role exists")
- return
-
- servers = self.get_role_servers()
-
- role_id = self.controller.roles.add_role_advanced(
- role_name, servers, manager
- )
-
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f"created role {role_name} (RID:{role_id})",
- server_id=0,
- source_ip=self.get_remote_ip(),
- )
- self.redirect("/panel/panel_config")
-
- else:
- self.set_status(404)
- page_data = {
- "lang": self.helper.get_setting("language"),
- "lang_page": Helpers.get_lang_page(self.helper.get_setting("language")),
- }
- self.render(
- "public/404.html", translate=self.translator.translate, data=page_data
- )
-
- @tornado.web.authenticated
- def delete(self, page):
- api_key, _token_data, exec_user = self.current_user
- superuser = exec_user["superuser"]
- if api_key is not None:
- superuser = superuser and api_key.superuser
-
- page_data = {
- # todo: make this actually pull and compare version data
- "update_available": False,
- "version_data": self.helper.get_version_string(),
- "user_data": exec_user,
- "hosts_data": self.controller.management.get_latest_hosts_stats(),
- "show_contribute": self.helper.get_setting("show_contribute_link", True),
- "lang": self.controller.users.get_user_lang_by_id(exec_user["user_id"]),
- "lang_page": Helpers.get_lang_page(
- self.controller.users.get_user_lang_by_id(exec_user["user_id"])
- ),
- }
-
- if page == "remove_apikey":
- key_id = bleach.clean(self.get_argument("id", None))
-
- if not superuser:
- self.redirect("/panel/error?error=Unauthorized access: not superuser")
- return
- if key_id is None or self.controller.users.get_user_api_key(key_id) is None:
- self.redirect("/panel/error?error=Invalid Key ID")
- return
- # does this user id exist?
- target_key = self.controller.users.get_user_api_key(key_id)
- if not target_key:
- self.redirect("/panel/error?error=Invalid Key ID")
- return
-
- key_obj = self.controller.users.get_user_api_key(key_id)
-
- if key_obj.user_id != exec_user["user_id"] and not exec_user["superuser"]:
- self.redirect(
- "/panel/error?error=You do not have access to change"
- + "this user's api key."
- )
- return
-
- self.controller.users.delete_user_api_key(key_id)
-
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f"Removed API key {target_key} "
- f"(ID: {key_id}) from user {exec_user['user_id']}",
- server_id=0,
- source_ip=self.get_remote_ip(),
- )
- self.finish()
- self.redirect(f"/panel/edit_user_apikeys?id={key_obj.user_id}")
- else:
- self.set_status(404)
- self.render(
- "public/404.html",
- data=page_data,
- translate=self.translator.translate,
- )
diff --git a/app/classes/web/public_handler.py b/app/classes/web/public_handler.py
index 76c6a8be..b7d1be9b 100644
--- a/app/classes/web/public_handler.py
+++ b/app/classes/web/public_handler.py
@@ -1,5 +1,5 @@
import logging
-import bleach
+import nh3
from app.classes.shared.helpers import Helpers
from app.classes.models.users import HelperUsers
@@ -28,8 +28,8 @@ class PublicHandler(BaseHandler):
# self.clear_cookie("user_data")
def get(self, page=None):
- error = bleach.clean(self.get_argument("error", "Invalid Login!"))
- error_msg = bleach.clean(self.get_argument("error_msg", ""))
+ error = nh3.clean(self.get_argument("error", "Invalid Login!"))
+ error_msg = nh3.clean(self.get_argument("error_msg", ""))
page_data = {
"version": self.helper.get_version_string(),
@@ -82,8 +82,8 @@ class PublicHandler(BaseHandler):
)
def post(self, page=None):
- error = bleach.clean(self.get_argument("error", "Invalid Login!"))
- error_msg = bleach.clean(self.get_argument("error_msg", ""))
+ error = nh3.clean(self.get_argument("error", "Invalid Login!"))
+ error_msg = nh3.clean(self.get_argument("error_msg", ""))
page_data = {
"version": self.helper.get_version_string(),
@@ -100,8 +100,8 @@ class PublicHandler(BaseHandler):
if self.request.query:
next_page = "/login?" + self.request.query
- entered_username = bleach.clean(self.get_argument("username"))
- entered_password = bleach.clean(self.get_argument("password"))
+ entered_username = nh3.clean(self.get_argument("username"))
+ entered_password = nh3.clean(self.get_argument("password"))
# pylint: disable=no-member
try:
diff --git a/app/classes/web/routes/api/api_handlers.py b/app/classes/web/routes/api/api_handlers.py
index 29ee02c5..706c346f 100644
--- a/app/classes/web/routes/api/api_handlers.py
+++ b/app/classes/web/routes/api/api_handlers.py
@@ -12,6 +12,7 @@ from app.classes.web.routes.api.roles.index import ApiRolesIndexHandler
from app.classes.web.routes.api.roles.role.index import ApiRolesRoleIndexHandler
from app.classes.web.routes.api.roles.role.servers import ApiRolesRoleServersHandler
from app.classes.web.routes.api.roles.role.users import ApiRolesRoleUsersHandler
+
from app.classes.web.routes.api.servers.index import ApiServersIndexHandler
from app.classes.web.routes.api.servers.server.action import (
ApiServersServerActionHandler,
@@ -21,25 +22,63 @@ from app.classes.web.routes.api.servers.server.logs import ApiServersServerLogsH
from app.classes.web.routes.api.servers.server.public import (
ApiServersServerPublicHandler,
)
+from app.classes.web.routes.api.servers.server.status import (
+ ApiServersServerStatusHandler,
+)
from app.classes.web.routes.api.servers.server.stats import ApiServersServerStatsHandler
+from app.classes.web.routes.api.servers.server.history import (
+ ApiServersServerHistoryHandler,
+)
from app.classes.web.routes.api.servers.server.stdin import ApiServersServerStdinHandler
from app.classes.web.routes.api.servers.server.tasks.index import (
ApiServersServerTasksIndexHandler,
)
+from app.classes.web.routes.api.servers.server.backups.index import (
+ ApiServersServerBackupsIndexHandler,
+)
+from app.classes.web.routes.api.servers.server.backups.backup.index import (
+ ApiServersServerBackupsBackupIndexHandler,
+)
+from app.classes.web.routes.api.servers.server.files import (
+ ApiServersServerFilesIndexHandler,
+ ApiServersServerFilesCreateHandler,
+ ApiServersServerFilesZipHandler,
+)
from app.classes.web.routes.api.servers.server.tasks.task.children import (
ApiServersServerTasksTaskChildrenHandler,
)
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
from app.classes.web.routes.api.users.user.permissions import (
ApiUsersUserPermissionsHandler,
)
+from app.classes.web.routes.api.users.user.api import ApiUsersUserKeyHandler
from app.classes.web.routes.api.users.user.pfp import ApiUsersUserPfpHandler
from app.classes.web.routes.api.users.user.public import ApiUsersUserPublicHandler
+from app.classes.web.routes.api.crafty.announcements.index import (
+ ApiAnnounceIndexHandler,
+)
+from app.classes.web.routes.api.crafty.config.index import (
+ ApiCraftyConfigIndexHandler,
+ ApiCraftyCustomizeIndexHandler,
+)
+from app.classes.web.routes.api.crafty.config.server_dir import (
+ ApiCraftyConfigServerDirHandler,
+)
+from app.classes.web.routes.api.crafty.stats.stats import ApiCraftyHostStatsHandler
+from app.classes.web.routes.api.crafty.clogs.index import ApiCraftyLogIndexHandler
+from app.classes.web.routes.api.crafty.imports.index import ApiImportFilesIndexHandler
+from app.classes.web.routes.api.crafty.exe_cache import ApiCraftyJarCacheIndexHandler
def api_handlers(handler_args):
@@ -55,12 +94,62 @@ def api_handlers(handler_args):
ApiAuthInvalidateTokensHandler,
handler_args,
),
+ (
+ r"/api/v2/crafty/announcements/?",
+ ApiAnnounceIndexHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/crafty/config/?",
+ ApiCraftyConfigIndexHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/crafty/config/customize/?",
+ ApiCraftyCustomizeIndexHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/crafty/config/servers_dir/?",
+ ApiCraftyConfigServerDirHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/crafty/stats/?",
+ ApiCraftyHostStatsHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/crafty/logs/([a-z0-9_]+)/?",
+ ApiCraftyLogIndexHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/crafty/JarCache/?",
+ ApiCraftyJarCacheIndexHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/import/file/unzip/?",
+ ApiImportFilesIndexHandler,
+ handler_args,
+ ),
# User routes
(
r"/api/v2/users/?",
ApiUsersIndexHandler,
handler_args,
),
+ (
+ r"/api/v2/users/([0-9]+)/key/?",
+ ApiUsersUserKeyHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/users/([0-9]+)/key/([0-9]+)/?",
+ ApiUsersUserKeyHandler,
+ handler_args,
+ ),
(
r"/api/v2/users/([0-9]+)/?",
ApiUsersUserIndexHandler,
@@ -107,11 +196,41 @@ def api_handlers(handler_args):
ApiServersIndexHandler,
handler_args,
),
+ (
+ r"/api/v2/servers/status/?",
+ ApiServersServerStatusHandler,
+ handler_args,
+ ),
(
r"/api/v2/servers/([0-9]+)/?",
ApiServersServerIndexHandler,
handler_args,
),
+ (
+ r"/api/v2/servers/([0-9]+)/backups/?",
+ ApiServersServerBackupsIndexHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/servers/([0-9]+)/backups/backup/?",
+ ApiServersServerBackupsBackupIndexHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/servers/([0-9]+)/files/?",
+ ApiServersServerFilesIndexHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/servers/([0-9]+)/files/create/?",
+ ApiServersServerFilesCreateHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/servers/([0-9]+)/files/zip/?",
+ ApiServersServerFilesZipHandler,
+ handler_args,
+ ),
(
r"/api/v2/servers/([0-9]+)/tasks/?",
ApiServersServerTasksIndexHandler,
@@ -132,6 +251,21 @@ def api_handlers(handler_args):
ApiServersServerStatsHandler,
handler_args,
),
+ (
+ r"/api/v2/servers/([0-9]+)/history/?",
+ ApiServersServerHistoryHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/servers/([0-9]+)/webhook/([0-9]+)/?",
+ ApiServersServerWebhooksManagementIndexHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/servers/([0-9]+)/webhook/?",
+ ApiServersServerWebhooksIndexHandler,
+ handler_args,
+ ),
(
r"/api/v2/servers/([0-9]+)/action/([a-z_]+)/?",
ApiServersServerActionHandler,
diff --git a/app/classes/web/routes/api/crafty/announcements/index.py b/app/classes/web/routes/api/crafty/announcements/index.py
new file mode 100644
index 00000000..409aceed
--- /dev/null
+++ b/app/classes/web/routes/api/crafty/announcements/index.py
@@ -0,0 +1,110 @@
+import logging
+import json
+from jsonschema import ValidationError, validate
+from app.classes.web.base_api_handler import BaseApiHandler
+
+logger = logging.getLogger(__name__)
+
+notif_schema = {
+ "type": "object",
+ "properties": {
+ "id": {"type": "string"},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+
+class ApiAnnounceIndexHandler(BaseApiHandler):
+ def get(self):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ (
+ _,
+ _exec_user_crafty_permissions,
+ _,
+ _,
+ _user,
+ ) = auth_data
+
+ data = self.helper.get_announcements()
+ cleared = str(
+ self.controller.users.get_user_by_id(auth_data[4]["user_id"])[
+ "cleared_notifs"
+ ]
+ ).split(",")
+ res = [d.get("id", None) for d in data]
+ # remove notifs that are no longer in Crafty.
+ for item in cleared[:]:
+ if item not in res:
+ cleared.remove(item)
+ updata = {"cleared_notifs": ",".join(cleared)}
+ self.controller.users.update_user(auth_data[4]["user_id"], updata)
+ if len(cleared) > 0:
+ for item in data[:]:
+ if item["id"] in cleared:
+ data.remove(item)
+
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": data,
+ },
+ )
+
+ def post(self):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ (
+ _,
+ _exec_user_crafty_permissions,
+ _,
+ _,
+ _user,
+ ) = auth_data
+ 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, notif_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+ announcements = self.helper.get_announcements()
+ res = [d.get("id", None) for d in announcements]
+ cleared_notifs = str(
+ self.controller.users.get_user_by_id(auth_data[4]["user_id"])[
+ "cleared_notifs"
+ ]
+ ).split(",")
+ # remove notifs that are no longer in Crafty.
+ for item in cleared_notifs[:]:
+ if item not in res:
+ cleared_notifs.remove(item)
+ if str(data["id"]) in str(res):
+ cleared_notifs.append(data["id"])
+ else:
+ self.finish_json(200, {"status": "error", "error": "INVALID_DATA"})
+ return
+ updata = {"cleared_notifs": ",".join(cleared_notifs)}
+ self.controller.users.update_user(auth_data[4]["user_id"], updata)
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": {},
+ },
+ )
diff --git a/app/classes/web/routes/api/crafty/clogs/index.py b/app/classes/web/routes/api/crafty/clogs/index.py
new file mode 100644
index 00000000..97a24a34
--- /dev/null
+++ b/app/classes/web/routes/api/crafty/clogs/index.py
@@ -0,0 +1,34 @@
+from app.classes.web.base_api_handler import BaseApiHandler
+
+
+class ApiCraftyLogIndexHandler(BaseApiHandler):
+ def get(self, log_type: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ (
+ _,
+ _,
+ _,
+ superuser,
+ _,
+ ) = auth_data
+
+ if not superuser:
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ log_types = ["audit", "session", "schedule"]
+ if log_type not in log_types:
+ raise NotImplementedError
+
+ if log_type == "audit":
+ return self.finish_json(
+ 200,
+ {"status": "ok", "data": self.controller.management.get_activity_log()},
+ )
+
+ if log_type == "session":
+ raise NotImplementedError
+
+ if log_type == "schedule":
+ raise NotImplementedError
diff --git a/app/classes/web/routes/api/crafty/config/index.py b/app/classes/web/routes/api/crafty/config/index.py
new file mode 100644
index 00000000..a2bff723
--- /dev/null
+++ b/app/classes/web/routes/api/crafty/config/index.py
@@ -0,0 +1,312 @@
+import os
+import json
+from jsonschema import ValidationError, validate
+import orjson
+from playhouse.shortcuts import model_to_dict
+from app.classes.shared.file_helpers import FileHelpers
+from app.classes.web.base_api_handler import BaseApiHandler
+
+config_json_schema = {
+ "type": "object",
+ "properties": {
+ "http_port": {"type": "integer"},
+ "https_port": {"type": "integer"},
+ "language": {
+ "type": "string",
+ },
+ "cookie_expire": {"type": "integer"},
+ "show_errors": {"type": "boolean"},
+ "history_max_age": {"type": "integer"},
+ "stats_update_frequency_seconds": {"type": "integer"},
+ "delete_default_json": {"type": "boolean"},
+ "show_contribute_link": {"type": "boolean"},
+ "virtual_terminal_lines": {"type": "integer"},
+ "max_log_lines": {"type": "integer"},
+ "max_audit_entries": {"type": "integer"},
+ "disabled_language_files": {"type": "array"},
+ "stream_size_GB": {"type": "integer"},
+ "keywords": {"type": "array"},
+ "allow_nsfw_profile_pictures": {"type": "boolean"},
+ "enable_user_self_delete": {"type": "boolean"},
+ "reset_secrets_on_next_boot": {"type": "boolean"},
+ "monitored_mounts": {"type": "array"},
+ "dir_size_poll_freq_minutes": {"type": "integer"},
+ "crafty_logs_delete_after_days": {"type": "integer"},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+customize_json_schema = {
+ "type": "object",
+ "properties": {
+ "photo": {"type": "string"},
+ "opacity": {"type": "string"},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+photo_delete_schema = {
+ "type": "object",
+ "properties": {
+ "photo": {"type": "string"},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+DEFAULT_PHOTO = "login_1.jpg"
+
+
+class ApiCraftyConfigIndexHandler(BaseApiHandler):
+ def get(self):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ (
+ _,
+ _,
+ _,
+ superuser,
+ _,
+ ) = auth_data
+
+ # GET /api/v2/roles?ids=true
+ get_only_ids = self.get_query_argument("ids", None) == "true"
+
+ if not superuser:
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": self.controller.roles.get_all_role_ids()
+ if get_only_ids
+ else [model_to_dict(r) for r in self.controller.roles.get_all_roles()],
+ },
+ )
+
+ def patch(self):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ (
+ _,
+ _,
+ _,
+ superuser,
+ user,
+ ) = auth_data
+
+ if not superuser:
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ try:
+ data = orjson.loads(self.request.body)
+ except orjson.decoder.JSONDecodeError as e:
+ return self.finish_json(
+ 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)}
+ )
+
+ try:
+ validate(data, config_json_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+
+ self.controller.set_config_json(data)
+
+ self.controller.management.add_to_audit_log(
+ user["user_id"],
+ "edited config.json",
+ server_id=0,
+ source_ip=self.get_remote_ip(),
+ )
+
+ self.finish_json(
+ 200,
+ {"status": "ok"},
+ )
+
+
+class ApiCraftyCustomizeIndexHandler(BaseApiHandler):
+ def get(self):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ (
+ _,
+ _,
+ _,
+ superuser,
+ _,
+ ) = auth_data
+
+ # GET /api/v2/roles?ids=true
+ get_only_ids = self.get_query_argument("ids", None) == "true"
+
+ if not superuser:
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": self.controller.roles.get_all_role_ids()
+ if get_only_ids
+ else [model_to_dict(r) for r in self.controller.roles.get_all_roles()],
+ },
+ )
+
+ def patch(self):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ (
+ _,
+ _,
+ _,
+ superuser,
+ user,
+ ) = auth_data
+ if not superuser:
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ try:
+ data = orjson.loads(self.request.body)
+ except orjson.decoder.JSONDecodeError as e:
+ return self.finish_json(
+ 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)}
+ )
+
+ try:
+ validate(data, customize_json_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+ if not self.helper.validate_traversal(
+ os.path.join(
+ self.controller.project_root,
+ "app/frontend/static/assets/images/auth/",
+ ),
+ os.path.join(
+ self.controller.project_root,
+ f"app/frontend/static/assets/images/auth/{data['photo']}",
+ ),
+ ):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "TRAVERSAL DETECTED",
+ "error_data": "TRIED TO REACH FILES THAT ARE"
+ " NOT SUPPOSED TO BE ACCESSIBLE",
+ },
+ )
+ self.controller.management.add_to_audit_log(
+ user["user_id"],
+ f"customized login photo: {data['photo']}/{data['opacity']}",
+ server_id=0,
+ source_ip=self.get_remote_ip(),
+ )
+ self.controller.management.set_login_opacity(int(data["opacity"]))
+ if data["photo"] == DEFAULT_PHOTO:
+ self.controller.management.set_login_image(DEFAULT_PHOTO)
+ self.controller.cached_login = f"{data['photo']}"
+ else:
+ self.controller.management.set_login_image(f"custom/{data['photo']}")
+ self.controller.cached_login = f"custom/{data['photo']}"
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": {"photo": data["photo"], "opacity": data["opacity"]},
+ },
+ )
+
+ def delete(self):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ if not auth_data[4]["superuser"]:
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ 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, photo_delete_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+ if not self.helper.validate_traversal(
+ os.path.join(
+ self.controller.project_root,
+ "app",
+ "frontend",
+ "/static/assets/images/auth/",
+ ),
+ os.path.join(
+ self.controller.project_root,
+ "app",
+ "frontend",
+ "/static/assets/images/auth/",
+ data["photo"],
+ ),
+ ):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "TRAVERSAL DETECTED",
+ "error_data": "TRIED TO REACH FILES THAT ARE"
+ " NOT SUPPOSED TO BE ACCESSIBLE",
+ },
+ )
+ if data["photo"] == DEFAULT_PHOTO:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID FILE",
+ "error_data": "CANNOT DELETE DEFAULT",
+ },
+ )
+ FileHelpers.del_file(
+ os.path.join(
+ self.controller.project_root,
+ f"app/frontend/static/assets/images/auth/custom/{data['photo']}",
+ )
+ )
+ current = self.controller.cached_login
+ split = current.split("/")
+ if len(split) == 1:
+ current_photo = current
+ else:
+ current_photo = split[1]
+ if current_photo == data["photo"]:
+ self.controller.management.set_login_image(DEFAULT_PHOTO)
+ self.controller.cached_login = DEFAULT_PHOTO
+ return self.finish_json(200, {"status": "ok"})
diff --git a/app/classes/web/routes/api/crafty/config/server_dir.py b/app/classes/web/routes/api/crafty/config/server_dir.py
new file mode 100644
index 00000000..4e41be14
--- /dev/null
+++ b/app/classes/web/routes/api/crafty/config/server_dir.py
@@ -0,0 +1,115 @@
+from jsonschema import ValidationError, validate
+import orjson
+from playhouse.shortcuts import model_to_dict
+from app.classes.web.base_api_handler import BaseApiHandler
+
+server_dir_schema = {
+ "type": "object",
+ "properties": {
+ "new_dir": {"type": "string"},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+
+class ApiCraftyConfigServerDirHandler(BaseApiHandler):
+ def get(self):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ (
+ _,
+ _,
+ _,
+ superuser,
+ _,
+ ) = auth_data
+
+ # GET /api/v2/roles?ids=true
+ get_only_ids = self.get_query_argument("ids", None) == "true"
+
+ if not superuser:
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": self.controller.roles.get_all_role_ids()
+ if get_only_ids
+ else [model_to_dict(r) for r in self.controller.roles.get_all_roles()],
+ },
+ )
+
+ def patch(self):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ (
+ _,
+ _,
+ _,
+ _,
+ _,
+ ) = auth_data
+
+ if not auth_data:
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ if not auth_data[4]["superuser"]:
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ if self.helper.is_env_docker():
+ raise NotImplementedError
+
+ try:
+ data = orjson.loads(self.request.body)
+ except orjson.decoder.JSONDecodeError as e:
+ return self.finish_json(
+ 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)}
+ )
+
+ try:
+ validate(data, server_dir_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+ if self.helper.dir_migration:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "IN PROGRESS",
+ "error_data": "Migration already in progress. Please be patient",
+ },
+ )
+ for server in self.controller.servers.get_all_servers_stats():
+ if server["stats"]["running"]:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "SERVER RUNNING",
+ },
+ )
+
+ new_dir = data["new_dir"]
+ self.controller.update_master_server_dir(new_dir, auth_data[4]["user_id"])
+
+ self.controller.management.add_to_audit_log(
+ auth_data[4]["user_id"],
+ f"updated master servers dir to {new_dir}/servers",
+ server_id=0,
+ source_ip=self.get_remote_ip(),
+ )
+
+ self.finish_json(
+ 200,
+ {"status": "ok"},
+ )
diff --git a/app/classes/web/routes/api/crafty/exe_cache.py b/app/classes/web/routes/api/crafty/exe_cache.py
new file mode 100644
index 00000000..8836aef8
--- /dev/null
+++ b/app/classes/web/routes/api/crafty/exe_cache.py
@@ -0,0 +1,27 @@
+from app.classes.web.base_api_handler import BaseApiHandler
+
+
+class ApiCraftyJarCacheIndexHandler(BaseApiHandler):
+ def get(self):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ (
+ _,
+ _,
+ _,
+ _,
+ _,
+ ) = auth_data
+
+ if not auth_data[4]["superuser"]:
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ self.controller.server_jars.manual_refresh_cache()
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": self.controller.server_jars.get_serverjar_data(),
+ },
+ )
diff --git a/app/classes/web/routes/api/crafty/imports/index.py b/app/classes/web/routes/api/crafty/imports/index.py
new file mode 100644
index 00000000..e6c8c548
--- /dev/null
+++ b/app/classes/web/routes/api/crafty/imports/index.py
@@ -0,0 +1,130 @@
+import os
+import logging
+import json
+import html
+from jsonschema import validate
+from jsonschema.exceptions import ValidationError
+from app.classes.models.crafty_permissions import EnumPermissionsCrafty
+from app.classes.shared.helpers import Helpers
+from app.classes.web.base_api_handler import BaseApiHandler
+
+logger = logging.getLogger(__name__)
+files_get_schema = {
+ "type": "object",
+ "properties": {
+ "page": {"type": "string", "minLength": 1},
+ "folder": {"type": "string"},
+ "upload": {"type": "boolean", "default": "False"},
+ "unzip": {"type": "boolean", "default": "True"},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+
+class ApiImportFilesIndexHandler(BaseApiHandler):
+ def post(self):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ if (
+ EnumPermissionsCrafty.SERVER_CREATION
+ not in self.controller.crafty_perms.get_crafty_permissions_list(
+ auth_data[4]["user_id"]
+ )
+ and not auth_data[4]["superuser"]
+ ):
+ # if the user doesn't have Files or Backup permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ 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, files_get_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+ # TODO: limit some columns for specific permissions?
+ folder = data["folder"]
+ user_id = auth_data[4]["user_id"]
+ root_path = False
+ if data["unzip"]:
+ # This is awful. Once uploads go to return
+ # JSON we need to remove this and just send
+ # the path.
+ if data["upload"]:
+ folder = os.path.join(
+ self.controller.project_root, "import", "upload", folder
+ )
+ if Helpers.check_file_exists(folder):
+ folder = self.file_helper.unzip_server(folder, user_id)
+ root_path = True
+ else:
+ if user_id:
+ user_lang = self.controller.users.get_user_lang_by_id(user_id)
+ self.helper.websocket_helper.broadcast_user(
+ user_id,
+ "send_start_error",
+ {
+ "error": self.helper.translation.translate(
+ "error", "no-file", user_lang
+ )
+ },
+ )
+ else:
+ if not self.helper.check_path_exists(folder) and user_id:
+ user_lang = self.controller.users.get_user_lang_by_id(user_id)
+ self.helper.websocket_helper.broadcast_user(
+ user_id,
+ "send_start_error",
+ {
+ "error": self.helper.translation.translate(
+ "error", "no-file", user_lang
+ )
+ },
+ )
+ return_json = {
+ "root_path": {
+ "path": folder,
+ "top": root_path,
+ }
+ }
+
+ dir_list = []
+ unsorted_files = []
+ file_list = os.listdir(folder)
+ for item in file_list:
+ if os.path.isdir(os.path.join(folder, item)):
+ dir_list.append(item)
+ else:
+ unsorted_files.append(item)
+ file_list = sorted(dir_list, key=str.casefold) + sorted(
+ unsorted_files, key=str.casefold
+ )
+ for raw_filename in file_list:
+ filename = html.escape(raw_filename)
+ rel = os.path.join(folder, raw_filename)
+ dpath = os.path.join(folder, filename)
+ dpath = self.helper.wtol_path(dpath)
+ if os.path.isdir(rel):
+ return_json[filename] = {
+ "path": dpath,
+ "dir": True,
+ }
+ else:
+ return_json[filename] = {
+ "path": dpath,
+ "dir": False,
+ }
+ self.finish_json(200, {"status": "ok", "data": return_json})
diff --git a/app/classes/web/routes/api/crafty/stats/stats.py b/app/classes/web/routes/api/crafty/stats/stats.py
new file mode 100644
index 00000000..84569469
--- /dev/null
+++ b/app/classes/web/routes/api/crafty/stats/stats.py
@@ -0,0 +1,21 @@
+import logging
+from app.classes.web.base_api_handler import BaseApiHandler
+
+logger = logging.getLogger(__name__)
+
+
+class ApiCraftyHostStatsHandler(BaseApiHandler):
+ def get(self):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ latest = self.controller.management.get_latest_hosts_stats()
+
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": latest,
+ },
+ )
diff --git a/app/classes/web/routes/api/roles/index.py b/app/classes/web/routes/api/roles/index.py
index 150bff0c..0d46e11b 100644
--- a/app/classes/web/routes/api/roles/index.py
+++ b/app/classes/web/routes/api/roles/index.py
@@ -28,9 +28,39 @@ create_role_schema = {
"required": ["server_id", "permissions"],
},
},
+ "manager": {"type": ["integer", "null"]},
},
- "required": ["name"],
"additionalProperties": False,
+ "minProperties": 1,
+}
+
+basic_create_role_schema = {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ },
+ "servers": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "server_id": {
+ "type": "integer",
+ "minimum": 1,
+ },
+ "permissions": {
+ "type": "string",
+ "pattern": "^[01]{8}$", # 8 bits, see EnumPermissionsServer
+ },
+ },
+ "required": ["server_id", "permissions"],
+ },
+ },
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
}
@@ -86,7 +116,10 @@ class ApiRolesIndexHandler(BaseApiHandler):
)
try:
- validate(data, create_role_schema)
+ if auth_data[4]["superuser"]:
+ validate(data, create_role_schema)
+ else:
+ validate(data, basic_create_role_schema)
except ValidationError as e:
return self.finish_json(
400,
@@ -98,6 +131,9 @@ class ApiRolesIndexHandler(BaseApiHandler):
)
role_name = data["name"]
+ manager = data.get("manager", None)
+ if manager == self.controller.users.get_id_by_name("SYSTEM") or manager == 0:
+ manager = None
# Get the servers
servers_dict = {server["server_id"]: server for server in data["servers"]}
@@ -116,9 +152,7 @@ class ApiRolesIndexHandler(BaseApiHandler):
400, {"status": "error", "error": "ROLE_NAME_ALREADY_EXISTS"}
)
- role_id = self.controller.roles.add_role_advanced(
- role_name, servers, user["user_id"]
- )
+ role_id = self.controller.roles.add_role_advanced(role_name, servers, manager)
self.controller.management.add_to_audit_log(
user["user_id"],
diff --git a/app/classes/web/routes/api/roles/role/index.py b/app/classes/web/routes/api/roles/role/index.py
index 20354722..0dd7d6c8 100644
--- a/app/classes/web/routes/api/roles/role/index.py
+++ b/app/classes/web/routes/api/roles/role/index.py
@@ -153,9 +153,18 @@ class ApiRolesRoleIndexHandler(BaseApiHandler):
},
)
+ manager = data.get(
+ "manager", self.controller.roles.get_role(role_id)["manager"]
+ )
+ if manager == self.controller.users.get_id_by_name("system") or manager == 0:
+ manager = None
+
try:
self.controller.roles.update_role_advanced(
- role_id, data.get("role_name", None), data.get("servers", None)
+ role_id,
+ data.get("name", None),
+ data.get("servers", None),
+ manager,
)
except DoesNotExist:
return self.finish_json(404, {"status": "error", "error": "ROLE_NOT_FOUND"})
diff --git a/app/classes/web/routes/api/servers/index.py b/app/classes/web/routes/api/servers/index.py
index edfec8fc..31dbd4c6 100644
--- a/app/classes/web/routes/api/servers/index.py
+++ b/app/classes/web/routes/api/servers/index.py
@@ -9,7 +9,7 @@ logger = logging.getLogger(__name__)
new_server_schema = {
"definitions": {},
- "$schema": "http://json-schema.org/draft-07/schema#",
+ "$schema": "https://json-schema.org/draft-07/schema#",
"title": "Root",
"type": "object",
"required": [
@@ -24,6 +24,7 @@ new_server_schema = {
"examples": ["My Server"],
"minLength": 2,
},
+ "roles": {"title": "Roles to add", "type": "array", "examples": [1, 2, 3]},
"stop_command": {
"title": "Stop command",
"description": '"" means the default for the server creation type.',
@@ -133,8 +134,13 @@ new_server_schema = {
"mem_min",
"mem_max",
"server_properties_port",
- "agree_to_eula",
+ "category",
],
+ "category": {
+ "title": "Jar Category",
+ "type": "string",
+ "examples": ["modded", "vanilla"],
+ },
"properties": {
"type": {
"title": "Server JAR Type",
@@ -185,7 +191,6 @@ new_server_schema = {
"mem_min",
"mem_max",
"server_properties_port",
- "agree_to_eula",
],
"properties": {
"existing_server_path": {
@@ -240,7 +245,6 @@ new_server_schema = {
"mem_min",
"mem_max",
"server_properties_port",
- "agree_to_eula",
],
"properties": {
"zip_path": {
@@ -336,12 +340,24 @@ new_server_schema = {
"title": "Creation type",
"type": "string",
"default": "import_server",
- "enum": ["import_server", "import_zip"],
+ "enum": ["download_exe", "import_server", "import_zip"],
+ },
+ "download_exe_create_data": {
+ "title": "Import server data",
+ "type": "object",
+ "required": [],
+ "properties": {
+ "agree_to_eula": {
+ "title": "Agree to the EULA",
+ "type": "boolean",
+ "enum": [True],
+ },
+ },
},
"import_server_create_data": {
"title": "Import server data",
"type": "object",
- "required": ["existing_server_path", "command"],
+ "required": ["existing_server_path", "executable"],
"properties": {
"existing_server_path": {
"title": "Server path",
@@ -350,6 +366,14 @@ new_server_schema = {
"examples": ["/var/opt/server"],
"minLength": 1,
},
+ "executable": {
+ "title": "Executable File",
+ "description": "File Crafty should execute"
+ "on server launch",
+ "type": "string",
+ "examples": ["bedrock_server.exe"],
+ "minlength": 1,
+ },
"command": {
"title": "Command",
"type": "string",
@@ -371,6 +395,14 @@ new_server_schema = {
"examples": ["/var/opt/server.zip"],
"minLength": 1,
},
+ "executable": {
+ "title": "Executable File",
+ "description": "File Crafty should execute"
+ "on server launch",
+ "type": "string",
+ "examples": ["bedrock_server.exe"],
+ "minlength": 1,
+ },
"zip_root": {
"title": "Server root directory",
"description": "The server root in the ZIP archive",
@@ -394,7 +426,9 @@ new_server_schema = {
"allOf": [
{
"if": {
- "properties": {"create_type": {"const": "import_exec"}}
+ "properties": {
+ "create_type": {"const": "import_server"}
+ }
},
"then": {"required": ["import_server_create_data"]},
},
@@ -404,6 +438,16 @@ new_server_schema = {
},
"then": {"required": ["import_zip_create_data"]},
},
+ {
+ "if": {
+ "properties": {"create_type": {"const": "download_exe"}}
+ },
+ "then": {
+ "required": [
+ "download_exe_create_data",
+ ]
+ },
+ },
],
},
{
@@ -411,6 +455,7 @@ new_server_schema = {
"oneOf": [
{"required": ["import_server_create_data"]},
{"required": ["import_zip_create_data"]},
+ {"required": ["download_exe_create_data"]},
],
},
],
@@ -651,7 +696,6 @@ class ApiServersIndexHandler(BaseApiHandler):
return self.finish_json(
400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)}
)
-
try:
validate(data, new_server_schema)
except ValidationError as e:
diff --git a/app/classes/web/routes/api/servers/server/action.py b/app/classes/web/routes/api/servers/server/action.py
index e5b3ae23..153b889d 100644
--- a/app/classes/web/routes/api/servers/server/action.py
+++ b/app/classes/web/routes/api/servers/server/action.py
@@ -31,6 +31,8 @@ class ApiServersServerActionHandler(BaseApiHandler):
if action == "clone_server":
return self._clone_server(server_id, auth_data[4]["user_id"])
+ if action == "eula":
+ return self._agree_eula(server_id, auth_data[4]["user_id"])
self.controller.management.send_command(
auth_data[4]["user_id"], server_id, self.get_remote_ip(), action
@@ -41,6 +43,11 @@ class ApiServersServerActionHandler(BaseApiHandler):
{"status": "ok"},
)
+ def _agree_eula(self, server_id, user):
+ svr = self.controller.servers.get_server_instance_by_id(server_id)
+ svr.agree_eula(user)
+ return self.finish_json(200, {"status": "ok"})
+
def _clone_server(self, server_id, user_id):
def is_name_used(name):
return Servers.select().where(Servers.server_name == name).exists()
diff --git a/app/classes/web/routes/api/servers/server/backups/backup/index.py b/app/classes/web/routes/api/servers/server/backups/backup/index.py
new file mode 100644
index 00000000..b92e1e9f
--- /dev/null
+++ b/app/classes/web/routes/api/servers/server/backups/backup/index.py
@@ -0,0 +1,217 @@
+import logging
+import json
+import os
+from apscheduler.jobstores.base import JobLookupError
+from jsonschema import validate
+from jsonschema.exceptions import ValidationError
+from app.classes.models.server_permissions import EnumPermissionsServer
+from app.classes.shared.file_helpers import FileHelpers
+from app.classes.web.base_api_handler import BaseApiHandler
+from app.classes.shared.helpers import Helpers
+
+logger = logging.getLogger(__name__)
+
+backup_schema = {
+ "type": "object",
+ "properties": {
+ "filename": {"type": "string", "minLength": 5},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+
+class ApiServersServerBackupsBackupIndexHandler(BaseApiHandler):
+ def get(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ if (
+ EnumPermissionsServer.BACKUP
+ 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, self.controller.management.get_backup_config(server_id))
+
+ def delete(self, server_id: str):
+ auth_data = self.authenticate_user()
+ backup_conf = self.controller.management.get_backup_config(server_id)
+ if not auth_data:
+ return
+ if (
+ EnumPermissionsServer.BACKUP
+ 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:
+ 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, backup_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+
+ try:
+ FileHelpers.del_file(
+ os.path.join(backup_conf["backup_path"], data["filename"])
+ )
+ except Exception:
+ return self.finish_json(
+ 400, {"status": "error", "error": "NO BACKUP FOUND"}
+ )
+ self.controller.management.add_to_audit_log(
+ auth_data[4]["user_id"],
+ f"Edited server {server_id}: removed backup {data['filename']}",
+ server_id,
+ self.get_remote_ip(),
+ )
+
+ return self.finish_json(200, {"status": "ok"})
+
+ def post(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ if (
+ EnumPermissionsServer.BACKUP
+ 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:
+ 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, backup_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+
+ try:
+ svr_obj = self.controller.servers.get_server_obj(server_id)
+ server_data = self.controller.servers.get_server_data_by_id(server_id)
+ zip_name = data["filename"]
+ # import the server again based on zipfile
+ backup_path = svr_obj.backup_path
+ if Helpers.validate_traversal(backup_path, zip_name):
+ temp_dir = Helpers.unzip_backup_archive(backup_path, zip_name)
+ if server_data["type"] == "minecraft-java":
+ new_server = self.controller.restore_java_zip_server(
+ svr_obj.server_name,
+ temp_dir,
+ server_data["executable"],
+ "1",
+ "2",
+ server_data["server_port"],
+ server_data["created_by"],
+ )
+ elif server_data["type"] == "minecraft-bedrock":
+ new_server = self.controller.restore_bedrock_zip_server(
+ svr_obj.server_name,
+ temp_dir,
+ server_data["executable"],
+ server_data["server_port"],
+ server_data["created_by"],
+ )
+ new_server_id = new_server
+ new_server = self.controller.servers.get_server_data(new_server)
+ self.controller.rename_backup_dir(
+ server_id, new_server_id, new_server["server_uuid"]
+ )
+ # preserve current schedules
+ for schedule in self.controller.management.get_schedules_by_server(
+ server_id
+ ):
+ job_data = self.controller.management.get_scheduled_task(
+ schedule.schedule_id
+ )
+ job_data["server_id"] = new_server_id
+ del job_data["schedule_id"]
+ self.tasks_manager.update_job(schedule.schedule_id, job_data)
+ # preserve execution command
+ new_server_obj = self.controller.servers.get_server_obj(new_server_id)
+ new_server_obj.execution_command = server_data["execution_command"]
+ # reset executable path
+ if svr_obj.path in svr_obj.executable:
+ new_server_obj.executable = str(svr_obj.executable).replace(
+ svr_obj.path, new_server_obj.path
+ )
+ # reset run command path
+ if svr_obj.path in svr_obj.execution_command:
+ new_server_obj.execution_command = str(
+ svr_obj.execution_command
+ ).replace(svr_obj.path, new_server_obj.path)
+ # reset log path
+ if svr_obj.path in svr_obj.log_path:
+ new_server_obj.log_path = str(svr_obj.log_path).replace(
+ svr_obj.path, new_server_obj.path
+ )
+ self.controller.servers.update_server(new_server_obj)
+
+ # preserve backup config
+ backup_config = self.controller.management.get_backup_config(server_id)
+ excluded_dirs = []
+ server_obj = self.controller.servers.get_server_obj(server_id)
+ loop_backup_path = self.helper.wtol_path(server_obj.path)
+ for item in self.controller.management.get_excluded_backup_dirs(
+ server_id
+ ):
+ item_path = self.helper.wtol_path(item)
+ bu_path = os.path.relpath(item_path, loop_backup_path)
+ bu_path = os.path.join(new_server_obj.path, bu_path)
+ excluded_dirs.append(bu_path)
+ self.controller.management.set_backup_config(
+ new_server_id,
+ new_server_obj.backup_path,
+ backup_config["max_backups"],
+ excluded_dirs,
+ backup_config["compress"],
+ backup_config["shutdown"],
+ )
+ # remove old server's tasks
+ try:
+ self.tasks_manager.remove_all_server_tasks(server_id)
+ except JobLookupError as e:
+ logger.info("No active tasks found for server: {e}")
+ self.controller.remove_server(server_id, True)
+ except Exception as e:
+ return self.finish_json(
+ 400, {"status": "error", "error": f"NO BACKUP FOUND {e}"}
+ )
+ self.controller.management.add_to_audit_log(
+ auth_data[4]["user_id"],
+ f"Restored server {server_id} backup {data['filename']}",
+ server_id,
+ self.get_remote_ip(),
+ )
+
+ return self.finish_json(200, {"status": "ok"})
diff --git a/app/classes/web/routes/api/servers/server/backups/index.py b/app/classes/web/routes/api/servers/server/backups/index.py
new file mode 100644
index 00000000..9e47bcfc
--- /dev/null
+++ b/app/classes/web/routes/api/servers/server/backups/index.py
@@ -0,0 +1,123 @@
+import logging
+import json
+from jsonschema import validate
+from jsonschema.exceptions import ValidationError
+from app.classes.models.server_permissions import EnumPermissionsServer
+from app.classes.web.base_api_handler import BaseApiHandler
+
+logger = logging.getLogger(__name__)
+
+backup_patch_schema = {
+ "type": "object",
+ "properties": {
+ "backup_path": {"type": "string", "minLength": 1},
+ "max_backups": {"type": "integer"},
+ "compress": {"type": "boolean"},
+ "shutdown": {"type": "boolean"},
+ "backup_before": {"type": "string"},
+ "backup_after": {"type": "string"},
+ "exclusions": {"type": "array"},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+basic_backup_patch_schema = {
+ "type": "object",
+ "properties": {
+ "max_backups": {"type": "integer"},
+ "compress": {"type": "boolean"},
+ "shutdown": {"type": "boolean"},
+ "backup_before": {"type": "string"},
+ "backup_after": {"type": "string"},
+ "exclusions": {"type": "array"},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+
+class ApiServersServerBackupsIndexHandler(BaseApiHandler):
+ def get(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ if (
+ EnumPermissionsServer.BACKUP
+ 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, self.controller.management.get_backup_config(server_id))
+
+ def patch(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:
+ if auth_data[4]["superuser"]:
+ validate(data, backup_patch_schema)
+ else:
+ validate(data, basic_backup_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.BACKUP
+ 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.controller.management.set_backup_config(
+ server_id,
+ data.get(
+ "backup_path",
+ self.controller.management.get_backup_config(server_id)["backup_path"],
+ ),
+ data.get(
+ "max_backups",
+ self.controller.management.get_backup_config(server_id)["max_backups"],
+ ),
+ data.get("exclusions"),
+ data.get(
+ "compress",
+ self.controller.management.get_backup_config(server_id)["compress"],
+ ),
+ data.get(
+ "shutdown",
+ self.controller.management.get_backup_config(server_id)["shutdown"],
+ ),
+ data.get(
+ "backup_before",
+ self.controller.management.get_backup_config(server_id)["before"],
+ ),
+ data.get(
+ "backup_after",
+ self.controller.management.get_backup_config(server_id)["after"],
+ ),
+ )
+ return self.finish_json(200, {"status": "ok"})
diff --git a/app/classes/web/routes/api/servers/server/files.py b/app/classes/web/routes/api/servers/server/files.py
new file mode 100644
index 00000000..9ed720ac
--- /dev/null
+++ b/app/classes/web/routes/api/servers/server/files.py
@@ -0,0 +1,555 @@
+import os
+import logging
+import json
+import html
+from jsonschema import validate
+from jsonschema.exceptions import ValidationError
+from app.classes.models.server_permissions import EnumPermissionsServer
+from app.classes.shared.helpers import Helpers
+from app.classes.shared.file_helpers import FileHelpers
+from app.classes.web.base_api_handler import BaseApiHandler
+
+logger = logging.getLogger(__name__)
+
+files_get_schema = {
+ "type": "object",
+ "properties": {
+ "page": {"type": "string", "minLength": 1},
+ "path": {"type": "string"},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+files_patch_schema = {
+ "type": "object",
+ "properties": {
+ "path": {"type": "string"},
+ "contents": {"type": "string"},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+files_unzip_schema = {
+ "type": "object",
+ "properties": {
+ "folder": {"type": "string"},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+files_create_schema = {
+ "type": "object",
+ "properties": {
+ "parent": {"type": "string"},
+ "name": {"type": "string"},
+ "directory": {"type": "boolean"},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+files_rename_schema = {
+ "type": "object",
+ "properties": {
+ "path": {"type": "string"},
+ "new_name": {"type": "string"},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+file_delete_schema = {
+ "type": "object",
+ "properties": {
+ "filename": {"type": "string", "minLength": 5},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+
+class ApiServersServerFilesIndexHandler(BaseApiHandler):
+ def post(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ 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.FILES
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ or EnumPermissionsServer.BACKUP
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Files or Backup permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ 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, files_get_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+ if not Helpers.validate_traversal(
+ self.controller.servers.get_server_data_by_id(server_id)["path"],
+ data["path"],
+ ):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "TRAVERSAL DETECTED",
+ "error_data": str(e),
+ },
+ )
+ if os.path.isdir(data["path"]):
+ # TODO: limit some columns for specific permissions?
+ folder = data["path"]
+ return_json = {
+ "root_path": {
+ "path": folder,
+ "top": data["path"]
+ == self.controller.servers.get_server_data_by_id(server_id)["path"],
+ }
+ }
+
+ dir_list = []
+ unsorted_files = []
+ file_list = os.listdir(folder)
+ for item in file_list:
+ if os.path.isdir(os.path.join(folder, item)):
+ dir_list.append(item)
+ else:
+ unsorted_files.append(item)
+ file_list = sorted(dir_list, key=str.casefold) + sorted(
+ unsorted_files, key=str.casefold
+ )
+ for raw_filename in file_list:
+ filename = html.escape(raw_filename)
+ rel = os.path.join(folder, raw_filename)
+ dpath = os.path.join(folder, filename)
+ if str(dpath) in self.controller.management.get_excluded_backup_dirs(
+ server_id
+ ):
+ if os.path.isdir(rel):
+ return_json[filename] = {
+ "path": dpath,
+ "dir": True,
+ "excluded": True,
+ }
+ else:
+ return_json[filename] = {
+ "path": dpath,
+ "dir": False,
+ "excluded": True,
+ }
+ else:
+ if os.path.isdir(rel):
+ return_json[filename] = {
+ "path": dpath,
+ "dir": True,
+ "excluded": False,
+ }
+ else:
+ return_json[filename] = {
+ "path": dpath,
+ "dir": False,
+ "excluded": False,
+ }
+ self.finish_json(200, {"status": "ok", "data": return_json})
+ else:
+ try:
+ with open(data["path"], encoding="utf-8") as file:
+ file_contents = file.read()
+ except UnicodeDecodeError as ex:
+ self.finish_json(
+ 400,
+ {"status": "error", "error": "DECODE_ERROR", "error_data": str(ex)},
+ )
+ self.finish_json(200, {"status": "ok", "data": file_contents})
+
+ def delete(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ 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.FILES
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Files permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ 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, file_delete_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+ if not Helpers.validate_traversal(
+ self.controller.servers.get_server_data_by_id(server_id)["path"],
+ data["filename"],
+ ):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "TRAVERSAL DETECTED",
+ "error_data": str(e),
+ },
+ )
+
+ if os.path.isdir(data["filename"]):
+ FileHelpers.del_dirs(data["filename"])
+ else:
+ FileHelpers.del_file(data["filename"])
+ return self.finish_json(200, {"status": "ok"})
+
+ def patch(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ 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.FILES
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Files permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ 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, files_patch_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+ if not Helpers.validate_traversal(
+ self.controller.servers.get_server_data_by_id(server_id)["path"],
+ data["path"],
+ ):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "TRAVERSAL DETECTED",
+ "error_data": str(e),
+ },
+ )
+ file_path = Helpers.get_os_understandable_path(data["path"])
+ file_contents = data["contents"]
+ # Open the file in write mode and store the content in file_object
+ with open(file_path, "w", encoding="utf-8") as file_object:
+ file_object.write(file_contents)
+ return self.finish_json(200, {"status": "ok"})
+
+ def put(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ 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.FILES
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Files permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ 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, files_create_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+ path = os.path.join(data["parent"], data["name"])
+ if not Helpers.validate_traversal(
+ self.controller.servers.get_server_data_by_id(server_id)["path"],
+ path,
+ ):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "TRAVERSAL DETECTED",
+ "error_data": str(e),
+ },
+ )
+ if Helpers.check_path_exists(os.path.abspath(path)):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "FILE EXISTS",
+ "error_data": str(e),
+ },
+ )
+ if data["directory"]:
+ os.mkdir(path)
+ else:
+ # Create the file by opening it
+ with open(path, "w", encoding="utf-8") as file_object:
+ file_object.close()
+ return self.finish_json(200, {"status": "ok"})
+
+
+class ApiServersServerFilesCreateHandler(BaseApiHandler):
+ def patch(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ 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.FILES
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Files permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ 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, files_rename_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+ path = data["path"]
+ new_item_name = data["new_name"]
+ new_item_path = os.path.join(os.path.split(path)[0], new_item_name)
+ if not Helpers.validate_traversal(
+ self.controller.servers.get_server_data_by_id(server_id)["path"],
+ path,
+ ) or not Helpers.validate_traversal(
+ self.controller.servers.get_server_data_by_id(server_id)["path"],
+ new_item_path,
+ ):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "TRAVERSAL DETECTED",
+ "error_data": str(e),
+ },
+ )
+ if Helpers.check_path_exists(os.path.abspath(new_item_path)):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "FILE EXISTS",
+ "error_data": {},
+ },
+ )
+
+ os.rename(path, new_item_path)
+ return self.finish_json(200, {"status": "ok"})
+
+ def put(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ 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.FILES
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Files permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ 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, files_create_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+ path = os.path.join(data["parent"], data["name"])
+ if not Helpers.validate_traversal(
+ self.controller.servers.get_server_data_by_id(server_id)["path"],
+ path,
+ ):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "TRAVERSAL DETECTED",
+ "error_data": str(e),
+ },
+ )
+ if Helpers.check_path_exists(os.path.abspath(path)):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "FILE EXISTS",
+ "error_data": str(e),
+ },
+ )
+ if data["directory"]:
+ os.mkdir(path)
+ else:
+ # Create the file by opening it
+ with open(path, "w", encoding="utf-8") as file_object:
+ file_object.close()
+ return self.finish_json(200, {"status": "ok"})
+
+
+class ApiServersServerFilesZipHandler(BaseApiHandler):
+ def post(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ 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.FILES
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Files permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ 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, files_unzip_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+ folder = data["folder"]
+ user_id = auth_data[4]["user_id"]
+ if not Helpers.validate_traversal(
+ self.controller.servers.get_server_data_by_id(server_id)["path"],
+ folder,
+ ):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "TRAVERSAL DETECTED",
+ "error_data": str(e),
+ },
+ )
+ if Helpers.check_file_exists(folder):
+ folder = self.file_helper.unzip_file(folder, user_id)
+ else:
+ if user_id:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "FILE_DOES_NOT_EXIST",
+ "error_data": str(e),
+ },
+ )
+ return self.finish_json(200, {"status": "ok"})
diff --git a/app/classes/web/routes/api/servers/server/history.py b/app/classes/web/routes/api/servers/server/history.py
new file mode 100644
index 00000000..1a4aac24
--- /dev/null
+++ b/app/classes/web/routes/api/servers/server/history.py
@@ -0,0 +1,28 @@
+import logging
+from app.classes.web.base_api_handler import BaseApiHandler
+from app.classes.controllers.servers_controller import ServersController
+
+
+logger = logging.getLogger(__name__)
+
+
+class ApiServersServerHistoryHandler(BaseApiHandler):
+ def get(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ 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"})
+
+ srv = ServersController().get_server_instance_by_id(server_id)
+ history = srv.get_server_history()
+
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": history,
+ },
+ )
diff --git a/app/classes/web/routes/api/servers/server/logs.py b/app/classes/web/routes/api/servers/server/logs.py
index 641a1163..94a8a71b 100644
--- a/app/classes/web/routes/api/servers/server/logs.py
+++ b/app/classes/web/routes/api/servers/server/logs.py
@@ -74,6 +74,6 @@ class ApiServersServerLogsHandler(BaseApiHandler):
if use_html:
for line in lines:
- self.write(f"{line} ")
- else:
- self.finish_json(200, {"status": "ok", "data": lines})
+ line = f"{line} "
+
+ self.finish_json(200, {"status": "ok", "data": lines})
diff --git a/app/classes/web/routes/api/servers/server/status.py b/app/classes/web/routes/api/servers/server/status.py
new file mode 100644
index 00000000..aab501d8
--- /dev/null
+++ b/app/classes/web/routes/api/servers/server/status.py
@@ -0,0 +1,32 @@
+import logging
+from app.classes.web.base_api_handler import BaseApiHandler
+
+logger = logging.getLogger(__name__)
+
+
+class ApiServersServerStatusHandler(BaseApiHandler):
+ def get(self):
+ servers_status = []
+ servers_list = self.controller.servers.get_all_servers_stats()
+ for server in servers_list:
+ if server.get("server_data").get("show_status") is True:
+ servers_status.append(
+ {
+ "id": server.get("server_data").get("server_id"),
+ "world_name": server.get("stats").get("world_name"),
+ "running": server.get("stats").get("running"),
+ "online": server.get("stats").get("online"),
+ "max": server.get("stats").get("max"),
+ "version": server.get("stats").get("version"),
+ "desc": server.get("stats").get("desc"),
+ "icon": server.get("stats").get("icon"),
+ }
+ )
+
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": servers_status,
+ },
+ )
diff --git a/app/classes/web/routes/api/servers/server/webhooks/index.py b/app/classes/web/routes/api/servers/server/webhooks/index.py
new file mode 100644
index 00000000..223171c8
--- /dev/null
+++ b/app/classes/web/routes/api/servers/server/webhooks/index.py
@@ -0,0 +1,108 @@
+# TODO: create and read
+
+import json
+import logging
+
+from jsonschema import ValidationError, validate
+from app.classes.models.server_permissions import EnumPermissionsServer
+from app.classes.web.base_api_handler import BaseApiHandler
+from app.classes.web.webhooks.webhook_factory import WebhookFactory
+
+
+logger = logging.getLogger(__name__)
+new_webhook_schema = {
+ "type": "object",
+ "properties": {
+ "webhook_type": {
+ "type": "string",
+ "enum": WebhookFactory.get_supported_providers(),
+ },
+ "name": {"type": "string"},
+ "url": {"type": "string"},
+ "bot_name": {"type": "string"},
+ "trigger": {"type": "array"},
+ "body": {"type": "string"},
+ "color": {"type": "string", "default": "#005cd1"},
+ "enabled": {
+ "type": "boolean",
+ "default": True,
+ },
+ },
+ "additionalProperties": False,
+ "minProperties": 7,
+}
+
+
+class ApiServersServerWebhooksIndexHandler(BaseApiHandler):
+ def get(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ if (
+ EnumPermissionsServer.CONFIG
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Schedule permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": self.controller.management.get_webhooks_by_server(server_id),
+ },
+ )
+
+ def post(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ try:
+ data = json.loads(self.request.body)
+ except json.decoder.JSONDecodeError as e:
+ return self.finish_json(
+ 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)}
+ )
+
+ try:
+ validate(data, new_webhook_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+
+ if server_id not in [str(x["server_id"]) for x in auth_data[0]]:
+ # if the user doesn't have access to the server, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ if (
+ EnumPermissionsServer.CONFIG
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Schedule permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ data["server_id"] = server_id
+
+ self.controller.management.add_to_audit_log(
+ auth_data[4]["user_id"],
+ f"Edited server {server_id}: added webhook",
+ server_id,
+ self.get_remote_ip(),
+ )
+ triggers = ""
+ for item in data["trigger"]:
+ string = item + ","
+ triggers += string
+ data["trigger"] = triggers
+ webhook_id = self.controller.management.create_webhook(data)
+
+ self.finish_json(200, {"status": "ok", "data": {"webhook_id": webhook_id}})
diff --git a/app/classes/web/routes/api/servers/server/webhooks/webhook/index.py b/app/classes/web/routes/api/servers/server/webhooks/webhook/index.py
new file mode 100644
index 00000000..4b58011e
--- /dev/null
+++ b/app/classes/web/routes/api/servers/server/webhooks/webhook/index.py
@@ -0,0 +1,187 @@
+# TODO: read and delete
+
+import json
+import logging
+
+from jsonschema import ValidationError, validate
+from app.classes.models.server_permissions import EnumPermissionsServer
+from app.classes.web.webhooks.webhook_factory import WebhookFactory
+from app.classes.web.base_api_handler import BaseApiHandler
+
+
+logger = logging.getLogger(__name__)
+
+webhook_patch_schema = {
+ "type": "object",
+ "properties": {
+ "webhook_type": {
+ "type": "string",
+ "enum": WebhookFactory.get_supported_providers(),
+ },
+ "name": {"type": "string"},
+ "url": {"type": "string"},
+ "bot_name": {"type": "string"},
+ "trigger": {"type": "array"},
+ "body": {"type": "string"},
+ "color": {"type": "string", "default": "#005cd1"},
+ "enabled": {
+ "type": "boolean",
+ "default": True,
+ },
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+
+class ApiServersServerWebhooksManagementIndexHandler(BaseApiHandler):
+ def get(self, server_id: str, webhook_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ if (
+ EnumPermissionsServer.CONFIG
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Schedule permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ if (
+ not str(webhook_id)
+ in self.controller.management.get_webhooks_by_server(server_id).keys()
+ ):
+ return self.finish_json(
+ 400, {"status": "error", "error": "NO WEBHOOK FOUND"}
+ )
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": self.controller.management.get_webhook_by_id(webhook_id),
+ },
+ )
+
+ def delete(self, server_id: str, webhook_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ if (
+ EnumPermissionsServer.CONFIG
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Schedule permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ try:
+ self.controller.management.delete_webhook(webhook_id)
+ except Exception:
+ return self.finish_json(
+ 400, {"status": "error", "error": "NO WEBHOOK FOUND"}
+ )
+ self.controller.management.add_to_audit_log(
+ auth_data[4]["user_id"],
+ f"Edited server {server_id}: removed webhook",
+ server_id,
+ self.get_remote_ip(),
+ )
+
+ return self.finish_json(200, {"status": "ok"})
+
+ def patch(self, server_id: str, webhook_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ try:
+ data = json.loads(self.request.body)
+ except json.decoder.JSONDecodeError as e:
+ return self.finish_json(
+ 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)}
+ )
+
+ try:
+ validate(data, webhook_patch_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+
+ if server_id not in [str(x["server_id"]) for x in auth_data[0]]:
+ # if the user doesn't have access to the server, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ if (
+ EnumPermissionsServer.CONFIG
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Schedule permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ data["server_id"] = server_id
+ if "trigger" in data.keys():
+ triggers = ""
+ for item in data["trigger"]:
+ string = item + ","
+ triggers += string
+ data["trigger"] = triggers
+ self.controller.management.modify_webhook(webhook_id, data)
+
+ self.controller.management.add_to_audit_log(
+ auth_data[4]["user_id"],
+ f"Edited server {server_id}: updated webhook",
+ server_id,
+ self.get_remote_ip(),
+ )
+
+ self.finish_json(200, {"status": "ok"})
+
+ def post(self, server_id: str, webhook_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ self.controller.management.add_to_audit_log(
+ auth_data[4]["user_id"],
+ "Tested webhook",
+ server_id,
+ self.get_remote_ip(),
+ )
+ if server_id not in [str(x["server_id"]) for x in auth_data[0]]:
+ # if the user doesn't have access to the server, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ if (
+ EnumPermissionsServer.CONFIG
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Schedule permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ webhook = self.controller.management.get_webhook_by_id(webhook_id)
+ try:
+ webhook_provider = WebhookFactory.create_provider(webhook["webhook_type"])
+ webhook_provider.send(
+ server_name=self.controller.servers.get_server_data_by_id(server_id)[
+ "server_name"
+ ],
+ title=f"Test Webhook: {webhook['name']}",
+ url=webhook["url"],
+ message=webhook["body"],
+ color=webhook["color"], # Prestigious purple!
+ bot_name="Crafty Webhooks Tester",
+ )
+ except Exception as e:
+ self.finish_json(500, {"status": "error", "error": str(e)})
+
+ self.finish_json(200, {"status": "ok"})
diff --git a/app/classes/web/routes/api/users/index.py b/app/classes/web/routes/api/users/index.py
index a1f849ef..f7341d38 100644
--- a/app/classes/web/routes/api/users/index.py
+++ b/app/classes/web/routes/api/users/index.py
@@ -93,10 +93,17 @@ class ApiUsersIndexHandler(BaseApiHandler):
"error_data": str(e),
},
)
-
username = data["username"]
username = str(username).lower()
- manager = int(user["user_id"])
+ manager = data.get("manager", None)
+ if user["superuser"]:
+ if (
+ manager == self.controller.users.get_id_by_name("SYSTEM")
+ or manager == 0
+ ):
+ manager = None
+ else:
+ manager = int(user["user_id"])
password = data["password"]
email = data.get("email", "default@example.com")
enabled = data.get("enabled", True)
diff --git a/app/classes/web/routes/api/users/user/api.py b/app/classes/web/routes/api/users/user/api.py
new file mode 100644
index 00000000..1c7635f2
--- /dev/null
+++ b/app/classes/web/routes/api/users/user/api.py
@@ -0,0 +1,243 @@
+import json
+import logging
+
+from jsonschema import ValidationError, validate
+from app.classes.web.base_api_handler import BaseApiHandler
+
+
+logger = logging.getLogger(__name__)
+
+
+class ApiUsersUserKeyHandler(BaseApiHandler):
+ def get(self, user_id: str, key_id=None):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ if key_id:
+ key = self.controller.users.get_user_api_key(key_id)
+ # does this user id exist?
+ if key is None:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID DATA",
+ "error_data": "INVALID KEY",
+ },
+ )
+
+ if (
+ str(key.user_id) != str(auth_data[4]["user_id"])
+ and not auth_data[4]["superuser"]
+ ):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "NOT AUTHORIZED",
+ "error_data": "TRIED TO EDIT KEY WIHTOUT AUTH",
+ },
+ )
+
+ self.controller.management.add_to_audit_log(
+ auth_data[4]["user_id"],
+ f"Generated a new API token for the key {key.name} "
+ f"from user with UID: {key.user_id}",
+ server_id=0,
+ source_ip=self.get_remote_ip(),
+ )
+ data_key = self.controller.authentication.generate(
+ key.user_id_id, {"token_id": key.token_id}
+ )
+
+ return self.finish_json(
+ 200,
+ {"status": "ok", "data": data_key},
+ )
+
+ if (
+ str(user_id) != str(auth_data[4]["user_id"])
+ and not auth_data[4]["superuser"]
+ ):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "NOT AUTHORIZED",
+ "error_data": "TRIED TO EDIT KEY WIHTOUT AUTH",
+ },
+ )
+ keys = []
+ for key in self.controller.users.get_user_api_keys(str(user_id)):
+ keys.append(
+ {
+ "id": key.token_id,
+ "name": key.name,
+ "server_permissions": key.server_permissions,
+ "crafty_permissions": key.crafty_permissions,
+ "superuser": key.superuser,
+ }
+ )
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": keys,
+ },
+ )
+
+ def patch(self, user_id: str):
+ user_key_schema = {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string", "minLength": 3},
+ "server_permissions_mask": {
+ "type": "string",
+ "pattern": "^[01]{8}$", # 8 bits, see EnumPermissionsServer
+ },
+ "crafty_permissions_mask": {
+ "type": "string",
+ "pattern": "^[01]{3}$", # 8 bits, see EnumPermissionsCrafty
+ },
+ "superuser": {"type": "boolean"},
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+ }
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ (
+ _,
+ _exec_user_crafty_permissions,
+ _,
+ _superuser,
+ user,
+ ) = auth_data
+
+ 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, user_key_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+
+ if user_id == "@me":
+ user_id = user["user_id"]
+ # does this user id exist?
+ if not self.controller.users.user_id_exists(user_id):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "USER NOT FOUND",
+ "error_data": "USER_NOT_FOUND",
+ },
+ )
+
+ if (
+ str(user_id) != str(auth_data[4]["user_id"])
+ and not auth_data[4]["superuser"]
+ ):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "NOT AUTHORIZED",
+ "error_data": "TRIED TO EDIT KEY WIHTOUT AUTH",
+ },
+ )
+
+ key_id = self.controller.users.add_user_api_key(
+ data["name"],
+ user_id,
+ data["superuser"],
+ data["server_permissions_mask"],
+ data["crafty_permissions_mask"],
+ )
+
+ self.controller.management.add_to_audit_log(
+ auth_data[4]["user_id"],
+ f"Added API key {data['name']} with crafty permissions "
+ f"{data['crafty_permissions_mask']}"
+ f" and {data['server_permissions_mask']} for user with UID: {user_id}",
+ server_id=0,
+ source_ip=self.get_remote_ip(),
+ )
+ self.finish_json(200, {"status": "ok", "data": {"id": key_id}})
+
+ def delete(self, _user_id: str, key_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ (
+ _,
+ _exec_user_crafty_permissions,
+ _,
+ _,
+ _user,
+ ) = auth_data
+ if key_id:
+ key = self.controller.users.get_user_api_key(key_id)
+ # does this user id exist?
+ if key is None:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID DATA",
+ "error_data": "INVALID KEY",
+ },
+ )
+
+ # does this user id exist?
+ target_key = self.controller.users.get_user_api_key(key_id)
+ if not target_key:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID KEY",
+ "error_data": "INVALID KEY ID",
+ },
+ )
+
+ if (
+ target_key.user_id != auth_data[4]["user_id"]
+ and not auth_data[4]["superuser"]
+ ):
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "NOT AUTHORIZED",
+ "error_data": "TRIED TO EDIT KEY WIHTOUT AUTH",
+ },
+ )
+
+ self.controller.users.delete_user_api_key(key_id)
+
+ self.controller.management.add_to_audit_log(
+ auth_data[4]["user_id"],
+ f"Removed API key {target_key} "
+ f"(ID: {key_id}) from user {auth_data[4]['user_id']}",
+ server_id=0,
+ source_ip=self.get_remote_ip(),
+ )
+
+ return self.finish_json(
+ 200,
+ {"status": "ok", "data": {"id": key_id}},
+ )
diff --git a/app/classes/web/routes/api/users/user/index.py b/app/classes/web/routes/api/users/user/index.py
index 47d8dd68..d416e800 100644
--- a/app/classes/web/routes/api/users/user/index.py
+++ b/app/classes/web/routes/api/users/user/index.py
@@ -4,10 +4,7 @@ import typing as t
from jsonschema import ValidationError, validate
from app.classes.controllers.users_controller import UsersController
-from app.classes.models.crafty_permissions import (
- EnumPermissionsCrafty,
- PermissionsCrafty,
-)
+from app.classes.models.crafty_permissions import EnumPermissionsCrafty
from app.classes.models.roles import HelperRoles
from app.classes.models.users import HelperUsers
from app.classes.web.base_api_handler import BaseApiHandler
@@ -166,7 +163,13 @@ class ApiUsersUserIndexHandler(BaseApiHandler):
return self.finish_json(
400, {"status": "error", "error": "INVALID_USERNAME"}
)
- if self.controller.users.get_id_by_name(data["username"]) is not None:
+ if self.controller.users.get_id_by_name(
+ data["username"]
+ ) is not None and self.controller.users.get_id_by_name(
+ data["username"]
+ ) != int(
+ user_id
+ ):
return self.finish_json(
400, {"status": "error", "error": "USER_EXISTS"}
)
@@ -210,13 +213,13 @@ class ApiUsersUserIndexHandler(BaseApiHandler):
400, {"status": "error", "error": "INVALID_ROLES_MODIFY"}
)
- if "password" in data and str(user["user_id"] == str(user_id)):
- # TODO: edit your own password
- return self.finish_json(
- 400, {"status": "error", "error": "INVALID_PASSWORD_MODIFY"}
- )
-
user_obj = HelperUsers.get_user_model(user_id)
+ if "password" in data and str(user["user_id"]) != str(user_id):
+ if str(user["user_id"]) != str(user_obj.manager):
+ # TODO: edit your own password
+ return self.finish_json(
+ 400, {"status": "error", "error": "INVALID_PASSWORD_MODIFY"}
+ )
if "roles" in data:
roles: t.Set[str] = set(data.pop("roles"))
@@ -236,30 +239,30 @@ class ApiUsersUserIndexHandler(BaseApiHandler):
user_id, removed_roles
)
+ if "manager" in data and (
+ data["manager"] == self.controller.users.get_id_by_name("SYSTEM")
+ or data["manager"] == 0
+ ):
+ data["manager"] = None
+ crafty_perms = None
if "permissions" in data:
permissions: t.List[UsersController.ApiPermissionDict] = data.pop(
"permissions"
)
permissions_mask = "0" * len(EnumPermissionsCrafty)
- limit_server_creation = 0
- limit_user_creation = 0
- limit_role_creation = 0
-
- for permission in permissions:
- self.controller.crafty_perms.set_permission(
- permissions_mask,
- EnumPermissionsCrafty.__members__[permission["name"]],
- "1" if permission["enabled"] else "0",
- )
-
- PermissionsCrafty.add_or_update_user(
- user_id,
- permissions_mask,
- limit_server_creation,
- limit_user_creation,
- limit_role_creation,
- )
-
+ if permissions is not None:
+ server_quantity = {}
+ permissions_mask = list(permissions_mask)
+ for permission in permissions:
+ server_quantity[permission["name"]] = permission["quantity"]
+ permissions_mask[
+ EnumPermissionsCrafty[permission["name"]].value
+ ] = ("1" if permission["enabled"] else "0")
+ permissions_mask = "".join(permissions_mask)
+ crafty_perms = {
+ "permissions_mask": permissions_mask,
+ "server_quantity": server_quantity,
+ }
# TODO: make this more efficient
if len(data) != 0:
for key in data:
@@ -268,7 +271,11 @@ class ApiUsersUserIndexHandler(BaseApiHandler):
if key == "password":
value = self.helper.encode_pass(value)
setattr(user_obj, key, value)
- user_obj.save()
+ self.controller.users.update_user(
+ user_id,
+ data,
+ crafty_perms,
+ )
self.controller.management.add_to_audit_log(
user["user_id"],
diff --git a/app/classes/web/routes/metrics/host.py b/app/classes/web/routes/metrics/host.py
new file mode 100644
index 00000000..fc4af9c5
--- /dev/null
+++ b/app/classes/web/routes/metrics/host.py
@@ -0,0 +1,31 @@
+from prometheus_client.exposition import _bake_output
+from prometheus_client.exposition import parse_qs, urlparse
+
+from app.classes.web.metrics_handler import BaseMetricsHandler
+
+
+# Decorate function with metric.
+class ApiOpenMetricsCraftyHandler(BaseMetricsHandler):
+ def get(self):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ if not auth_data[3]:
+ # if the user doesn't have access to the server, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ self.get_registry()
+
+ def get_registry(self) -> None:
+ # Prepare parameters
+ registry = self.controller.management.host_registry
+ accept_header = self.request.headers.get("Accept")
+ accept_encoding_header = self.request.headers.get("Accept-Encoding")
+ params = parse_qs(urlparse(self.request.path).query)
+ # Bake output
+ status, headers, output = _bake_output(
+ registry, accept_header, accept_encoding_header, params, False
+ )
+ # Return output
+ self.finish_metrics(int(status.split(" ", maxsplit=1)[0]), headers, output)
diff --git a/app/classes/web/routes/metrics/index.py b/app/classes/web/routes/metrics/index.py
new file mode 100644
index 00000000..60315265
--- /dev/null
+++ b/app/classes/web/routes/metrics/index.py
@@ -0,0 +1,21 @@
+from prometheus_client import Info
+from app.classes.web.metrics_handler import BaseMetricsHandler
+
+CRAFTY_INFO = Info("Crafty_Controller", "Infos of this Crafty Instance")
+
+
+# Decorate function with metric.
+class ApiOpenMetricsIndexHandler(BaseMetricsHandler):
+ def get(self):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ version = f"{self.helper.get_version().get('major')} \
+ .{self.helper.get_version().get('minor')} \
+ .{self.helper.get_version().get('sub')}"
+ CRAFTY_INFO.info(
+ {"version": version, "docker": f"{self.helper.is_env_docker()}"}
+ )
+
+ self.get_registry()
diff --git a/app/classes/web/routes/metrics/metrics_handlers.py b/app/classes/web/routes/metrics/metrics_handlers.py
new file mode 100644
index 00000000..fa43a909
--- /dev/null
+++ b/app/classes/web/routes/metrics/metrics_handlers.py
@@ -0,0 +1,24 @@
+from app.classes.web.routes.metrics.index import ApiOpenMetricsIndexHandler
+from app.classes.web.routes.metrics.host import ApiOpenMetricsCraftyHandler
+from app.classes.web.routes.metrics.servers import ApiOpenMetricsServersHandler
+
+
+def metrics_handlers(handler_args):
+ return [
+ # OpenMetrics routes
+ (
+ r"/metrics/?",
+ ApiOpenMetricsIndexHandler,
+ handler_args,
+ ),
+ (
+ r"/metrics/host/?",
+ ApiOpenMetricsCraftyHandler,
+ handler_args,
+ ),
+ (
+ r"/metrics/servers/([0-9]+)/?",
+ ApiOpenMetricsServersHandler,
+ handler_args,
+ ),
+ ]
diff --git a/app/classes/web/routes/metrics/servers.py b/app/classes/web/routes/metrics/servers.py
new file mode 100644
index 00000000..7f374ec1
--- /dev/null
+++ b/app/classes/web/routes/metrics/servers.py
@@ -0,0 +1,37 @@
+from prometheus_client.exposition import _bake_output
+from prometheus_client.exposition import parse_qs, urlparse
+
+from app.classes.web.metrics_handler import BaseMetricsHandler
+from app.classes.controllers.servers_controller import ServersController
+
+
+# Decorate function with metric.
+class ApiOpenMetricsServersHandler(BaseMetricsHandler):
+ def get(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ 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"})
+
+ self.get_registry(server_id)
+
+ def get_registry(self, server_id=None) -> None:
+ if server_id is None:
+ return self.finish_json(500, {"status": "error", "error": "UNKNOWN_SERVER"})
+
+ # Prepare parameters
+ registry = (
+ ServersController().get_server_instance_by_id(server_id).server_registry
+ )
+ accept_header = self.request.headers.get("Accept")
+ accept_encoding_header = self.request.headers.get("Accept-Encoding")
+ params = parse_qs(urlparse(self.request.path).query)
+ # Bake output
+ status, headers, output = _bake_output(
+ registry, accept_header, accept_encoding_header, params, False
+ )
+ # Return output
+ self.finish_metrics(int(status.split(" ", maxsplit=1)[0]), headers, output)
diff --git a/app/classes/web/server_handler.py b/app/classes/web/server_handler.py
index eae3ce0c..69864049 100644
--- a/app/classes/web/server_handler.py
+++ b/app/classes/web/server_handler.py
@@ -1,14 +1,10 @@
import json
import logging
-import os
-import time
import tornado.web
import tornado.escape
-import bleach
from app.classes.models.crafty_permissions import EnumPermissionsCrafty
from app.classes.shared.helpers import Helpers
-from app.classes.shared.file_helpers import FileHelpers
from app.classes.shared.main_models import DatabaseShortcuts
from app.classes.web.base_handler import BaseHandler
@@ -174,441 +170,3 @@ class ServerHandler(BaseHandler):
data=page_data,
translate=self.translator.translate,
)
-
- @tornado.web.authenticated
- def post(self, page):
- api_key, _token_data, exec_user = self.current_user
- superuser = exec_user["superuser"]
- if api_key is not None:
- superuser = superuser and api_key.superuser
-
- template = "public/404.html"
- page_data = {
- "version_data": "version_data_here", # TODO
- "user_data": exec_user,
- "show_contribute": self.helper.get_setting("show_contribute_link", True),
- "background": self.controller.cached_login,
- "lang": self.controller.users.get_user_lang_by_id(exec_user["user_id"]),
- "lang_page": Helpers.get_lang_page(
- self.controller.users.get_user_lang_by_id(exec_user["user_id"])
- ),
- }
-
- if page == "command":
- server_id = bleach.clean(self.get_argument("id", None))
- command = bleach.clean(self.get_argument("command", None))
-
- if server_id is not None:
- if command == "clone_server":
- if (
- not superuser
- and not self.controller.crafty_perms.can_create_server(
- exec_user["user_id"]
- )
- ):
- time.sleep(3)
- self.helper.websocket_helper.broadcast_user(
- exec_user["user_id"],
- "send_start_error",
- {
- "error": ""
- " Not a server creator or server limit reached."
- },
- )
- return
-
- def is_name_used(name):
- for server in self.controller.servers.get_all_defined_servers():
- if server["server_name"] == name:
- return True
- return
-
- template = "/panel/dashboard"
- server_data = self.controller.servers.get_server_data_by_id(
- server_id
- )
- new_server_name = server_data.get("server_name") + " (Copy)"
-
- name_counter = 1
- while is_name_used(new_server_name):
- name_counter += 1
- new_server_name = (
- server_data.get("server_name") + f" (Copy {name_counter})"
- )
-
- new_server_uuid = Helpers.create_uuid()
- while os.path.exists(
- os.path.join(self.helper.servers_dir, new_server_uuid)
- ):
- new_server_uuid = Helpers.create_uuid()
- new_server_path = os.path.join(
- self.helper.servers_dir, new_server_uuid
- )
-
- # copy the old server
- FileHelpers.copy_dir(server_data.get("path"), new_server_path)
-
- # TODO get old server DB data to individual variables
- stop_command = server_data.get("stop_command")
- new_server_command = str(server_data.get("execution_command"))
- new_executable = server_data.get("executable")
- new_server_log_file = str(
- Helpers.get_os_understandable_path(server_data.get("log_path"))
- )
- backup_path = os.path.join(self.helper.backup_path, new_server_uuid)
- server_port = server_data.get("server_port")
- server_type = server_data.get("type")
- created_by = exec_user["user_id"]
-
- new_server_id = self.controller.servers.create_server(
- new_server_name,
- new_server_uuid,
- new_server_path,
- backup_path,
- new_server_command,
- new_executable,
- new_server_log_file,
- stop_command,
- server_type,
- created_by,
- server_port,
- )
- if not exec_user["superuser"]:
- new_server_uuid = self.controller.servers.get_server_data_by_id(
- new_server_id
- ).get("server_uuid")
- role_id = self.controller.roles.add_role(
- f"Creator of Server with uuid={new_server_uuid}",
- exec_user["user_id"],
- )
- self.controller.server_perms.add_role_server(
- new_server_id, role_id, "11111111"
- )
- self.controller.users.add_role_to_user(
- exec_user["user_id"], role_id
- )
-
- self.controller.servers.init_all_servers()
-
- return
-
- self.controller.management.send_command(
- exec_user["user_id"], server_id, self.get_remote_ip(), command
- )
-
- if page == "step1":
- if not superuser and not self.controller.crafty_perms.can_create_server(
- exec_user["user_id"]
- ):
- self.redirect(
- "/panel/error?error=Unauthorized access: "
- "not a server creator or server limit reached"
- )
- return
-
- if not superuser:
- user_roles = self.controller.roles.get_all_roles()
- else:
- user_roles = self.get_user_roles()
- server = bleach.clean(self.get_argument("server", ""))
- server_name = bleach.clean(self.get_argument("server_name", ""))
- min_mem = bleach.clean(self.get_argument("min_memory", ""))
- max_mem = bleach.clean(self.get_argument("max_memory", ""))
- port = bleach.clean(self.get_argument("port", ""))
- if int(port) < 1 or int(port) > 65535:
- self.redirect(
- "/panel/error?error=Constraint Error: "
- "Port must be greater than 0 and less than 65535"
- )
- return
- import_type = bleach.clean(self.get_argument("create_type", ""))
- import_server_path = bleach.clean(self.get_argument("server_path", ""))
- import_server_jar = bleach.clean(self.get_argument("server_jar", ""))
- server_parts = server.split("|")
- captured_roles = []
- for role in user_roles:
- if bleach.clean(self.get_argument(str(role), "")) == "on":
- captured_roles.append(role)
-
- if not server_name:
- self.redirect("/panel/error?error=Server name cannot be empty!")
- return
-
- if import_type == "import_jar":
- if self.helper.is_subdir(
- self.controller.project_root, import_server_path
- ):
- self.redirect(
- "/panel/error?error=Loop Error: The selected path will cause"
- " an infinite copy loop. Make sure Crafty's directory is not"
- " in your server path."
- )
- return
- good_path = self.controller.verify_jar_server(
- import_server_path, import_server_jar
- )
-
- if not good_path:
- self.redirect(
- "/panel/error?error=Server path or Server Jar not found!"
- )
- return
-
- new_server_id = self.controller.import_jar_server(
- server_name,
- import_server_path,
- import_server_jar,
- min_mem,
- max_mem,
- port,
- exec_user["user_id"],
- )
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f'imported a jar server named "{server_name}"',
- new_server_id,
- self.get_remote_ip(),
- )
- elif import_type == "import_zip":
- # here import_server_path means the zip path
- zip_path = bleach.clean(self.get_argument("root_path"))
- good_path = Helpers.check_path_exists(zip_path)
- if not good_path:
- self.redirect("/panel/error?error=Temp path not found!")
- return
-
- new_server_id = self.controller.import_zip_server(
- server_name,
- zip_path,
- import_server_jar,
- min_mem,
- max_mem,
- port,
- exec_user["user_id"],
- )
- if new_server_id == "false":
- self.redirect(
- f"/panel/error?error=Zip file not accessible! "
- f"You can fix this permissions issue with "
- f"sudo chown -R crafty:crafty {import_server_path} "
- f"And sudo chmod 2775 -R {import_server_path}"
- )
- return
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f'imported a zip server named "{server_name}"',
- new_server_id,
- self.get_remote_ip(),
- )
- else:
- if len(server_parts) != 3:
- self.redirect("/panel/error?error=Invalid server data")
- return
- jar_type, server_type, server_version = server_parts
- # TODO: add server type check here and call the correct server
- # add functions if not a jar
- if server_type == "forge" and not self.helper.detect_java():
- translation = self.helper.translation.translate(
- "error",
- "installerJava",
- self.controller.users.get_user_lang_by_id(exec_user["user_id"]),
- ).format(server_name)
- self.redirect(f"/panel/error?error={translation}")
- return
- new_server_id = self.controller.create_jar_server(
- jar_type,
- server_type,
- server_version,
- server_name,
- min_mem,
- max_mem,
- port,
- exec_user["user_id"],
- )
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f"created a {server_version} {str(server_type).capitalize()}"
- f' server named "{server_name}"',
- # Example: Admin created a 1.16.5 Bukkit server named "survival"
- new_server_id,
- self.get_remote_ip(),
- )
-
- # These lines create a new Role for the Server with full permissions
- # and add the user to it if he's not a superuser
- if len(captured_roles) == 0:
- if not superuser:
- new_server_uuid = self.controller.servers.get_server_data_by_id(
- new_server_id
- ).get("server_uuid")
- role_id = self.controller.roles.add_role(
- f"Creator of Server with uuid={new_server_uuid}",
- exec_user["user_id"],
- )
- self.controller.server_perms.add_role_server(
- new_server_id, role_id, "11111111"
- )
- self.controller.users.add_role_to_user(
- exec_user["user_id"], role_id
- )
-
- else:
- for role in captured_roles:
- role_id = role
- self.controller.server_perms.add_role_server(
- new_server_id, role_id, "11111111"
- )
-
- self.controller.servers.stats.record_stats()
- self.redirect("/panel/dashboard")
-
- if page == "bedrock_step1":
- if not superuser and not self.controller.crafty_perms.can_create_server(
- exec_user["user_id"]
- ):
- self.redirect(
- "/panel/error?error=Unauthorized access: "
- "not a server creator or server limit reached"
- )
- return
- if not superuser:
- user_roles = self.controller.roles.get_all_roles()
- else:
- user_roles = self.controller.roles.get_all_roles()
- server = bleach.clean(self.get_argument("server", ""))
- server_name = bleach.clean(self.get_argument("server_name", ""))
- port = bleach.clean(self.get_argument("port", ""))
-
- if not port:
- port = 19132
- if int(port) < 1 or int(port) > 65535:
- self.redirect(
- "/panel/error?error=Constraint Error: "
- "Port must be greater than 0 and less than 65535"
- )
- return
- import_type = bleach.clean(self.get_argument("create_type", ""))
- import_server_path = bleach.clean(self.get_argument("server_path", ""))
- import_server_exe = bleach.clean(self.get_argument("server_jar", ""))
- server_parts = server.split("|")
- captured_roles = []
- for role in user_roles:
- if bleach.clean(self.get_argument(str(role), "")) == "on":
- captured_roles.append(role)
-
- if not server_name:
- self.redirect("/panel/error?error=Server name cannot be empty!")
- return
-
- if import_type == "import_jar":
- if self.helper.is_subdir(
- self.controller.project_root, import_server_path
- ):
- self.redirect(
- "/panel/error?error=Loop Error: The selected path will cause"
- " an infinite copy loop. Make sure Crafty's directory is not"
- " in your server path."
- )
- return
- good_path = self.controller.verify_jar_server(
- import_server_path, import_server_exe
- )
-
- if not good_path:
- self.redirect(
- "/panel/error?error=Server path or Server Jar not found!"
- )
- return
-
- new_server_id = self.controller.import_bedrock_server(
- server_name,
- import_server_path,
- import_server_exe,
- port,
- exec_user["user_id"],
- )
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f'imported a jar server named "{server_name}"',
- new_server_id,
- self.get_remote_ip(),
- )
- elif import_type == "import_zip":
- # here import_server_path means the zip path
- zip_path = bleach.clean(self.get_argument("root_path"))
- good_path = Helpers.check_path_exists(zip_path)
- if not good_path:
- self.redirect("/panel/error?error=Temp path not found!")
- return
-
- new_server_id = self.controller.import_bedrock_zip_server(
- server_name,
- zip_path,
- import_server_exe,
- port,
- exec_user["user_id"],
- )
- if new_server_id == "false":
- self.redirect(
- f"/panel/error?error=Zip file not accessible! "
- f"You can fix this permissions issue with"
- f"sudo chown -R crafty:crafty {import_server_path} "
- f"And sudo chmod 2775 -R {import_server_path}"
- )
- return
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- f'imported a zip server named "{server_name}"',
- new_server_id,
- self.get_remote_ip(),
- )
- else:
- new_server_id = self.controller.create_bedrock_server(
- server_name,
- exec_user["user_id"],
- )
- self.controller.management.add_to_audit_log(
- exec_user["user_id"],
- "created a Bedrock " f'server named "{server_name}"',
- # Example: Admin created a 1.16.5 Bukkit server named "survival"
- new_server_id,
- self.get_remote_ip(),
- )
-
- # These lines create a new Role for the Server with full permissions
- # and add the user to it if he's not a superuser
- if len(captured_roles) == 0:
- if not superuser:
- new_server_uuid = self.controller.servers.get_server_data_by_id(
- new_server_id
- ).get("server_uuid")
- role_id = self.controller.roles.add_role(
- f"Creator of Server with uuid={new_server_uuid}",
- exec_user["user_id"],
- )
- self.controller.server_perms.add_role_server(
- new_server_id, role_id, "11111111"
- )
- self.controller.users.add_role_to_user(
- exec_user["user_id"], role_id
- )
-
- else:
- for role in captured_roles:
- role_id = role
- self.controller.server_perms.add_role_server(
- new_server_id, role_id, "11111111"
- )
-
- self.controller.servers.stats.record_stats()
- self.redirect("/panel/dashboard")
-
- try:
- self.render(
- template,
- data=page_data,
- translate=self.translator.translate,
- )
- except RuntimeError:
- self.redirect("/panel/dashboard")
diff --git a/app/classes/web/tornado_handler.py b/app/classes/web/tornado_handler.py
index d2b047d7..621c930a 100644
--- a/app/classes/web/tornado_handler.py
+++ b/app/classes/web/tornado_handler.py
@@ -14,14 +14,14 @@ import tornado.httpserver
from app.classes.models.management import HelpersManagement
from app.classes.shared.console import Console
from app.classes.shared.helpers import Helpers
+from app.classes.shared.file_helpers import FileHelpers
from app.classes.shared.main_controller import Controller
-from app.classes.web.file_handler import FileHandler
from app.classes.web.public_handler import PublicHandler
from app.classes.web.panel_handler import PanelHandler
from app.classes.web.default_handler import DefaultHandler
from app.classes.web.routes.api.api_handlers import api_handlers
+from app.classes.web.routes.metrics.metrics_handlers import metrics_handlers
from app.classes.web.server_handler import ServerHandler
-from app.classes.web.ajax_handler import AjaxHandler
from app.classes.web.api_handler import (
ServersStats,
NodeStats,
@@ -34,7 +34,7 @@ from app.classes.web.api_handler import (
ListServers,
SendCommand,
)
-from app.classes.web.websocket_handler import SocketHandler
+from app.classes.web.websocket_handler import WebSocketHandler
from app.classes.web.static_handler import CustomStaticHandler
from app.classes.web.upload_handler import UploadHandler
from app.classes.web.http_handler import HTTPHandler, HTTPHandlerPage
@@ -48,13 +48,20 @@ class Webserver:
controller: Controller
helper: Helpers
- def __init__(self, helper, controller, tasks_manager):
+ def __init__(
+ self,
+ helper: Helpers,
+ controller: Controller,
+ tasks_manager,
+ file_helper: FileHelpers,
+ ):
self.ioloop = None
self.http_server = None
self.https_server = None
self.helper = helper
self.controller = controller
self.tasks_manager = tasks_manager
+ self.file_helper = file_helper
self._asyncio_patch()
@staticmethod
@@ -146,14 +153,13 @@ class Webserver:
"controller": self.controller,
"tasks_manager": self.tasks_manager,
"translator": self.helper.translation,
+ "file_helper": self.file_helper,
}
handlers = [
(r"/", DefaultHandler, handler_args),
(r"/panel/(.*)", PanelHandler, handler_args),
(r"/server/(.*)", ServerHandler, handler_args),
- (r"/ajax/(.*)", AjaxHandler, handler_args),
- (r"/files/(.*)", FileHandler, handler_args),
- (r"/ws", SocketHandler, handler_args),
+ (r"/ws", WebSocketHandler, handler_args),
(r"/upload", UploadHandler, handler_args),
(r"/status", StatusHandler, handler_args),
# API Routes V1
@@ -169,6 +175,8 @@ class Webserver:
(r"/api/v1/users/delete_user", DeleteUser, handler_args),
# API Routes V2
*api_handlers(handler_args),
+ # API Routes OpenMetrics
+ *metrics_handlers(handler_args),
# Using this one at the end
# to catch all the other requests to Public Handler
(r"/(.*)", PublicHandler, handler_args),
diff --git a/app/classes/web/upload_handler.py b/app/classes/web/upload_handler.py
index adce3ab9..0667dd12 100644
--- a/app/classes/web/upload_handler.py
+++ b/app/classes/web/upload_handler.py
@@ -12,6 +12,7 @@ from app.classes.shared.console import Console
from app.classes.shared.helpers import Helpers
from app.classes.shared.main_controller import Controller
from app.classes.web.base_handler import BaseHandler
+from app.classes.shared.websocket_manager import WebSocketManager
logger = logging.getLogger(__name__)
@@ -25,11 +26,13 @@ class UploadHandler(BaseHandler):
controller: Controller = None,
tasks_manager=None,
translator=None,
+ file_helper=None,
):
self.helper = helper
self.controller = controller
self.tasks_manager = tasks_manager
self.translator = translator
+ self.file_helper = file_helper
def prepare(self):
# Class & Function Defination
@@ -99,7 +102,8 @@ class UploadHandler(BaseHandler):
)
self.do_upload = False
- path = os.path.join(self.controller.project_root, "imports")
+ path = os.path.join(self.controller.project_root, "import", "upload")
+ self.helper.ensure_dir_exists(path)
# Delete existing files
if len(os.listdir(path)) > 0:
for item in os.listdir():
@@ -113,7 +117,7 @@ class UploadHandler(BaseHandler):
self.request.headers.get("X-FileName", None)
)
if not str(filename).endswith(".zip"):
- self.helper.websocket_helper.broadcast("close_upload_box", "error")
+ WebSocketManager().broadcast("close_upload_box", "error")
self.finish("error")
full_path = os.path.join(path, filename)
@@ -313,13 +317,13 @@ class UploadHandler(BaseHandler):
if self.do_upload:
time.sleep(5)
if files_left == 0:
- self.helper.websocket_helper.broadcast("close_upload_box", "success")
+ WebSocketManager().broadcast("close_upload_box", "success")
self.finish("success") # Nope, I'm sending "success"
self.f.close()
else:
time.sleep(5)
if files_left == 0:
- self.helper.websocket_helper.broadcast("close_upload_box", "error")
+ WebSocketManager().broadcast("close_upload_box", "error")
self.finish("error")
def data_received(self, chunk):
diff --git a/app/classes/web/webhooks/base_webhook.py b/app/classes/web/webhooks/base_webhook.py
new file mode 100644
index 00000000..75e485fc
--- /dev/null
+++ b/app/classes/web/webhooks/base_webhook.py
@@ -0,0 +1,39 @@
+from abc import ABC, abstractmethod
+import logging
+import requests
+
+from app.classes.shared.helpers import Helpers
+
+logger = logging.getLogger(__name__)
+helper = Helpers()
+
+
+class WebhookProvider(ABC):
+ """
+ Base class for all webhook providers.
+
+ Provides a common interface for all webhook provider implementations,
+ ensuring that each provider will have a send method.
+ """
+
+ WEBHOOK_USERNAME = "Crafty Webhooks"
+ WEBHOOK_PFP_URL = (
+ "https://gitlab.com/crafty-controller/crafty-4/-"
+ + "/raw/master/app/frontend/static/assets/images/"
+ + "Crafty_4-0.png"
+ )
+ CRAFTY_VERSION = helper.get_version_string()
+
+ def _send_request(self, url, payload, headers=None):
+ """Send a POST request to the given URL with the provided payload."""
+ try:
+ response = requests.post(url, json=payload, headers=headers, timeout=10)
+ response.raise_for_status()
+ return "Dispatch successful"
+ except requests.RequestException as error:
+ logger.error(error)
+ raise RuntimeError(f"Failed to dispatch notification: {error}") from error
+
+ @abstractmethod
+ def send(self, server_name, title, url, message, **kwargs):
+ """Abstract method that derived classes will implement for sending webhooks."""
diff --git a/app/classes/web/webhooks/discord_webhook.py b/app/classes/web/webhooks/discord_webhook.py
new file mode 100644
index 00000000..eebe38aa
--- /dev/null
+++ b/app/classes/web/webhooks/discord_webhook.py
@@ -0,0 +1,82 @@
+from datetime import datetime
+from app.classes.web.webhooks.base_webhook import WebhookProvider
+
+
+class DiscordWebhook(WebhookProvider):
+ def _construct_discord_payload(self, server_name, title, message, color, bot_name):
+ """
+ Constructs the payload required for sending a Discord webhook notification.
+
+ This method prepares a payload for the Discord webhook API using the provided
+ message content, the Crafty Controller version, and the current UTC datetime.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ message (str): The main content of the notification message.
+ color (int): The color code for the side stripe in the Discord embed message.
+ bot_name (str): Override for the Webhook's name set on creation
+
+ Returns:
+ tuple: A tuple containing the constructed payload (dict) incl headers (dict).
+
+ Note:
+ - Discord embed designer
+ - https://discohook.org/
+ """
+ current_datetime = datetime.utcnow()
+ formatted_datetime = (
+ current_datetime.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
+ )
+
+ # Convert the hex to an integer
+ sanitized_hex = color[1:] if color.startswith("#") else color
+ color_int = int(sanitized_hex, 16)
+
+ headers = {"Content-type": "application/json"}
+ payload = {
+ "username": bot_name,
+ "avatar_url": self.WEBHOOK_PFP_URL,
+ "embeds": [
+ {
+ "title": title,
+ "description": message,
+ "color": color_int,
+ "author": {"name": server_name},
+ "footer": {"text": f"Crafty Controller v.{self.CRAFTY_VERSION}"},
+ "timestamp": formatted_datetime,
+ }
+ ],
+ }
+
+ return payload, headers
+
+ def send(self, server_name, title, url, message, **kwargs):
+ """
+ Sends a Discord webhook notification using the given details.
+
+ The method constructs and dispatches a payload suitable for
+ Discords's webhook system.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ url (str): The webhook URL to send the notification to.
+ message (str): The main content or body of the notification message.
+ color (str, optional): The color code for the embed's side stripe.
+ Defaults to a pretty blue if not provided.
+ bot_name (str): Override for the Webhook's name set on creation
+
+ Returns:
+ str: "Dispatch successful!" if the message is sent successfully, otherwise an
+ exception is raised.
+
+ Raises:
+ Exception: If there's an error in dispatching the webhook.
+ """
+ color = kwargs.get("color", "#005cd1") # Default to a color if not provided.
+ bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME)
+ payload, headers = self._construct_discord_payload(
+ server_name, title, message, color, bot_name
+ )
+ return self._send_request(url, payload, headers)
diff --git a/app/classes/web/webhooks/mattermost_webhook.py b/app/classes/web/webhooks/mattermost_webhook.py
new file mode 100644
index 00000000..3dc97c05
--- /dev/null
+++ b/app/classes/web/webhooks/mattermost_webhook.py
@@ -0,0 +1,74 @@
+from app.classes.web.webhooks.base_webhook import WebhookProvider
+
+
+class MattermostWebhook(WebhookProvider):
+ def _construct_mattermost_payload(self, server_name, title, message, bot_name):
+ """
+ Constructs the payload required for sending a Mattermost webhook notification.
+
+ The method formats the given information into a Markdown-styled message for MM,
+ including an information card containing the Crafty version.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ message (str): The main content of the notification message.
+ bot_name (str): Override for the Webhook's name set on creation.
+
+ Returns:
+ tuple: A tuple containing the constructed payload (dict) incl headers (dict).
+ """
+ formatted_text = (
+ f"-----\n\n"
+ f"#### {title}\n"
+ f"##### Server: ```{server_name}```\n\n"
+ f"```\n{message}\n```\n\n"
+ f"-----"
+ )
+
+ headers = {"Content-Type": "application/json"}
+ payload = {
+ "text": formatted_text,
+ "username": bot_name,
+ "icon_url": self.WEBHOOK_PFP_URL,
+ "props": {
+ "card": (
+ f"[Crafty Controller "
+ f"v.{self.CRAFTY_VERSION}](https://craftycontrol.com)"
+ )
+ },
+ }
+
+ return payload, headers
+
+ def send(self, server_name, title, url, message, **kwargs):
+ """
+ Sends a Mattermost webhook notification using the given details.
+
+ The method constructs and dispatches a payload suitable for
+ Mattermost's webhook system.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ url (str): The webhook URL to send the notification to.
+ message (str): The main content or body of the notification message.
+ bot_name (str): Override for the Webhook's name set on creation, see note!
+
+ Returns:
+ str: "Dispatch successful!" if the message is sent successfully, otherwise an
+ exception is raised.
+
+ Raises:
+ Exception: If there's an error in dispatching the webhook.
+
+ Note:
+ - To set webhook username & pfp Mattermost needs to be configured to allow this!
+ - Mattermost's `config.json` setting is `"EnablePostUsernameOverride": true`
+ - Mattermost's `config.json` setting is `"EnablePostIconOverride": true`
+ """
+ bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME)
+ payload, headers = self._construct_mattermost_payload(
+ server_name, title, message, bot_name
+ )
+ return self._send_request(url, payload, headers)
diff --git a/app/classes/web/webhooks/slack_webhook.py b/app/classes/web/webhooks/slack_webhook.py
new file mode 100644
index 00000000..cd7c71bf
--- /dev/null
+++ b/app/classes/web/webhooks/slack_webhook.py
@@ -0,0 +1,98 @@
+from app.classes.web.webhooks.base_webhook import WebhookProvider
+
+
+class SlackWebhook(WebhookProvider):
+ def _construct_slack_payload(self, server_name, title, message, color, bot_name):
+ """
+ Constructs the payload required for sending a Slack webhook notification.
+
+ The method formats the given information into a Markdown-styled message for MM,
+ including an information card containing the Crafty version.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ message (str): The main content of the notification message.
+ color (int): The color code for the side stripe in the Slack block.
+ bot_name (str): Override for the Webhook's name set on creation, (not working).
+
+ Returns:
+ tuple: A tuple containing the constructed payload (dict) incl headers (dict).
+
+ Note:
+ - Block Builder/designer
+ - https://app.slack.com/block-kit-builder/
+ """
+ headers = {"Content-Type": "application/json"}
+ payload = {
+ "username": bot_name,
+ "attachments": [
+ {
+ "color": color,
+ "blocks": [
+ {
+ "type": "section",
+ "text": {"type": "plain_text", "text": server_name},
+ },
+ {"type": "divider"},
+ {
+ "type": "section",
+ "text": {
+ "type": "mrkdwn",
+ "text": f"*{title}*\n{message}",
+ },
+ "accessory": {
+ "type": "image",
+ "image_url": self.WEBHOOK_PFP_URL,
+ "alt_text": "Crafty Controller Logo",
+ },
+ },
+ {
+ "type": "context",
+ "elements": [
+ {
+ "type": "mrkdwn",
+ "text": (
+ f"*Crafty Controller "
+ f"v{self.CRAFTY_VERSION}*"
+ ),
+ }
+ ],
+ },
+ {"type": "divider"},
+ ],
+ }
+ ],
+ }
+
+ return payload, headers
+
+ def send(self, server_name, title, url, message, **kwargs):
+ """
+ Sends a Slack webhook notification using the given details.
+
+ The method constructs and dispatches a payload suitable for
+ Slack's webhook system.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ url (str): The webhook URL to send the notification to.
+ message (str): The main content or body of the notification message.
+ color (str, optional): The color code for the blocks's colour accent.
+ Defaults to a pretty blue if not provided.
+ bot_name (str): Override for the Webhook's name set on creation, (not working).
+
+ Returns:
+ str: "Dispatch successful!" if the message is sent successfully, otherwise an
+ exception is raised.
+
+ Raises:
+ Exception: If there's an error in dispatching the webhook.
+ """
+ color = kwargs.get("color", "#005cd1") # Default to a color if not provided.
+ bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME)
+ payload, headers = self._construct_slack_payload(
+ server_name, title, message, color, bot_name
+ )
+ return self._send_request(url, payload, headers)
diff --git a/app/classes/web/webhooks/teams_adaptive_webhook.py b/app/classes/web/webhooks/teams_adaptive_webhook.py
new file mode 100644
index 00000000..6342de65
--- /dev/null
+++ b/app/classes/web/webhooks/teams_adaptive_webhook.py
@@ -0,0 +1,126 @@
+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": (
+ "https://adaptivecards.io/schemas/adaptive-card.json"
+ ),
+ "version": "1.6",
+ },
+ }
+ ],
+ }
+
+ return payload, headers
+
+ def send(self, server_name, title, url, message, **kwargs):
+ """
+ Sends a Teams Adaptive card notification using the given details.
+
+ The method constructs and dispatches a payload suitable for
+ Discords's webhook system.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ url (str): The webhook URL to send the notification to.
+ message (str): The main content or body of the notification message.
+ Defaults to a pretty blue if not provided.
+
+ Returns:
+ str: "Dispatch successful!" if the message is sent successfully, otherwise an
+ exception is raised.
+
+ Raises:
+ Exception: If there's an error in dispatching the webhook.
+ """
+ payload, headers = self._construct_teams_payload(server_name, title, message)
+ return self._send_request(url, payload, headers)
diff --git a/app/classes/web/webhooks/webhook_factory.py b/app/classes/web/webhooks/webhook_factory.py
new file mode 100644
index 00000000..608bf4e5
--- /dev/null
+++ b/app/classes/web/webhooks/webhook_factory.py
@@ -0,0 +1,85 @@
+from app.classes.web.webhooks.discord_webhook import DiscordWebhook
+from app.classes.web.webhooks.mattermost_webhook import MattermostWebhook
+from app.classes.web.webhooks.slack_webhook import SlackWebhook
+from app.classes.web.webhooks.teams_adaptive_webhook import TeamsWebhook
+
+
+class WebhookFactory:
+ """
+ A factory class responsible for the creation and management of webhook providers.
+
+ This class provides methods to instantiate specific webhook providers based on
+ their name and to retrieve a list of supported providers. It uses a registry pattern
+ to manage the available providers.
+
+ Attributes:
+ - _registry (dict): A dictionary mapping provider names to their classes.
+ """
+
+ _registry = {
+ "Discord": DiscordWebhook,
+ "Mattermost": MattermostWebhook,
+ "Slack": SlackWebhook,
+ "Teams": TeamsWebhook,
+ # "Custom",
+ }
+
+ @classmethod
+ def create_provider(cls, provider_name, *args, **kwargs):
+ """
+ Creates and returns an instance of the specified webhook provider.
+
+ This method looks up the provider in the registry, then instantiates it w/ the
+ provided arguments. If the provider is not recognized, a ValueError is raised.
+
+ Arguments:
+ - provider_name (str): The name of the desired webhook provider.
+
+ Additional arguments supported that we may use for if a provider
+ requires initialization:
+ - *args: Positional arguments to pass to the provider's constructor.
+ - **kwargs: Keyword arguments to pass to the provider's constructor.
+
+ Returns:
+ WebhookProvider: An instance of the desired webhook provider.
+
+ Raises:
+ ValueError: If the specified provider name is not recognized.
+ """
+ if provider_name not in cls._registry:
+ raise ValueError(f"Provider {provider_name} is not supported.")
+ return cls._registry[provider_name](*args, **kwargs)
+
+ @classmethod
+ def get_supported_providers(cls):
+ """
+ Retrieves the names of all supported webhook providers.
+
+ This method returns a list containing the names of all providers
+ currently registered in the factory's registry.
+
+ Returns:
+ List[str]: A list of supported provider names.
+ """
+ return list(cls._registry.keys())
+
+ @staticmethod
+ def get_monitored_events():
+ """
+ Retrieves the list of supported events for monitoring.
+
+ This method provides a list of common server events that the webhook system can
+ monitor and notify about.
+
+ Returns:
+ List[str]: A list of supported monitored actions.
+ """
+ return [
+ "start_server",
+ "stop_server",
+ "crash_detected",
+ "backup_server",
+ "jar_update",
+ "send_command",
+ "kill",
+ ]
diff --git a/app/classes/web/websocket_handler.py b/app/classes/web/websocket_handler.py
index 78b33951..cde97584 100644
--- a/app/classes/web/websocket_handler.py
+++ b/app/classes/web/websocket_handler.py
@@ -4,26 +4,34 @@ import asyncio
from urllib.parse import parse_qsl
import tornado.websocket
+from app.classes.shared.main_controller import Controller
from app.classes.shared.helpers import Helpers
+from app.classes.shared.websocket_manager import WebSocketManager
logger = logging.getLogger(__name__)
-class SocketHandler(tornado.websocket.WebSocketHandler):
+class WebSocketHandler(tornado.websocket.WebSocketHandler):
page = None
page_query_params = None
- controller = None
+ controller: Controller = None
tasks_manager = None
translator = None
io_loop = None
def initialize(
- self, helper=None, controller=None, tasks_manager=None, translator=None
+ self,
+ helper=None,
+ controller=None,
+ tasks_manager=None,
+ translator=None,
+ file_helper=None,
):
self.helper = helper
self.controller = controller
self.tasks_manager = tasks_manager
self.translator = translator
+ self.file_helper = file_helper
self.io_loop = tornado.ioloop.IOLoop.current()
def get_remote_ip(self):
@@ -34,23 +42,16 @@ class SocketHandler(tornado.websocket.WebSocketHandler):
)
return remote_ip
- def get_user_id(self):
- _, _, user = self.controller.authentication.check(self.get_cookie("token"))
- return user["user_id"]
-
- def check_auth(self):
- return self.controller.authentication.check_bool(self.get_cookie("token"))
-
# pylint: disable=arguments-differ
def open(self):
logger.debug("Checking WebSocket authentication")
if self.check_auth():
self.handle()
else:
- self.helper.websocket_helper.send_message(
+ WebSocketManager().broadcast_to_admins(
self, "notification", "Not authenticated for WebSocket connection"
)
- self.close()
+ self.close(1011, "Forbidden WS Access")
self.controller.management.add_to_audit_log_raw(
"unknown",
0,
@@ -58,7 +59,7 @@ class SocketHandler(tornado.websocket.WebSocketHandler):
"Someone tried to connect via WebSocket without proper authentication",
self.get_remote_ip(),
)
- self.helper.websocket_helper.broadcast(
+ WebSocketManager().broadcast(
"notification",
"Someone tried to connect via WebSocket without proper authentication",
)
@@ -73,24 +74,34 @@ class SocketHandler(tornado.websocket.WebSocketHandler):
Helpers.remove_prefix(self.get_query_argument("page_query_params"), "?")
)
)
- self.helper.websocket_helper.add_client(self)
+ WebSocketManager().add_client(self)
logger.debug("Opened WebSocket connection")
# pylint: disable=arguments-renamed
- @staticmethod
- def on_message(raw_message):
+ def on_message(self, raw_message):
logger.debug(f"Got message from WebSocket connection {raw_message}")
message = json.loads(raw_message)
logger.debug(f"Event Type: {message['event']}, Data: {message['data']}")
def on_close(self):
- self.helper.websocket_helper.remove_client(self)
+ WebSocketManager().remove_client(self)
logger.debug("Closed WebSocket connection")
async def write_message_int(self, message):
self.write_message(message)
- def write_message_helper(self, message):
+ def write_message_async(self, message):
asyncio.run_coroutine_threadsafe(
self.write_message_int(message), self.io_loop.asyncio_loop
)
+
+ def send_message(self, event_type: str, data):
+ message = str(json.dumps({"event": event_type, "data": data}))
+ self.write_message_async(message)
+
+ def get_user_id(self):
+ _, _, user = self.controller.authentication.check(self.get_cookie("token"))
+ return user["user_id"]
+
+ def check_auth(self):
+ return self.controller.authentication.check_bool(self.get_cookie("token"))
diff --git a/app/config/version.json b/app/config/version.json
index 51fc5283..6c1274e0 100644
--- a/app/config/version.json
+++ b/app/config/version.json
@@ -1,5 +1,5 @@
{
"major": 4,
- "minor": 1,
- "sub": 3
+ "minor": 2,
+ "sub": 0
}
diff --git a/app/frontend/static/assets/css/dark/style.css b/app/frontend/static/assets/css/dark/style.css
index c8ac5eb9..12320636 100755
--- a/app/frontend/static/assets/css/dark/style.css
+++ b/app/frontend/static/assets/css/dark/style.css
@@ -22979,27 +22979,42 @@ ul li {
padding-left: 0;
}
-.tab-simple-styled {
+.nav-tabs.tab-simple-styled {
border-bottom: none;
margin-top: 30px;
margin-bottom: 30px;
}
-.tab-simple-styled .nav-item {
+.nav-tabs.tab-simple-styled .nav-item {
margin-right: 30px;
}
-.tab-simple-styled .nav-item .nav-link {
+.nav-tabs.tab-simple-styled .nav-item .nav-link {
border: none;
padding: 0;
color: var(--base-text);
}
-.tab-simple-styled .nav-item .nav-link.active {
+.nav-tabs.tab-simple-styled .nav-item .nav-link.active {
background: var(--dropdown-bg);
color: var(--info);
}
+.nav-pills.tab-simple-styled {
+ border-bottom: none;
+ /*margin-top: 1.5rem;*/
+ margin-bottom: 1.5rem;
+}
+
+/*.nav-pills.tab-simple-styled .nav-item {
+ margin-right: 1.5rem;
+}*/
+
+.nav-pills.tab-simple-styled .nav-item .nav-link.active {
+ background: var(--info);
+ color: #ffffff;
+}
+
.tab-tile-style {
display: -webkit-box;
display: -ms-flexbox;
diff --git a/app/frontend/static/assets/css/shared/style.css b/app/frontend/static/assets/css/shared/style.css
index 7352d637..a4b6df04 100755
--- a/app/frontend/static/assets/css/shared/style.css
+++ b/app/frontend/static/assets/css/shared/style.css
@@ -21494,27 +21494,42 @@ ul li {
padding-left: 0;
}
-.tab-simple-styled {
+.nav-tabs.tab-simple-styled {
border-bottom: none;
margin-top: 30px;
margin-bottom: 30px;
}
-.tab-simple-styled .nav-item {
+.nav-tabs.tab-simple-styled .nav-item {
margin-right: 30px;
}
-.tab-simple-styled .nav-item .nav-link {
+.nav-tabs.tab-simple-styled .nav-item .nav-link {
border: none;
padding: 0;
color: var(--gray);
}
-.tab-simple-styled .nav-item .nav-link.active {
+.nav-tabs.tab-simple-styled .nav-item .nav-link.active {
background: #fff;
color: var(--info);
}
+.nav-pills.tab-simple-styled {
+ border-bottom: none;
+ /*margin-top: 1.5rem;*/
+ margin-bottom: 1.5rem;
+}
+
+/*.nav-pills.tab-simple-styled .nav-item {
+ margin-right: 1.5rem;
+}*/
+
+.nav-pills.tab-simple-styled .nav-item .nav-link.active {
+ background: var(--info);
+ color: #ffffff;
+}
+
.tab-tile-style {
display: -webkit-box;
display: -ms-flexbox;
diff --git a/app/frontend/static/assets/js/shared/root-dir.js b/app/frontend/static/assets/js/shared/root-dir.js
new file mode 100644
index 00000000..6882b577
--- /dev/null
+++ b/app/frontend/static/assets/js/shared/root-dir.js
@@ -0,0 +1,137 @@
+
+function show_file_tree() {
+ $("#dir_select").modal();
+}
+function getDirView(event = false) {
+ if (event) {
+ try {
+ let path = event.target.parentElement.getAttribute('data-path');
+ if (event.target.parentElement.classList.contains('clicked')) {
+
+ if ($(`#${path}span`).hasClass('files-tree-title')) {
+ $(`#${path}ul`).toggleClass("d-block");
+ $(`#${path}span`).toggleClass("tree-caret-down");
+ }
+ return;
+ } else {
+ getTreeView(path);
+ }
+ } catch {
+ console.log("Well that failed");
+ }
+ } else if ($("#root_files_button").hasClass("clicked")) {
+ getTreeView($("#zip_server_path").val(), true);
+ } else {
+ getTreeView($("#file-uploaded").val(), true, true);
+ }
+}
+
+
+async function getTreeView(path, unzip = false, upload = false) {
+ const token = getCookie("_xsrf");
+ console.log("IN TREE VIEW")
+ console.log({ "page": "import", "folder": path, "upload": upload, "unzip": unzip });
+ let res = await fetch(`/api/v2/import/file/unzip/`, {
+ method: 'POST',
+ headers: {
+ 'X-XSRFToken': token
+ },
+ body: JSON.stringify({ "page": "import", "folder": path, "upload": upload, "unzip": unzip }),
+ });
+ let responseData = await res.json();
+ if (responseData.status === "ok") {
+ console.log(responseData);
+ process_tree_response(responseData);
+ let x = document.querySelector('.bootbox');
+ if (x) {
+ x.remove()
+ }
+ x = document.querySelector('.modal-backdrop');
+ if (x) {
+ x.remove()
+ }
+ show_file_tree();
+
+ } else {
+
+ bootbox.alert({
+ title: responseData.status,
+ message: responseData.error
+ });
+ }
+}
+
+function process_tree_response(response) {
+ const styles = window.getComputedStyle(document.getElementById("lower_half"));
+ //If this value is still hidden we know the user is executing a zip import and not an upload
+ if (styles.visibility === "hidden") {
+ document.getElementById('zip_submit').disabled = false;
+ } else {
+ document.getElementById('upload_submit').disabled = false;
+ }
+ let path = response.data.root_path.path;
+ $(".root-input").val(response.data.root_path.path);
+ let text = `
`;
+ Object.entries(response.data).forEach(([key, value]) => {
+ if (key === "root_path" || key === "db_stats") {
+ //continue is not valid in for each. Return acts as a continue.
+ return;
+ }
+
+ let dpath = value.path;
+ let filename = key;
+ if (value.dir) {
+ text += `
+
+
+
+
+
+ ${filename}
+
+
`
+ } else {
+ text += `
+ ${filename}
+ `
+ }
+ });
+ text += `
`;
+
+ if (response.data.root_path.top) {
+ try {
+ document.getElementById('main-tree-div').innerHTML += text;
+ document.getElementById('main-tree').parentElement.classList.add("clicked");
+ } catch {
+ document.getElementById('files-tree').innerHTML = text;
+ }
+ } else {
+ try {
+ document.getElementById(path + "span").classList.add('tree-caret-down');
+ document.getElementById(path).innerHTML += text;
+ document.getElementById(path).classList.add("clicked");
+ } catch {
+ console.log("Bad")
+ }
+
+ let toggler = document.getElementById(path + "span");
+
+ if (toggler.classList.contains('files-tree-title')) {
+ document.getElementById(path + "span").addEventListener("click", function caretListener() {
+ document.getElementById(path + "ul").classList.toggle("d-block");
+ document.getElementById(path + "span").classList.toggle("tree-caret-down");
+ });
+ }
+ }
+}
+
+function getToggleMain(event) {
+ const path = event.target.parentElement.getAttribute('data-path');
+ document.getElementById("files-tree").classList.toggle("d-block");
+ document.getElementById(path + "span").classList.toggle("tree-caret-down");
+ document.getElementById(path + "span").classList.toggle("tree-caret");
+}
\ No newline at end of file
diff --git a/app/frontend/static/assets/js/shared/service-worker.js b/app/frontend/static/assets/js/shared/service-worker.js
index f8073c39..4d3eac9e 100644
--- a/app/frontend/static/assets/js/shared/service-worker.js
+++ b/app/frontend/static/assets/js/shared/service-worker.js
@@ -1,46 +1,14 @@
// This is the "Offline page" service worker
-importScripts('https://storage.googleapis.com/workbox-cdn/releases/5.1.2/workbox-sw.js');
+importScripts(
+ "https://storage.googleapis.com/workbox-cdn/releases/5.1.2/workbox-sw.js"
+);
const CACHE = "crafty-controller";
-// TODO: replace the following with the correct offline fallback page i.e.: const offlineFallbackPage = "offline.html";
-const offlineFallbackPage = "/offline";
-
-self.addEventListener("message", (event) => {
- if (event.data && event.data.type === "SKIP_WAITING") {
- self.skipWaiting();
- }
-});
-
-self.addEventListener('install', async (event) => {
- event.waitUntil(
- caches.open(CACHE)
- .then((cache) => cache.add(offlineFallbackPage))
- );
-});
+//This service worker is basically just here to make browsers
+//accept the PWA. It's not doing much anymore
if (workbox.navigationPreload.isSupported()) {
- workbox.navigationPreload.enable();
+ workbox.navigationPreload.enable();
}
-
-self.addEventListener('fetch', (event) => {
- if (event.request.mode === 'navigate') {
- event.respondWith((async () => {
- try {
- const preloadResp = await event.preloadResponse;
-
- if (preloadResp) {
- return preloadResp;
- }
- const networkResp = await fetch(event.request);
- return networkResp;
- } catch (error) {
-
- const cache = await caches.open(CACHE);
- const cachedResp = await cache.match(offlineFallbackPage);
- return cachedResp;
- }
- })());
- }
-});
\ No newline at end of file
diff --git a/app/frontend/static/assets/vendors/css/bootstrap-toggle.min.css b/app/frontend/static/assets/vendors/css/bootstrap-toggle.min.css
new file mode 100644
index 00000000..0d42ed09
--- /dev/null
+++ b/app/frontend/static/assets/vendors/css/bootstrap-toggle.min.css
@@ -0,0 +1,28 @@
+/*! ========================================================================
+ * Bootstrap Toggle: bootstrap-toggle.css v2.2.0
+ * http://www.bootstraptoggle.com
+ * ========================================================================
+ * Copyright 2014 Min Hur, The New York Times Company
+ * Licensed under MIT
+ * ======================================================================== */
+.checkbox label .toggle,.checkbox-inline .toggle{margin-left:-20px;margin-right:5px}
+.toggle{position:relative;overflow:hidden}
+.toggle input[type=checkbox]{display:none}
+.toggle-group{position:absolute;width:200%;top:0;bottom:0;left:0;transition:left .35s;-webkit-transition:left .35s;-moz-user-select:none;-webkit-user-select:none}
+.toggle.off .toggle-group{left:-100%}
+.toggle-on{position:absolute;top:0;bottom:0;left:0;right:50%;margin:0;border:0;border-radius:0}
+.toggle-off{position:absolute;top:0;bottom:0;left:50%;right:0;margin:0;border:0;border-radius:0}
+.toggle-handle{position:relative;margin:0 auto;padding-top:0;padding-bottom:0;height:100%;width:0;border-width:0 1px}
+.toggle.btn{min-width:59px;min-height:34px}
+.toggle-on.btn{padding-right:24px}
+.toggle-off.btn{padding-left:24px}
+.toggle.btn-lg{min-width:79px;min-height:45px}
+.toggle-on.btn-lg{padding-right:31px}
+.toggle-off.btn-lg{padding-left:31px}
+.toggle-handle.btn-lg{width:40px}
+.toggle.btn-sm{min-width:50px;min-height:30px}
+.toggle-on.btn-sm{padding-right:20px}
+.toggle-off.btn-sm{padding-left:20px}
+.toggle.btn-xs{min-width:35px;min-height:22px}
+.toggle-on.btn-xs{padding-right:12px}
+.toggle-off.btn-xs{padding-left:12px}
\ No newline at end of file
diff --git a/app/frontend/static/assets/vendors/js/bootbox.min.js b/app/frontend/static/assets/vendors/js/bootbox.min.js
new file mode 100644
index 00000000..8b8a0197
--- /dev/null
+++ b/app/frontend/static/assets/vendors/js/bootbox.min.js
@@ -0,0 +1 @@
+!function(t,e){"use strict";"function"==typeof define&&define.amd?define(["jquery"],e):"object"==typeof exports?module.exports=e(require("jquery")):t.bootbox=e(t.jQuery)}(this,function e(p,u){"use strict";var r,n,i,l;Object.keys||(Object.keys=(r=Object.prototype.hasOwnProperty,n=!{toString:null}.propertyIsEnumerable("toString"),l=(i=["toString","toLocaleString","valueOf","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","constructor"]).length,function(t){if("function"!=typeof t&&("object"!=typeof t||null===t))throw new TypeError("Object.keys called on non-object");var e,o,a=[];for(e in t)r.call(t,e)&&a.push(e);if(n)for(o=0;o
',header:'
',footer:'',closeButton:'',form:'',button:'',option:"",promptMessage:'',inputs:{text:'',textarea:'',email:'',select:'',checkbox:'',radio:'',date:'',time:'',number:'',password:'',range:''}},m={locale:"en",backdrop:"static",animate:!0,className:null,closeButton:!0,show:!0,container:"body",value:"",inputType:"text",swapButtonOrder:!1,centerVertical:!1,multiple:!1,scrollable:!1};function c(t,e,o){return p.extend(!0,{},t,function(t,e){var o=t.length,a={};if(o<1||2").attr("label",e.group)),o=i[e.group]);var a=p(f.option);a.attr("value",e.value).text(e.text),o.append(a)}),v(i,function(t,e){n.append(e)}),n.val(r.value);break;case"checkbox":var l=p.isArray(r.value)?r.value:[r.value];if(!(a=r.inputOptions||[]).length)throw new Error('prompt with "inputType" set to "checkbox" requires at least one option');n=p(''),v(a,function(t,o){if(o.value===u||o.text===u)throw new Error('each option needs a "value" property and a "text" property');var a=p(f.inputs[r.inputType]);a.find("input").attr("value",o.value),a.find("label").append("\n"+o.text),v(l,function(t,e){e===o.value&&a.find("input").prop("checked",!0)}),n.append(a)});break;case"radio":if(r.value!==u&&p.isArray(r.value))throw new Error('prompt with "inputType" set to "radio" requires a single, non-array value for "value"');if(!(a=r.inputOptions||[]).length)throw new Error('prompt with "inputType" set to "radio" requires at least one option');n=p('');var s=!0;v(a,function(t,e){if(e.value===u||e.text===u)throw new Error('each option needs a "value" property and a "text" property');var o=p(f.inputs[r.inputType]);o.find("input").attr("value",e.value),o.find("label").append("\n"+e.text),r.value!==u&&e.value===r.value&&(o.find("input").prop("checked",!0),s=!1),n.append(o)}),s&&n.find('input[type="radio"]').first().prop("checked",!0)}if(t.append(n),t.on("submit",function(t){t.preventDefault(),t.stopPropagation(),e.find(".bootbox-accept").trigger("click")}),""!==p.trim(r.message)){var c=p(f.promptMessage).html(r.message);t.prepend(c),r.message=t}else r.message=t;return(e=d.dialog(r)).off("shown.bs.modal",g),e.on("shown.bs.modal",function(){n.focus()}),!0===o&&e.modal("show"),e},d});
\ No newline at end of file
diff --git a/app/frontend/static/assets/vendors/js/bootstrap-select.min.js b/app/frontend/static/assets/vendors/js/bootstrap-select.min.js
new file mode 100644
index 00000000..f9cb0638
--- /dev/null
+++ b/app/frontend/static/assets/vendors/js/bootstrap-select.min.js
@@ -0,0 +1,9 @@
+/*!
+ * Bootstrap-select v1.13.10 (https://developer.snapappointments.com/bootstrap-select)
+ *
+ * Copyright 2012-2019 SnapAppointments, LLC
+ * Licensed under MIT (https://github.com/snapappointments/bootstrap-select/blob/master/LICENSE)
+ */
+
+!function(e,t){void 0===e&&void 0!==window&&(e=window),"function"==typeof define&&define.amd?define(["jquery"],function(e){return t(e)}):"object"==typeof module&&module.exports?module.exports=t(require("jquery")):t(e.jQuery)}(this,function(e){!function(z){"use strict";var d=["sanitize","whiteList","sanitizeFn"],r=["background","cite","href","itemtype","longdesc","poster","src","xlink:href"],e={"*":["class","dir","id","lang","role","tabindex","style",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},l=/^(?:(?:https?|mailto|ftp|tel|file):|[^&:/?#]*(?:[/?#]|$))/gi,a=/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i;function v(e,t){var i=e.nodeName.toLowerCase();if(-1!==z.inArray(i,t))return-1===z.inArray(i,r)||Boolean(e.nodeValue.match(l)||e.nodeValue.match(a));for(var s=z(t).filter(function(e,t){return t instanceof RegExp}),n=0,o=s.length;n]+>/g,"")),s&&(a=w(a)),a=a.toUpperCase(),o="contains"===i?0<=a.indexOf(t):a.startsWith(t)))break}return o}function A(e){return parseInt(e,10)||0}z.fn.triggerNative=function(e){var t,i=this[0];i.dispatchEvent?(u?t=new Event(e,{bubbles:!0}):(t=document.createEvent("Event")).initEvent(e,!0,!1),i.dispatchEvent(t)):i.fireEvent?((t=document.createEventObject()).eventType=e,i.fireEvent("on"+e,t)):this.trigger(e)};var f={"\xc0":"A","\xc1":"A","\xc2":"A","\xc3":"A","\xc4":"A","\xc5":"A","\xe0":"a","\xe1":"a","\xe2":"a","\xe3":"a","\xe4":"a","\xe5":"a","\xc7":"C","\xe7":"c","\xd0":"D","\xf0":"d","\xc8":"E","\xc9":"E","\xca":"E","\xcb":"E","\xe8":"e","\xe9":"e","\xea":"e","\xeb":"e","\xcc":"I","\xcd":"I","\xce":"I","\xcf":"I","\xec":"i","\xed":"i","\xee":"i","\xef":"i","\xd1":"N","\xf1":"n","\xd2":"O","\xd3":"O","\xd4":"O","\xd5":"O","\xd6":"O","\xd8":"O","\xf2":"o","\xf3":"o","\xf4":"o","\xf5":"o","\xf6":"o","\xf8":"o","\xd9":"U","\xda":"U","\xdb":"U","\xdc":"U","\xf9":"u","\xfa":"u","\xfb":"u","\xfc":"u","\xdd":"Y","\xfd":"y","\xff":"y","\xc6":"Ae","\xe6":"ae","\xde":"Th","\xfe":"th","\xdf":"ss","\u0100":"A","\u0102":"A","\u0104":"A","\u0101":"a","\u0103":"a","\u0105":"a","\u0106":"C","\u0108":"C","\u010a":"C","\u010c":"C","\u0107":"c","\u0109":"c","\u010b":"c","\u010d":"c","\u010e":"D","\u0110":"D","\u010f":"d","\u0111":"d","\u0112":"E","\u0114":"E","\u0116":"E","\u0118":"E","\u011a":"E","\u0113":"e","\u0115":"e","\u0117":"e","\u0119":"e","\u011b":"e","\u011c":"G","\u011e":"G","\u0120":"G","\u0122":"G","\u011d":"g","\u011f":"g","\u0121":"g","\u0123":"g","\u0124":"H","\u0126":"H","\u0125":"h","\u0127":"h","\u0128":"I","\u012a":"I","\u012c":"I","\u012e":"I","\u0130":"I","\u0129":"i","\u012b":"i","\u012d":"i","\u012f":"i","\u0131":"i","\u0134":"J","\u0135":"j","\u0136":"K","\u0137":"k","\u0138":"k","\u0139":"L","\u013b":"L","\u013d":"L","\u013f":"L","\u0141":"L","\u013a":"l","\u013c":"l","\u013e":"l","\u0140":"l","\u0142":"l","\u0143":"N","\u0145":"N","\u0147":"N","\u014a":"N","\u0144":"n","\u0146":"n","\u0148":"n","\u014b":"n","\u014c":"O","\u014e":"O","\u0150":"O","\u014d":"o","\u014f":"o","\u0151":"o","\u0154":"R","\u0156":"R","\u0158":"R","\u0155":"r","\u0157":"r","\u0159":"r","\u015a":"S","\u015c":"S","\u015e":"S","\u0160":"S","\u015b":"s","\u015d":"s","\u015f":"s","\u0161":"s","\u0162":"T","\u0164":"T","\u0166":"T","\u0163":"t","\u0165":"t","\u0167":"t","\u0168":"U","\u016a":"U","\u016c":"U","\u016e":"U","\u0170":"U","\u0172":"U","\u0169":"u","\u016b":"u","\u016d":"u","\u016f":"u","\u0171":"u","\u0173":"u","\u0174":"W","\u0175":"w","\u0176":"Y","\u0177":"y","\u0178":"Y","\u0179":"Z","\u017b":"Z","\u017d":"Z","\u017a":"z","\u017c":"z","\u017e":"z","\u0132":"IJ","\u0133":"ij","\u0152":"Oe","\u0153":"oe","\u0149":"'n","\u017f":"s"},m=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,g=RegExp("[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff\\u1ab0-\\u1aff\\u1dc0-\\u1dff]","g");function b(e){return f[e]}function w(e){return(e=e.toString())&&e.replace(m,b).replace(g,"")}var I,x,$,y,S,E=(I={"&":"&","<":"<",">":">",'"':""","'":"'","`":"`"},x=function(e){return I[e]},$="(?:"+Object.keys(I).join("|")+")",y=RegExp($),S=RegExp($,"g"),function(e){return e=null==e?"":""+e,y.test(e)?e.replace(S,x):e}),C={32:" ",48:"0",49:"1",50:"2",51:"3",52:"4",53:"5",54:"6",55:"7",56:"8",57:"9",59:";",65:"A",66:"B",67:"C",68:"D",69:"E",70:"F",71:"G",72:"H",73:"I",74:"J",75:"K",76:"L",77:"M",78:"N",79:"O",80:"P",81:"Q",82:"R",83:"S",84:"T",85:"U",86:"V",87:"W",88:"X",89:"Y",90:"Z",96:"0",97:"1",98:"2",99:"3",100:"4",101:"5",102:"6",103:"7",104:"8",105:"9"},L=27,N=13,D=32,H=9,B=38,W=40,M={success:!1,major:"3"};try{M.full=(z.fn.dropdown.Constructor.VERSION||"").split(" ")[0].split("."),M.major=M.full[0],M.success=!0}catch(e){}var R=0,U=".bs.select",j={DISABLED:"disabled",DIVIDER:"divider",SHOW:"open",DROPUP:"dropup",MENU:"dropdown-menu",MENURIGHT:"dropdown-menu-right",MENULEFT:"dropdown-menu-left",BUTTONCLASS:"btn-default",POPOVERHEADER:"popover-title",ICONBASE:"glyphicon",TICKICON:"glyphicon-ok"},V={MENU:"."+j.MENU},F={span:document.createElement("span"),i:document.createElement("i"),subtext:document.createElement("small"),a:document.createElement("a"),li:document.createElement("li"),whitespace:document.createTextNode("\xa0"),fragment:document.createDocumentFragment()};F.a.setAttribute("role","option"),F.subtext.className="text-muted",F.text=F.span.cloneNode(!1),F.text.className="text",F.checkMark=F.span.cloneNode(!1);var _=new RegExp(B+"|"+W),G=new RegExp("^"+H+"$|"+L),q=function(e,t,i){var s=F.li.cloneNode(!1);return e&&(1===e.nodeType||11===e.nodeType?s.appendChild(e):s.innerHTML=e),void 0!==t&&""!==t&&(s.className=t),null!=i&&s.classList.add("optgroup-"+i),s},K=function(e,t,i){var s=F.a.cloneNode(!0);return e&&(11===e.nodeType?s.appendChild(e):s.insertAdjacentHTML("beforeend",e)),void 0!==t&&""!==t&&(s.className=t),"4"===M.major&&s.classList.add("dropdown-item"),i&&s.setAttribute("style",i),s},Y=function(e,t){var i,s,n=F.text.cloneNode(!1);if(e.content)n.innerHTML=e.content;else{if(n.textContent=e.text,e.icon){var o=F.whitespace.cloneNode(!1);(s=(!0===t?F.i:F.span).cloneNode(!1)).className=e.iconBase+" "+e.icon,F.fragment.appendChild(s),F.fragment.appendChild(o)}e.subtext&&((i=F.subtext.cloneNode(!1)).textContent=e.subtext,n.appendChild(i))}if(!0===t)for(;0'},maxOptions:!1,mobile:!1,selectOnTab:!1,dropdownAlignRight:!1,windowPadding:0,virtualScroll:600,display:!1,sanitize:!0,sanitizeFn:null,whiteList:e},J.prototype={constructor:J,init:function(){var i=this,e=this.$element.attr("id");R++,this.selectId="bs-select-"+R,this.$element[0].classList.add("bs-select-hidden"),this.multiple=this.$element.prop("multiple"),this.autofocus=this.$element.prop("autofocus"),this.$element[0].classList.contains("show-tick")&&(this.options.showTick=!0),this.$newElement=this.createDropdown(),this.$element.after(this.$newElement).prependTo(this.$newElement),this.$button=this.$newElement.children("button"),this.$menu=this.$newElement.children(V.MENU),this.$menuInner=this.$menu.children(".inner"),this.$searchbox=this.$menu.find("input"),this.$element[0].classList.remove("bs-select-hidden"),!0===this.options.dropdownAlignRight&&this.$menu[0].classList.add(j.MENURIGHT),void 0!==e&&this.$button.attr("data-id",e),this.checkDisabled(),this.clickListener(),this.options.liveSearch?(this.liveSearchListener(),this.focusedParent=this.$searchbox[0]):this.focusedParent=this.$menuInner[0],this.setStyle(),this.render(),this.setWidth(),this.options.container?this.selectPosition():this.$element.on("hide"+U,function(){if(i.isVirtual()){var e=i.$menuInner[0],t=e.firstChild.cloneNode(!1);e.replaceChild(t,e.firstChild),e.scrollTop=0}}),this.$menu.data("this",this),this.$newElement.data("this",this),this.options.mobile&&this.mobile(),this.$newElement.on({"hide.bs.dropdown":function(e){i.$element.trigger("hide"+U,e)},"hidden.bs.dropdown":function(e){i.$element.trigger("hidden"+U,e)},"show.bs.dropdown":function(e){i.$element.trigger("show"+U,e)},"shown.bs.dropdown":function(e){i.$element.trigger("shown"+U,e)}}),i.$element[0].hasAttribute("required")&&this.$element.on("invalid"+U,function(){i.$button[0].classList.add("bs-invalid"),i.$element.on("shown"+U+".invalid",function(){i.$element.val(i.$element.val()).off("shown"+U+".invalid")}).on("rendered"+U,function(){this.validity.valid&&i.$button[0].classList.remove("bs-invalid"),i.$element.off("rendered"+U)}),i.$button.on("blur"+U,function(){i.$element.trigger("focus").trigger("blur"),i.$button.off("blur"+U)})}),setTimeout(function(){i.createLi(),i.$element.trigger("loaded"+U)})},createDropdown:function(){var e=this.multiple||this.options.showTick?" show-tick":"",t=this.multiple?' aria-multiselectable="true"':"",i="",s=this.autofocus?" autofocus":"";M.major<4&&this.$element.parent().hasClass("input-group")&&(i=" input-group-btn");var n,o="",r="",l="",a="";return this.options.header&&(o='