Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
138 commits
Select commit Hold shift + click to select a range
6f3d41c
sharing: extend config with defaults
pbiering Feb 7, 2026
f7df65a
sharing: extend documentation with new options
pbiering Feb 7, 2026
9ec4fc4
sharing: add support for new config
pbiering Feb 7, 2026
c0cef2e
sharing: initial support
pbiering Feb 7, 2026
6886638
typo
pbiering Feb 7, 2026
f0ea99d
add support for permission filter
pbiering Feb 7, 2026
2cf0a12
sharing: add
pbiering Feb 7, 2026
29687fe
sharing: add support for "GET"
pbiering Feb 7, 2026
3a65b62
bugfix
pbiering Feb 7, 2026
3273ba0
sharing: add post hook
pbiering Feb 7, 2026
22c279e
add list function
pbiering Feb 7, 2026
cccb8cd
sharing: add POST api with initial support for "list"
pbiering Feb 7, 2026
dad5725
initial
pbiering Feb 8, 2026
4abe638
add support for content-type and accept header
pbiering Feb 8, 2026
49e42c1
add sharing-by-token
pbiering Feb 8, 2026
4ae38d1
rework
pbiering Feb 8, 2026
9cce87e
snapshot
pbiering Feb 8, 2026
f4a5357
replace selfmade filter by already existing one
pbiering Feb 9, 2026
fb8ced1
pass also user to mapping function
pbiering Feb 9, 2026
dca1768
api rework + function extensions
pbiering Feb 9, 2026
4a98db1
api rework + extensions
pbiering Feb 9, 2026
eadfc4e
add token+map testcases
pbiering Feb 9, 2026
2ffce86
cosmetics
pbiering Feb 9, 2026
4f027c4
fix test cases
pbiering Feb 9, 2026
bf9744d
flake8 fixes
pbiering Feb 9, 2026
e134e0f
review
pbiering Feb 10, 2026
730911d
add test cases, optimize code
pbiering Feb 12, 2026
50439c4
align mapping call
pbiering Feb 12, 2026
649a643
reorg/cleanup
pbiering Feb 12, 2026
162ff3b
reorg/cleanup
pbiering Feb 12, 2026
0055df9
add missing prototype
pbiering Feb 12, 2026
008f301
fix logic
pbiering Feb 12, 2026
0c0972a
code cleanup
pbiering Feb 12, 2026
7240a1b
flake8
pbiering Feb 12, 2026
b306144
flake8
pbiering Feb 12, 2026
77f46b8
flake8
pbiering Feb 12, 2026
5c380c0
flake8
pbiering Feb 12, 2026
8229e4e
add dummy init
pbiering Feb 12, 2026
375d7d2
typing
pbiering Feb 12, 2026
c2b9a97
review
pbiering Feb 12, 2026
4c0f18a
fix typing
pbiering Feb 12, 2026
26023e7
fix typing
pbiering Feb 12, 2026
9ab766b
fix typing
pbiering Feb 12, 2026
958e57f
fix typing
pbiering Feb 12, 2026
a6160ec
fix typing
pbiering Feb 12, 2026
6bcda96
fix typing
pbiering Feb 12, 2026
8e185ef
cosmetics
pbiering Feb 12, 2026
5e51638
fix typing
pbiering Feb 12, 2026
b817d3c
change to status codes on create
pbiering Feb 13, 2026
4252bf0
add permission check for owner/user access mismatch
pbiering Feb 13, 2026
cc207a1
additional testcases
pbiering Feb 13, 2026
b4e5dbd
fix conflict check
pbiering Feb 13, 2026
01e35df
start permission checks
pbiering Feb 13, 2026
6e947c1
bugfix
pbiering Feb 13, 2026
91272b1
add mapping to PUT
pbiering Feb 13, 2026
d4bae7c
extend debug
pbiering Feb 13, 2026
2944bd6
add parent_path fallback for mapping
pbiering Feb 13, 2026
8f3aecb
add testcases, cosmetics
pbiering Feb 13, 2026
e5b09ad
remove not required import
pbiering Feb 14, 2026
813f625
extend test case
pbiering Feb 14, 2026
032865f
apply filter also on parent permissions
pbiering Feb 14, 2026
3e4abdb
fix parent path replacement
pbiering Feb 14, 2026
d0ac8ce
add sharing support
pbiering Feb 14, 2026
c8831af
extend test cases
pbiering Feb 14, 2026
890c71b
add sharing support
pbiering Feb 14, 2026
489b493
cosmetics
pbiering Feb 14, 2026
48ae1a5
cosmetics
pbiering Feb 14, 2026
8549c80
cosmetics
pbiering Feb 14, 2026
91013d5
cosmetics
pbiering Feb 14, 2026
c35712e
cosmetics, add propfind test cases
pbiering Feb 14, 2026
5a9494d
sharing for propfind
pbiering Feb 14, 2026
7c83ccd
add sharing support
pbiering Feb 14, 2026
038d49f
proppatch tests
pbiering Feb 14, 2026
5c3bf52
add sharing to move
pbiering Feb 14, 2026
2a7d45e
extend trace log
pbiering Feb 14, 2026
aceb14a
add tests for move
pbiering Feb 14, 2026
86f5318
add new function parent_path
pbiering Feb 15, 2026
ff4d16a
use new function parent_path
pbiering Feb 15, 2026
0eb3d2b
use new func
pbiering Feb 15, 2026
ff33fa5
use new func
pbiering Feb 15, 2026
be59798
use new func
pbiering Feb 15, 2026
5c88b97
bugfix + extend filter
pbiering Feb 15, 2026
e6c469d
extensions for hidden and others
pbiering Feb 15, 2026
d79d5ea
add support for shared collections
pbiering Feb 15, 2026
d1f5cb7
add testcases for propfind/shared
pbiering Feb 15, 2026
529dc21
map/delete do not need user filter
pbiering Feb 15, 2026
d6ca0a7
test info hook and all/list
pbiering Feb 15, 2026
ccf57c8
catch errors related to list and others
pbiering Feb 15, 2026
d7442b2
cosmetics
pbiering Feb 15, 2026
a35b1f9
add support for "update"
pbiering Feb 15, 2026
42b4f03
add support for "update"
pbiering Feb 15, 2026
f9f6d67
testcases for "update"
pbiering Feb 15, 2026
f6308b8
use boolean instead of string representation
pbiering Feb 15, 2026
514de80
extend info otput
pbiering Feb 15, 2026
af48773
Add missing import
maxberger Feb 15, 2026
e00d136
Moved import to avoid circular dependency
maxberger Feb 15, 2026
a20631f
Fix: use HTTP_ACCEPT to conform with WSGIEnviron
maxberger Feb 15, 2026
754c02d
catch incompatible csv database
pbiering Feb 15, 2026
b4314b7
fix csv/dict
pbiering Feb 15, 2026
ce4c7fb
convert bool/int
pbiering Feb 15, 2026
a3a07d0
skip header line
pbiering Feb 15, 2026
ba13278
adj debug
pbiering Feb 15, 2026
44a52d1
add missing timestamp
pbiering Feb 15, 2026
b7c30fd
fixes
pbiering Feb 15, 2026
2f1282a
Add additional information on bad request response
maxberger Feb 15, 2026
491d90d
fix/extend filter on "list"
pbiering Feb 17, 2026
cd40e79
extend text cases
pbiering Feb 17, 2026
b17231a
cleanup
pbiering Feb 17, 2026
80da52a
check for conflicts
pbiering Feb 17, 2026
3382e79
check for conflicts
pbiering Feb 17, 2026
19ce8fb
check for conflicts
pbiering Feb 17, 2026
72d4c40
test: check for conflicts
pbiering Feb 17, 2026
0c4e81d
add sharing database verification
pbiering Feb 18, 2026
e88e8b9
rename option, adjust db types
pbiering Feb 18, 2026
b94ee28
add locking, cosmetics
pbiering Feb 18, 2026
0a6a2df
file-based sharing config storage
pbiering Feb 18, 2026
8097f6e
add test cases for files database
pbiering Feb 18, 2026
2ba6254
add enable flag
pbiering Feb 22, 2026
1c2e4d8
align
pbiering Feb 22, 2026
b883fe0
fix
pbiering Feb 22, 2026
b712dc4
extend copyright
pbiering Feb 22, 2026
47a115f
extend copyright
pbiering Feb 22, 2026
aa5241c
extend copyright
pbiering Feb 22, 2026
097246c
code reorg
pbiering Feb 22, 2026
58b9d37
extend copyright
pbiering Feb 22, 2026
abaa6d8
remove no longer required include related to use new parent_path
pbiering Feb 22, 2026
e9d74ad
code fix
pbiering Feb 22, 2026
2952f0d
catch sharing database "none" better
pbiering Feb 22, 2026
6333c84
python 3.9 support
pbiering Feb 22, 2026
476272f
flake8 fixes
pbiering Feb 22, 2026
5f1e122
remove leftover code
pbiering Feb 22, 2026
d70f3f1
cleanup leftovers
pbiering Feb 22, 2026
0e4c7dc
isort fixes
pbiering Feb 22, 2026
8b505ce
prevent user overtaken from from user
pbiering Feb 22, 2026
a6628ca
mypy fixes
pbiering Feb 22, 2026
80a1c30
python 3.9 fixes
pbiering Feb 22, 2026
2c2f2bd
extend copyright
pbiering Feb 22, 2026
74aaf09
Add UI for share by token
maxberger Feb 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,12 @@ _(>= 3.6.0)_

