Merge branch 'dev' into 'master'

v4.5.5

See merge request crafty-controller/crafty-4!905
This commit is contained in:
Iain Powrie
2025-10-14 18:48:36 +00:00
18 changed files with 121 additions and 54 deletions

View File

@@ -44,8 +44,7 @@ docker-build:
mkdir -p ~/.docker/cli-plugins
mv docker-buildx ~/.docker/cli-plugins/docker-buildx
docker version
- docker run --rm --privileged aptman/qus -- -r
- docker run --rm --privileged aptman/qus -s -- -p aarch64 x86_64
- docker run --rm --privileged tonistiigi/binfmt --install arm64,amd64
- echo $CI_JOB_TOKEN | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY
- echo $DOCKERHUB_TOKEN | docker login -u "$DOCKERHUB_USER" --password-stdin $DOCKERHUB_REGISTRY
- docker context create tls-environment-$CI_JOB_ID
@@ -83,7 +82,7 @@ docker-build:
--build-arg "CRAFTY_VER=${VERSION}" \
--provenance false \
$TARGS \
--platform linux/arm64/v8,linux/amd64 \
--platform linux/arm64,linux/amd64 \
--push .
else
echo "Using cache for build."
@@ -95,7 +94,7 @@ docker-build:
--build-arg "CRAFTY_VER=${VERSION}" \
--provenance false \
$TARGS \
--platform linux/arm64/v8,linux/amd64 \
--platform linux/arm64,linux/amd64 \
--push .
fi
@@ -104,5 +103,5 @@ docker-build:
- docker context rm tls-environment-$CI_JOB_ID || true
- echo "Please review multi-arch manifests are present:"
- if [ "$ENVIRONMENT_NAME" = "development" ]; then docker buildx imagetools inspect "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"; fi
- if [ "$ENVIRONMENT_NAME" = "production" ] && [ -n "$VERSION" ]; then docker buildx imagetools inspect "$CI_REGISTRY_IMAGE:$VERSION"; fi
- if [ "$ENVIRONMENT_NAME" = "production" ]; then docker buildx imagetools inspect "$CI_REGISTRY_IMAGE:$VERSION"; fi
- if [ "$ENVIRONMENT_NAME" = "nightly" ]; then docker buildx imagetools inspect "$CI_REGISTRY_IMAGE:nightly"; fi

View File

@@ -1,4 +1,21 @@
# Changelog
## --- [4.5.5] - 2025/10/14
### Bug fixes
- Fix MFA login failure when the totp `dict`'s attempted codes list changes size while being processed ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/899))
- Resolve additional json being appended to downloaded files ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/902))
- Fix certain users not showing up following a change made in `4.5.0` ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/901))
- Fix password dialogue closing with no indicator the password did not change when the passwords did not match ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/901))
- Fix empty 'Reason' when banning user from crafty UI ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/904))
### Tweaks
- Remove triple option for validation and use sole robust error ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/901))
- Allow numeric passwords ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/901))
- Enable use of `<enter>` key for password form submission ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/901))
- Add extended debug logging to help troubleshoot MFA issues ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/898))
- Change upload progress bar to monitor chunk processing ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/896))
### Lang
- Consolidate "passLength" & "numbericPassword" to single "passProp" translation for validators ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/901))
<br><br>
## --- [4.5.4] - 2025/09/15
### Bug fixes
- Refactor upload chunk removal to ensure file operations are asynchronous ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/894))

View File

