diff --git a/integ_tests/__init__.py b/integ_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/integ_tests/common.py b/integ_tests/common.py new file mode 100644 index 00000000..aedc13be --- /dev/null +++ b/integ_tests/common.py @@ -0,0 +1,99 @@ +import os +import pathlib +import socket +import subprocess +import sys +import time +from typing import Any, Generator + +from playwright.sync_api import Page + + +def get_free_port(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def start_radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + port = get_free_port() + config_path = tmp_path / "config" + user_path = tmp_path / "users" + storage_path = tmp_path / "collections" + + # Create a local config file + with open(config_path, "w") as f: + f.write( + f"""[server] +hosts = 127.0.0.1:{port} +[storage] +filesystem_folder = {storage_path} +[auth] +type = htpasswd +htpasswd_filename = {user_path} +[web] +type = internal +[sharing] +type = csv +collection_by_map = true +collection_by_token = true +permit_create_token = true +permit_create_map = true + +""" + ) + with open(user_path, "w") as f: + f.write( + """admin:adminpassword +""" + ) + + env = os.environ.copy() + # Ensure the radicale package is in PYTHONPATH + # Assuming this test file is in /integ_tests/ + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "") + + # Run the server + process = subprocess.Popen( + [sys.executable, "-m", "radicale", "--config", str(config_path)], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + # Wait for the server to start listening + start_time = time.time() + while time.time() - start_time < 10: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.1): + break + except (OSError, ConnectionRefusedError): + if process.poll() is not None: + _stdout, stderr = process.communicate() + raise RuntimeError( + f"Radicale failed to start (code {process.returncode}):\n{stderr.decode()}" + ) + time.sleep(0.1) + else: + process.terminate() + process.wait() + raise RuntimeError("Timeout waiting for Radicale to start") + + yield f"http://127.0.0.1:{port}" + + # Cleanup + process.terminate() + process.wait() + + +def login(page: Page, radicale_server: str) -> None: + page.goto(radicale_server) + page.fill('#loginscene input[data-name="user"]', "admin") + page.fill('#loginscene input[data-name="password"]', "adminpassword") + page.click('button:has-text("Next")') + + +def create_collection(page: Page, radicale_server: str) -> None: + page.click('.fabcontainer a[data-name="new"]') + page.click('#createcollectionscene button[data-name="submit"]') diff --git a/integ_tests/test_basic_operation.py b/integ_tests/test_basic_operation.py index e0d07994..def5dc07 100644 --- a/integ_tests/test_basic_operation.py +++ b/integ_tests/test_basic_operation.py @@ -1,88 +1,20 @@ -import os -import socket -import subprocess -import sys -import time +import pathlib +from typing import Any, Generator import pytest from playwright.sync_api import Page, expect - -def get_free_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] +from integ_tests.common import login, start_radicale_server @pytest.fixture -def radicale_server(tmp_path): - port = get_free_port() - config_path = tmp_path / "config" - user_path = tmp_path / "users" - storage_path = tmp_path / "collections" - - # Create a local config file - with open(config_path, "w") as f: - f.write( - f"""[server] -hosts = 127.0.0.1:{port} -[storage] -filesystem_folder = {storage_path} -[auth] -type = htpasswd -htpasswd_filename = {user_path} -[web] -type = internal -""" - ) - with open(user_path, "w") as f: - f.write( - """admin:adminpassword -""" - ) - - env = os.environ.copy() - # Ensure the radicale package is in PYTHONPATH - # Assuming this test file is in /integ_tests/ - repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) - env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "") - - # Run the server - process = subprocess.Popen( - [sys.executable, "-m", "radicale", "--config", str(config_path)], - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - # Wait for the server to start listening - start_time = time.time() - while time.time() - start_time < 10: - try: - with socket.create_connection(("127.0.0.1", port), timeout=0.1): - break - except (OSError, ConnectionRefusedError): - if process.poll() is not None: - stdout, stderr = process.communicate() - raise RuntimeError( - f"Radicale failed to start (code {process.returncode}):\n{stderr.decode()}" - ) - time.sleep(0.1) - else: - process.terminate() - process.wait() - raise RuntimeError("Timeout waiting for Radicale to start") - - yield f"http://127.0.0.1:{port}" - - # Cleanup - process.terminate() - process.wait() +def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + yield from start_radicale_server(tmp_path) -def test_index_html_loads(page: Page, radicale_server): +def test_index_html_loads(page: Page, radicale_server: str) -> None: """Test that the index.html loads from the server.""" - console_msgs = [] + console_msgs: list[str] = [] page.on("console", lambda msg: console_msgs.append(msg.text)) page.goto(radicale_server) expect(page).to_have_title("Radicale Web Interface") @@ -90,13 +22,9 @@ def test_index_html_loads(page: Page, radicale_server): assert len(console_msgs) == 0 -def test_user_login_works(page: Page, radicale_server): +def test_user_login_works(page: Page, radicale_server: str) -> None: """Test that the login form works.""" - page.goto(radicale_server) - # Fill in the login form - page.fill('#loginscene input[data-name="user"]', "admin") - page.fill('#loginscene input[data-name="password"]', "adminpassword") - page.click('button:has-text("Next")') + login(page, radicale_server) # After login, we should see the collections list (which is empty) expect( diff --git a/integ_tests/test_sharing.py b/integ_tests/test_sharing.py new file mode 100644 index 00000000..63d7456f --- /dev/null +++ b/integ_tests/test_sharing.py @@ -0,0 +1,46 @@ +import pathlib +from typing import Any, Generator + +import pytest +from playwright.sync_api import Page, expect + +from integ_tests.common import create_collection, login, start_radicale_server + + +@pytest.fixture +def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + yield from start_radicale_server(tmp_path) + + +def test_create_and_delete_share_by_key(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(0) + + page.click('button[data-name="sharebytoken_ro"]') + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(1) + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden) img[alt='RO']") + ).to_be_visible() + page.click('tr:not(.hidden) button[data-name="delete"]', strict=True) + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(0) + page.click('button[data-name="sharebytoken_rw"]') + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(1) + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden) img[alt='RW']") + ).to_be_visible() + page.click('tr:not(.hidden) button[data-name="delete"]', strict=True) + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(0) diff --git a/pyproject.toml b/pyproject.toml index 297f57a5..7b322d44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,11 +98,7 @@ radicale = [ "web/internal_data/css/loading.svg", "web/internal_data/css/logo.svg", "web/internal_data/css/main.css", - "web/internal_data/css/icons/delete.svg", - "web/internal_data/css/icons/download.svg", - "web/internal_data/css/icons/edit.svg", - "web/internal_data/css/icons/new.svg", - "web/internal_data/css/icons/upload.svg", + "web/internal_data/css/icons/*.svg", "web/internal_data/*.js", "web/internal_data/index.html", "py.typed", diff --git a/radicale/web/internal_data/CollectionsScene.js b/radicale/web/internal_data/CollectionsScene.js index 214c3a43..2c8b05fa 100644 --- a/radicale/web/internal_data/CollectionsScene.js +++ b/radicale/web/internal_data/CollectionsScene.js @@ -20,6 +20,7 @@ import { Scene, push_scene, pop_scene, scene_stack } from "./scene_manager.js"; import { CreateEditCollectionScene } from "./CreateEditCollectionScene.js"; +import { CreateShareCollectionScene } from "./ShareCollectionScene.js"; import { UploadCollectionScene } from "./UploadCollectionScene.js"; import { DeleteCollectionScene } from "./DeleteCollectionScene.js"; import { LoadingScene } from "./LoadingScene.js"; @@ -78,6 +79,16 @@ export function CollectionsScene(user, password, collection, onerror) { return false; } + function onshare(collection) { + try { + let share_collection_scene = new CreateShareCollectionScene(user, password, collection); + push_scene(share_collection_scene, false); + } catch(err) { + console.error(err); + } + return false; + } + function ondelete(collection) { try { let delete_collection_scene = new DeleteCollectionScene(user, password, collection); @@ -102,6 +113,7 @@ export function CollectionsScene(user, password, collection, onerror) { let color_form = node.querySelector("[data-name=color]"); let delete_btn = node.querySelector("[data-name=delete]"); let edit_btn = node.querySelector("[data-name=edit]"); + let share_btn = node.querySelector("[data-name=share]"); let download_btn = node.querySelector("[data-name=download]"); if (collection.color) { color_form.style.background = collection.color; @@ -144,6 +156,7 @@ export function CollectionsScene(user, password, collection, onerror) { } delete_btn.onclick = function() {return ondelete(collection);}; edit_btn.onclick = function() {return onedit(collection);}; + share_btn.onclick = function() {return onshare(collection);}; node.classList.remove("hidden"); nodes.push(node); template.parentNode.insertBefore(node, template); diff --git a/radicale/web/internal_data/LoginScene.js b/radicale/web/internal_data/LoginScene.js index 1314df08..63c68f2f 100644 --- a/radicale/web/internal_data/LoginScene.js +++ b/radicale/web/internal_data/LoginScene.js @@ -20,8 +20,9 @@ import { Scene, push_scene, pop_scene, scene_stack } from "./scene_manager.js"; import { LoadingScene } from "./LoadingScene.js"; -import { get_principal } from "./api.js"; +import { get_principal, discover_server_features } from "./api.js"; import { CollectionsScene } from "./CollectionsScene.js"; +import { maybe_enable_sharing_options } from "./ShareCollectionScene.js"; /** * @constructor @@ -89,6 +90,7 @@ export function LoginScene() { error = error1; user = saved_user; }); + discover_server_features(saved_user, password, maybe_enable_sharing_options); push_scene(collections_scene, true); } }); diff --git a/radicale/web/internal_data/ShareCollectionScene.js b/radicale/web/internal_data/ShareCollectionScene.js new file mode 100644 index 00000000..37a830bf --- /dev/null +++ b/radicale/web/internal_data/ShareCollectionScene.js @@ -0,0 +1,164 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { + add_share_by_token, + delete_share_by_token, + reload_sharing_list, + server_features, +} from "./api.js"; +import { pop_scene, scene_stack } from "./scene_manager.js"; + +/** + * @constructor + * @implements {Scene} + * @param {string} user + * @param {string} password + * @param {Collection} collection The collection on which to edit sharing setting. Must exist. + */ +export function CreateShareCollectionScene(user, password, collection) { + /** @type {?number} */ let scene_index = null; + + let html_scene = document.getElementById("sharecollectionscene"); + + let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + let share_by_token_btn_ro = html_scene.querySelector( + "[data-name=sharebytoken_ro]", + ); + let share_by_token_btn_rw = html_scene.querySelector( + "[data-name=sharebytoken_rw]", + ); + + let title = html_scene.querySelector("[data-name=title]"); + + function oncancel() { + try { + pop_scene(scene_index - 1); + } catch (err) { + console.error(err); + } + return false; + } + + function onsharebytoken_rw() { + add_share_by_token(user, password, collection, "rw", function () { + update_share_list(user, password, collection); + }); + } + + function onsharebytoken_ro() { + add_share_by_token(user, password, collection, "r", function () { + update_share_list(user, password, collection); + }); + } + + this.show = function () { + this.release(); + scene_index = scene_stack.length - 1; + html_scene.classList.remove("hidden"); + cancel_btn.onclick = oncancel; + if (server_features["sharing"]["FeatureEnabledCollectionByToken"]) { + share_by_token_btn_ro.onclick = onsharebytoken_ro; + share_by_token_btn_rw.onclick = onsharebytoken_rw; + } else { + share_by_token_btn_ro.parentElement.removeChild(share_by_token_btn_ro); + share_by_token_btn_rw.parentElement.removeChild(share_by_token_btn_rw); + } + title.textContent = collection.displayname || collection.href; + update_share_list(user, password, collection); + }; + this.hide = function () { + html_scene.classList.add("hidden"); + cancel_btn.onclick = null; + }; + this.release = function () { + scene_index = null; + }; +} + +function update_share_list(user, password, collection) { + let share_rows = document.querySelectorAll( + "[data-name=sharetokenrowtemplate]", + ); + share_rows.forEach(function (row) { + if (!row.classList.contains("hidden")) { + row.parentNode.removeChild(row); + } + }); + + reload_sharing_list(user, password, collection, function (response) { + add_share_rows(user, password, collection, response["Content"] || []); + }); +} + +function add_share_rows(user, password, collection, shares) { + let template = document.querySelector("[data-name=sharetokenrowtemplate]"); + shares.forEach(function (share) { + let pathortoken = share["PathOrToken"] || ""; + let pathmapped = share["PathMapped"] || ""; + if ( + collection.href.includes(pathmapped) || + collection.href.includes(pathortoken) + ) { + let node = template.cloneNode(true); + node.classList.remove("hidden"); + node.querySelector("[data-name=pathortoken]").value = pathortoken; + let permissions = (share["Permissions"] || "").toLowerCase(); + if (permissions === "rw") { + node + .querySelector("[data-name=ro]") + .parentNode.removeChild(node.querySelector("[data-name=ro]")); + } else if (permissions === "r") { + node + .querySelector("[data-name=rw]") + .parentNode.removeChild(node.querySelector("[data-name=rw]")); + } else { + console.warn("Unknown permissions", permissions); + } + node.querySelector("[data-name=delete]").onclick = function () { + delete_share_by_token( + user, + password, + share["PathOrToken"], + function () { + update_share_list(user, password, collection); + }, + ); + }; + + template.parentNode.insertBefore(node, template); + } + }); +} + +export function maybe_enable_sharing_options() { + if (!server_features["sharing"]) return; + let map_is_enabled = + server_features["sharing"]["FeatureEnabledCollectionByMap"] || false; + let token_is_enabled = + server_features["sharing"]["FeatureEnabledCollectionByToken"] || false; + if (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]; + share_option.classList.remove("hidden"); + } + } +} diff --git a/radicale/web/internal_data/api.js b/radicale/web/internal_data/api.js index 3a84dfb4..eaf818ac 100644 --- a/radicale/web/internal_data/api.js +++ b/radicale/web/internal_data/api.js @@ -22,6 +22,8 @@ import { Collection, CollectionType } from "./models.js"; import { SERVER, ROOT_PATH, COLOR_RE } from "./constants.js"; import { escape_xml } from "./utils.js"; +export let server_features = {}; + /** * Find the principal collection. * @param {string} user @@ -339,4 +341,131 @@ export function create_collection(user, password, collection, callback) { */ export function edit_collection(user, password, collection, callback) { return create_edit_collection(user, password, collection, false, callback); -} \ No newline at end of file +} +/* Sharing API */ + +function call_sharing_api( + user, + password, + path, + body, + on_success, + on_not_found = null, + on_error = null, +) { + let request = new XMLHttpRequest(); + request.open( + "POST", + SERVER + ROOT_PATH + ".sharing/v1/" + path, + true, + user, + encodeURIComponent(password), + ); + request.onreadystatechange = function () { + if (request.readyState !== 4) { + return; + } + if (200 <= request.status && request.status < 300) { + on_success(request.responseText); + } else if (request.status === 404) { + if (on_not_found) { + on_not_found(); + } else if (on_error) { + on_error("Not found"); + } else { + console.error("Not found"); + } + } else { + if (on_error) { + on_error(request.status + " " + request.statusText); + } else { + console.error(request.status + " " + request.statusText); + } + } + }; + request.setRequestHeader("Accept", "application/json"); + request.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + request.send(body ? JSON.stringify(body) : null); + return request; +} + +export function discover_server_features(user, password, callback) { + call_sharing_api( + user, + password, + "all/info", + {}, + function (response) { + server_features["sharing"] = JSON.parse(response); + callback(); + }, + function () { + // sharing is disabled on the server + server_features["sharing"] = {}; + callback(); + }, + function (error) { + console.error("Failed to discover sharing features: " + error); + }, + ); +} + +export function reload_sharing_list(user, password, collection, callback) { + call_sharing_api( + user, + password, + "all/list", + { PathMapped: collection.href }, + function (response) { + callback(JSON.parse(response)); + }, + ); +} + +export function add_share_by_token( + user, + password, + collection, + permissions, + callback, +) { + call_sharing_api( + user, + password, + "token/create", + { + PathMapped: collection.href, + Permissions: permissions, + }, + function (response) { + let json_response = JSON.parse(response); + if (json_response["Status"] !== "success") { + console.error("Failed to create share token: " + (json_response["Status"] || "Unknown error")); + } else { + callback(); + } + }, + ); +} + +export function delete_share_by_token( + user, + password, + token, + callback, +) { + call_sharing_api( + user, + password, + "token/delete", + { PathOrToken: token }, + function (response) { + let json_response = JSON.parse(response); + if (json_response["Status"] !== "success") { + console.error("Failed to create delete token " + token + ": " + (json_response["Status"] || "Unknown error")); + } else { + callback(); + } + }, + ); +} diff --git a/radicale/web/internal_data/css/icons/credits.md b/radicale/web/internal_data/css/icons/credits.md new file mode 100644 index 00000000..39948ae6 --- /dev/null +++ b/radicale/web/internal_data/css/icons/credits.md @@ -0,0 +1,21 @@ +# Credits + +## share.svg + +* +* MIT License + +## key.svg + +* +* MIT License + +## repeat.svg + +* +* MIT License + +## eye.svg + +* +* MIT License diff --git a/radicale/web/internal_data/css/icons/eye.svg b/radicale/web/internal_data/css/icons/eye.svg new file mode 100755 index 00000000..65d96f76 --- /dev/null +++ b/radicale/web/internal_data/css/icons/eye.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/key.svg b/radicale/web/internal_data/css/icons/key.svg new file mode 100644 index 00000000..e778e74e --- /dev/null +++ b/radicale/web/internal_data/css/icons/key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/repeat.svg b/radicale/web/internal_data/css/icons/repeat.svg new file mode 100644 index 00000000..c7657b08 --- /dev/null +++ b/radicale/web/internal_data/css/icons/repeat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/share.svg b/radicale/web/internal_data/css/icons/share.svg new file mode 100644 index 00000000..09b1c7bc --- /dev/null +++ b/radicale/web/internal_data/css/icons/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css index 1e2dcef0..8c4086f9 100644 --- a/radicale/web/internal_data/css/main.css +++ b/radicale/web/internal_data/css/main.css @@ -39,6 +39,14 @@ main{ color: #484848; } +.container h2{ + margin: 0; + width: 100%; + text-align: left; + color: #484848; + font-size: 1.5em; +} + #loginscene .infcloudlink{ margin: 0; width: 100%; @@ -303,6 +311,29 @@ main{ filter: invert(1); } +.small_icon{ + width: 1em; + height: 1em; + filter: invert(1); +} + +.med_icon{ + width: 1.5em; + height: 1.5em; +} + +.badge_icon{ + width: 1em; + height: 1em; + position: absolute; + top: -5px; + right: -5px; + filter: invert(1); + border-radius: 50%; + background-color: white; +} + + .smalltext{ font-size: 75% !important; } @@ -345,6 +376,7 @@ button{ margin-left: 10px; background: black; cursor: pointer; + position: relative; } input, select{ @@ -360,6 +392,10 @@ input, select{ outline: none !important; } +input.inline { + margin-bottom: 0 !important; +} + input[type=text], input[type=password]{ width: calc(100% - 30px); } @@ -414,6 +450,12 @@ button.blue:active, a.blue:active{ cursor: pointer !important; } +button.inline { + padding-inline: 1px; + min-width: 2em; + margin: 0; +} + @media only screen and (max-width: 600px) { #collectionsscene{ flex-direction: column !important; diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index 16ef91d3..8689fc38 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -90,6 +90,11 @@ ✏️ +
  • ❌ @@ -133,6 +138,39 @@
    + +