Verification of a particular item file

##### --verify-sharing

_(>= 3.7.0)_

Verification of local sharing database

##### -C|--config <file>

Load one or more specified config file(s)
Expand Down Expand Up @@ -2043,6 +2049,49 @@ is thrown instead of returning the results.

Default: 10000

#### [sharing]

_(>= 3.7.0)_

##### type

_(>= 3.7.0)_

Sharing database type

One of:
* `none`
* `csv`
* `files`

Default: `none` (implicit disabling the feature)

##### database_path

_(>= 3.7.0)_

Sharing database path

Default:
* type `csv`: `(filesystem_folder)/collection-db/sharing.csv`
* type `files`: `(filesystem_folder)/collection-db/files`

##### collection_by_token

_(>= 3.7.0)_

Share collection by token

Default: `false`

##### collection_by_map

_(>= 3.7.0)_

Share collection by map

Default: `false`

## Supported Clients

Radicale has been tested with:
Expand Down
19 changes: 19 additions & 0 deletions config
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,25 @@
#predefined_collections =


[sharing]

# Sharing database type
# Value: none | csv | files
#type = none

# Sharing database path for type 'csv'
#database_path = (filesystem_folder)/collection-db/sharing.csv

# Sharing database path for type 'files'
#database_path = (filesystem_folder)/collection-db/files

