diff --git a/.gitlab/windows-build.yml b/.gitlab/windows-build.yml
index 2af1acc2..dd8dc796 100644
--- a/.gitlab/windows-build.yml
+++ b/.gitlab/windows-build.yml
@@ -49,6 +49,7 @@ win-prod-build:
paths:
- .venv/
rules:
+ - if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
- if: $CI_COMMIT_TAG
environment:
name: production
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ea631bc..5b1d9960 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,47 +1,53 @@
# Changelog
-## [4.0.3] - 2022/06/18
+## --- [4.0.4] - 2022/06/21
### New features
-- Integrate Wiki iframe into panel instead of link ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/367))
+- Add shutdown on backup feature ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/373))
+- Add detection and dropdown of java versions ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/375))
+- Add file-editor size toggle ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/378))
+### Bug fixes
+- Backup/Config.json rework for API key hardening ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/369))
+- Fix stack on ping result being falsy ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/371))
+- Fix sec bug with server creation roles ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/376))
+### Tweaks
+- Spelling mistake fixed in German lang file ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/370))
+- Backup failure warning (Tab text goes red) ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/373))
+- - ([Merge Request 2](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/377))
+- Rework server list on dashboard display for use on small screens ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/372))
+- File handling enhancements ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/362))
+
+## --- [4.0.3] - 2022/06/18
+### New features
+- Integrate Wiki iframe into panel instead of link ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/367))
### Bug fixes
- Amend Java system variable fix to be more specfic since they only affect Oracle. ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/364))
- API Token authentication hardening ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/364))
### Tweaks
- Add better error logging for statistic collection ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/359))
+
-## [4.0.2-hotfix1] - 2022/06/17
-
+## --- [4.0.2-hotfix1] - 2022/06/17
### Crit Bug fixes
- Fix blank server_detail page for general users ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/358))
+
-## [4.0.2] - 2022/06/16
-
+## --- [4.0.2] - 2022/06/16
### New features
None
-
### Bug fixes
- Fix winreg import pass on non-NT systems ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/344))
- Make the WebSocket automatically reconnect. ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/345))
-- Fix an error when there are no servers ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/346))
-- Use relative paths for the jarfile and logs ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/347))
-- Flatten all instances of username creation or editing, usernames should be lower case.
-- - ([Merge Request 1](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/342))
- - ([Merge Request 2](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/351))
- Add version inheretence & config check ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/353))
- Fix support log temp file deletion issue/hang ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/354))
+
-## [4.0.1] - 2022/06/15
-
+## --- [4.0.1] - 2022/06/15
### New features
None
-
### Bug fixes
-
- Remove session.lock warning ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/338))
- Correct Dutch Spacing Issue ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/340))
- Remove no-else-* pylint exemptions and tidy code. ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/342))
-- Make unRAID more readable, and flatten path to lower, to fit standard practice. ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/337))
-- Fix Java Pathing issues on windows ([Commit](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/343/diffs?commit_id=cda2120579083d447db5dbeb5489822880f4cae7))
-
diff --git a/README.md b/README.md
index 19e8fca6..ced866fc 100644
--- a/README.md
+++ b/README.md
@@ -2,11 +2,11 @@
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Supported Python Versions](https://shields.io/badge/python-3.8%20%7C%203.9%20%7C%203.10%20-blue)](https://www.python.org)
-[![Version(temp-hardcoded)](https://img.shields.io/badge/release-v4.0.3--beta-orange)](https://gitlab.com/crafty-controller/crafty-4/-/releases)
+[![Version(temp-hardcoded)](https://img.shields.io/badge/release-v4.0.4--beta-orange)](https://gitlab.com/crafty-controller/crafty-4/-/releases)
[![Code Quality(temp-hardcoded)](https://img.shields.io/badge/code%20quality-10-brightgreen)](https://gitlab.com/crafty-controller/crafty-4)
[![Build Status](https://gitlab.com/crafty-controller/crafty-4/badges/master/pipeline.svg)](https://gitlab.com/crafty-controller/crafty-4/-/commits/master)
-# Crafty Controller 4.0.3-beta
+# Crafty Controller 4.0.4-beta
> 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 099dbf0d..b0b1f10a 100644
--- a/app/classes/controllers/management_controller.py
+++ b/app/classes/controllers/management_controller.py
@@ -17,6 +17,14 @@ class ManagementController:
def get_latest_hosts_stats():
return HelpersManagement.get_latest_hosts_stats()
+ @staticmethod
+ def set_crafty_api_key(key):
+ HelpersManagement.set_secret_api_key(key)
+
+ @staticmethod
+ def get_crafty_api_key():
+ return HelpersManagement.get_secret_api_key()
+
# **********************************************************************************
# Commands Methods
# **********************************************************************************
@@ -128,9 +136,10 @@ class ManagementController:
max_backups: int = None,
excluded_dirs: list = None,
compress: bool = False,
+ shutdown: bool = False,
):
return self.management_helper.set_backup_config(
- server_id, backup_path, max_backups, excluded_dirs, compress
+ server_id, backup_path, max_backups, excluded_dirs, compress, shutdown
)
@staticmethod
diff --git a/app/classes/controllers/servers_controller.py b/app/classes/controllers/servers_controller.py
index fc8ff3e8..ca59fbdc 100644
--- a/app/classes/controllers/servers_controller.py
+++ b/app/classes/controllers/servers_controller.py
@@ -5,6 +5,7 @@ import json
import typing as t
from app.classes.controllers.roles_controller import RolesController
+from app.classes.shared.file_helpers import FileHelpers
from app.classes.shared.singleton import Singleton
from app.classes.shared.server import ServerInstance
@@ -28,8 +29,9 @@ logger = logging.getLogger(__name__)
class ServersController(metaclass=Singleton):
servers_list: ServerInstance
- def __init__(self, helper, servers_helper, management_helper):
+ def __init__(self, helper, servers_helper, management_helper, file_helper):
self.helper: Helpers = helper
+ self.file_helper: FileHelpers = file_helper
self.servers_helper: HelperServers = servers_helper
self.management_helper = management_helper
self.servers_list = []
@@ -189,6 +191,7 @@ class ServersController(metaclass=Singleton):
self.helper,
self.management_helper,
self.stats,
+ self.file_helper,
),
"server_settings": settings.props,
}
diff --git a/app/classes/minecraft/stats.py b/app/classes/minecraft/stats.py
index 4b699717..3cc52f88 100644
--- a/app/classes/minecraft/stats.py
+++ b/app/classes/minecraft/stats.py
@@ -218,7 +218,7 @@ class Stats:
return level_total_size
- def get_server_players(self, server_id): # pylint: disable=no-self-use
+ def get_server_players(self, server_id):
server = HelperServers.get_server_data_by_id(server_id)
@@ -265,15 +265,24 @@ class Stats:
logger.info(
"Unable to read the server icon due to the following error:", exc_info=e
)
-
- ping_data = {
- "online": online_stats.get("online", 0),
- "max": online_stats.get("max", 0),
- "players": online_stats.get("players", 0),
- "server_description": ping_obj.description,
- "server_version": ping_obj.version,
- "server_icon": server_icon,
- }
+ if ping_obj:
+ ping_data = {
+ "online": online_stats.get("online", 0),
+ "max": online_stats.get("max", 0),
+ "players": online_stats.get("players", 0),
+ "server_description": ping_obj.description,
+ "server_version": ping_obj.version,
+ "server_icon": server_icon,
+ }
+ else:
+ ping_data = {
+ "online": online_stats.get("online", 0),
+ "max": online_stats.get("max", 0),
+ "players": online_stats.get("players", 0),
+ "server_description": "",
+ "server_version": "",
+ "server_icon": server_icon,
+ }
return ping_data
diff --git a/app/classes/models/management.py b/app/classes/models/management.py
index 6cdf7a8a..085bdd37 100644
--- a/app/classes/models/management.py
+++ b/app/classes/models/management.py
@@ -38,6 +38,16 @@ class AuditLog(BaseModel):
table_name = "audit_log"
+# **********************************************************************************
+# Crafty Settings Class
+# **********************************************************************************
+class CraftySettings(BaseModel):
+ secret_api_key = CharField(default="")
+
+ class Meta:
+ table_name = "crafty_settings"
+
+
# **********************************************************************************
# Host_Stats Class
# **********************************************************************************
@@ -118,6 +128,7 @@ class Backups(BaseModel):
max_backups = IntegerField()
server_id = ForeignKeyField(Servers, backref="backups_server")
compress = BooleanField(default=False)
+ shutdown = BooleanField(default=False)
class Meta:
table_name = "backups"
@@ -231,6 +242,17 @@ class HelpersManagement:
else:
return
+ @staticmethod
+ def set_secret_api_key(key):
+ CraftySettings.insert(secret_api_key=key).execute()
+
+ @staticmethod
+ def get_secret_api_key():
+ settings = CraftySettings.select(CraftySettings.secret_api_key).where(
+ CraftySettings.id == 1
+ )
+ return settings[0].secret_api_key
+
# **********************************************************************************
# Schedules Methods
# **********************************************************************************
@@ -330,6 +352,7 @@ class HelpersManagement:
"max_backups": row.max_backups,
"server_id": row.server_id_id,
"compress": row.compress,
+ "shutdown": row.shutdown,
}
except IndexError:
conf = {
@@ -338,6 +361,7 @@ class HelpersManagement:
"max_backups": 0,
"server_id": server_id,
"compress": False,
+ "shutdown": False,
}
return conf
@@ -348,6 +372,7 @@ class HelpersManagement:
max_backups: int = None,
excluded_dirs: list = None,
compress: bool = False,
+ shutdown: bool = False,
):
logger.debug(f"Updating server {server_id} backup config with {locals()}")
if Backups.select().where(Backups.server_id == server_id).exists():
@@ -359,6 +384,7 @@ class HelpersManagement:
"max_backups": 0,
"server_id": server_id,
"compress": False,
+ "shutdown": False,
}
new_row = True
if max_backups is not None:
@@ -367,6 +393,7 @@ class HelpersManagement:
dirs_to_exclude = ",".join(excluded_dirs)
conf["excluded_dirs"] = dirs_to_exclude
conf["compress"] = compress
+ conf["shutdown"] = shutdown
if not new_row:
with self.database.atomic():
if backup_path is not None:
diff --git a/app/classes/shared/authentication.py b/app/classes/shared/authentication.py
index 330a8883..d9b2c053 100644
--- a/app/classes/shared/authentication.py
+++ b/app/classes/shared/authentication.py
@@ -5,6 +5,7 @@ import jwt
from jwt import PyJWTError
from app.classes.models.users import HelperUsers, ApiKeys
+from app.classes.controllers.management_controller import ManagementController
logger = logging.getLogger(__name__)
@@ -13,11 +14,14 @@ class Authentication:
def __init__(self, helper):
self.helper = helper
self.secret = "my secret"
- self.secret = self.helper.get_setting("apikey_secret", None)
-
- if self.secret is None or self.secret == "random":
+ try:
+ self.secret = ManagementController.get_crafty_api_key()
+ if self.secret == "":
+ self.secret = self.helper.random_string_generator(64)
+ ManagementController.set_crafty_api_key(str(self.secret))
+ except:
self.secret = self.helper.random_string_generator(64)
- self.helper.set_setting("apikey_secret", self.secret)
+ ManagementController.set_crafty_api_key(str(self.secret))
def generate(self, user_id, extra=None):
if extra is None:
diff --git a/app/classes/shared/file_helpers.py b/app/classes/shared/file_helpers.py
index dad6ddef..620b34f8 100644
--- a/app/classes/shared/file_helpers.py
+++ b/app/classes/shared/file_helpers.py
@@ -2,14 +2,22 @@ import os
import shutil
import logging
import pathlib
+import tempfile
+import zipfile
from zipfile import ZipFile, ZIP_DEFLATED
+from app.classes.shared.helpers import Helpers
+from app.classes.shared.console import Console
+
logger = logging.getLogger(__name__)
class FileHelpers:
allowed_quotes = ['"', "'", "`"]
+ def __init__(self, helper):
+ self.helper: Helpers = helper
+
@staticmethod
def del_dirs(path):
path = pathlib.Path(path)
@@ -82,7 +90,6 @@ class FileHelpers:
f"Error backing up: {os.path.join(root, file)}!"
f" - Error was: {e}"
)
-
return True
@staticmethod
@@ -113,3 +120,173 @@ class FileHelpers:
)
return True
+
+ def make_compressed_backup(
+ self, path_to_destination, path_to_zip, excluded_dirs, server_id
+ ):
+ # create a ZipFile object
+ path_to_destination += ".zip"
+ ex_replace = [p.replace("\\", "/") for p in excluded_dirs]
+ total_bytes = 0
+ dir_bytes = Helpers.get_dir_size(path_to_zip)
+ results = {
+ "percent": 0,
+ "total_files": self.helper.human_readable_file_size(dir_bytes),
+ }
+ self.helper.websocket_helper.broadcast_page_params(
+ "/panel/server_detail",
+ {"id": str(server_id)},
+ "backup_status",
+ results,
+ )
+ with ZipFile(path_to_destination, "w", ZIP_DEFLATED) as zip_file:
+ for root, dirs, files in os.walk(path_to_zip, topdown=True):
+ for l_dir in dirs:
+ if str(os.path.join(root, l_dir)).replace("\\", "/") in ex_replace:
+ dirs.remove(l_dir)
+ ziproot = path_to_zip
+ for file in files:
+ if (
+ str(os.path.join(root, file)).replace("\\", "/")
+ not in ex_replace
+ and file != "crafty.sqlite"
+ ):
+ try:
+ logger.info(f"backing up: {os.path.join(root, file)}")
+ if os.name == "nt":
+ zip_file.write(
+ os.path.join(root, file),
+ os.path.join(root.replace(ziproot, ""), file),
+ )
+ else:
+ zip_file.write(
+ os.path.join(root, file),
+ os.path.join(root.replace(ziproot, "/"), file),
+ )
+
+ except Exception as e:
+ logger.warning(
+ f"Error backing up: {os.path.join(root, file)}!"
+ f" - Error was: {e}"
+ )
+ total_bytes += os.path.getsize(os.path.join(root, file))
+ percent = round((total_bytes / dir_bytes) * 100, 2)
+ results = {
+ "percent": percent,
+ "total_files": self.helper.human_readable_file_size(dir_bytes),
+ }
+ self.helper.websocket_helper.broadcast_page_params(
+ "/panel/server_detail",
+ {"id": str(server_id)},
+ "backup_status",
+ results,
+ )
+
+ return True
+
+ def make_backup(self, path_to_destination, path_to_zip, excluded_dirs, server_id):
+ # create a ZipFile object
+ path_to_destination += ".zip"
+ ex_replace = [p.replace("\\", "/") for p in excluded_dirs]
+ total_bytes = 0
+ dir_bytes = Helpers.get_dir_size(path_to_zip)
+ results = {
+ "percent": 0,
+ "total_files": self.helper.human_readable_file_size(dir_bytes),
+ }
+ self.helper.websocket_helper.broadcast_page_params(
+ "/panel/server_detail",
+ {"id": str(server_id)},
+ "backup_status",
+ results,
+ )
+ with ZipFile(path_to_destination, "w") as zip_file:
+ for root, dirs, files in os.walk(path_to_zip, topdown=True):
+ for l_dir in dirs:
+ if str(os.path.join(root, l_dir)).replace("\\", "/") in ex_replace:
+ dirs.remove(l_dir)
+ ziproot = path_to_zip
+ for file in files:
+ if (
+ str(os.path.join(root, file)).replace("\\", "/")
+ not in ex_replace
+ and file != "crafty.sqlite"
+ ):
+ try:
+ logger.info(f"backing up: {os.path.join(root, file)}")
+ if os.name == "nt":
+ zip_file.write(
+ os.path.join(root, file),
+ os.path.join(root.replace(ziproot, ""), file),
+ )
+ else:
+ zip_file.write(
+ os.path.join(root, file),
+ os.path.join(root.replace(ziproot, "/"), file),
+ )
+
+ except Exception as e:
+ logger.warning(
+ f"Error backing up: {os.path.join(root, file)}!"
+ f" - Error was: {e}"
+ )
+ total_bytes += os.path.getsize(os.path.join(root, file))
+ percent = round((total_bytes / dir_bytes) * 100, 2)
+ results = {
+ "percent": percent,
+ "total_files": self.helper.human_readable_file_size(dir_bytes),
+ }
+ self.helper.websocket_helper.broadcast_page_params(
+ "/panel/server_detail",
+ {"id": str(server_id)},
+ "backup_status",
+ results,
+ )
+ return True
+
+ @staticmethod
+ def unzip_file(zip_path):
+ new_dir_list = zip_path.split("/")
+ new_dir = ""
+ for i in range(len(new_dir_list) - 1):
+ if i == 0:
+ new_dir += new_dir_list[i]
+ else:
+ new_dir += "/" + new_dir_list[i]
+
+ if Helpers.check_file_perms(zip_path) and os.path.isfile(zip_path):
+ Helpers.ensure_dir_exists(new_dir)
+ temp_dir = tempfile.mkdtemp()
+ try:
+ with zipfile.ZipFile(zip_path, "r") as zip_ref:
+ zip_ref.extractall(temp_dir)
+ for i in enumerate(zip_ref.filelist):
+ if len(zip_ref.filelist) > 1 or not zip_ref.filelist[
+ i
+ ].filename.endswith("/"):
+ break
+
+ full_root_path = temp_dir
+
+ for item in os.listdir(full_root_path):
+ if os.path.isdir(os.path.join(full_root_path, item)):
+ try:
+ FileHelpers.move_dir(
+ os.path.join(full_root_path, item),
+ os.path.join(new_dir, item),
+ )
+ except Exception as ex:
+ logger.error(f"ERROR IN ZIP IMPORT: {ex}")
+ else:
+ try:
+ FileHelpers.move_file(
+ os.path.join(full_root_path, item),
+ os.path.join(new_dir, item),
+ )
+ except Exception as ex:
+ logger.error(f"ERROR IN ZIP IMPORT: {ex}")
+ except Exception as ex:
+ Console.error(ex)
+ else:
+ return "false"
+ return
diff --git a/app/classes/shared/helpers.py b/app/classes/shared/helpers.py
index 31273a60..4273c38f 100644
--- a/app/classes/shared/helpers.py
+++ b/app/classes/shared/helpers.py
@@ -15,6 +15,8 @@ import html
import zipfile
import pathlib
import ctypes
+import subprocess
+import itertools
from datetime import datetime
from socket import gethostname
from contextlib import redirect_stderr, suppress
@@ -22,7 +24,6 @@ from contextlib import redirect_stderr, suppress
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.file_helpers import FileHelpers
from app.classes.shared.translation import Translation
from app.classes.web.websocket_helper import WebSocketHelper
@@ -81,6 +82,60 @@ class Helpers:
print(f"Import Error: Unable to load {ex.name} module")
installer.do_install()
+ @staticmethod
+ def find_java_installs():
+ # If we're windows return oracle java versions,
+ # otherwise java vers need to be manual.
+ if os.name == "nt":
+ # Adapted from LeeKamentsky >>>
+ # https://github.com/LeeKamentsky/python-javabridge/blob/master/javabridge/locate.py
+ jdk_key_paths = (
+ "SOFTWARE\\JavaSoft\\JDK",
+ "SOFTWARE\\JavaSoft\\Java Development Kit",
+ )
+ java_paths = []
+ for jdk_key_path in jdk_key_paths:
+ try:
+ with suppress(OSError), winreg.OpenKey(
+ winreg.HKEY_LOCAL_MACHINE, jdk_key_path
+ ) as kjdk:
+ for i in itertools.count():
+ version = winreg.EnumKey(kjdk, i)
+ kjdk_current = winreg.OpenKey(
+ winreg.HKEY_LOCAL_MACHINE,
+ jdk_key_path,
+ )
+ kjdk_current = winreg.OpenKey(
+ winreg.HKEY_LOCAL_MACHINE,
+ jdk_key_path + "\\" + version,
+ )
+ kjdk_current_values = dict( # pylint: disable=consider-using-dict-comprehension
+ [
+ winreg.EnumValue(kjdk_current, i)[:2]
+ for i in range(winreg.QueryInfoKey(kjdk_current)[1])
+ ]
+ )
+ java_paths.append(kjdk_current_values["JavaHome"])
+ except OSError as e:
+ if e.errno == 2:
+ continue
+ raise
+ return java_paths
+
+ # If we get here we're linux so we will use 'update-alternatives'
+ # (If distro does not have update-alternatives then manual input.)
+ try:
+ paths = subprocess.check_output(
+ ["/usr/bin/update-alternatives", "--list", "java"], encoding="utf8"
+ )
+
+ if re.match("^(/[^/ ]*)+/?$", paths):
+ return paths.split("\n")
+
+ except Exception as e:
+ print("Java Detect Error: ", e)
+ logger.error(f"Java Detect Error: {e}")
+
@staticmethod
def float_to_string(gbs: float):
s = str(float(gbs) * 1000).rstrip("0").rstrip(".")
@@ -89,7 +144,8 @@ class Helpers:
@staticmethod
def check_file_perms(path):
try:
- open(path, "r", encoding="utf-8").close()
+ with open(path, "r", encoding="utf-8"):
+ pass
logger.info(f"{path} is readable")
return True
except PermissionError:
@@ -425,7 +481,8 @@ class Helpers:
def check_writeable(path: str):
filename = os.path.join(path, "tempfile.txt")
try:
- open(filename, "w", encoding="utf-8").close()
+ with open(filename, "w", encoding="utf-8"):
+ pass
os.remove(filename)
logger.info(f"{filename} is writable")
@@ -441,53 +498,6 @@ class Helpers:
return ctypes.windll.shell32.IsUserAnAdmin() == 1
return os.geteuid() == 0
- @staticmethod
- def unzip_file(zip_path):
- new_dir_list = zip_path.split("/")
- new_dir = ""
- for i in range(len(new_dir_list) - 1):
- if i == 0:
- new_dir += new_dir_list[i]
- else:
- new_dir += "/" + new_dir_list[i]
-
- if Helpers.check_file_perms(zip_path) and os.path.isfile(zip_path):
- Helpers.ensure_dir_exists(new_dir)
- temp_dir = tempfile.mkdtemp()
- try:
- with zipfile.ZipFile(zip_path, "r") as zip_ref:
- zip_ref.extractall(temp_dir)
- for i in enumerate(zip_ref.filelist):
- if len(zip_ref.filelist) > 1 or not zip_ref.filelist[
- i
- ].filename.endswith("/"):
- break
-
- full_root_path = temp_dir
-
- for item in os.listdir(full_root_path):
- if os.path.isdir(os.path.join(full_root_path, item)):
- try:
- FileHelpers.move_dir(
- os.path.join(full_root_path, item),
- os.path.join(new_dir, item),
- )
- except Exception as ex:
- logger.error(f"ERROR IN ZIP IMPORT: {ex}")
- else:
- try:
- FileHelpers.move_file(
- os.path.join(full_root_path, item),
- os.path.join(new_dir, item),
- )
- except Exception as ex:
- logger.error(f"ERROR IN ZIP IMPORT: {ex}")
- except Exception as ex:
- Console.error(ex)
- else:
- return "false"
- return
-
def ensure_logging_setup(self):
log_file = os.path.join(os.path.curdir, "logs", "commander.log")
session_log_file = os.path.join(os.path.curdir, "logs", "session.log")
@@ -510,7 +520,8 @@ class Helpers:
# ensure the log file is there
try:
- open(log_file, "a", encoding="utf-8").close()
+ with open(log_file, "a", encoding="utf-8"):
+ pass
except Exception as e:
Console.critical(f"Unable to open log file! {e}")
sys.exit(1)
@@ -640,7 +651,7 @@ class Helpers:
session_data = {"pid": pid, "started": now.strftime("%d-%m-%Y, %H:%M:%S")}
with open(self.session_file, "w", encoding="utf-8") as f:
- json.dump(session_data, f, indent=True)
+ json.dump(session_data, f, indent=4)
# because this is a recursive function, we will return bytes,
# and set human readable later
@@ -774,13 +785,15 @@ class Helpers:
cert.set_version(2)
cert.sign(k, "sha256")
- f = open(cert_file, "w", encoding="utf-8")
- f.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode())
- f.close()
+ with open(cert_file, "w", encoding="utf-8") as cert_file_handle:
+ cert_file_handle.write(
+ crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode()
+ )
- f = open(key_file, "w", encoding="utf-8")
- f.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, k).decode())
- f.close()
+ with open(key_file, "w", encoding="utf-8") as key_file_handle:
+ key_file_handle.write(
+ crypto.dump_privatekey(crypto.FILETYPE_PEM, k).decode()
+ )
@staticmethod
def random_string_generator(size=6, chars=string.ascii_uppercase + string.digits):
@@ -832,7 +845,7 @@ class Helpers:
for item in file_list:
if os.path.isdir(os.path.join(folder, item)):
dir_list.append(item)
- else:
+ elif str(item) != "crafty.sqlite":
unsorted_files.append(item)
file_list = sorted(dir_list, key=str.casefold) + sorted(
unsorted_files, key=str.casefold
@@ -863,13 +876,14 @@ class Helpers:
@staticmethod
def generate_dir(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)
- else:
+ elif str(item) != "crafty.sqlite":
unsorted_files.append(item)
file_list = sorted(dir_list, key=str.casefold) + sorted(
unsorted_files, key=str.casefold
@@ -986,14 +1000,6 @@ class Helpers:
[parent_path, child_path]
)
- @staticmethod
- def copy_files(source, dest):
- if os.path.isfile(source):
- FileHelpers.copy_file(source, dest)
- logger.info("Copying jar %s to %s", source, dest)
- else:
- logger.info("Source jar does not exist.")
-
@staticmethod
def download_file(executable_url, jar_path):
try:
@@ -1006,7 +1012,8 @@ class Helpers:
return False
try:
- open(jar_path, "wb").write(response.content)
+ with open(jar_path, "wb") as jar_file:
+ jar_file.write(response.content)
except Exception as e:
logger.error("Unable to finish executable download. Error: %s", e)
return False
diff --git a/app/classes/shared/main_controller.py b/app/classes/shared/main_controller.py
index 39db11cd..ffe8ee0c 100644
--- a/app/classes/shared/main_controller.py
+++ b/app/classes/shared/main_controller.py
@@ -34,8 +34,9 @@ logger = logging.getLogger(__name__)
class Controller:
- def __init__(self, database, helper):
+ def __init__(self, database, helper, file_helper):
self.helper: Helpers = helper
+ self.file_helper: FileHelpers = file_helper
self.server_jars: ServerJars = ServerJars(helper)
self.users_helper: HelperUsers = HelperUsers(database, self.helper)
self.roles_helper: HelperRoles = HelperRoles(database)
@@ -53,7 +54,7 @@ class Controller:
)
self.server_perms: ServerPermsController = ServerPermsController()
self.servers: ServersController = ServersController(
- self.helper, self.servers_helper, self.management_helper
+ self.helper, self.servers_helper, self.management_helper, self.file_helper
)
self.users: UsersController = UsersController(
self.helper, self.users_helper, self.authentication
diff --git a/app/classes/shared/main_models.py b/app/classes/shared/main_models.py
index 5e809c48..ae4636c2 100644
--- a/app/classes/shared/main_models.py
+++ b/app/classes/shared/main_models.py
@@ -17,8 +17,6 @@ class DatabaseBuilder:
logger.info("Fresh Install Detected - Creating Default Settings")
Console.info("Fresh Install Detected - Creating Default Settings")
default_data = self.helper.find_default_password()
- # Reset this value if the DB has been dumped
- self.helper.set_setting("apikey_secret", "random")
username = default_data.get("username", "admin")
password = default_data.get("password", "crafty")
diff --git a/app/classes/shared/server.py b/app/classes/shared/server.py
index eb43ff47..07317ff2 100644
--- a/app/classes/shared/server.py
+++ b/app/classes/shared/server.py
@@ -9,7 +9,6 @@ import threading
import logging.config
import subprocess
import html
-import tempfile
# TZLocal is set as a hidden import on win pipeline
from tzlocal import get_localzone
@@ -102,12 +101,14 @@ class ServerOutBuf:
class ServerInstance:
server_object: Servers
helper: Helpers
+ file_helper: FileHelpers
management_helper: HelpersManagement
stats: Stats
stats_helper: HelperServerStats
- def __init__(self, server_id, helper, management_helper, stats):
+ def __init__(self, server_id, helper, management_helper, stats, file_helper):
self.helper = helper
+ self.file_helper = file_helper
self.management_helper = management_helper
# holders for our process
self.process = None
@@ -126,6 +127,7 @@ class ServerInstance:
self.stats = stats
self.server_object = HelperServers.get_server_obj(self.server_id)
self.stats_helper = HelperServerStats(self.server_id)
+ self.last_backup_failed = False
try:
tz = get_localzone()
except ZoneInfoNotFoundError:
@@ -800,10 +802,9 @@ class ServerInstance:
self.server_scheduler.remove_job("c_" + str(self.server_id))
def agree_eula(self, user_id):
- file = os.path.join(self.server_path, "eula.txt")
- f = open(file, "w", encoding="utf-8")
- f.write("eula=true")
- f.close()
+ eula_file = os.path.join(self.server_path, "eula.txt")
+ with open(eula_file, "w", encoding="utf-8") as f:
+ f.write("eula=true")
self.run_threaded_server(user_id)
def backup_server(self):
@@ -846,6 +847,7 @@ class ServerInstance:
"backup_reload",
{"percent": 0, "total_files": 0},
)
+ was_server_running = None
logger.info(f"Starting server {self.name} (ID {self.server_id}) backup")
server_users = PermissionsServers.get_server_user_list(self.server_id)
for user in server_users:
@@ -858,6 +860,15 @@ class ServerInstance:
)
time.sleep(3)
conf = HelpersManagement.get_backup_config(self.server_id)
+ if conf["shutdown"]:
+ logger.info(
+ "Found shutdown preference. Delaying"
+ + "backup start. Shutting down server."
+ )
+ if self.check_running():
+ self.stop_server()
+ was_server_running = True
+
self.helper.ensure_dir_exists(self.settings["backup_path"])
try:
backup_filename = (
@@ -869,62 +880,27 @@ class ServerInstance:
f" (ID#{self.server_id}, path={self.server_path}) "
f"at '{backup_filename}'"
)
-
- temp_dir = tempfile.mkdtemp()
- self.server_scheduler.add_job(
- self.backup_status,
- "interval",
- seconds=1,
- id="backup_" + str(self.server_id),
- args=[temp_dir + "/", backup_filename + ".zip"],
- )
- # pylint: disable=unexpected-keyword-arg
- try:
- FileHelpers.copy_dir(self.server_path, temp_dir, dirs_exist_ok=True)
- except shutil.Error as e:
- logger.error(f"Failed to fully complete backup due to shutil error {e}")
excluded_dirs = HelpersManagement.get_excluded_backup_dirs(self.server_id)
server_dir = Helpers.get_os_understandable_path(self.settings["path"])
-
- for my_dir in excluded_dirs:
- # Take the full path of the excluded dir and replace the
- # server path with the temp path, this is so that we're
- # only deleting excluded dirs from the temp path
- # and not the server path
- excluded_dir = Helpers.get_os_understandable_path(my_dir).replace(
- server_dir, Helpers.get_os_understandable_path(temp_dir)
- )
- # Next, check to see if it is a directory
- if os.path.isdir(excluded_dir):
- # If it is a directory,
- # recursively delete the entire directory from the backup
- try:
- FileHelpers.del_dirs(excluded_dir)
- except FileNotFoundError:
- Console.error(
- f"Excluded dir {excluded_dir} not found. Moving on..."
- )
- else:
- # If not, just remove the file
- try:
- os.remove(excluded_dir)
- except:
- Console.error(
- f"Excluded dir {excluded_dir} not found. Moving on..."
- )
if conf["compress"]:
logger.debug(
"Found compress backup to be true. Calling compressed archive"
)
- FileHelpers.make_compressed_archive(
- Helpers.get_os_understandable_path(backup_filename), temp_dir
+ self.file_helper.make_compressed_backup(
+ Helpers.get_os_understandable_path(backup_filename),
+ server_dir,
+ excluded_dirs,
+ self.server_id,
)
else:
logger.debug(
"Found compress backup to be false. Calling NON-compressed archive"
)
- FileHelpers.make_archive(
- Helpers.get_os_understandable_path(backup_filename), temp_dir
+ self.file_helper.make_backup(
+ Helpers.get_os_understandable_path(backup_filename),
+ server_dir,
+ excluded_dirs,
+ self.server_id,
)
while (
@@ -939,7 +915,6 @@ class ServerInstance:
self.is_backingup = False
logger.info(f"Backup of server: {self.name} completed")
- self.server_scheduler.remove_job("backup_" + str(self.server_id))
results = {"percent": 100, "total_files": 0, "current_file": 0}
if len(self.helper.websocket_helper.clients) > 0:
self.helper.websocket_helper.broadcast_page_params(
@@ -959,12 +934,17 @@ class ServerInstance:
HelperUsers.get_user_lang_by_id(user),
).format(self.name),
)
+ if was_server_running:
+ logger.info(
+ "Backup complete. User had shutdown preference. Starting server."
+ )
+ self.start_server(HelperUsers.get_user_id_by_name("system"))
time.sleep(3)
+ self.last_backup_failed = False
except:
logger.exception(
f"Failed to create backup of server {self.name} (ID {self.server_id})"
)
- self.server_scheduler.remove_job("backup_" + str(self.server_id))
results = {"percent": 100, "total_files": 0, "current_file": 0}
if len(self.helper.websocket_helper.clients) > 0:
self.helper.websocket_helper.broadcast_page_params(
@@ -974,8 +954,12 @@ class ServerInstance:
results,
)
self.is_backingup = False
- finally:
- FileHelpers.del_dirs(temp_dir)
+ if was_server_running:
+ logger.info(
+ "Backup complete. User had shutdown preference. Starting server."
+ )
+ self.start_server(HelperUsers.get_user_id_by_name("system"))
+ self.last_backup_failed = True
def backup_status(self, source_path, dest_path):
results = Helpers.calc_percent(source_path, dest_path)
@@ -988,6 +972,9 @@ class ServerInstance:
results,
)
+ def last_backup_status(self):
+ return self.last_backup_failed
+
def send_backup_status(self):
try:
return self.backup_stats
@@ -1093,7 +1080,7 @@ class ServerInstance:
)
# copies to backup dir
- Helpers.copy_files(current_executable, backup_executable)
+ FileHelpers.copy_file(current_executable, backup_executable)
# boolean returns true for false for success
downloaded = Helpers.download_file(
diff --git a/app/classes/web/file_handler.py b/app/classes/web/file_handler.py
index 886441ed..e1131c3c 100644
--- a/app/classes/web/file_handler.py
+++ b/app/classes/web/file_handler.py
@@ -220,7 +220,7 @@ class FileHandler(BaseHandler):
path = Helpers.get_os_understandable_path(self.get_argument("path", None))
if Helpers.is_os_windows():
path = Helpers.wtol_path(path)
- Helpers.unzip_file(path)
+ FileHelpers.unzip_file(path)
self.redirect(f"/panel/server_detail?id={server_id}&subpage=files")
return
diff --git a/app/classes/web/panel_handler.py b/app/classes/web/panel_handler.py
index 9dfbb17a..6927bca4 100644
--- a/app/classes/web/panel_handler.py
+++ b/app/classes/web/panel_handler.py
@@ -6,6 +6,7 @@ import typing as t
import json
import logging
import threading
+import shlex
import bleach
import libgravatar
import requests
@@ -482,6 +483,14 @@ class PanelHandler(BaseHandler):
if str(server_id) not in server_ids[:]:
user_order.remove(server_id)
page_data["servers"] = page_servers
+ for server in page_data["servers"]:
+ server_obj = self.controller.servers.get_server_instance_by_id(
+ server["server_data"]["server_id"]
+ )
+ alert = False
+ if server_obj.last_backup_status():
+ alert = True
+ server["alert"] = alert
# num players is set to zero here. If we poll all servers while
# dashboard is loading it takes FOREVER. We leave this to the
@@ -497,6 +506,10 @@ class PanelHandler(BaseHandler):
if server_id is None:
return
+ server_obj = self.controller.servers.get_server_instance_by_id(server_id)
+ page_data["backup_failed"] = server_obj.last_backup_status()
+ server_obj = None
+
valid_subpages = [
"term",
"logs",
@@ -627,6 +640,18 @@ class PanelHandler(BaseHandler):
"/panel/error?error=Unauthorized access Server Config"
)
return
+ page_data["java_versions"] = Helpers.find_java_installs()
+ server_obj: Servers = self.controller.servers.get_server_obj(server_id)
+ page_java = []
+ page_data["java_versions"].append("java")
+ for version in page_data["java_versions"]:
+ if os.name == "nt":
+ page_java.append(version)
+ else:
+ if len(version) > 0:
+ page_java.append(version)
+
+ page_data["java_versions"] = page_java
if subpage == "files":
if (
@@ -1342,6 +1367,8 @@ class PanelHandler(BaseHandler):
if Helpers.is_os_windows():
log_path.replace(" ", "^ ")
log_path = Helpers.wtol_path(log_path)
+ if not self.helper.validate_traversal(server_obj.path, log_path):
+ log_path = ""
executable = self.get_argument("executable", None)
execution_command = self.get_argument("execution_command", None)
server_ip = self.get_argument("server_ip", None)
@@ -1355,11 +1382,50 @@ class PanelHandler(BaseHandler):
auto_start = int(float(self.get_argument("auto_start", "0")))
crash_detection = int(float(self.get_argument("crash_detection", "0")))
logs_delete_after = int(float(self.get_argument("logs_delete_after", "0")))
+ java_selection = self.get_argument("java_selection", None)
# subpage = self.get_argument('subpage', None)
server_id = self.check_server_id()
if server_id is None:
return
+ if java_selection:
+ try:
+ execution_list = shlex.split(execution_command)
+ except ValueError:
+ self.redirect(
+ "/panel/error?error=Invalid execution command. Java path"
+ " must be surrounded by quotes."
+ " (Are you missing a closing quote?)"
+ )
+ if (
+ not any(
+ java_selection in path for path in Helpers.find_java_installs()
+ )
+ and java_selection != "java"
+ ):
+ self.redirect(
+ "/panel/error?error=Attack attempted."
+ + " A copy of this report is being sent to server owner."
+ )
+ self.controller.management.add_to_audit_log_raw(
+ exec_user["username"],
+ exec_user["user_id"],
+ server_id,
+ f"Attempted to send bad java path for {server_id}."
+ + " Possible attack. Act accordingly.",
+ self.get_remote_ip(),
+ )
+ return
+ if java_selection != "java":
+ if self.helper.is_os_windows():
+ execution_list[0] = '"' + java_selection + '/bin/java"'
+ else:
+ execution_list[0] = '"' + java_selection + '"'
+ else:
+ execution_list[0] = "java"
+ execution_command = ""
+ for item in execution_list:
+ execution_command += item + " "
server_obj: Servers = self.controller.servers.get_server_obj(server_id)
stale_executable = server_obj.executable
@@ -1389,7 +1455,7 @@ class PanelHandler(BaseHandler):
server_obj.path = server_obj.path
server_obj.log_path = server_obj.log_path
server_obj.executable = server_obj.executable
- server_obj.execution_command = server_obj.execution_command
+ server_obj.execution_command = execution_command
server_obj.server_ip = server_obj.server_ip
server_obj.server_port = server_obj.server_port
server_obj.executable_update_url = server_obj.executable_update_url
@@ -1433,6 +1499,7 @@ class PanelHandler(BaseHandler):
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")
if str(check_changed) == str(1):
checked = self.get_body_arguments("root_path")
@@ -1448,6 +1515,18 @@ class PanelHandler(BaseHandler):
max_backups = bleach.clean(self.get_argument("max_backups", None))
server_obj = self.controller.servers.get_server_obj(server_id)
+ if (
+ not backup_path
+ == self.helper.wtol_path(
+ os.path.join(self.helper.backup_path, server_obj.server_uuid)
+ )
+ and self.helper.wtol_path(self.controller.project_root) in backup_path
+ ):
+ self.redirect(
+ "/panel/error?error=Nefarious activities detected."
+ " User attempted to make backup path within Crafty's root."
+ )
+ return
server_obj.backup_path = backup_path
self.controller.servers.update_server(server_obj)
self.controller.management.set_backup_config(
@@ -1455,6 +1534,7 @@ class PanelHandler(BaseHandler):
max_backups=max_backups,
excluded_dirs=checked,
compress=bool(compress),
+ shutdown=bool(shutdown),
)
self.controller.management.add_to_audit_log(
@@ -1941,7 +2021,10 @@ class PanelHandler(BaseHandler):
self.redirect("/panel/error?error=Invalid Key ID")
return
- if key.user_id != exec_user["user_id"]:
+ 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."
)
diff --git a/app/classes/web/server_handler.py b/app/classes/web/server_handler.py
index bf4e930d..3505e1de 100644
--- a/app/classes/web/server_handler.py
+++ b/app/classes/web/server_handler.py
@@ -17,6 +17,15 @@ logger = logging.getLogger(__name__)
class ServerHandler(BaseHandler):
+ def get_user_roles(self):
+ user_roles = {}
+ for user_id in self.controller.users.get_all_user_ids():
+ user_roles_list = self.controller.users.get_user_roles_names(user_id)
+ # user_servers =
+ # self.controller.servers.get_authorized_servers(user.user_id)
+ user_roles[user_id] = user_roles_list
+ return user_roles
+
@tornado.web.authenticated
def get(self, page):
(
@@ -271,11 +280,19 @@ class ServerHandler(BaseHandler):
)
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.controller.roles.get_all_roles()
+ 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", ""))
@@ -396,6 +413,14 @@ class ServerHandler(BaseHandler):
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:
diff --git a/app/config/version.json b/app/config/version.json
index 0f1b738a..4ae669ee 100644
--- a/app/config/version.json
+++ b/app/config/version.json
@@ -1,6 +1,6 @@
{
"major": 4,
"minor": 0,
- "sub": 3,
+ "sub": 4,
"meta": "beta"
}
diff --git a/app/frontend/static/assets/css/crafty.css b/app/frontend/static/assets/css/crafty.css
index f0fdda00..a4bfe57c 100644
--- a/app/frontend/static/assets/css/crafty.css
+++ b/app/frontend/static/assets/css/crafty.css
@@ -9,7 +9,7 @@
}
-.sidebar > .nav > .nav-item:not(.nav-profile) > .nav-link:before {
+.sidebar>.nav>.nav-item:not(.nav-profile)>.nav-link:before {
content: none;
position: absolute;
left: 30px;
@@ -21,7 +21,7 @@
display: block;
}
-.sidebar > .nav > .nav-item:not(.nav-profile) > .nav-link:before {
+.sidebar>.nav>.nav-item:not(.nav-profile)>.nav-link:before {
content: none;
position: absolute;
left: 30px;
@@ -33,43 +33,48 @@
display: block;
}
-.sidebar > .nav .nav-item .nav-link, .collapsed{
+.sidebar>.nav .nav-item .nav-link,
+.collapsed {
padding: 15px 30px;
}
-.mc-log-time{
- color:#19d895;
+.mc-log-time {
+ color: #19d895;
}
-.mc-log-info{
- color:#8862e0;
+.mc-log-info {
+ color: #8862e0;
}
-.mc-log-warn{
- color:#ffaf00;
+.mc-log-warn {
+ color: #ffaf00;
}
-.mc-log-error{
- color:#af463f;
+.mc-log-error {
+ color: #af463f;
}
-.mc-log-fatal{
- color:#da0f00;
+.mc-log-fatal {
+ color: #da0f00;
}
-.mc-log-keyword{
- color:#2196f3;
+.mc-log-keyword {
+ color: #2196f3;
}
.scrollable-element {
- scrollbar-color: red yellow;
+ scrollbar-color: red yellow;
}
+
.term-nav-item {
padding: 1%;
}
/* Fix body scrollbar color */
-body { background-color: var(--dark) !important; /* Firefox */ }
+body {
+ background-color: var(--dark) !important;
+ /* Firefox */
+}
/* Webkit */
/* Didn't really work out
@@ -81,11 +86,20 @@ body { background-color: var(--dark) !important; /* Firefox */ }
::-webkit-scrollbar-track { background-color: #202538; }
::-webkit-scrollbar-corner { background-color: #202538; }*/
-.actions_serverlist > a > i {
- cursor: pointer;
+.actions_serverlist>a>i {
+ cursor: pointer;
}
+
+.actions_serveritem {
+ cursor: pointer;
+}
+
.corner {
position: absolute;
margin-top: 0;
margin-left: 0;
+}
+
+.accordion .card {
+ margin-bottom: 0px;
}
\ No newline at end of file
diff --git a/app/frontend/templates/panel/dashboard.html b/app/frontend/templates/panel/dashboard.html
index 8c215a77..b6eb09a2 100644
--- a/app/frontend/templates/panel/dashboard.html
+++ b/app/frontend/templates/panel/dashboard.html
@@ -25,21 +25,21 @@
{% if data['first_log'] %}
-{% end %}
+{% end %}
\ No newline at end of file
diff --git a/app/frontend/templates/panel/parts/m_server_controls_list.html b/app/frontend/templates/panel/parts/m_server_controls_list.html
index a82d02ff..4a114b52 100644
--- a/app/frontend/templates/panel/parts/m_server_controls_list.html
+++ b/app/frontend/templates/panel/parts/m_server_controls_list.html
@@ -15,8 +15,12 @@
{{ translate('serverDetails', 'schedule', data['lang']) }}
{% end %}
{% if data['permissions']['Backup'] in data['user_permissions'] %}
+ {% if data['backup_failed'] %}
+ {{ translate('serverDetails', 'backup', data['lang']) }}
+ {% else %}
{{ translate('serverDetails', 'backup', data['lang']) }}
{% end %}
+ {% end %}
{% if data['permissions']['Files'] in data['user_permissions'] %}
{{ translate('serverDetails', 'files', data['lang']) }}
{% end %}
diff --git a/app/frontend/templates/panel/parts/server_controls_list.html b/app/frontend/templates/panel/parts/server_controls_list.html
index a232f022..8e40e7ec 100644
--- a/app/frontend/templates/panel/parts/server_controls_list.html
+++ b/app/frontend/templates/panel/parts/server_controls_list.html
@@ -19,11 +19,18 @@
{% end %}
{% if data['permissions']['Backup'] in data['user_permissions'] %}
+ {% if data['backup_failed'] %}
+
Backing up {{data['backup_stats']['total_files']}} Files
+Backing up {{data['server_stats']['world_size']}}
{% end %}