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/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/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/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;
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");