# Share collection by map
#collection_by_map = false

# Share collection by token
#collection_by_token = false


[web]

# Web interface backend
Expand Down
Empty file added integ_tests/__init__.py
Empty file.
95 changes: 95 additions & 0 deletions integ_tests/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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
"""
)
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 <repo>/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"]')
90 changes: 9 additions & 81 deletions integ_tests/test_basic_operation.py
Original file line number Diff line number Diff line change
@@ -1,102 +1,30 @@
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 <repo>/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,
)
def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]:
yield from start_radicale_server(tmp_path)

# 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 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")
# There should be no errors on the console
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(
Expand Down
42 changes: 42 additions & 0 deletions integ_tests/test_sharing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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)
# TODO: Check for R/O state
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)
# TODO: Check for R/W state
page.click('tr:not(.hidden) button[data-name="delete"]', strict=True)
expect(
page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)")
).to_have_count(0)
20 changes: 18 additions & 2 deletions radicale/__main__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2011-2017 Guillaume Ayoub
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
# Copyright © 2024-2026 Peter Bieringer <pb@bieringer.de>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
Expand Down Expand Up @@ -33,7 +33,8 @@
from types import FrameType
from typing import List, Optional, cast

from radicale import VERSION, config, item, log, server, storage, types
from radicale import (VERSION, config, item, log, server, sharing, storage,
types)
from radicale.log import logger


Expand Down Expand Up @@ -67,6 +68,8 @@ def exit_signal_handler(signal_number: int,
help="check the storage for errors and exit")
parser.add_argument("--verify-item", action="store", nargs=1,
help="check the provided item file for errors and exit")
parser.add_argument("--verify-sharing", action="store_true",
help="check the sharing database for errors and exit")
parser.add_argument("-C", "--config",
help="use specific configuration files", nargs="*")
parser.add_argument("-D", "--debug", action="store_const", const="debug",
Expand Down Expand Up @@ -209,6 +212,19 @@ def exit_signal_handler(signal_number: int,
sys.exit(1)
return

if args_ns.verify_sharing:
logger.info("Verifying sharing database")
try:
sharing_ = sharing.load(configuration)
if not sharing_.verify():
logger.critical("Sharing database verification failed")
sys.exit(1)
except Exception as e:
logger.critical("An exception occurred during sharing database "
"verification: %s", e, exc_info=True)
sys.exit(1)
return

# Create a socket pair to notify the server of program shutdown
shutdown_socket, shutdown_socket_out = socket.socketpair()

Expand Down
Loading