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();