From cdf9e62e0b1a53f07d1cbef643efb0eb3d937220 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Wed, 29 Apr 2026 23:13:36 +0200 Subject: [PATCH] expose server version via PROPFIND on root collection and display in the UI for authenticated users --- radicale/app/propfind.py | 7 ++++ radicale/tests/test_base.py | 34 +++++++++++++++++++ radicale/web/internal_data/css/main.css | 11 ++++++ radicale/web/internal_data/index.html | 1 + radicale/web/internal_data/js/api/api.js | 9 +++-- .../web/internal_data/js/models/collection.js | 4 ++- .../web/internal_data/js/scenes/LoginScene.js | 6 ++++ 7 files changed, 68 insertions(+), 4 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 45f5e18d..77127274 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -168,6 +168,8 @@ def xml_propfind_response( props = [] # Should list all properties that can be retrieved by the code below props.append(xmlutils.make_clark("D:principal-collection-set")) + if user: + props.append(xmlutils.make_clark("RADICALE:version")) props.append(xmlutils.make_clark("D:current-user-principal")) props.append(xmlutils.make_clark("D:current-user-privilege-set")) props.append(xmlutils.make_clark("D:supported-report-set")) @@ -467,6 +469,11 @@ def xml_propfind_response( element.append(child_element) else: is404 = True + elif tag == xmlutils.make_clark("RADICALE:version"): + if user: + element.text = utils.package_version("radicale") + else: + is404 = True else: human_tag = xmlutils.make_human_tag(tag) tag_text = collection.get_meta(human_tag) diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index 2126f147..465d04b0 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -2310,6 +2310,40 @@ permissions: RrWw""") element = prop.find(xmlutils.make_clark("D:href")) assert element is not None and element.text == "/user/" + def test_version_exposure_authenticated(self) -> None: + """Test if the version is returned for authenticated users.""" + self.configure({"auth": {"type": "none"}}) + status, responses = self.propfind("/", """\ + + + + + +""", login="user:") + assert status == 207 + response = responses["/"] + assert not isinstance(response, int) + status_code, prop = response["RADICALE:version"] + assert status_code == 200 + assert prop.text == utils.package_version("radicale") + + def test_version_exposure_unauthenticated(self) -> None: + """Test if the version is hidden for unauthenticated users.""" + self.configure({"auth": {"type": "none"}}) + # Without login, 'user' will be empty + status, responses = self.propfind("/", """\ + + + + + +""") + assert status == 207 + response = responses["/"] + assert not isinstance(response, int) + status_code, _ = response["RADICALE:version"] + assert status_code == 404 + def test_authentication(self) -> None: """Test if server sends authentication request.""" self.configure({"auth": {"type": "htpasswd", diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css index 101e8f18..04b67885 100644 --- a/radicale/web/internal_data/css/main.css +++ b/radicale/web/internal_data/css/main.css @@ -179,6 +179,17 @@ summary:hover { float: left; } +#logoutview .version { + float: right !important; + width: auto !important; + margin-right: 10px; + position: relative; + right: 25px; + font-size: 0.8em; + color: #999; + line-height: 2.2em; +} + #collectionsscene { display: flex; flex-direction: row; diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index 5eadde28..ae6adc8c 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -22,6 +22,7 @@ Refresh Logout +
diff --git a/radicale/web/internal_data/js/api/api.js b/radicale/web/internal_data/js/api/api.js index dcc70070..788b6f2a 100644 --- a/radicale/web/internal_data/js/api/api.js +++ b/radicale/web/internal_data/js/api/api.js @@ -42,6 +42,7 @@ export function get_principal(user, password, callback) { if (xml) { let principal_element = xml.querySelector("*|multistatus:root > *|response:first-of-type > *|propstat > *|prop > *|current-user-principal > *|href"); let displayname_element = xml.querySelector("*|multistatus:root > *|response:first-of-type > *|propstat > *|prop > *|displayname"); + let version_element = xml.querySelector("*|multistatus:root > *|response:first-of-type > *|propstat > *|prop > *|version"); if (principal_element) { callback(new Collection( principal_element.textContent, @@ -52,7 +53,8 @@ export function get_principal(user, password, callback) { 0, 0, "", - []), null,); + [], + version_element ? version_element.textContent : ""), null,); } else { callback(null, "No valid XML received") } @@ -64,10 +66,11 @@ export function get_principal(user, password, callback) { } }; request.send('' + - '' + + '' + '' + '' + '' + + '' + '' + ''); return request; @@ -259,7 +262,7 @@ function _parse_collection(response, collection_href) { } if (href.endsWith("/") && href !== collection_href && type) { - return new Collection(href, type, displayname, description, sane_color, count, size, source, permissions); + return new Collection(href, type, displayname, description, sane_color, count, size, source, permissions, ""); } return null; } diff --git a/radicale/web/internal_data/js/models/collection.js b/radicale/web/internal_data/js/models/collection.js index 05474aa8..b52c3b6a 100644 --- a/radicale/web/internal_data/js/models/collection.js +++ b/radicale/web/internal_data/js/models/collection.js @@ -123,8 +123,9 @@ export class Collection { * @param {number} size * @param {string} source * @param {Array} permissions + * @param {string} version */ - constructor(href, type, displayname, description, color, contentcount, size, source, permissions) { + constructor(href, type, displayname, description, color, contentcount, size, source, permissions, version = "") { this.href = href; this.type = type; this.displayname = displayname; @@ -134,6 +135,7 @@ export class Collection { this.contentcount = contentcount; this.size = size; this.permissions = permissions; + this.version = version; } has_permission(/** @type {string} */ permission) { diff --git a/radicale/web/internal_data/js/scenes/LoginScene.js b/radicale/web/internal_data/js/scenes/LoginScene.js index 2b9dab14..a0aaddf6 100644 --- a/radicale/web/internal_data/js/scenes/LoginScene.js +++ b/radicale/web/internal_data/js/scenes/LoginScene.js @@ -44,6 +44,7 @@ export class LoginScene { this._logout_view = get_element_by_id("logoutview"); this._logout_user_form = get_element(this._logout_view, "[data-name=user]"); this._logout_btn = get_element(this._logout_view, "[data-name=logout]"); + this._logout_version_form = get_element(this._logout_view, "[data-name=version]"); this._refresh_btn = get_element(this._logout_view, "[data-name=refresh]"); this._user = ""; @@ -91,6 +92,11 @@ export class LoginScene { pop_scene(); } else if (principal_collection) { this._logout_user_form.textContent = extract_title(principal_collection) + "'s Collections"; + if (principal_collection.version) { + this._logout_version_form.textContent = "Radicale v" + principal_collection.version; + } else { + this._logout_version_form.textContent = ""; + } // clear error on successful login this._errorHandler.clearError();