From 52111c02cf2c9ca7abd61ef91c628bcde09571be Mon Sep 17 00:00:00 2001 From: Max Berger Date: Thu, 2 Apr 2026 22:30:01 +0200 Subject: [PATCH 1/2] UI: Retrieve permissions when loading collections --- radicale/web/internal_data/js/api/api.js | 201 ++++++++++++------ .../web/internal_data/js/models/collection.js | 4 +- .../js/scenes/CreateEditCollectionScene.js | 2 +- 3 files changed, 137 insertions(+), 70 deletions(-) diff --git a/radicale/web/internal_data/js/api/api.js b/radicale/web/internal_data/js/api/api.js index 5b65e5ce..dcc70070 100644 --- a/radicale/web/internal_data/js/api/api.js +++ b/radicale/web/internal_data/js/api/api.js @@ -51,7 +51,8 @@ export function get_principal(user, password, callback) { "", 0, 0, - ""), null); + "", + []), null,); } else { callback(null, "No valid XML received") } @@ -94,81 +95,20 @@ export function get_collections(user, password, collection, callback) { let response_query = "*|multistatus:root > *|response"; let responses = xml.querySelectorAll(response_query); for (let i = 0; i < responses.length; i++) { - let response = responses[i]; - let href_element = response.querySelector(response_query + " > *|href"); - let resourcetype_query = response_query + " > *|propstat > *|prop > *|resourcetype"; - let resourcetype_element = response.querySelector(resourcetype_query); - let displayname_element = response.querySelector(response_query + " > *|propstat > *|prop > *|displayname"); - let calendarcolor_element = response.querySelector(response_query + " > *|propstat > *|prop > *|calendar-color"); - let addressbookcolor_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-color"); - let calendardesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|calendar-description"); - let addressbookdesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-description"); - let contentcount_element = response.querySelector(response_query + " > *|propstat > *|prop > *|getcontentcount"); - let contentlength_element = response.querySelector(response_query + " > *|propstat > *|prop > *|getcontentlength"); - let webcalsource_element = response.querySelector(response_query + " > *|propstat > *|prop > *|source"); - let components_query = response_query + " > *|propstat > *|prop > *|supported-calendar-component-set"; - let components_element = response.querySelector(components_query); - let href = href_element ? href_element.textContent : ""; - let displayname = displayname_element ? displayname_element.textContent : ""; - let type = ""; - let color = ""; - let description = ""; - let source = ""; - let count = 0; - let size = 0; - if (resourcetype_element) { - if (resourcetype_element.querySelector(resourcetype_query + " > *|addressbook")) { - type = CollectionType.ADDRESSBOOK; - color = addressbookcolor_element ? addressbookcolor_element.textContent : ""; - description = addressbookdesc_element ? addressbookdesc_element.textContent : ""; - count = contentcount_element ? parseInt(contentcount_element.textContent) : 0; - size = contentlength_element ? parseInt(contentlength_element.textContent) : 0; - } else if (resourcetype_element.querySelector(resourcetype_query + " > *|subscribed")) { - type = CollectionType.WEBCAL; - source = webcalsource_element ? webcalsource_element.textContent : ""; - color = calendarcolor_element ? calendarcolor_element.textContent : ""; - description = calendardesc_element ? calendardesc_element.textContent : ""; - } else if (resourcetype_element.querySelector(resourcetype_query + " > *|calendar")) { - if (components_element) { - if (components_element.querySelector(components_query + " > *|comp[name=VEVENT]")) { - type = CollectionType.union(type, CollectionType.CALENDAR); - } - if (components_element.querySelector(components_query + " > *|comp[name=VJOURNAL]")) { - type = CollectionType.union(type, CollectionType.JOURNAL); - } - if (components_element.querySelector(components_query + " > *|comp[name=VTODO]")) { - type = CollectionType.union(type, CollectionType.TASKS); - } - } - color = calendarcolor_element ? calendarcolor_element.textContent : ""; - description = calendardesc_element ? calendardesc_element.textContent : ""; - count = contentcount_element ? parseInt(contentcount_element.textContent) : 0; - size = contentlength_element ? parseInt(contentlength_element.textContent) : 0; - } - } - let sane_color = color.trim(); - if (sane_color) { - let color_match = COLOR_RE.exec(sane_color); - if (color_match) { - sane_color = color_match[1]; - } else { - sane_color = ""; - } - } - if (href.substr(-1) === "/" && href !== collection.href && type) { - collections.push(new Collection(href, type, displayname, description, sane_color, count, size, source)); + let parsedCollection = _parse_collection(responses[i], collection.href); + if (parsedCollection) { + collections.push(parsedCollection); } } collections.sort(function (a, b) { - /** @type {string} */ let ca = a.displayname || a.href; - /** @type {string} */ let cb = b.displayname || b.href; + /** @type {string} */ let ca = a.displayname || a.href; + /** @type {string} */ let cb = b.displayname || b.href; return ca.localeCompare(cb); }); callback(collections, null); } else { - callback(null, "No valid XML received") + callback(null, "No valid XML received"); } - } else { callback(null, to_error_message(request)); } @@ -194,11 +134,136 @@ export function get_collections(user, password, collection, callback) { '' + '' + '' + + '' + '' + ''); return request; } +/** + * Parses permissions from the current-user-privilege-set element. + * @param {Element|null} current_user_privilege_set_element + * @returns {Array} + */ +function _parse_permissions(current_user_privilege_set_element) { + if (!current_user_privilege_set_element) return []; + let permissions = []; + let privileges = current_user_privilege_set_element.querySelectorAll("*|privilege"); + for (let j = 0; j < privileges.length; j++) { + let privilege = privileges[j]; + let privilege_children = privilege.children; + for (let k = 0; k < privilege_children.length; k++) { + let child = privilege_children[k]; + let prefix = "D:"; + if (child.namespaceURI) { + if (child.namespaceURI === "DAV:") { + prefix = "D:"; + } else if (child.namespaceURI === "http://radicale.org/ns/") { + prefix = "RADICALE:"; + } else { + prefix = child.namespaceURI + ":"; + } + } else if (child.nodeName.includes(":")) { + prefix = ""; // nodeName already contains prefix + } + let permName = child.localName || child.nodeName; + if (!permName.includes(":")) { + permName = prefix + permName; + } + permissions.push(permName); + } + } + return permissions; +} + +/** + * Parses a single response element into a Collection object. + * @param {Element} response + * @param {string} collection_href + * @returns {Collection|null} + */ +function _parse_collection(response, collection_href) { + let href_element = response.querySelector("*|href"); + let current_user_privilege_set_element = response.querySelector("*|propstat > *|prop > *|current-user-privilege-set"); + let resourcetype_element = response.querySelector("*|propstat > *|prop > *|resourcetype"); + let displayname_element = response.querySelector("*|propstat > *|prop > *|displayname"); + + let href = href_element ? href_element.textContent : ""; + let displayname = displayname_element ? displayname_element.textContent : ""; + let type = ""; + let color = ""; + let description = ""; + let source = ""; + let count = 0; + let size = 0; + let permissions = _parse_permissions(current_user_privilege_set_element); + + if (resourcetype_element) { + if (resourcetype_element.querySelector("*|addressbook")) { + type = CollectionType.ADDRESSBOOK; + let addressbookcolor_element = response.querySelector("*|propstat > *|prop > *|addressbook-color"); + let addressbookdesc_element = response.querySelector("*|propstat > *|prop > *|addressbook-description"); + let contentcount_element = response.querySelector("*|propstat > *|prop > *|getcontentcount"); + let contentlength_element = response.querySelector("*|propstat > *|prop > *|getcontentlength"); + + color = addressbookcolor_element ? addressbookcolor_element.textContent : ""; + description = addressbookdesc_element ? addressbookdesc_element.textContent : ""; + count = contentcount_element ? parseInt(contentcount_element.textContent, 10) : 0; + size = contentlength_element ? parseInt(contentlength_element.textContent, 10) : 0; + if (isNaN(count)) count = 0; + if (isNaN(size)) size = 0; + } else if (resourcetype_element.querySelector("*|subscribed")) { + type = CollectionType.WEBCAL; + let webcalsource_element = response.querySelector("*|propstat > *|prop > *|source"); + let calendarcolor_element = response.querySelector("*|propstat > *|prop > *|calendar-color"); + let calendardesc_element = response.querySelector("*|propstat > *|prop > *|calendar-description"); + + source = webcalsource_element ? webcalsource_element.textContent : ""; + color = calendarcolor_element ? calendarcolor_element.textContent : ""; + description = calendardesc_element ? calendardesc_element.textContent : ""; + } else if (resourcetype_element.querySelector("*|calendar")) { + let components_element = response.querySelector("*|propstat > *|prop > *|supported-calendar-component-set"); + if (components_element) { + if (components_element.querySelector("*|comp[name=VEVENT]")) { + type = CollectionType.union(type, CollectionType.CALENDAR); + } + if (components_element.querySelector("*|comp[name=VJOURNAL]")) { + type = CollectionType.union(type, CollectionType.JOURNAL); + } + if (components_element.querySelector("*|comp[name=VTODO]")) { + type = CollectionType.union(type, CollectionType.TASKS); + } + } + let calendarcolor_element = response.querySelector("*|propstat > *|prop > *|calendar-color"); + let calendardesc_element = response.querySelector("*|propstat > *|prop > *|calendar-description"); + let contentcount_element = response.querySelector("*|propstat > *|prop > *|getcontentcount"); + let contentlength_element = response.querySelector("*|propstat > *|prop > *|getcontentlength"); + + color = calendarcolor_element ? calendarcolor_element.textContent : ""; + description = calendardesc_element ? calendardesc_element.textContent : ""; + count = contentcount_element ? parseInt(contentcount_element.textContent, 10) : 0; + size = contentlength_element ? parseInt(contentlength_element.textContent, 10) : 0; + if (isNaN(count)) count = 0; + if (isNaN(size)) size = 0; + } + } + + let sane_color = color.trim(); + if (sane_color) { + let color_match = COLOR_RE.exec(sane_color); + if (color_match) { + sane_color = color_match[1]; + } else { + sane_color = ""; + } + } + + if (href.endsWith("/") && href !== collection_href && type) { + return new Collection(href, type, displayname, description, sane_color, count, size, source, permissions); + } + return null; +} + /** * @param {string} user * @param {?string} password diff --git a/radicale/web/internal_data/js/models/collection.js b/radicale/web/internal_data/js/models/collection.js index d8e007a2..1e51224c 100644 --- a/radicale/web/internal_data/js/models/collection.js +++ b/radicale/web/internal_data/js/models/collection.js @@ -110,8 +110,9 @@ export class Collection { * @param {number} contentcount * @param {number} size * @param {string} source + * @param {Array} permissions */ - constructor(href, type, displayname, description, color, contentcount, size, source) { + constructor(href, type, displayname, description, color, contentcount, size, source, permissions) { this.href = href; this.type = type; this.displayname = displayname; @@ -120,5 +121,6 @@ export class Collection { this.source = source; this.contentcount = contentcount; this.size = size; + this.permissions = permissions; } } \ No newline at end of file diff --git a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js index 18583c81..5e60eda9 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js @@ -137,7 +137,7 @@ export class CreateEditCollectionScene { } let loading_scene = new LoadingScene(); push_scene(loading_scene); - let collection = new Collection(this._href, this._type, this._displayname, this._description, sane_color, 0, 0, this._source); + let collection = new Collection(this._href, this._type, this._displayname, this._description, sane_color, 0, 0, this._source, []); let callback = (/** @type {?string} */ error1) => { if (!is_current_scene(loading_scene)) { return; From 327da6b9c354eaaed8f008e0943ec4ed524bc8c8 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sat, 4 Apr 2026 18:34:06 +0200 Subject: [PATCH 2/2] UI: Base sharing settings on collection permissions rather than server features --- integ_tests/test_sharing.py | 100 +++++++++++++++++- .../js/scenes/CollectionsScene.js | 16 ++- .../js/scenes/ShareCollectionScene.js | 68 +++++------- 3 files changed, 137 insertions(+), 47 deletions(-) diff --git a/integ_tests/test_sharing.py b/integ_tests/test_sharing.py index 94bde826..5be65b99 100644 --- a/integ_tests/test_sharing.py +++ b/integ_tests/test_sharing.py @@ -24,8 +24,8 @@ from typing import Any, Generator import pytest from playwright.sync_api import BrowserContext, Page, expect -from integ_tests.common import (SHARING_HTPASSWD, SHARING_XREMOTE, Config, - create_collection, login, +from integ_tests.common import (SHARING_HTPASSWD, SHARING_XREMOTE, AuthType, + Config, SharingType, create_collection, login, start_radicale_server) @@ -463,3 +463,99 @@ def test_bday_section_visible_for_addressbook( page.click('button[data-name="sharebymap"]') expect(page.locator("details[data-name='conversions']")).to_be_visible() page.click('#newshare button[data-name="cancel"]') + + +@pytest.fixture +def map_disabled_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + config = Config( + name="map_disabled", + auth_type=AuthType.HTPASSWD, + sharing_type=SharingType.NOSHARING, + extra_config=f"""[sharing] +type = csv +collection_by_map = false +collection_by_token = true +permit_create_token = true +permit_create_map = false +database_path = {tmp_path / 'sharing.csv'} +""", + ) + yield from start_radicale_server(tmp_path, config) + + +@pytest.fixture +def token_disabled_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + config = Config( + name="token_disabled", + auth_type=AuthType.HTPASSWD, + sharing_type=SharingType.NOSHARING, + extra_config=f"""[sharing] +type = csv +collection_by_map = true +collection_by_token = false +permit_create_token = false +permit_create_map = true +database_path = {tmp_path / 'sharing.csv'} +""", + ) + yield from start_radicale_server(tmp_path, config) + + +@pytest.fixture +def both_disabled_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + config = Config( + name="both_disabled", + auth_type=AuthType.HTPASSWD, + sharing_type=SharingType.NOSHARING, + extra_config=f"""[sharing] +type = csv +collection_by_map = false +collection_by_token = false +permit_create_token = false +permit_create_map = false +database_path = {tmp_path / 'sharing.csv'} +""", + ) + yield from start_radicale_server(tmp_path, config) + + +def test_map_sharing_disabled( + context: BrowserContext, page: Page, map_disabled_server: str +) -> None: + login(page, map_disabled_server, SHARING_HTPASSWD, context=context) + create_collection(page, map_disabled_server) + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + + expect( + page.locator('#sharecollectionscene button[data-name="sharebymap"]') + ).to_be_hidden() + expect( + page.locator('#sharecollectionscene button[data-name="sharebytoken"]') + ).to_be_visible() + + +def test_token_sharing_disabled( + context: BrowserContext, page: Page, token_disabled_server: str +) -> None: + login(page, token_disabled_server, SHARING_HTPASSWD, context=context) + create_collection(page, token_disabled_server) + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + + expect( + page.locator('#sharecollectionscene button[data-name="sharebytoken"]') + ).to_be_hidden() + expect( + page.locator('#sharecollectionscene button[data-name="sharebymap"]') + ).to_be_visible() + + +def test_both_sharing_disabled( + context: BrowserContext, page: Page, both_disabled_server: str +) -> None: + login(page, both_disabled_server, SHARING_HTPASSWD, context=context) + create_collection(page, both_disabled_server) + page.hover("article:not(.hidden)") + + expect(page.locator('article:not(.hidden) a[data-name="share"]')).to_be_hidden() diff --git a/radicale/web/internal_data/js/scenes/CollectionsScene.js b/radicale/web/internal_data/js/scenes/CollectionsScene.js index 8bd5e11b..b4d85f07 100644 --- a/radicale/web/internal_data/js/scenes/CollectionsScene.js +++ b/radicale/web/internal_data/js/scenes/CollectionsScene.js @@ -25,11 +25,11 @@ import { Collection, CollectionType } from "../models/collection.js"; import { collectionsCache } from "../utils/collections_cache.js"; import { ErrorHandler } from "../utils/error.js"; import { bytesToHumanReadable, get_element, get_element_by_id } from "../utils/misc.js"; +import { UrlTextHandler } from "../utils/url_text.js"; import { CreateEditCollectionScene } from "./CreateEditCollectionScene.js"; import { DeleteConfirmationScene } from "./DeleteConfirmationScene.js"; import { IncomingSharingScene } from "./IncomingSharingScene.js"; import { Scene, push_scene } from "./scene_manager.js"; -import { UrlTextHandler } from "../utils/url_text.js"; import { ShareCollectionScene, maybe_enable_sharing_options } from "./ShareCollectionScene.js"; import { UploadCollectionScene } from "./UploadCollectionScene.js"; @@ -187,6 +187,20 @@ export class CollectionsScene { get_element(node, "[data-name=" + e + "]").classList.add("hidden"); } }); + + let share_option = get_element(node, "[data-name=shareoption]"); + let can_share = collection.permissions && ( + collection.permissions.includes("RADICALE:share-map") || + collection.permissions.includes("RADICALE:share-token") + ); + if (share_option) { + if (can_share) { + share_option.classList.remove("hidden"); + } else { + share_option.classList.add("hidden"); + } + } + let share_info = get_element(node, "[data-name=shared-by]"); let transformed_from = get_element(node, "[data-name=transformed-from]"); let share = (shares || []).find( diff --git a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js index 186e6eac..d8b71d08 100644 --- a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js @@ -25,14 +25,14 @@ import { reload_sharing_list, } from "../api/sharing.js"; import { Collection } from "../models/collection.js"; -import { collectionsCache } from "../utils/collections_cache.js"; + import { ErrorHandler } from "../utils/error.js"; import { get_element, get_element_by_id } from "../utils/misc.js"; import { displayPermissionsOrConversion } from "../utils/permissions.js"; +import { UrlTextHandler } from "../utils/url_text.js"; import { CreateEditShareScene } from "./CreateEditShareScene.js"; import { DeleteConfirmationScene } from "./DeleteConfirmationScene.js"; import { Scene, pop_scene, push_scene } from "./scene_manager.js"; -import { UrlTextHandler } from "../utils/url_text.js"; /** * @implements {Scene} @@ -88,38 +88,30 @@ export class ShareCollectionScene { }); this._cancel_btn.onclick = () => this._oncancel(); - collectionsCache.getServerFeatures(this._user, this._password, this._errorHandler.setError, (features) => { - if (features.sharing && features.sharing.PermittedCreateCollectionByToken) { - if (this._share_by_token_btn) { - this._share_by_token_btn.classList.remove("hidden"); - this._share_by_token_btn.onclick = () => this._onsharebytoken(); - } - } else { - if (this._share_by_token_btn) this._share_by_token_btn.classList.add("hidden"); - } + let can_share_by_token = this._collection.permissions && this._collection.permissions.includes("RADICALE:share-token"); + let can_share_by_map = this._collection.permissions && this._collection.permissions.includes("RADICALE:share-map"); - if (features.sharing && features.sharing.FeatureEnabledCollectionByToken) { - if (this._share_by_token_div) this._share_by_token_div.classList.remove("hidden"); - } else { - if (this._share_by_token_div) this._share_by_token_div.classList.add("hidden"); + if (can_share_by_token) { + if (this._share_by_token_btn) { + this._share_by_token_btn.classList.remove("hidden"); + this._share_by_token_btn.onclick = () => this._onsharebytoken(); } + if (this._share_by_token_div) this._share_by_token_div.classList.remove("hidden"); + } else { + if (this._share_by_token_btn) this._share_by_token_btn.classList.add("hidden"); + if (this._share_by_token_div) this._share_by_token_div.classList.add("hidden"); + } - if (features.sharing && features.sharing.PermittedCreateCollectionByMap) { - if (this._share_by_map_btn) { - this._share_by_map_btn.classList.remove("hidden"); - this._share_by_map_btn.onclick = () => this._onsharebymap(); - } - } else { - if (this._share_by_map_btn) this._share_by_map_btn.classList.add("hidden"); + if (can_share_by_map) { + if (this._share_by_map_btn) { + this._share_by_map_btn.classList.remove("hidden"); + this._share_by_map_btn.onclick = () => this._onsharebymap(); } - - if (features.sharing && features.sharing.FeatureEnabledCollectionByMap) { - if (this._share_by_map_div) this._share_by_map_div.classList.remove("hidden"); - } else { - if (this._share_by_map_div) this._share_by_map_div.classList.add("hidden"); - } - - }); + if (this._share_by_map_div) this._share_by_map_div.classList.remove("hidden"); + } else { + if (this._share_by_map_btn) this._share_by_map_btn.classList.add("hidden"); + if (this._share_by_map_div) this._share_by_map_div.classList.add("hidden"); + } this._title.textContent = this._collection.displayname || this._collection.href; update_share_list(this._user, this._password, this._collection, this._errorHandler); @@ -245,23 +237,11 @@ function add_share_rows(user, password, collection, shares, errorHandler) { */ export function maybe_enable_sharing_options(features) { if (!features || !features.sharing) return; - let map_is_enabled = features.sharing.FeatureEnabledCollectionByMap || false; - let token_is_enabled = features.sharing.FeatureEnabledCollectionByToken || false; - let any_sharing_enabled = map_is_enabled || token_is_enabled; - - let share_options = document.querySelectorAll("[data-name=shareoption]"); - for (let i = 0; i < share_options.length; i++) { - let share_option = share_options[i]; - if (any_sharing_enabled) { - share_option.classList.remove("hidden"); - } else { - share_option.classList.add("hidden"); - } - } + let has_sharing = features.sharing.ApiVersion !== undefined; let incomingshares_btn = document.querySelector("#collectionsscene [data-name=incomingshares]"); if (incomingshares_btn) { - if (any_sharing_enabled) { + if (has_sharing) { incomingshares_btn.classList.remove("hidden"); } else { incomingshares_btn.classList.add("hidden");