@@ -1,5 +1,5 @@
[![Crafty Logo](app/frontend/static/assets/images/logo_long.svg)](https://craftycontrol.com)
# Crafty Controller 4.5.4
# Crafty Controller 4.5.5
> Python based Control Panel for your Minecraft Server
## What is Crafty Controller?

View File

@@ -62,11 +62,14 @@ class TOTPController:
_type_: _description_
"""
user = HelperUsers.get_by_id(user_id)
logger.debug("Validating TOTP entry for user %s", user.username)
authenticated = False
# Iterate through just in case a user has multiple 2FA methods
now = datetime.now(tz=timezone.utc)
logger.debug("TOTP sequence: current time is %s", now)
# Check to see if someone is trying to reuse a key in the 60 second window
logger.debug("TOTP sequence: checking for reused code.")
if str(user_id) in self.used_totp_codes:
if str(totp_code) in self.used_totp_codes[
str(user_id)
@@ -78,14 +81,17 @@ class TOTPController:
)
return authenticated
else:
logger.debug("TOTP sequence: No previous code dict found. Creating one")
self.used_totp_codes[str(user_id)] = {} # Init empty dict if not in there
logger.debug("TOTP sequence: reused code check passed. Validating user code")
# Store OTP as used for 60 seconds
self.used_totp_codes[str(user_id)][str(totp_code)] = now
self.clear_stale_entries()
for totp in user.totp_user:
logger.debug("TOTP sequence: Starting TOTP factory initialization")
totp_factory = pyotp.TOTP(totp.totp_secret)
if totp_factory.verify(
totp_code,
@@ -94,15 +100,20 @@ class TOTPController:
):
logger.info("Successfully verified user MFA %s", user_id)
authenticated = True
if not authenticated:
logger.error("TOTP sequence: Code validation failed. Code is not valid.")
return authenticated
def clear_stale_entries(self):
"""clears out totp codes older than 1 minute when one is sent"""
now = datetime.now(tz=timezone.utc)
# Clean up expired entries reclaim some memory
for key, totp_dict in self.used_totp_codes.items():
for item, timestamp in totp_dict.items():
logger.debug("Checking for used codes entries older than 1 minute")
for key, totp_dict in list(self.used_totp_codes.items()):
# Iterate over copy of dict (list) to prevent size change during iteration
for item, timestamp in list(totp_dict.items()):
if now - timestamp > timedelta(seconds=60):
logger.debug("Found saved code older than one minute. Deleting...")
# needs to ref the self var to remove expired entries
del self.used_totp_codes[ # pylint: disable=unnecessary-dict-index-lookup
key

View File

@@ -62,14 +62,10 @@ class UsersController:
"password": {
"type": "string",
"minLength": self.helper.minimum_password_length,
"pattern": "(?=.*[^0-9])",
"pattern": "^.{8,}$",
"examples": ["crafty"],
"title": "Password",
"error": {
"minLength": "passLength",
"type": "numbericPassword",
"pattern": "numbericPassword",
},
"error": "passProp",
},
"email": {
"type": "string",

View File

@@ -716,7 +716,7 @@ class PanelHandler(BaseHandler):
html += f"""
<li class="playerItem banned">
<h3>{player['name']}</h3>
<span>Banned by {player['source']} for reason: {player['reason']}</span>
<span>Banned by {player.get('source', '')} for reason: {player.get('reason', 'None')}</span>
<button onclick="send_command_to_server('pardon {player['name']}')" type="button" class="btn btn-danger">Unban</button>
</li>
"""

View File

@@ -6,6 +6,7 @@ from PIL import Image
from app.classes.models.server_permissions import EnumPermissionsServer
from app.classes.helpers.helpers import Helpers
from app.classes.web.base_api_handler import BaseApiHandler
from app.classes.web.websocket_handler import WebSocketManager
logger = logging.getLogger(__name__)
IMAGE_MIME_TYPES = [
@@ -295,6 +296,11 @@ class ApiFilesUploadHandler(BaseApiHandler):
if len(received_chunks) == total_chunks:
async with await anyio.open_file(file_path, "wb") as outfile:
for i in range(total_chunks):
WebSocketManager().broadcast_user(
auth_data[4]["user_id"],
"upload_process",
{"cur_file": i, "total_files": total_chunks, "type": u_type},
)
chunk_file = os.path.join(self.temp_dir, f"{self.filename}.part{i}")
async with await anyio.open_file(chunk_file, "rb") as infile:
await outfile.write(await infile.read())

View File

@@ -934,4 +934,4 @@ class ApiServersServerFileDownload(BaseApiHandler):
if directory_download:
os.remove(download_path)
return self.finish_json(200, {"status": "ok"})
return None

View File

@@ -101,9 +101,14 @@ class ApiUsersIndexHandler(BaseApiHandler):
offending_key = ""
if why.schema.get("fill", None):
offending_key = why.path[0] if why.path else None
schema_error = why.schema.get("error", "additionalProperties")
# We need to get the type of this for additional password property errors
translate_key = schema_error
if isinstance(schema_error, dict):
translate_key = schema_error[why.validator]
err = f"""{offending_key} {self.translator.translate(
"validators",
why.schema.get("error", "additionalProperties")[why.validator],
translate_key,
self.controller.users.get_user_lang_by_id(auth_data[4]["user_id"]),
)} {why.schema.get("enum", "")}"""
return self.finish_json(

View File

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

View File

@@ -223,4 +223,16 @@ function uuidv4() {
v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
if (webSocket) {
webSocket.on('upload_process', function (data) {
if (data.total_files === data.cur_file) {
updateProgressBar(100, data.type, data.cur_file)
} else {
let progress = Math.round((data.cur_file / data.total_files) * 100, 1);
updateProgressBar(progress, data.type, data.cur_file)
}
});
}

View File

@@ -24,40 +24,58 @@ $(document).on("submit", ".bootbox form", function (e) {
$(".edit_password").on("click", async function () {
const token = getCookie("_xsrf");
let user_id = $(this).data('id');
bootbox.confirm(`<form class="form" id='infos' action=''>\
bootbox.dialog({
message: `
<form class="form" id='infos' action=''>
<div class="form-group">
<label for="new_password">${$(this).data("translate1")}</label>
<input class="form-control" type='password' id="password0" name='new_password' /></br>\
<label for="new_password">${$(this).data("translate1")}</label>
<input class="form-control" type='password' id="password0" name='new_password' /><br>
</div>
<div class="form-group">
<label for="confirm_password">${$(this).data("translate2")}</label>
<input class="form-control" type='password' id="password1" name='confirm_password' />\
<label for="confirm_password">${$(this).data("translate2")}</label>
<input class="form-control" type='password' id="password1" name='confirm_password' />
</div>
</form>`, async function (result) {
if (result) {
let password = validateForm();
if (!password) {
return;
}
let res = await fetch(`/api/v2/users/${user_id}`, {
method: 'PATCH',
headers: {
'X-XSRFToken': token
},
body: JSON.stringify({ "password": password }),
});
let responseData = await res.json();
if (responseData.status === "ok") {
console.log(responseData.data)
} else {
</form>
`,
buttons: {
cancel: {
label: "Cancel",
className: "btn-secondary"
},
confirm: {
label: "OK",
className: "btn-primary",
callback: function () {
let password = validateForm();
if (!password) {
return false;
}
bootbox.alert({
title: responseData.status,
message: responseData.error_data
});
(async () => {
password = password.toString();
let res = await fetch(`/api/v2/users/${user_id}`, {
method: 'PATCH',
headers: { 'X-XSRFToken': token },
body: JSON.stringify({ "password": password }),
});
let responseData = await res.json();
if (responseData.status === "ok") {
console.log(responseData.data);
bootbox.hideAll();
} else {
bootbox.hideAll();
bootbox.alert({
title: responseData.status,
message: responseData.error_data
});
}
})();
}
}
}
});
});
$(".edit_user").on("click", function () {

View File

@@ -410,10 +410,14 @@ data['lang']) }}{% end %}
}
}
function replacer(key, value) {
if (typeof value == "boolean" || key === "email" || key === "permissions" || key === "roles" || key === "password") {
if (typeof value == "boolean" || key === "email" || key === "permissions" || key === "roles") {
console.log(key)
return value
} else {
} else if (key === "password"){
value = value.toString()
return value
}
else {
console.log(key, value)
return (isNaN(value) ? value : +value);
}

View File

@@ -55,7 +55,7 @@
<tr id="playerItem-{{ player }}" class="playerItem--">
<td><strong> {{ player['name'] }}</strong></td>
<td>Banned on {{ player['banned_on'] }}</td>
<td>Banned by : {{ player['source'] }} <br />Reason : {{ player['reason'] }}</td>
<td>Banned by : {{ player.get('source', '') }} <br />Reason : {{ player.get('reason', 'None') }}</td>
<td class="buttons">
<button onclick="send_command_to_server(`pardon {{ player['name'] }}`)" type="button" class="btn btn-danger">Unban</button>
</td>

View File

@@ -182,13 +182,13 @@
let formDataObject = Object.fromEntries(formData.entries());
let body = {
"username": formDataObject.username,
"password": formDataObject.password,
"password": (formDataObject.password).toString(),
}
if (formDataObject.totp != "") {
let key = $("#2fa-type").val();
body = {
"username": formDataObject.username,
"password": formDataObject.password,
"password": (formDataObject.password).toString(),
[key]: formDataObject.totp,
}
}

View File

@@ -763,8 +763,7 @@
"filesPageLen": "length must be greater than 1 for property",
"insufficientPerms": "Permission Error: Missing permissions for this resource",
"mfaName": "Input must be of type string and a minimum of 3 characters for property",
"passLength": "Password Too Short. Minimum Length: 8",
"numbericPassword": "Numeric Password. Needs at least 1 alphabetic character",
"passProp": "Password must be a string with a min length of 8 characters.",
"roleManager": "Role manager must be of type integer (manager ID) or None",
"roleName": "Role name must be a string that is greater than 1 character. It must not include any of the following symbols: [ ] , ",
"roleServerId": "Server ID property must be a string with a minimum length of 1",

View File

@@ -120,8 +120,8 @@
"datatables": {
"i18n": {
"aria": {
"sortAscending": ": activer pour trier les colonnes dans l'ordre croissant",
"sortDescending": ": activer pour trier les colonnes dans l'ordre décroissant"
"sortAscending": ": activer pour trier les colonnes dans l'ordre croissant",
"sortDescending": ": activer pour trier les colonnes dans l'ordre décroissant"
},
"buttons": {
"collection": "Collection <span class='ui-button-icon-primary ui-icon ui-icon-triangle-1-s'/>",

View File

@@ -3,7 +3,7 @@ sonar.organization=crafty-controller
# This is the name and version displayed in the SonarCloud UI.
sonar.projectName=Crafty 4
sonar.projectVersion=4.5.4
sonar.projectVersion=4.5.5
sonar.python.version=3.9, 3.10, 3.11
sonar.exclusions=app/migrations/**, app/frontend/static/assets/vendors/**