diff --git a/.github/workflows/docker-nightly-cleanup.yml b/.github/workflows/docker-nightly-cleanup.yml new file mode 100644 index 00000000..77550c98 --- /dev/null +++ b/.github/workflows/docker-nightly-cleanup.yml @@ -0,0 +1,41 @@ +name: Cleanup old nightly docker images + +on: + schedule: + - cron: '10 0 * * *' + workflow_dispatch: + + +jobs: + delete-package-versions: + name: Cleanup old nightly docker images + runs-on: ubuntu-latest + steps: + - name: Get list of all docker image versions in registry + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api --paginate -X GET "/orgs/Kozea/packages/container/Radicale/versions" -F package_type=container -F per_page=200 > data.json + + - name: Delete each nightly image older than cutoff date + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cutoff_date=$(date --date="30 days ago" --iso-8601) + echo "Cutoff date is: $cutoff_date" + + # Loop through each nightly image version (tag) older than the cutoff date + jq --arg cutoff_date "$cutoff_date" -r '.[] | select((.metadata.container.tags | any(. | contains("nightly"))) and (.created_at < $cutoff_date)) | [.metadata.container.tags[], .id] | @tsv' data.json | while IFS=$'\t' read -r tag nightly_image_id ; do + echo "Tag - $tag" + + # Because of multi-platform, manifest for each tag would contain more than 1 image. Loop through all + all_digests=$(docker manifest inspect "ghcr.io/kozea/radicale:${tag}" | jq -r 'if .manifests then .manifests[]?.digest else empty end') + for digest in $all_digests; do + image_id=$(jq -r --arg digest "$digest" '.[] | select(.name == $digest) | .id' data.json) + echo "Deleting $image_id" + gh api -X DELETE "/orgs/Kozea/packages/container/Radicale/versions/$image_id" + done + # Now that all dependents are deleted, delete this tag + echo "Deleting $tag with ID: $nightly_image_id" + gh api -X DELETE "/orgs/Kozea/packages/container/Radicale/versions/$nightly_image_id" + done diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7537b5b6..85df03d3 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,13 +2,13 @@ name: Build and publish Docker image on: release: - types: [published] + types: [released] schedule: - cron: '0 0 * * *' workflow_dispatch: env: - REGISTRY: ghcr.io + GHCR_REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: @@ -22,23 +22,34 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Log in to the Container registry + - name: Log in to the ghcr container registry uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} + registry: ${{ env.GHCR_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Log in to the dockerhub container registry + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_ORGNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract metadata for Docker build id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: | + name=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }} + name=${{ env.IMAGE_NAME }} flavor: latest=true tags: | type=semver,pattern={{version}} + type=semver,pattern={{major}} + type=semver,pattern={{major}}.{{minor}} type=schedule,prefix=nightly-,pattern={{date 'YYYYMMDD'}} type=raw,enable=${{ github.event_name == 'workflow_dispatch' }},value=workflow_dispatch-{{branch}}-{{sha}} + type=raw,enable=${{ github.event_name == 'release' }},value=stable - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index be98f3bb..57cee4a0 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -1,7 +1,7 @@ name: PyPI publish on: release: - types: [published] + types: [released] jobs: publish: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 82ac574f..f16e26f9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,14 +2,105 @@ name: Test on: [push, pull_request] jobs: - test: + + test-ubuntu-python-newest: + needs: lint + strategy: + matrix: + os: [ubuntu-latest] + python-version: ['3.14'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Test dependencies + run: pip install tox + - name: Test with latest Python on Ubuntu + run: tox -c pyproject.toml -e py + + test-ubuntu-python-oldest: + needs: [lint, test-ubuntu-python-newest] + strategy: + matrix: + os: [ubuntu-latest] + python-version: ['3.9'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Test dependencies + run: pip install tox + - name: Test with oldest Python on Ubuntu + run: tox -c pyproject.toml -e py + + test-otheros-python-newest: + needs: [lint, test-ubuntu-python-newest] + strategy: + matrix: + os: [macos-latest, windows-latest] + python-version: ['3.14'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Test dependencies + run: pip install tox + - name: Test with latest Python on other OS + run: tox -c pyproject.toml -e py + + test-otheros-python-oldest: + needs: [lint, test-ubuntu-python-oldest, test-otheros-python-newest] + strategy: + matrix: + os: [macos-latest, windows-latest] + python-version: ['3.9'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Test dependencies + run: pip install tox + - name: Test with oldest Python on other OS + run: tox -c pyproject.toml -e py + + test-python-versions: + needs: [lint, test-otheros-python-oldest, test-otheros-python-newest, test-ubuntu-python-oldest, test-ubuntu-python-newest] strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.9', '3.10', '3.11', '3.12.3', '3.13.0', pypy-3.9] + python-version: ['3.10', '3.11', '3.12', '3.13', 'pypy-3.9', 'pypy-3.10', 'pypy-3.11'] exclude: - os: windows-latest - python-version: pypy-3.9 + python-version: 'pypy-3.9' + - os: windows-latest + python-version: 'pypy-3.10' + - os: windows-latest + python-version: 'pypy-3.11' + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Test dependencies + run: pip install tox + - name: Test with older Python + run: tox -c pyproject.toml -e py + + coveralls-test: + if: github.event_name == 'push' + strategy: + matrix: + os: [ubuntu-latest] + python-version: ['3.13'] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -19,7 +110,7 @@ jobs: - name: Install Test dependencies run: pip install tox - name: Test - run: tox -e py + run: tox -c pyproject.toml -e py - name: Install Coveralls if: github.event_name == 'push' run: pip install coveralls @@ -31,7 +122,7 @@ jobs: run: coveralls --service=github coveralls-finish: - needs: test + needs: coveralls-test if: github.event_name == 'push' runs-on: ubuntu-latest steps: @@ -51,8 +142,8 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.13' - name: Install tox run: pip install tox - name: Lint - run: tox -e flake8,mypy,isort + run: tox -c pyproject.toml -e flake8,mypy,isort diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eafbee6..763676ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,61 @@ # Changelog -## 3.5.5.dev +## 3.6.1.dev + +* Fix: MOVE failing with URL-encoded destination header +* Improve: add workaround to remove empty lines in item to avoid reject by vobject parser + +## 3.6.0 + +* Extend: logwatch script +* Extend: [logging] bad_put_request_content: log checksum and hexdump of request on debug level +* Extend: [logging] request_content_on_debug: log checksum of request on debug level +* Extend: add command line option "--verify-item " for dedicated item file analysis +* Extend: PROPFIND response for VADDRESSBOOK with "CR:supported-address-data" and "CS:getctag" +* Extend: conditionally announce vCard 4.0 in case vobject version is >= 1.0.0 +* Fix: hook for server-side e-mail notification +* Change: dependency PyPI/passlib (stale since 2020) replaced with PyPI/libpass >= 1.9.3 +* Extend: add a check whether bcrypt version is compatible with passlib(libpass) version (requires "packaging") +* Improve: autodetection of hashes in htpasswd (SHA256/SHA512 "rounds" are now supported) + +## 3.5.10 +* Improve: logging of broken calendar items during PUT +* Add: logging of broken contact items during PUT +* Extend: [auth] imap: add fallback support for LOGIN towards remote IMAP server (replaced in 3.5.0) +* Fix: improper detection of HTTP_X_FORWARDED_PORT on MOVE +* Extend: [logging] with profiling log per reqest or regular per request method +* Add: [logging] option to log response header on debug loglevel +* Adjust: [logging] header/content debug log indended by space to be skipped by logwatch +* Improve: remove unnecessary open+read for mtime+size cache +* Extend: add selected XML query properties to request result log line for improved timing analysis incl. logwatch support +* Add: [server] max_resource_size option +* Add: support PROPFIND/max-resource-size by max_resource_size (capped to 80% of max_content_length) + +## 3.5.9 +* Extend: [auth] add support for type http_remote_user +* Extend: logging of invalid sync-token with user, path, remote host and useragent +* Fix: typo related to collection delete hook + +## 3.5.8 +* Extend: [auth] re-factor & overhaul LDAP authentication, especially for Python's ldap module +* Fix: out-of-range timestamp on 32-bit systems +* Feature: extend logging with response size in bytes and flag served as plain or gzip +* Feature: [storage] strict_preconditions: new config option to enforce strict preconditions check on PUT in case item already exists [RFC6352#9.2] +* Fix: format_ut problem on 32-bit systems +* Doc: Telugu translation + +## 3.5.7 +* Extend: [auth] dovecot: add support for version >= 2.4 +* Fix: report/getetag with enabled expand +* Adjust: use of option [auth] ldap_ignore_attribute_create_modify_timestamp for support of Authentik LDAP server + +## 3.5.6 +* Fix: broken start when UID does not exist (potential container startup case) +* Improve: user/group retrievement for running service and directories +* Extend/Improve: [auth] ldap: group membership lookup +* Add: [auth] remote_ip_source: set the remote IP source for auth algorithms + +## 3.5.5 * Improve: [auth] ldap: do not read server info by bind to avoid needless network traffic * Fix: [storage] broken support of 'folder_umask' * Improve: add details about platform and effective user on startup @@ -15,6 +70,8 @@ * Add: [hook] dryrun: option to disable real hook action for testing, add tests for email+rabbitmq * Fix: storage hook path now added to DELETE, MKCOL, MKCALENDAR, MOVE, and PROPPATCH * Add: storage hook placeholder now supports "request" and "to_path" (MOVE only) +* Improve: catch items having tzinfo only on dtstart or dtend set for whatever reason, overtake tzinfo from the other one +* Improve: conditional log level for base_prefix strip action depending on auth and web type ## 3.5.4 * Improve: item filter enhanced for 3rd level supporting VALARM and honoring TRIGGER (offset or absolute) @@ -127,7 +184,7 @@ * Fix: Using icalendar's tzinfo on created datetime to fix issue with icalendar * Fix: typos in code * Enhancement: Added free-busy report -* Enhancement: Added 'max_freebusy_occurrences` setting to avoid potential DOS on reports +* Enhancement: Added 'max_freebusy_occurrences` setting to avoid potential DoS on reports * Enhancement: remove unexpected control codes from uploaded items * Enhancement: add 'strip_domain' setting for username handling * Enhancement: add option to toggle debug log of rights rule with doesn't match diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6527fb14..55c5d5ad 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1,5 +1,9 @@ # Documentation +## Translations of this page + +* [Telugu](https://github.com/Kozea/Radicale/blob/master/docs/DOCUMENTATION.te.md) + ## Getting started #### About Radicale @@ -10,7 +14,8 @@ Radicale is a small but powerful CalDAV (calendars, to-do lists) and CardDAV * Shares calendars and contact lists through CalDAV, CardDAV and HTTP. * Supports events, todos, journal entries and business cards. * Works out-of-the-box, no complicated setup or configuration required. -* Can limit access by authentication. +* Offers flexible authentication options. +* Can limit access by authorization. * Can secure connections with TLS. * Works with many [CalDAV and CardDAV clients](#supported-clients). @@ -25,11 +30,9 @@ Check * [Tutorials](#tutorials) * [Documentation](#documentation-1) * [Wiki on GitHub](https://github.com/Kozea/Radicale/wiki) -* [Disussions on GitHub](https://github.com/Kozea/Radicale/discussions) +* [Discussions on GitHub](https://github.com/Kozea/Radicale/discussions) * [Open and already Closed Issues on GitHub](https://github.com/Kozea/Radicale/issues?q=is%3Aissue) -Hint: instead of downloading from PyPI look for packages provided by used [distribution](#linux-distribution-packages), they contain also startup scripts to run daemonized. - #### What's New? Read the [Changelog on GitHub](https://github.com/Kozea/Radicale/blob/master/CHANGELOG.md). @@ -38,18 +41,23 @@ Read the [Changelog on GitHub](https://github.com/Kozea/Radicale/blob/master/CHA ### Simple 5-minute setup -You want to try Radicale but only have 5 minutes free in your calendar? Let's -go right now and play a bit with Radicale! +You want to try Radicale but only have 5 minutes free in your calendar? +Let's go right now and play a bit with Radicale! -When everything works, you can get a [client](#supported-clients) -and start creating calendars and address books. The server, configured with settings from this section, only binds to localhost (is not reachable over the network) -and you can log in with any user name and password. When everything works, you may get a local client and start creating calendars and address books. -If Radicale fits your needs, it may be time for some [basic configuration](#basic-configuration) to support remote clients and desired authentication type. +The server, configured with settings from this section, only binds to localhost +(i.e. it is not reachable over the network), and you can log in with any username and password. +When everything works, you may get a local [client](#supported-clients) +and start creating calendars and address books. +If Radicale fits your needs, it may be time for some [basic configuration](#basic-configuration) +to support remote clients and desired authentication type. Follow one of the chapters below depending on your operating system. #### Linux / \*BSD +Hint: instead of downloading from PyPI, look for packages provided by your [distribution](#linux-distribution-packages). +They contain also startup scripts integrated into your distributions, that allow Radicale to run daemonized. + First, make sure that **python** 3.9 or later and **pip** are installed. On most distributions it should be enough to install the package ``python3-pip``. @@ -62,7 +70,8 @@ Recommended only for testing - open a console and type: python3 -m pip install --user --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz ``` -If _install_ is not working and instead `error: externally-managed-environment` is displayed, create and activate a virtual environment in advance +If _install_ is not working and instead `error: externally-managed-environment` is displayed, +create and activate a virtual environment in advance. ```bash python3 -m venv ~/venv @@ -84,15 +93,15 @@ python3 -m radicale --storage-filesystem-folder=~/.var/lib/radicale/collections ##### as system user (or as root) -Alternative one can install and run as system user or as root (not recommended) +Alternatively, you can install and run as system user or as root (not recommended): ```bash -# Run the following command as root (not required) -# or non-root system user (can require --user in case of dependencies are not available system-wide and/or virtual environment) +# Run the following command as root (not recommended) or non-root system user +# (the later may require --user in case dependencies are not available system-wide and/or virtual environment) python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz ``` -Start the service manually, data is stored in a system folder +Start the service manually, with data stored in a system folder under `/var/lib/radicale/collections`: ```bash # Start, data is stored in a system folder (requires write permissions to /var/lib/radicale/collections) @@ -116,12 +125,12 @@ python -m radicale --storage-filesystem-folder=~/radicale/collections --auth-typ ##### Common -Victory! Open in your browser! +Success!!! Open in your browser! You can log in with any username and password as no authentication is required by example option `--auth-type none`. -But this is INSECURE, see [Configuration/Authentication](#auth) for more. +This is **INSECURE**, see [Configuration/Authentication](#auth) for more details. Just note that default configuration for security reason binds the server to `localhost` (IPv4: `127.0.0.1`, IPv6: `::1`). -See [Addresses](#addresses) and [Configuration/Server](#server) for more. +See [Addresses](#addresses) and [Configuration/Server](#server) for more details. ### Basic Configuration @@ -144,9 +153,12 @@ All configuration options are described in detail in the #### Authentication -In its default configuration since 3.5.0 Radicale rejects by default all authentication by `type = denyall` (introduced with 3.2.2) until explicitly configured. +In its default configuration since version 3.5.0, Radicale rejects all +authentication attempts by using config option `type = denyall` (introduced +with 3.2.2) as default until explicitly configured. -Before 3.5.0 it didn't check usernames or passwords if not explicitly configured, and if the server is reachable over a network, you should change this as soon as possible. +Versions before 3.5.0 did not check usernames or passwords at all, unless explicitly configured. +If such a server is reachable over a network, you should change this as soon as possible. First a `users` file with all usernames and passwords must be created. It can be stored in the same directory as the configuration file. @@ -156,11 +168,12 @@ It can be stored in the same directory as the configuration file. The `users` file can be created and managed with [htpasswd](https://httpd.apache.org/docs/current/programs/htpasswd.html): -Note: some OS contain unpatched `htpasswd` (< 2.4.59) without supporting SHA-256 or SHA-512 -(e.g. Ubuntu LTS 22), in this case use '-B' for "bcrypt" hash method or stay with -insecure MD5 (default) or SHA-1 ('-s'). +Note: some OSes or distributions contain outdated versions of `htpasswd` (< 2.4.59) without +support for SHA-256 or SHA-512 (e.g. Ubuntu LTS 22). +In these cases, use `htpasswd`'s command line option `-B` for the `bcrypt` hash method (recommended), +or stay with the insecure (not recommended) MD5 (default) or SHA-1 (command line option `-s`). -Note that support of SHA-256 or SHA-512 was introduced with 3.1.9 +Note: support of SHA-256 and SHA-512 was introduced with 3.1.9 ```bash # Create a new htpasswd file with the user "user1" using SHA-512 as hash method @@ -204,7 +217,7 @@ htpasswd_encryption = plain #### Addresses -The default configuration binds the server to localhost. It can't be reached +The default configuration binds the server to localhost. It cannot be reached from other computers. This can be changed with the following configuration options (IPv4 and IPv6): @@ -223,7 +236,7 @@ be changed with the following configuration: filesystem_folder = /path/to/storage ``` -> **Security:** The storage folder should not be readable by unauthorized users. +> **Security:** The storage folder shall not be readable by unauthorized users. > Otherwise, they can read the calendar data and lock the storage. > You can find OS dependent instructions in the > [Running as a service](#running-as-a-service) section. @@ -256,20 +269,30 @@ requirements. #### Linux with systemd system-wide -Recommendation: check support by [Linux Distribution Packages](#linux-distribution-packages) instead of manual setup / initial configuration. +Recommendation: check support by [Linux Distribution Packages](#linux-distribution-packages) +instead of manual setup / initial configuration. -Create the **radicale** user and group for the Radicale service. (Run -`useradd --system --user-group --home-dir / --shell /sbin/nologin radicale` as root.) -The storage folder must be writable by **radicale**. (Run -`mkdir -p /var/lib/radicale/collections && chown -R radicale:radicale /var/lib/radicale/collections` -as root.) +Create the **radicale** user and group for the Radicale service by running (as `root`): +```bash +useradd --system --user-group --home-dir / --shell /sbin/nologin radicale +``` -If a dedicated cache folder is configured (see option 'storage' -> 'filesystem_cache_folder'), it also must be also writable by **radicale**. (Run -`mkdir -p /var/cache/radicale && chown -R radicale:radicale /var/cache/radicale` -as root.) +The storage folder must be made writable by the **radicale** user by running (as `root`): +```bash +mkdir -p /var/lib/radicale/collections && chown -R radicale:radicale /var/lib/radicale/collections +``` -> **Security:** The storage should not be readable by others. -> (Run `chmod -R o= /var/lib/radicale/collections` as root.) +If a dedicated cache folder is configured (see option [filesystem_cache_folder](#filesystem_cache_folder)), +it also must be made writable by **radicale**. To achieve that, run (as `root`): +```bash +mkdir -p /var/cache/radicale && chown -R radicale:radicale /var/cache/radicale +```` + +> **Security:** The storage shall not be readable by others. +> To make sure this is the case, run (as `root`): +> ```bash +> chmod -R o= /var/lib/radicale/collections +> ``` Create the file `/etc/systemd/system/radicale.service`: @@ -295,14 +318,14 @@ ProtectKernelModules=true ProtectControlGroups=true NoNewPrivileges=true ReadWritePaths=/var/lib/radicale/ -# Replace with following in case of dedicated cache folder should be used +# Replace with following in case dedicated cache folder should be used #ReadWritePaths=/var/lib/radicale/ /var/cache/radicale/ [Install] WantedBy=multi-user.target ``` -Radicale will load the configuration file from `/etc/radicale/config`. +In this system-wide implementation, Radicale will load the configuration from the file `/etc/radicale/config`. To enable and manage the service run: @@ -333,7 +356,8 @@ Restart=on-failure WantedBy=default.target ``` -Radicale will load the configuration file from `~/.config/radicale/config`. +In this user-specific configuration, Radicale will load the configuration from +the file `~/.config/radicale/config`. You should set the configuration option `filesystem_folder` in the `storage` section to something like `~/.var/lib/radicale/collections`. @@ -390,7 +414,7 @@ See also for latest examples: https://github.com/Kozea/Radicale/tree/master/cont ```nginx location /radicale/ { # The trailing / is important! - proxy_pass http://localhost:5232/; # The / is important! + proxy_pass http://localhost:5232; proxy_set_header X-Script-Name /radicale; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host; @@ -427,6 +451,9 @@ RewriteRule ^/radicale$ /radicale/ [R,L] RequestHeader set X-Script-Name /radicale RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME} + = 2.4.40> + Proxy100Continue Off + ``` @@ -449,17 +476,11 @@ RequestHeader set X-Forwarded-Proto "https" Example **lighttpd** configuration: ```lighttpd -server.modules += ( "mod_proxy" , "mod_setenv", "mod_rewrite" ) +server.modules += ( "mod_proxy" , "mod_setenv" ) $HTTP["url"] =~ "^/radicale/" { proxy.server = ( "" => (( "host" => "127.0.0.1", "port" => "5232" )) ) - proxy.header = ( "map-urlpath" => ( "/radicale/" => "/" )) - - setenv.add-request-header = ( - "X-Script-Name" => "/radicale", - "Script-Name" => "/radicale", - ) - url.rewrite-once = ( "^/radicale/radicale/(.*)" => "/radicale/$1" ) + setenv.add-request-header = ( "X-Script-Name" => "/radicale" ) } ``` @@ -472,7 +493,7 @@ incorrect authentication attempts. Connections are terminated after a timeout. Set the configuration option `type` in the `auth` section to `http_x_remote_user`. Radicale uses the username provided in the `X-Remote-User` HTTP header and -disables HTTP authentication. +disables its internal HTTP authentication. Example **nginx** configuration: @@ -517,6 +538,9 @@ RewriteRule ^/radicale$ /radicale/ [R,L] ProxyPass http://localhost:5232/ retry=0 ProxyPassReverse http://localhost:5232/ + = 2.4.40> + Proxy100Continue Off + RequestHeader set X-Script-Name /radicale RequestHeader set X-Remote-User expr=%{REMOTE_USER} @@ -541,17 +565,17 @@ RequestHeader set X-Remote-User expr=%{REMOTE_USER} > **Security:** Untrusted clients should not be able to access the Radicale > server directly. Otherwise, they can authenticate as any user by simply -> setting related HTTP header. This can be prevented by restrict listen to -> loopback interface only or at least a local firewall rule. +> setting related HTTP header. This can be prevented by listening to the +> loopback interface only or local firewall rules. #### Secure connection between Radicale and the reverse proxy SSL certificates can be used to encrypt and authenticate the connection between -Radicale and the reverse proxy. First you have to generate a certificate for +Radicale and the reverse proxy. First you need to generate a certificate for Radicale and a certificate for the reverse proxy. The following commands generate self-signed certificates. You will be asked to enter additional -information about the certificate, the values don't matter and you can keep the -defaults. +information about the certificate, these values do not really matter, and you can +keep the defaults. ```bash openssl req -x509 -newkey rsa:4096 -keyout server_key.pem -out server_cert.pem \ @@ -570,7 +594,7 @@ key = /path/to/server_key.pem certificate_authority = /path/to/client_cert.pem ``` -If you're using the Let's Encrypt's Certbot, the configuration should look similar to this: +If you are using the Let's Encrypt Certbot, the configuration should look similar to this: ```ini [server] @@ -620,10 +644,10 @@ gunicorn --bind '127.0.0.1:5232' --env 'RADICALE_CONFIG=/etc/radicale/config' \ #### Manage user accounts with the WSGI server Set the configuration option `type` in the `auth` section to `remote_user`. -Radicale uses the username provided by the WSGI server and disables -authentication over HTTP. +This way Radicale uses the username provided by the WSGI server and disables +its internal authentication over HTTP. -### Versioning with Git +### Versioning collections with Git This tutorial describes how to keep track of all changes to calendars and address books with **git** (or any other version control system). @@ -684,11 +708,52 @@ Reason for problems can be ## Documentation +### Options + +#### General Options + +##### --version + +Print version + +##### --verify-storage + +Verification of local collections storage + +##### --verify-item + +_(>= 3.6.0)_ + +Verification of a particular item file + +##### -C|--config + +Load one or more specified config file(s) + +##### -D|--debug + +Turns log level to debug + +#### Configuration Options + +Each supported option from config file can be provided/overridden by command line +replacing `_` with `-` and prepending the section followed by a `-`, e.g. + +``` +[logging] +backtrace_on_debug = False +``` + +can be enabled using `--logging-backtrace-on-debug=true` on command line. + ### Configuration Radicale can be configured with a configuration file or with command line arguments. +Configuration files have INI-style syntax comprising key-value pairs +grouped into sections with section headers enclosed in brackets. + An example configuration file looks like: ```ini @@ -723,7 +788,7 @@ python3 -m radicale --server-hosts 0.0.0.0:5232,[::]:5232 \ Add the argument `--config ""` to stop Radicale from loading the default configuration files. Run `python3 -m radicale --help` for more information. -One can also use command line options in startup scripts using following examples: +You can also use command-line options in startup scripts as shown in the following examples: ```bash ## simple variable containing multiple options @@ -741,12 +806,12 @@ RADICALE_OPTIONS+=("--config=/etc/radicale/config") /usr/bin/radicale ${RADICALE_OPTIONS[@]} ``` -In the following, all configuration categories and options are described. +The following describes all configuration sections and options. -#### server +#### [server] -The configuration options in this category are only relevant in standalone -mode. All options are ignored, when Radicale runs via WSGI. +The configuration options in this section are only relevant in standalone +mode; they are ignored, when Radicale runs on WSGI. ##### hosts @@ -764,9 +829,21 @@ Default: `8` The maximum size of the request body. (bytes) -Default: `100000000` +Default: `100000000` (100 Mbyte) -In case of using a reverse proxy in front of check also there related option +In case of using a reverse proxy in front of check also there related option. + +##### max_resource_size + +_(>= 3.5.10)_ + +The maximum size of a resource. (bytes) + +Default: `10000000` (10 Mbyte) + +Limited to 80% of max_content_length to cover plain base64 encoded payload. + +Announced to clients requesting "max-resource-size" via PROPFIND. ##### timeout @@ -782,7 +859,7 @@ Default: `False` ##### certificate -Path of the SSL certifcate. +Path of the SSL certificate. Default: `/etc/ssl/radicale.cert.pem` @@ -829,7 +906,7 @@ Strip script name from URI if called by reverse proxy Default: (taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME) -#### encoding +#### [encoding] ##### request @@ -843,55 +920,64 @@ Encoding for storing local collections Default: `utf-8` -#### auth +#### [auth] ##### type The method to verify usernames and passwords. -Available backends: +Available types are: -`none` -: Just allows all usernames and passwords. +* `none` + Just allows all usernames and passwords. -`denyall` _(>= 3.2.2)_ -: Just denies all usernames and passwords. +* `denyall` _(>= 3.2.2)_ + Just denies all usernames and passwords. -`htpasswd` -: Use an +* `htpasswd` + Use an [Apache htpasswd file](https://httpd.apache.org/docs/current/programs/htpasswd.html) to store usernames and passwords. -`remote_user` -: Takes the username from the `REMOTE_USER` environment variable and disables - HTTP authentication. This can be used to provide the username from a WSGI - server which authenticated the client upfront. Required to validate, otherwise - client can supply the header itself which is unconditionally trusted then. +* `remote_user` + Takes the username from the `REMOTE_USER` environment variable and disables + Radicale's internal HTTP authentication. This can be used to provide the + username from a WSGI server which authenticated the client upfront. + Requires validation, otherwise clients can supply the header themselves, + which then is unconditionally trusted. -`http_x_remote_user` -: Takes the username from the `X-Remote-User` HTTP header and disables HTTP - authentication. This can be used to provide the username from a reverse - proxy which authenticated the client upfront. Required to validate, otherwise - client can supply the header itself which is unconditionally trusted then. +* `http_remote_user` _(>= 3.5.9)_ + Takes the username from the Remote-User HTTP header `HTTP_REMOTE_USER` and disables + Radicale's internal HTTP authentication. This can be used to provide the + username from a reverse proxy which authenticated the client upfront. + Requires validation, otherwise clients can supply the header themselves, + which then is unconditionally trusted. -`ldap` _(>= 3.3.0)_ -: Use a LDAP or AD server to authenticate users by relaying credentials from client and handle result. +* `http_x_remote_user` + Takes the username from the X-Remote-User HTTP header `HTTP_X_REMOTE_USER` and disables + Radicale's internal HTTP authentication. This can be used to provide the + username from a reverse proxy which authenticated the client upfront. + Requires validation, otherwise clients can supply the header themselves, + which then is unconditionally trusted. -`dovecot` _(>= 3.3.1)_ -: Use a Dovecot server to authenticate users by relaying credentials from client and handle result. +* `ldap` _(>= 3.3.0)_ + Use a LDAP or AD server to authenticate users by relaying credentials from clients and handle results. -`imap` _(>= 3.4.1)_ -: Use an IMAP server to authenticate users by relaying credentials from client and handle result. +* `dovecot` _(>= 3.3.1)_ + Use a Dovecot server to authenticate users by relaying credentials from clients and handle results. -`oauth2` _(>= 3.5.0)_ -: Use an OAuth2 server to authenticate users by relaying credentials from client and handle result. - Oauth2 authentication (SSO) directly on client is not supported. Use herefore `http_x_remote_user` +* `imap` _(>= 3.4.1)_ + Use an IMAP server to authenticate users by relaying credentials from clients and handle results. + +* `oauth2` _(>= 3.5.0)_ + Use an OAuth2 server to authenticate users by relaying credentials from clients and handle results. + OAuth2 authentication (SSO) directly on client is not supported. Use herefore `http_x_remote_user` in combination with SSO support in reverse proxy (e.g. Apache+mod_auth_openidc). -`pam` _(>= 3.5.0)_ -: Use local PAM to authenticate users by relaying credentials from client and handle result.. +* `pam` _(>= 3.5.0)_ + Use local PAM to authenticate users by relaying credentials from client and handle result.. -Default: `none` _(< 3.5.0)_ `denyall` _(>= 3.5.0)_ +Default: `none` _(< 3.5.0)_ / `denyall` _(>= 3.5.0)_ ##### cache_logins @@ -900,7 +986,7 @@ _(>= 3.4.0)_ Cache successful/failed logins until expiration time. Enable this to avoid overload of authentication backends. -Default: `false` +Default: `False` ##### cache_successful_logins_expiry @@ -926,14 +1012,15 @@ Default: `/etc/radicale/users` ##### htpasswd_encryption -The encryption method that is used in the htpasswd file. Use the +The encryption method that is used in the htpasswd file. Use [htpasswd](https://httpd.apache.org/docs/current/programs/htpasswd.html) -or similar to generate this files. +or similar to generate this file. Available methods: -`plain` -: Passwords are stored in plaintext. This is obviously not secure! +* `plain` + Passwords are stored in plaintext. + This is not recommended. as it is obviously **insecure!** The htpasswd file for this can be created by hand and looks like: ```htpasswd @@ -941,27 +1028,28 @@ Available methods: user2:password2 ``` -`bcrypt` -: This uses a modified version of the Blowfish stream cipher. It's very secure. - The installation of **bcrypt** is required for this. +* `bcrypt` + This uses a modified version of the Blowfish stream cipher, which is considered very secure. + The installation of Python's **bcrypt** module is required for this to work. + Also consider version of passlib(libpass): bcrypt >= 5.0.0 requires passlib(libpass) >= 1.9.3 -`md5` -: This uses an iterated MD5 digest of the password with a salt (nowadays insecure). +* `md5` + Use an iterated MD5 digest of the password with salt (nowadays insecure). -`sha256` _(>= 3.1.9)_ -: This uses an iterated SHA-256 digest of the password with a salt. +* `sha256` _(>= 3.1.9)_ + Use an iterated SHA-256 digest of the password with salt. -`sha512` _(>= 3.1.9)_ -: This uses an iterated SHA-512 digest of the password with a salt. +* `sha512` _(>= 3.1.9)_ + Use an iterated SHA-512 digest of the password with salt. -`argon2` _(>= 3.5.3)_ -: This uses an iterated ARGON2 digest of the password with a salt. - The installation of **argon2-cffi** is required for this. +* `argon2` _(>= 3.5.3)_ + Use an iterated ARGON2 digest of the password with salt. + The installation of Python's **argon2-cffi** module is required for this to work. -`autodetect` _(>= 3.1.9)_ -: This selects autodetection of method per entry. +* `autodetect` _(>= 3.1.9)_ + Automatically detect the encryption method used per user entry. -Default: `md5` _(< 3.3.0)_ `autodetect` _(>= 3.3.0)_ +Default: `md5` _(< 3.3.0)_ / `autodetect` _(>= 3.3.0)_ ##### htpasswd_cache @@ -973,7 +1061,7 @@ Default: `False` ##### delay -Average delay after failed login attempts in seconds. +Average delay (in seconds) after failed login attempts. Default: `1` @@ -987,7 +1075,8 @@ Default: `Radicale - Password Required` _(>= 3.3.0)_ -The URI to the ldap server +URI to the LDAP server. +Mandatory for auth type `ldap`. Default: `ldap://localhost` @@ -995,39 +1084,44 @@ Default: `ldap://localhost` _(>= 3.3.0)_ -LDAP base DN of the ldap server. This parameter must be provided if auth type is ldap. +Base DN of the LDAP server. +Mandatory for auth type `ldap`. -Default: +Default: (unset) ##### ldap_reader_dn _(>= 3.3.0)_ -The DN of a ldap user with read access to get the user accounts. This parameter must be provided if auth type is ldap. +DN of a LDAP user with read access users and - if defined - groups. +Mandatory for auth type `ldap`. -Default: +Default: (unset) ##### ldap_secret _(>= 3.3.0)_ -The password of the ldap_reader_dn. Either this parameter or `ldap_secret_file` must be provided if auth type is ldap. +Password of `ldap_reader_dn`. +Mandatory for auth type `ldap` unless `ldap_secret_file` is given. -Default: +Default: (unset) ##### ldap_secret_file _(>= 3.3.0)_ -Path of the file containing the password of the ldap_reader_dn. Either this parameter or `ldap_secret` must be provided if auth type is ldap. +Path to the file containing the password of `ldap_reader_dn`. +Mandatory for auth type `ldap` unless `ldap_secret` is given. -Default: +Default: (unset) ##### ldap_filter _(>= 3.3.0)_ -The search filter to find the user DN to authenticate by the username. User '{0}' as placeholder for the user name. +Filter to search for the LDAP entry of the user to authenticate. +It must contain '{0}' as placeholder for the login name. Default: `(cn={0})` @@ -1035,72 +1129,138 @@ Default: `(cn={0})` _(>= 3.4.0)_ -The LDAP attribute whose value shall be used as the user name after successful authentication +LDAP attribute whose value shall be used as the username after successful authentication. -Default: not set, i.e. the login name given is used directly. +If set, you can use flexible logins in `ldap_filter` and still have consolidated usernames, +e.g. to allow users to login using mail addresses as an alternative to cn, simply set +```ini +ldap_filter = (&(objectclass=inetOrgPerson)(|(cn={0})(mail={0}))) +ldap_user_attribute = cn +``` +Even for simple filter setups, it is recommended to set it in order to get usernames exactly +as they are stored in LDAP and to avoid inconsistencies in the upper-/lower-case spelling of the +login names. -##### ldap_groups_attribute - -_(>= 3.4.0)_ - -The LDAP attribute to read the group memberships from in the authenticated user's LDAP entry. - -If set, load the LDAP group memberships from the attribute given -These memberships can be used later on to define rights. -This also gives you access to the group calendars, if they exist. -* The group calendar will be placed under collection_root_folder/GROUPS -* The name of the calendar directory is the base64 encoded group name. -* The group calendar folders will not be created automatically. This must be done manually. In the [LDAP-authentication section of Radicale's wiki](https://github.com/Kozea/Radicale/wiki/LDAP-authentication) you can find a script to create a group calendar. - -Use 'memberOf' if you want to load groups on Active Directory and alikes, 'groupMembership' on Novell eDirectory, ... - -Default: (unset) +Default: (unset, in which case the login name is directly used as the username) ##### ldap_use_ssl _(>= 3.3.0)_ -Use ssl on the ldap connection (soon to be deprecated, use ldap_security instead) +Use ssl on the LDAP connection. **Deprecated!** Use `ldap_security` instead. ##### ldap_security _(>= 3.5.2)_ -Use encryption on the ldap connection. none, tls, starttls +Use encryption on the LDAP connection. -Default: none +One of +* `none` +* `tls` +* `starttls` + +Default: `none` ##### ldap_ssl_verify_mode _(>= 3.3.0)_ -The certificate verification mode. Works for tls and starttls. NONE, OPTIONAL or REQUIRED +Certificate verification mode for tls and starttls. -Default: REQUIRED +One of +* `NONE` +* `OPTIONAL` +* `REQUIRED`. + +Default: `REQUIRED` ##### ldap_ssl_ca_file _(>= 3.3.0)_ -The path to the CA file in pem format which is used to certificate the server certificate +Path to the CA file in PEM format which is used to certify the server certificate -Default: +Default: (unset) + +##### ldap_groups_attribute + +_(>= 3.4.0)_ + +LDAP attribute in the authenticated user's LDAP entry to read the group memberships from. + +E.g. `memberOf` to get groups on Active Directory and alikes, `groupMembership` on Novell eDirectory, ... + +If set, get the user's LDAP groups from the attribute given. + +For DN-valued attributes, the value of the RDN is used to determine the group names. +The implementation also supports non-DN-valued attributes: their values are taken directly. + +The user's group names can be used later to define rights. +They also give you access to the group calendars, if those exist. +* Group calendars are placed directly under *collection_root_folder*`/GROUPS/` + with the base64-encoded group name as the calendar folder name. +* Group calendar folders are not created automatically. + This must be done manually. In the [LDAP-authentication section of Radicale's wiki](https://github.com/Kozea/Radicale/wiki/LDAP-authentication) you can find a script to create a group calendar. + +Default: (unset) + +##### ldap_group_members_attribute + +_(>= 3.5.6)_ + +Attribute in the group entries to read the group's members from. + +E.g. `member` for groups with objectclass `groupOfNames`. + +Using `ldap_group_members_attribute`, `ldap_group_base` and `ldap_group_filter` is an alternative +approach to getting the user's groups. Instead of reading them from `ldap_groups_attribute` +in the user's entry, an additional query is performed to seach for those groups beneath `ldap_group_base`, +that have the user's DN in their `ldap_group_members_attribute` and additionally fulfil `ldap_group_filter`. + +As with DN-valued `ldap_groups_attribute`, the value of the RDN is used to determine the group names. + +Default: (unset) + +##### ldap_group_base + +_(>= 3.5.6)_ + +Base DN to search for groups. +Only necessary if `ldap_group_members_attribute` is set, and if the base DN for groups differs from `ldap_base`. + +Default: (unset, in which case `ldap_base` is used as fallback) + +##### ldap_group_filter + +_(>= 3.5.6)_ + +Search filter to search for groups having the user DN found as member. +Only necessary `ldap_group_members_attribute` is set, and you want the groups returned to be restricted +instead of all groups the user's DN is in. + +Default: (unset) ##### ldap_ignore_attribute_create_modify_timestamp _(>= 3.5.1)_ -Add modifyTimestamp and createTimestamp to the exclusion list of internal ldap3 client -so that these schema attributes are not checked. This is needed at least for Authentik -LDAP server as not providing these both attributes. +Quirks for Authentik LDAP server, which violates the LDAP RFCs: +add modifyTimestamp and createTimestamp to the exclusion list of internal ldap3 client +so that these schema attributes are not checked. -Default: false +Default: `False` -##### dovecot_connection_type = AF_UNIX +##### dovecot_connection_type _(>= 3.4.1)_ -Connection type for dovecot authentication (AF_UNIX|AF_INET|AF_INET6) +Connection type for dovecot authentication. + +One of: +* `AF_UNIX` +* `AF_INET` +* `AF_INET6` Note: credentials are transmitted in cleartext @@ -1110,7 +1270,8 @@ Default: `AF_UNIX` _(>= 3.3.1)_ -The path to the Dovecot client authentication socket (eg. /run/dovecot/auth-client on Fedora). Radicale must have read / write access to the socket. +Path to the Dovecot client authentication socket (eg. /run/dovecot/auth-client on Fedora). +Radicale must have read & write access to the socket. Default: `/var/run/dovecot/auth-client` @@ -1118,7 +1279,7 @@ Default: `/var/run/dovecot/auth-client` _(>= 3.4.1)_ -Host of via network exposed dovecot socket +Host of dovecot socket exposed via network Default: `localhost` @@ -1126,15 +1287,49 @@ Default: `localhost` _(>= 3.4.1)_ -Port of via network exposed dovecot socket +Port of dovecot socket exposed via network Default: `12345` +##### remote_ip_source + +_(>= 3.5.6)_ + +For authentication mechanisms that are made aware of the remote IP +(such as dovecot via the `rip=` auth protocol parameter), determine +the source to use. Currently, valid values are + +`REMOTE_ADDR` (default) +: Use the REMOTE_ADDR environment variable that captures the remote + address of the socket connection. + +`X-Remote-Addr` +: Use the `X-Remote-Addr` HTTP header value. + +In the case of `X-Remote-Addr`, Radicale must be running be running +behind a proxy that you control and that sets/overwrites the +`X-Remote-Addr` header (doesn't pass it) so that the value passed +to dovecot is reliable. For example, for nginx, add + +``` + proxy_set_header X-Remote-Addr $remote_addr; +``` + +to the configuration sample. + +Default: `REMOTE_ADDR` + ##### imap_host _(>= 3.4.1)_ -IMAP server hostname: address | address:port | [address]:port | imap.server.tld +IMAP server hostname. + +One of: +* address +* address:port +* [address]:port (for IPv5 addresses) +* imap.server.tld Default: `localhost` @@ -1142,7 +1337,12 @@ Default: `localhost` _(>= 3.4.1)_ -Secure the IMAP connection: tls | starttls | none +Secure the IMAP connection: + +One of: +* `tls` +* `starttls` +* `none` Default: `tls` @@ -1150,17 +1350,17 @@ Default: `tls` _(>= 3.5.0)_ -OAuth2 token endpoint URL +Endpoint URL for the OAuth2 token -Default: +Default: (unset) ##### pam_service _(>= 3.5.0)_ -PAM service +PAM service name -Default: radicale +Default: `radicale` ##### pam_group_membership @@ -1168,27 +1368,31 @@ _(>= 3.5.0)_ PAM group user should be member of -Default: +Default: (unset) ##### lc_username -Сonvert username to lowercase, must be true for case-insensitive auth -providers like ldap, kerberos +Сonvert username to lowercase. +Recommended to be `True` for case-insensitive auth providers like ldap, kerberos, ... Default: `False` -Note: cannot be enabled together with `uc_username` +Notes: +* `lc_username` and `uc_username` are mutually exclusive +* for auth type `ldap` the use of `ldap_user_attribute` is preferred over `lc_username` ##### uc_username _(>= 3.3.2)_ -Сonvert username to uppercase, must be true for case-insensitive auth -providers like ldap, kerberos +Сonvert username to uppercase. +Recommended to be `True` for case-insensitive auth providers like ldap, kerberos, ... Default: `False` -Note: cannot be enabled together with `lc_username` +Notes: +* `uc_username` and `lc_username` are mutually exclusive +* for auth type `ldap` the use of `ldap_user_attribute` is preferred over `uc_username` ##### strip_domain @@ -1202,85 +1406,93 @@ Default: `False` _(>= 3.5.3)_ -URL Decode the username. When the username is an email, some clients send the username URL-encoded (notably iOS devices) -breaking the authentication process (user@example.com becomes user%40example.com). This setting will force decoding the username. +URL-decode the username. +If the username is an email address, some clients send the username URL-encoded +(notably iOS devices) breaking the authentication process +(user@example.com becomes user%40example.com). +This setting forces decoding the username. Default: `False` -#### rights +#### [rights] ##### type -The backend that is used to check the access rights of collections. +Authorization backend that is used to check the access rights to collections. -The recommended backend is `owner_only`. If access to calendars -and address books outside the home directory of users (that's `/USERNAME/`) -is granted, clients won't detect these collections and will not show them to -the user. Choosing any other method is only useful if you access calendars and -address books directly via URL. +The default and recommended backend is `owner_only`. If access to calendars +and address books outside the user's collection directory (that's `/username/`) +is granted, clients will not detect these collections automatically and +will not show them to the users. +Choosing any other authorization backend is only useful if you access +calendars and address books directly via URL. -Available backends: +Available backends are: -`authenticated` -: Authenticated users can read and write everything. +* `authenticated` + Authenticated users can read and write everything. -`owner_only` -: Authenticated users can read and write their own collections under the path +* `owner_only` + Authenticated users can read and write their own collections under the path */USERNAME/*. -`owner_write` -: Authenticated users can read everything and write their own collections under +* `owner_write` + Authenticated users can read everything and write their own collections under the path */USERNAME/*. -`from_file` -: Load the rules from a file. +* `from_file` + Load the rules from a file. Default: `owner_only` ##### file -File for the rights backend `from_file`. See the -[Rights](#authentication-and-rights) section. +Name of the file containing the authorization rules for the `from_file` backend. +See the [Rights](#authorization-and-rights) section for details. + +Default: `/etc/radicale/rights` ##### permit_delete_collection _(>= 3.1.9)_ -Global control of permission to delete complete collection (default: True) +Global permission to delete complete collections. +* If `False` it can be explicitly granted per collection by `permissions: D` +* If `True` it can be explicitly forbidden per collection by `permissions: d` -If False it can be permitted by permissions per section with: D -If True it can be forbidden by permissions per section with: d +Default: `True` ##### permit_overwrite_collection _(>= 3.3.0)_ -Global control of permission to overwrite complete collection (default: True) +Global permission to overwrite complete collections. +* If `False` it can be explicitly granted per collection by `permissions: O` +* If `True` it can be explicitly forbidden per collection by `permissions: o` -If False it can be permitted by permissions per section with: O -If True it can be forbidden by permissions per section with: o +Default: `True` -#### storage +#### [storage] ##### type -The backend that is used to store data. +Backend used to store data. -Available backends: +Available backends are: -`multifilesystem` -: Stores the data in the filesystem. +* `multifilesystem` + Stores the data in the filesystem. -`multifilesystem_nolock` -: The `multifilesystem` backend without file-based locking. +* `multifilesystem_nolock` + The `multifilesystem` backend without file-based locking. Must only be used with a single process. Default: `multifilesystem` ##### filesystem_folder -Folder for storing local collections, created if not present. +Folder for storing local collections; will be auto-created if not present. Default: `/var/lib/radicale/collections` @@ -1288,11 +1500,11 @@ Default: `/var/lib/radicale/collections` _(>= 3.3.2)_ -Folder for storing cache of local collections, created if not present +Folder for storing cache of local collections; will be auto-created if not present Default: (filesystem_folder) -Note: only used in case of use_cache_subfolder_* options are active +Note: only used if use_cache_subfolder_* options are active Note: can be used on multi-instance setup to cache files on local node (see below) @@ -1314,7 +1526,7 @@ Use subfolder `collection-cache` for cache file structure of 'history' instead o Default: `False` -Note: use only on single-instance setup, will break consistency with client in multi-instance setup +Note: only use on single-instance setup: it will break consistency with clients in multi-instance setup ##### use_cache_subfolder_for_synctoken @@ -1324,33 +1536,38 @@ Use subfolder `collection-cache` for cache file structure of 'sync-token' instea Default: `False` -Note: use only on single-instance setup, will break consistency with client in multi-instance setup +Note: only use on single-instance setup: it will break consistency with clients in multi-instance setup ##### use_mtime_and_size_for_item_cache _(>= 3.3.2)_ -Use last modifiction time (nanoseconds) and size (bytes) for 'item' cache instead of SHA256 (improves speed) +Use last modification time (in nanoseconds) and size (in bytes) for 'item' cache instead of SHA256 (improves speed) Default: `False` -Note: check used filesystem mtime precision before enabling - -Note: conversion is done on access, bulk conversion can be done offline using storage verification option `radicale --verify-storage` +Notes: +* check used filesystem mtime precision before enabling +* conversion is done on access +* bulk conversion can be done offline using the storage verification option `radicale --verify-storage` ##### folder_umask _(>= 3.3.2)_ -Use configured umask for folder creation (not applicable for OS Windows) +umask to use for folder creation (not applicable for OS Windows) -Default: (system-default, usual `0022`) +Default: (system-default, usually `0022`) -Useful value: `0077` (user:rw group:- other:-) or `0027` (user:rw group:r other:-) or `0007` (user:rw group:rw other:-) or `0022` (user:rw group:r other:r) +Useful values: +* `0077` (user:rw group:- other:-) +* `0027` (user:rw group:r other:-) +* `0007` (user:rw group:rw other:-) +* `0022` (user:rw group:r other:r) ##### max_sync_token_age -Delete sync-token that are older than the specified time. (seconds) +Delete sync-tokens that are older than the specified time (in seconds). Default: `2592000` @@ -1362,12 +1579,21 @@ Skip broken item instead of triggering an exception Default: `True` +##### strict_preconditions + +_(>= 3.5.8)_ + +Strict preconditions check on PUT in case item already exists [RFC6352#9.2](https://www.rfc-editor.org/rfc/rfc6352#section-9.2) + +Default: `False` + ##### hook -Command that is run after changes to storage. Take a look at the -[Versioning with Git](#versioning-with-git) tutorial for an example. +Command that is run after changes to storage. See the +[Versioning collections with Git](#versioning-collections-with-git) +tutorial for an example. -Default: +Default: (unset) Supported placeholders: - `%(user)s`: logged-in user @@ -1376,53 +1602,58 @@ Supported placeholders: - `%(to_path)s`: full path of destination item (only set on MOVE request) _(>= 3.5.5)_ - `%(request)s`: request method _(>= 3.5.5)_ -Command will be executed with base directory defined in `filesystem_folder` (see above) +The command will be executed with base directory defined in `filesystem_folder` (see above) ##### predefined_collections -Create predefined user collections +Create predefined user collections. - Example: +Example: +```json +{ + "def-addressbook": { + "D:displayname": "Personal Address Book", + "tag": "VADDRESSBOOK" + }, + "def-calendar": { + "C:supported-calendar-component-set": "VEVENT,VJOURNAL,VTODO", + "D:displayname": "Personal Calendar", + "tag": "VCALENDAR" + } +} +``` +Default: (unset) - { - "def-addressbook": { - "D:displayname": "Personal Address Book", - "tag": "VADDRESSBOOK" - }, - "def-calendar": { - "C:supported-calendar-component-set": "VEVENT,VJOURNAL,VTODO", - "D:displayname": "Personal Calendar", - "tag": "VCALENDAR" - } - } - -Default: - -#### web +#### [web] ##### type The backend that provides the web interface of Radicale. -Available backends: +Available backends are: -`none` -: Just shows the message "Radicale works!". +* `none` + Simply shows the message "Radicale works!". -`internal` -: Allows creation and management of address books and calendars. +* `internal` + Allows creation and management of address books and calendars. Default: `internal` -#### logging +#### [logging] ##### level Set the logging level. -Available levels: **debug**, **info**, **warning**, **error**, **critical** +Available levels are: +* `debug` +* `info` +* `warning` +* `error` +* `critical` -Default: `warning` _(< 3.2.0)_ `info` _(>= 3.2.0)_ +Default: `warning` _(< 3.2.0)_ / `info` _(>= 3.2.0)_ ##### trace_on_debug @@ -1438,13 +1669,13 @@ _(> 3.5.4)_ Filter debug messages starting with 'TRACE/' -Precondition: `trace_on_debug = True` +Prerequisite: `trace_on_debug = True` Default: (empty) ##### mask_passwords -Don't include passwords in logs. +Do not include passwords in logs. Default: `True` @@ -1460,7 +1691,7 @@ Default: `False` _(>= 3.2.2)_ -Log backtrace on level=debug +Log backtrace on `level = debug` Default: `False` @@ -1468,7 +1699,7 @@ Default: `False` _(>= 3.2.2)_ -Log request on level=debug +Log request header on `level = debug` Default: `False` @@ -1476,7 +1707,15 @@ Default: `False` _(>= 3.2.2)_ -Log request on level=debug +Log request content (body) on `level = debug` + +Default: `False` + +##### response_header_on_debug + +_(>= 3.5.10)_ + +Log response header on `level = debug` Default: `False` @@ -1484,7 +1723,7 @@ Default: `False` _(>= 3.2.2)_ -Log response on level=debug +Log response content (body) on `level = debug` Default: `False` @@ -1492,7 +1731,7 @@ Default: `False` _(>= 3.2.3)_ -Log rights rule which doesn't match on level=debug +Log rights rule which doesn't match on `level = debug` Default: `False` @@ -1500,14 +1739,67 @@ Default: `False` _(>= 3.3.2)_ -Log storage cache actions on level=debug +Log storage cache actions on `level = debug` Default: `False` -#### headers +##### profiling_per_request -In this section additional HTTP headers that are sent to clients can be -specified. +_(>= 3.5.10)_ + +Log profiling data on level=info + +Default: `none` + +One of +* `none` (disabled) +* `per_request` (above minimum duration) +* `per_request_method` (regular interval) + +##### profiling_per_request_min_duration + +_(>= 3.5.10)_ + +Log profiling data per request minimum duration (seconds) before logging, otherwise skip + +Default: `3` + +##### profiling_per_request_header + +_(>= 3.5.10)_ + +Log profiling request header (if passing minimum duration) + +Default: `False` + +##### profiling_per_request_xml + +_(>= 3.5.10)_ + +Log profiling request XML (if passing minimum duration) + +Default: `False` + +##### profiling_per_request_method_interval + +_(>= 3.5.10)_ + +Log profiling data per method interval (seconds) +Triggered by request, not active on idle systems + +Default: `600` + +##### profiling_top_x_functions + +_(>= 3.5.10)_ + +Log profiling top X functions (limit) + +Default: `10` + +#### [headers] + +This section can be used to specify additional HTTP headers that will be sent to clients. An example to relax the same-origin policy: @@ -1515,21 +1807,22 @@ An example to relax the same-origin policy: Access-Control-Allow-Origin = * ``` -#### hook +#### [hook] + ##### type Hook binding for event changes and deletion notifications. -Available types: +Available types are: -`none` -: Disabled. Nothing will be notified. +* `none` + Disabled. Nothing will be notified. -`rabbitmq` _(>= 3.2.0)_ -: Push the message to the rabbitmq server. +* `rabbitmq` _(>= 3.2.0)_ + Push the message to the rabbitmq server. -`email` _(>= 3.5.5)_ -: Send an email notification to event attendees. +* `email` _(>= 3.5.5)_ + Send an email notification to event attendees. Default: `none` @@ -1537,7 +1830,7 @@ Default: `none` _(> 3.5.4)_ -Dry-Run (do not really trigger hook action) +Dry-Run / simulate (i.e. do not really trigger) the hook action. Default: `False` @@ -1546,17 +1839,17 @@ Default: `False` _(>= 3.2.0)_ End-point address for rabbitmq server. -Ex: amqp://user:password@localhost:5672/ +E.g.: `amqp://user:password@localhost:5672/` -Default: +Default: (unset) ##### rabbitmq_topic _(>= 3.2.0)_ -RabbitMQ topic to publish message. +RabbitMQ topic to publish message in. -Default: +Default: (unset) ##### rabbitmq_queue_type @@ -1564,21 +1857,21 @@ _(>= 3.2.0)_ RabbitMQ queue type for the topic. -Default: classic +Default: `classic` ##### smtp_server _(>= 3.5.5)_ -Address to connect to SMTP server. +Address of SMTP server to connect to. -Default: +Default: (unset) ##### smtp_port _(>= 3.5.5)_ -Port to connect to SMTP server. +Port on SMTP server to connect to. Default: @@ -1586,33 +1879,45 @@ Default: _(>= 3.5.5)_ -Use encryption on the SMTP connection. none, tls, starttls +Use encryption on the SMTP connection. -Default: none +One of: +* `none` +* `tls` +* `starttls` + +Default: `none` ##### smtp_ssl_verify_mode _(>= 3.5.5)_ -The certificate verification mode. Works for tls and starttls. NONE, OPTIONAL or REQUIRED +The certificate verification mode for tls and starttls. -Default: REQUIRED +One of: +* `NONE` +* `OPTIONAL` +* `REQUIRED` + +Default: `REQUIRED` ##### smtp_username _(>= 3.5.5)_ -Username to authenticate with SMTP server. Leave empty to disable authentication (e.g. using local mail server). +Username to authenticate with SMTP server. +Leave empty to disable authentication (e.g. using local mail server). -Default: +Default: (unset) ##### smtp_password _(>= 3.5.5)_ -Password to authenticate with SMTP server. Leave empty to disable authentication (e.g. using local mail server). +Password to authenticate with SMTP server. +Leave empty to disable authentication (e.g. using local mail server). -Default: +Default: (unset) ##### from_email @@ -1620,30 +1925,31 @@ _(>= 3.5.5)_ Email address to use as sender in email notifications. -Default: +Default: (unset) ##### mass_email _(>= 3.5.5)_ -When enabled, send one email to all attendee email addresses. When disabled, send one email per attendee email address. +When enabled, send one email to all attendee email addresses. +When disabled, send one email per attendee email address. Default: `False` -##### added_template +##### new_or_added_to_event_template _(>= 3.5.5)_ -Template to use for added/updated event email body. +Template to use for added/updated event email body sent to an attendee when the event is created or they are added to a pre-existing event. The following placeholders will be replaced: -- `$organizer_name`: Name of the organizer, or "Unknown Organizer" if not set in event -- `$from_email`: Email address the email is sent from -- `$attendee_name`: Name of the attendee (email recipient), or "everyone" if mass email enabled. -- `$event_name`: Name/summary of the event, or "No Title" if not set in event -- `$event_start_time`: Start time of the event in ISO 8601 format -- `$event_end_time`: End time of the event in ISO 8601 format, or "No End Time" if the event has no end time -- `$event_location`: Location of the event, or "No Location Specified" if not set in event +* `$organizer_name`: Name of the organizer, or "Unknown Organizer" if not set in event +* `$from_email`: Email address the email is sent from +* `$attendee_name`: Name of the attendee (email recipient), or "everyone" if mass email enabled. +* `$event_name`: Name/summary of the event, or "No Title" if not set in event +* `$event_start_time`: Start time of the event in ISO 8601 format +* `$event_end_time`: End time of the event in ISO 8601 format, or "No End Time" if the event has no end time +* `$event_location`: Location of the event, or "No Location Specified" if not set in event Providing any words prefixed with $ not included in the list above will result in an error. @@ -1660,28 +1966,28 @@ You have been added as an attendee to the following calendar event. This is an automated message. Please do not reply. ``` -##### removed_template +##### deleted_or_removed_from_event_template _(>= 3.5.5)_ -Template to use for deleted event email body. +Template to use for deleted/removed event email body sent to an attendee when the event is deleted or they are removed from the event. The following placeholders will be replaced: -- `$organizer_name`: Name of the organizer, or "Unknown Organizer" if not set in event -- `$from_email`: Email address the email is sent from -- `$attendee_name`: Name of the attendee (email recipient), or "everyone" if mass email enabled. -- `$event_name`: Name/summary of the event, or "No Title" if not set in event -- `$event_start_time`: Start time of the event in ISO 8601 format -- `$event_end_time`: End time of the event in ISO 8601 format, or "No End Time" if the event has no end time -- `$event_location`: Location of the event, or "No Location Specified" if not set in event +* `$organizer_name`: Name of the organizer, or "Unknown Organizer" if not set in event +* `$from_email`: Email address the email is sent from +* `$attendee_name`: Name of the attendee (email recipient), or "everyone" if mass email enabled. +* `$event_name`: Name/summary of the event, or "No Title" if not set in event +* `$event_start_time`: Start time of the event in ISO 8601 format +* `$event_end_time`: End time of the event in ISO 8601 format, or "No End Time" if the event has no end time +* `$event_location`: Location of the event, or "No Location Specified" if not set in event Providing any words prefixed with $ not included in the list above will result in an error. -Default: +Default: ``` Hello $attendee_name, -You have been removed as an attendee from the following calendar event. +The following event has been deleted. $event_title $event_start_time - $event_end_time @@ -1690,7 +1996,39 @@ You have been removed as an attendee from the following calendar event. This is an automated message. Please do not reply. ``` -#### reporting +##### updated_event_template + +_(>= 3.5.5)_ + +Template to use for updated event email body sent to an attendee when non-attendee-related details of the event are updated. + +Existing attendees will NOT be notified of a modified event if the only changes are adding/removing other attendees. + +The following placeholders will be replaced: +* `$organizer_name`: Name of the organizer, or "Unknown Organizer" if not set in event +* `$from_email`: Email address the email is sent from +* `$attendee_name`: Name of the attendee (email recipient), or "everyone" if mass email enabled. +* `$event_name`: Name/summary of the event, or "No Title" if not set in event +* `$event_start_time`: Start time of the event in ISO 8601 format +* `$event_end_time`: End time of the event in ISO 8601 format, or "No End Time" if the event has no end time +* `$event_location`: Location of the event, or "No Location Specified" if not set in event + +Providing any words prefixed with $ not included in the list above will result in an error. + +Default: +``` +Hello $attendee_name, + +The following event has been updated. + + $event_title + $event_start_time - $event_end_time + $event_location + +This is an automated message. Please do not reply. +``` + +#### [reporting] ##### max_freebusy_occurrence @@ -1715,6 +2053,8 @@ Radicale has been tested with: * [GNOME Calendar](https://wiki.gnome.org/Apps/Calendar), [Contacts](https://wiki.gnome.org/Apps/Contacts) and [Evolution](https://wiki.gnome.org/Apps/Evolution) +* [KDE PIM Applications](https://kontact.kde.org/), + [KDE Merkuro](https://apps.kde.org/de/merkuro/) * [Mozilla Thunderbird](https://www.mozilla.org/thunderbird/) ([Thunderbird/Radicale](https://github.com/Kozea/Radicale/wiki/Client-Thunderbird)) with [CardBook](https://addons.mozilla.org/thunderbird/addon/cardbook/) and [Lightning](https://www.mozilla.org/projects/calendar/) @@ -1728,10 +2068,9 @@ Many clients do not support the creation of new calendars and address books. You can use Radicale's web interface (e.g. ) to create and manage address books and calendars. -In some clients you can just enter the URL of the Radicale server +In some clients, it is sufficient to simply enter the URL of the Radicale server (e.g. `http://localhost:5232`) and your username. In others, you have to -enter the URL of the collection directly -(e.g. `http://localhost:5232/user/calendar`). +enter the URL of the collection directly (e.g. `http://localhost:5232/user/calendar`). Some clients (notably macOS's Calendar.app) may silently refuse to include account credentials over unsecured HTTP, leading to unexpected authentication @@ -1742,11 +2081,11 @@ failures. In these cases, you want to make sure the Radicale server is Enter the URL of the Radicale server (e.g. `http://localhost:5232`) and your username. DAVx⁵ will show all existing calendars and address books and you -can create new. +can create new ones. #### OneCalendar -When adding account, select CalDAV account type, then enter user name, password and the +When adding account, select CalDAV account type, then enter username, password and the Radicale server (e.g. `https://yourdomain:5232`). OneCalendar will show all existing calendars and (FIXME: address books), you need to select which ones you want to see. OneCalendar supports many other server types too. @@ -1755,7 +2094,10 @@ you want to see. OneCalendar supports many other server types too. GNOME 46 added CalDAV and CardDAV support to _GNOME Online Accounts_. -Open GNOME Settings, navigate to _Online Accounts_ > _Connect an Account_ > _Calendar, Contacts and Files_. Enter the URL (e.g. `https://example.com/radicale`) and your credentials then click _Sign In_. In the pop-up dialog, turn off _Files_. After adding Radicale in _GNOME Online Accounts_, it should be available in GNOME Contacts and GNOME Calendar. +Open GNOME Settings, navigate to _Online Accounts_ > _Connect an Account_ > _Calendar, Contacts and Files_. +Enter the URL (e.g. `https://example.com/radicale`) and your credentials then click _Sign In_. +In the pop-up dialog, turn off _Files_. After adding Radicale in _GNOME Online Accounts_, +it should be available in GNOME Contacts and GNOME Calendar. #### Evolution @@ -1764,7 +2106,16 @@ Enter the URL of the Radicale server (e.g. `http://localhost:5232`) and your username. Clicking on the search button will list the existing calendars and address books. -Adding CalDAV and CardDAV accounts in Evolution will automatically make them available in GNOME Contacts and GNOME Calendar. +Adding CalDAV and CardDAV accounts in Evolution will automatically make them +available in GNOME Contacts and GNOME Calendar. + +#### KDE PIM Applications + +In **Kontact** add a _DAV Groupware resource_ to Akonadi under +_Settings > Configure Kontact > Calendar > General > Calendars_, +select the protocol (CalDAV or CardDAV), add the URL to the Radicale collections +and enter the credentials. After synchronization of the calendar resp. +addressbook items, you can manage them in Kontact. #### Thunderbird @@ -1781,8 +2132,8 @@ It will list your existing address books. #### InfCloud, CalDavZAP and CardDavMATE You can integrate InfCloud into Radicale's web interface with by simply -download latest package from [InfCloud](https://www.inf-it.com/open-source/clients/infcloud/) -and extract content to new folder `infcloud` in `radicale/web/internal_data/`. +downloading the latest package from [InfCloud](https://www.inf-it.com/open-source/clients/infcloud/) +and extract the content into a folder named `infcloud` in `radicale/web/internal_data/`. No further adjustments are required as content is adjusted on the fly (tested with 0.13.1). @@ -1851,16 +2202,16 @@ curl -u user -X DELETE 'http://localhost:5232/user/calendar' Note: requires config/option `permit_delete_collection = True` -### Authentication and Rights +### Authorization and Rights This section describes the format of the rights file for the `from_file` authentication backend. The configuration option `file` in the `rights` section must point to the rights file. -The recommended rights method is `owner_only`. If access to calendars -and address books outside the home directory of users (that's `/USERNAME/`) -is granted, clients won't detect these collections and will not show them to -the user. +The recommended rights method is `owner_only`. If access is granted +to calendars and address books outside the home directory of users +(that's `/USERNAME/`), clients will not detect these collections automatically, +and will not show them to the users. This is only useful if you access calendars and address books directly via URL. An example rights file: @@ -1912,40 +2263,40 @@ The following `permissions` are recognized: (CalDAV/CardDAV is susceptible to expensive search requests) * **W:** write collections (excluding address books and calendars) * **w:** write address book and calendar collections -* **D:** permit delete of collection in case permit_delete_collection=False _(>= 3.3.0)_ -* **d:** forbid delete of collection in case permit_delete_collection=True _(>= 3.3.0)_ -* **O:** permit overwrite of collection in case permit_overwrite_collection=False -* **o:** forbid overwrite of collection in case permit_overwrite_collection=True +* **D:** allow deleting a collection in case `permit_delete_collection=False` _(>= 3.3.0)_ +* **d:** deny deleting a collection in case `permit_delete_collection=True` _(>= 3.3.0)_ +* **O:** allow overwriting a collection in case `permit_overwrite_collection=False` +* **o:** deny overwriting a collection in case `permit_overwrite_collection=True` ### Storage -This document describes the layout and format of the file system storage -(`multifilesystem` backend). +This document describes the layout and format of the file system storage, +the `multifilesystem` backend. -It's safe to access and manipulate the data by hand or with scripts. -Scripts can be invoked manually, periodically (e.g. with +It is safe to access and manipulate the data by hand or with scripts. +Scripts can be invoked manually, periodically (e.g. using [cron](https://manpages.debian.org/unstable/cron/cron.8.en.html)) or after each change to the storage with the configuration option `hook` in the `storage` -section (e.g. [Versioning with Git](#versioning-with-git)). +section (e.g. [Versioning collections with Git](#versioning-collections-with-git)). #### Layout -The file system contains the following files and folders: - +The file system comprises the following files and folders: * `.Radicale.lock`: The lock file for locking the storage. * `collection-root`: This folder contains all collections and items. -A collection is represented by a folder. This folder may contain the file +Each collection is represented by a folder. This folder may contain the file `.Radicale.props` with all WebDAV properties of the collection encoded as [JSON](https://en.wikipedia.org/wiki/JSON). -An item is represented by a file containing the iCalendar data. +Each item in a calendar or address book collection is represented by +a file containing the item's iCalendar resp. vCard data. -All files and folders, whose names start with a dot but not `.Radicale.` +All files and folders, whose names start with a dot but not with `.Radicale.` (internal files) are ignored. -If you introduce syntax errors in any of the files, all requests that access -the faulty data will fail. The logging output should contain the names of the +Syntax errors in any of the files will cause all requests accessing +the faulty data to fail. The logging output should contain the names of the culprits. Caches and sync-tokens are stored in the `.Radicale.cache` folder inside of @@ -1953,14 +2304,14 @@ collections. This folder may be created or modified, while the storage is locked for shared access. In theory, it should be safe to delete the folder. Caches will be recreated -automatically and clients will be told that their sync-token isn't valid +automatically and clients will be told that their sync-token is not valid anymore. You may encounter files or folders that start with `.Radicale.tmp-`. Radicale uses them for atomic creation and deletion of files and folders. -They should be deleted after requests are finished but it's possible that +They should be deleted after requests are finished but it is possible that they are left behind when Radicale or the computer crashes. -It's safe to delete them. +You can safely delete them. #### Locking @@ -1973,12 +2324,13 @@ The storage is locked with exclusive access while the `hook` runs. Use the [flock](https://manpages.debian.org/unstable/util-linux/flock.1.en.html) -utility. +utility to acquire exclusive or shared locks for the commands you want to run +on Radicale's data. ```bash -# Exclusive +# Exclusive lock for COMMAND $ flock --exclusive /path/to/storage/.Radicale.lock COMMAND -# Shared +# Shared lock for COMMAND $ flock --shared /path/to/storage/.Radicale.lock COMMAND ``` @@ -2000,17 +2352,17 @@ and `nNumberOfBytesToLockHigh` to `0` works. #### Manually creating collections -To create a new collection, you have to create the corresponding folder in the +To create a new collection, you need to create the corresponding folder in the file system storage (e.g. `collection-root/user/calendar`). -To tell Radicale and clients that the collection is a calendar, you have to +To indicate to Radicale and clients that the collection is a calendar, you have to create the file ``.Radicale.props`` with the following content in the folder: ```json {"tag": "VCALENDAR"} ``` -The calendar is now available at the URL path ``/user/calendar``. -For address books the file must contain: +The calendar is now available at the URL path (e.g. ``/user/calendar``). +For address books ``.Radicale.props`` must contain: ```json {"tag": "VADDRESSBOOK"} @@ -2050,12 +2402,12 @@ an address book through network: Radicale is **only the server part** of this architecture. -Please note that: +Please note: -* CalDAV and CardDAV are superset protocols of WebDAV, -* WebDAV is a superset protocol of HTTP. +* CalDAV and CardDAV are extension protocols of WebDAV, +* WebDAV is an extension of the HTTP protocol. -Radicale being a CalDAV/CardDAV server, it also can be seen as a special WebDAV +Radicale being a CalDAV/CardDAV server, can also be seen as a special WebDAV and HTTP server. Radicale is **not the client part** of this architecture. It means that @@ -2071,59 +2423,59 @@ icons and buttons, a terminal or another web application. The ``radicale`` package offers the following modules. -`__init__` -: Contains the entry point for WSGI. +* `__init__` + : Contains the entry point for WSGI. -`__main__` -: Provides the entry point for the ``radicale`` executable and +* `__main__` + : Provides the entry point for the ``radicale`` executable and includes the command line parser. It loads configuration files from the default (or specified) paths and starts the internal server. -`app` -: This is the core part of Radicale, with the code for the CalDAV/CardDAV +* `app` + : This is the core part of Radicale, with the code for the CalDAV/CardDAV server. The code managing the different HTTP requests according to the CalDAV/CardDAV specification can be found here. -`auth` -: Used for authenticating users based on username and password, mapping +* `auth` + : Used for authenticating users based on username and password, mapping usernames to internal users and optionally retrieving credentials from the environment. -`config` -: Contains the code for managing configuration and loading settings from files. +* `config` + : Contains the code for managing configuration and loading settings from files. -`ìtem` -: Internal representation of address book and calendar entries. Based on +* `ìtem` + : Internal representation of address book and calendar entries. Based on [VObject](https://github.com/py-vobject/vobject/). -`log` -: The logger for Radicale based on the default Python logging module. +* `log` + : The logger for Radicale based on the default Python logging module. -`rights` -: This module is used by Radicale to manage access rights to collections, +* `rights` + : This module is used by Radicale to manage access rights to collections, address books and calendars. -`server` +* `server` : The integrated HTTP server for standalone use. -`storage` -: This module contains the classes representing collections in Radicale and +* `storage` + : This module contains the classes representing collections in Radicale and the code for storing and loading them in the filesystem. -`web` -: This module contains the web interface. +* `web` + : This module contains the web interface. -`utils` -: Contains general helper functions. +* `utils` + : Contains general helper functions. -`httputils` -: Contains helper functions for working with HTTP. +* `httputils` + : Contains helper functions for working with HTTP. -`pathutils` -: Helper functions for working with paths and the filesystem. +* `pathutils` + : Helper functions for working with paths and the filesystem. -`xmlutils` -: Helper functions for working with the XML part of CalDAV/CardDAV requests +* `xmlutils` + : Helper functions for working with the XML part of CalDAV/CardDAV requests and responses. It's based on the ElementTree XML API. ### Plugins @@ -2131,7 +2483,7 @@ The ``radicale`` package offers the following modules. Radicale can be extended by plugins for authentication, rights management and storage. Plugins are **python** modules. -#### Getting started +#### Getting started with plugin development To get started we walk through the creation of a simple authentication plugin, that accepts login attempts with a static password. @@ -2278,14 +2630,47 @@ You can find the source packages of all releases on #### Docker -Radicale is available as a [Docker image](https://github.com/Kozea/Radicale/pkgs/container/radicale) for platforms `linux/amd64` and `linux/arm64`. To install the latest version, run: +Radicale is available as a [Docker image](https://github.com/Kozea/Radicale/pkgs/container/radicale) for platforms `linux/amd64` and `linux/arm64`. -```bash -docker pull ghcr.io/kozea/radicale:latest -``` +Here are the steps to install Radicale via Docker Compose: -An example `docker-compose.yml` and detailed instructions will soon be updated. +1. Create required directories + Create a directory to store the data, configuration and compose file. + + For example, assuming `./radicale`: + + ```bash + $ mkdir radicale + $ cd radicale + ``` + Create directories to store data and configuration. + + For example, assuming data directory as `./data` and configuration directory as `./config`: + + ```bash + $ mkdir config data + ``` + +2. Download the compose file + + ```bash + $ wget https://raw.githubusercontent.com/Kozea/Radicale/refs/heads/master/compose.yaml + ``` + + The compose file assumes `./config` and `./data` directories. Review the file and modify as needed. + +3. Create Radicale configuration file as necessary + + Create new or place existing configuration file in the `./config` directory. + +4. Start Radicale + + ```bash + $ docker compose up -d + ``` + + This will start the Radicale container in detached mode. #### Linux Distribution Packages diff --git a/Dockerfile b/Dockerfile index f6ac22f6..1fe5f380 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,14 +19,12 @@ WORKDIR /app RUN addgroup -g 1000 radicale \ && adduser radicale --home /var/lib/radicale --system --uid 1000 --disabled-password -G radicale \ - && apk add --no-cache ca-certificates openssl + && apk add --no-cache ca-certificates openssl curl COPY --chown=radicale:radicale --from=builder /app/venv /app # Persistent storage for data VOLUME /var/lib/radicale -# TCP port of Radicale -EXPOSE 5232 # Run Radicale ENTRYPOINT [ "/app/bin/python", "/app/bin/radicale"] CMD ["--hosts", "0.0.0.0:5232,[::]:5232"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..0e4abb8d --- /dev/null +++ b/compose.yaml @@ -0,0 +1,25 @@ +name: Radicale +services: + radicale: + image: ghcr.io/kozea/radicale:stable + ports: + - 5232:5232 + volumes: + - config:/etc/radicale + - data:/var/lib/radicale + +volumes: + config: + name: radicale-config + driver: local + driver_opts: + type: none + o: bind + device: ./config + data: + name: radicale-data + driver: local + driver_opts: + type: none + o: bind + device: ./data diff --git a/config b/config index d3e2283f..33ab4d4e 100644 --- a/config +++ b/config @@ -21,10 +21,15 @@ # Max parallel connections #max_connections = 8 -# Max size of request body (bytes) +# Max size of request body (bytes), default: 100 Mbyte # In case of using a reverse proxy in front of check also there related option #max_content_length = 100000000 +# Max resource size (bytes), default: 10 Mbyte +# Limited to 80% of max_content_length to cover plain base64 encoded payload +# Announced to clients requesting "max-resource-size" via PROPFIND +#max_ressource_size = 10000000 + # Socket timeout (seconds) #timeout = 30 @@ -63,7 +68,7 @@ [auth] # Authentication method -# Value: none | htpasswd | remote_user | http_x_remote_user | dovecot | ldap | oauth2 | pam | denyall +# Value: none | htpasswd | remote_user | http_remote_user | http_x_remote_user | dovecot | ldap | oauth2 | pam | denyall #type = denyall # Cache logins for until expiration time @@ -75,46 +80,54 @@ ## Expiration time of caching failed logins in seconds #cache_failed_logins_expiry = 90 -# Ignore modifyTimestamp and createTimestamp attributes. Required e.g. for Authentik LDAP server -#ldap_ignore_attribute_create_modify_timestamp = false - # URI to the LDAP server #ldap_uri = ldap://localhost -# The base DN where the user accounts have to be searched +# Base DN of the LDAP server to search for user accounts #ldap_base = ##BASE_DN## -# The reader DN of the LDAP server +# Reader DN of the LDAP server; (needs read access to users and - if defined - groups) #ldap_reader_dn = CN=ldapreader,CN=Users,##BASE_DN## -# Password of the reader DN +# Password of the reader DN (better: use 'ldap_secret_file'!) #ldap_secret = ldapreader-secret -# Path of the file containing password of the reader DN +# Path to the file containing the password of the reader DN #ldap_secret_file = /run/secrets/ldap_password -# the attribute to read the group memberships from in the user's LDAP entry (default: not set) -#ldap_groups_attribute = memberOf - -# The filter to find the DN of the user. This filter must contain a python-style placeholder for the login +# Filter to search for the LDAP entry of the user to authenticate. It must contain '{0}' as placeholder for the login name. #ldap_filter = (&(objectClass=person)(uid={0})) -# the attribute holding the value to be used as username after authentication +# Attribute holding the value to be used as username after authentication #ldap_user_attribute = cn -# Use ssl on the ldap connection -# Soon to be deprecated, use ldap_security instead +# Use ssl on the LDAP connection (DEPRECATED - use 'ldap_security'!) #ldap_use_ssl = False -# the encryption mode to be used: tls, starttls, default is none +# Encryption mode to be used. Default: none; one of: none, tls, starttls #ldap_security = none -# The certificate verification mode. Works for ssl and starttls. NONE, OPTIONAL, default is REQUIRED +# Certificate verification mode for tls & starttls. Default: REQUIRED; one of NONE, OPTIONAL, REQUIRED #ldap_ssl_verify_mode = REQUIRED -# The path to the CA file in pem format which is used to certificate the server certificate +# Path to the CA file in PEM format to certify the server certificate #ldap_ssl_ca_file = +# Attribute in the user's LDAP entry to read the group memberships from; default: not set +#ldap_groups_attribute = memberOf + +# Attribute in the group entries to read the group's members from, e.g. member; default: not set +#ldap_group_members_attribute = member + +# Base DN to search for groups; only if it differs from 'ldap_base' and if 'ldap_group_members_attribute' is set +#ldap_group_base = ##GROUP_BASE_DN## + +# Search filter to search for groups having the user DN found as member; only if 'ldap_group_members_attribute' is set +#ldap_group_filter = (objectclass=groupOfNames) + +# Quirks for Authentik LDAP server: ignore modifyTimestamp and createTimestamp attributes +#ldap_ignore_attribute_create_modify_timestamp = false + # Connection type for dovecot authentication (AF_UNIX|AF_INET|AF_INET6) # Note: credentials are transmitted in cleartext #dovecot_connection_type = AF_UNIX @@ -128,6 +141,10 @@ # Port of via network exposed dovecot socket #dovecot_port = 12345 +# Remote address source for authentication mechanisms (such as dovecot) +# that are passed this information. +#remote_ip_source = REMOTE_ADDR + # IMAP server hostname # Syntax: address | address:port | [address]:port | imap.server.tld #imap_host = localhost @@ -169,6 +186,9 @@ # Strip domain name from username #strip_domain = False +# URL Decode the given username (when URL-encoded by the client - useful for iOS devices when using email address) +#urldecode_username = False + [rights] @@ -185,8 +205,6 @@ # Permit overwrite of a collection (global) #permit_overwrite_collection = True -# URL Decode the given username (when URL-encoded by the client - useful for iOS devices when using email address) -# urldecode_username = False [storage] @@ -229,6 +247,9 @@ # Skip broken item instead of triggering an exception #skip_broken_item = True +# Strict preconditions check on PUT +#strict_preconditions = False + # Command that is run after changes to storage, default is emtpy # Supported placeholders: # %(user)s: logged-in user @@ -247,18 +268,31 @@ # # json format: # -# { -# "def-addressbook": { +# predefined_collections = { +# "def-personal-addressbook": { # "D:displayname": "Personal Address Book", # "tag": "VADDRESSBOOK" # }, -# "def-calendar": { +# "def-work-addressbook": { +# "D:displayname": "Work Address Book", +# "tag": "VADDRESSBOOK" +# }, +# "def-personal-calendar": { # "C:supported-calendar-component-set": "VEVENT,VJOURNAL,VTODO", # "D:displayname": "Personal Calendar", # "tag": "VCALENDAR" -# } -# } -# +# }, +# "def-birthday-calendar": { +# "C:supported-calendar-component-set": "VEVENT", +# "D:displayname": "Birthday Calendar", +# "tag": "VCALENDAR" +# }, +# "def-work-calendar": { +# "C:supported-calendar-component-set": "VEVENT", +# "D:displayname": "Work Calendar", +# "tag": "VCALENDAR" +# }, +# } #predefined_collections = @@ -296,6 +330,9 @@ # Log request content on level=debug #request_content_on_debug = False +# Log response header on level=debug +#response_header_on_debug = False + # Log response content on level=debug #response_content_on_debug = False @@ -305,6 +342,26 @@ # Log storage cache actions on level=debug #storage_cache_actions_on_debug = False +# Log profiling data on level=info +# Value: per_request | per_request_method | none +#profiling = none + +# Log profiling data per request minimum duration (seconds) +#profiling_per_request_min_duration = 3 + +# Log profiling request header (if passing minimum duration) +#profiling_per_request_header = False + +# Log profiling request XML (if passing minimum duration) +#profiling_per_request_xml = False + +# Log profiling data per request method interval (seconds) +#profiling_per_request_method_interval = 600 + +# Log profiling top X functions (limit) +#profiling_top_x_functions = 10 + + [headers] # Additional HTTP headers @@ -334,10 +391,13 @@ #smtp_password = #from_email = #mass_email = False +#new_or_added_to_event_template = +#deleted_or_removed_from_event_template = +#updated_event_template = [reporting] # When returning a free-busy report, limit the number of returned -# occurences per event to prevent DOS attacks. +# occurences per event to prevent DoS attacks. #max_freebusy_occurrence = 10000 diff --git a/contrib/apache/radicale.conf b/contrib/apache/radicale.conf index d92c5c31..385ee159 100644 --- a/contrib/apache/radicale.conf +++ b/contrib/apache/radicale.conf @@ -59,6 +59,9 @@ ProxyPass http://localhost:5232/ retry=0 ProxyPassReverse http://localhost:5232/ + = 2.4.40> + Proxy100Continue Off + Require local @@ -74,6 +77,9 @@ ProxyPass http://localhost:5232/ retry=0 ProxyPassReverse http://localhost:5232/ + = 2.4.40> + Proxy100Continue Off + ## User authentication handled by "radicale" @@ -221,6 +227,9 @@ CustomLog logs/ssl_request_log "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b" ProxyPass http://localhost:5232/ retry=0 ProxyPassReverse http://localhost:5232/ + = 2.4.40> + Proxy100Continue Off + Require local @@ -234,6 +243,9 @@ CustomLog logs/ssl_request_log "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b" ProxyPass http://localhost:5232/ retry=0 ProxyPassReverse http://localhost:5232/ + = 2.4.40> + Proxy100Continue Off + ## User authentication handled by "radicale" diff --git a/contrib/caddy/radicale.caddyfile b/contrib/caddy/radicale.caddyfile index 6739283b..b578b383 100644 --- a/contrib/caddy/radicale.caddyfile +++ b/contrib/caddy/radicale.caddyfile @@ -16,11 +16,17 @@ caldav.example.com { not path /.web/* } + # disable this in case authentication is handled by Radicale basic_auth @not-webui { USER HASH } reverse_proxy localhost:5232 { + # disable this in case authentication is handled by Radicale header_up X-Remote-User {http.auth.user.id} + # replace "HOST" with configured hostname of URL (FQDN) in client + header_up Host HOST + # replace "PORT" with configured port of URL in client + header_up X-Forwarded-Port PORT } } diff --git a/contrib/lighttpd/radicale.conf b/contrib/lighttpd/radicale.conf new file mode 100644 index 00000000..d3c58ced --- /dev/null +++ b/contrib/lighttpd/radicale.conf @@ -0,0 +1,6 @@ +server.modules += ( "mod_proxy" , "mod_setenv" ) + +$HTTP["url"] =~ "^/radicale/" { + proxy.server = ( "" => (( "host" => "127.0.0.1", "port" => "5232" )) ) + setenv.add-request-header = ( "X-Script-Name" => "/radicale" ) +} diff --git a/contrib/logwatch/radicale b/contrib/logwatch/radicale index 45298ad4..c659f62f 100644 --- a/contrib/logwatch/radicale +++ b/contrib/logwatch/radicale @@ -1,20 +1,34 @@ # This file is related to Radicale - CalDAV and CardDAV server # for logwatch (script) -# Copyright © 2024-2024 Peter Bieringer +# Copyright © 2024-2025 Peter Bieringer # # Detail levels -# >= 5: Logins -# >= 10: ResponseTimes +# < 5 : Request + ResponseCounters +# >= 5 : incl. Logins +# >= 10: incl. ResponseTimes + ResponseSize +# >= 15: incl. ResponseTimes + ResponseSize incl. RequestFlags +# >= 18: incl. UserAgents +# >= 20: incl. Locations where supported, anonymize logins + +use Digest::SHA; $Detail = $ENV{'LOGWATCH_DETAIL_LEVEL'} || 0; +my %ResponseTimesLocUsr; +my %ResponseSizesLocUsr; my %ResponseTimes; +my %ResponseSizes; my %Responses; my %Requests; +my %UserAgents; my %Logins; my %Loglevel; my %OtherEvents; +my %Locations; +my %LocationsFile; +my %LoginsHash; + my $sum; my $length; @@ -26,7 +40,7 @@ sub ResponseTimesMinMaxSum($$) { if (! defined $ResponseTimes{$req}->{'min'}) { $ResponseTimes{$req}->{'min'} = $time; - } elsif ($ResponseTimes->{$req}->{'min'} > $time) { + } elsif ($ResponseTimes{$req}->{'min'} > $time) { $ResponseTimes{$req}->{'min'} = $time; } @@ -39,6 +53,28 @@ sub ResponseTimesMinMaxSum($$) { $ResponseTimes{$req}->{'sum'} += $time; } +sub ResponseSizesMinMaxSum($$$) { + my $req = $_[0]; + my $type = $_[1]; + my $size = $_[2]; + + $ResponseSizes{$type}->{$req}->{'cnt'}++; + + if (! defined $ResponseSizes{$type}->{$req}->{'min'}) { + $ResponseSizes{$type}->{$req}->{'min'} = $size; + } elsif ($ResponseSizes{$type}->{$req}->{'min'} > $size) { + $ResponseSizes{$type}->{$req}->{'min'} = $size; + } + + if (! defined $ResponseSizes{$type}->{$req}->{'max'}) { + $ResponseSizes{$type}->{$req}{'max'} = $size; + } elsif ($ResponseSizes{$type}->{$req}->{'max'} < $size) { + $ResponseSizes{$type}->{$req}{'max'} = $size; + } + + $ResponseSizes{$type}->{$req}->{'sum'} += $size; +} + sub Sum($) { my $phash = $_[0]; my $sum = 0; @@ -57,6 +93,64 @@ sub MaxLength($) { return $length; } +sub ConvertTokens($) { + my %tokens_h; + # unique + foreach my $token (split(" ", $_[0])) { + $tokens_h{$token} = 1; + } + # map tokens + my @result_a; + if (defined $tokens_h{"sync-token"}) { + push @result_a, "ST"; + } + if (defined $tokens_h{"sync-collection"}) { + push @result_a, "SC"; + } + if (defined $tokens_h{"getctag"}) { + push @result_a, "GCT"; + } + if (defined $tokens_h{"getetag"}) { + push @result_a, "GET"; + } + # TODO: add potential others which causing long duration + $result = ""; + if (scalar(@result_a) > 0) { + $result = ":F=" . join(",", @result_a); + } + return $result; +} + +sub ConvertLoc($) { + my $loc = $_[0]; + if (defined $Locations{$loc}) { + # from cache + return ":L=" . $Locations{$loc}; + } elsif (defined $LocationsFile{$loc}) { + # from cache + return ":L=" . $LocationsFile{$loc}; + } + + if ($loc =~ /\/'$/o) { + $Locations{$loc} = "L=" . substr(Digest::SHA::sha256_hex($loc), 0, 8); + return ":" . $Locations{$loc}; + } else { + $LocationsFile{$loc} = "L="; + return ":" . $Locations{$loc}; + } +} + +sub ConvertLogin($) { + my $login = $_[0]; + if (defined $LoginsHash{$loginc}) { + # from cache + return $LoginsHash{$login}; + } + + $LoginsHash{$login} = "U=" . substr(Digest::SHA::sha256_hex($login), 0, 8); + return $LoginsHash{$login}; +} + while (defined($ThisLine = )) { # count loglevel if ( $ThisLine =~ /\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\] /o ) { @@ -72,34 +166,94 @@ while (defined($ThisLine = )) { } elsif ( $ThisLine =~ / (\S+) response status/o ) { my $req = $1; - if ( $ThisLine =~ / \S+ response status for .* with depth '(\d)' in ([0-9.]+) seconds: (\d+)/o ) { - $req .= ":D=" . $1 . ":R=" . $3; + if ( $ThisLine =~ / \S+ response status for (.*) with depth '(\d)' in ([0-9.]+) seconds: (\d+)/o ) { + $req .= ":D=" . $2 . ":R=" . $4; + $req .= ConvertLoc($1) if ($Detail >= 20); ResponseTimesMinMaxSum($req, $2) if ($Detail >= 10); - } elsif ( $ThisLine =~ / \S+ response status for .* in ([0-9.]+) seconds: (\d+)/ ) { - $req .= ":R=" . $2; + } elsif ( $ThisLine =~ / \S+ response status for (.*) in ([0-9.]+) seconds: (\d+)/o ) { + $req .= ":R=" . $3; + $req .= ConvertLoc($1) if ($Detail >= 20); ResponseTimesMinMaxSum($req, $1) if ($Detail >= 10); + } elsif ( $ThisLine =~ / \S+ response status for (.*) with depth '(\d)' in ([0-9.]+) seconds (\S+) (\d+) bytes: (\d+)/o ) { + $req .= ":D=" . $2 . ":R=" . $6; + $req .= ConvertLoc($1) if ($Detail >= 20); + ResponseTimesMinMaxSum($req, $3) if ($Detail >= 10); + ResponseSizesMinMaxSum($req, $4, $5) if ($Detail >= 10); + } elsif ( $ThisLine =~ / \S+ response status for (.*) in ([0-9.]+) seconds (\S+) (\d+) bytes: (\d+)/o ) { + $req .= ":R=" . $5; + $req .= ConvertLoc($1) if ($Detail >= 20); + ResponseTimesMinMaxSum($req, $2) if ($Detail >= 10); + ResponseSizesMinMaxSum($req, $3, $4) if ($Detail >= 10); + } elsif ( $ThisLine =~ / \S+ response status for (.*) with depth '(\d)' in ([0-9.]+) seconds (\S+) (\d+) bytes \((.*)\): (\d+)/o ) { + $req .= ":D=" . $2 . ":R=" . $7; + $req .= ConvertLoc($1) if ($Detail >= 20); + $req .= ConvertTokens($6) if ($Detail >= 15); + ResponseTimesMinMaxSum($req, $3) if ($Detail >= 10); + ResponseSizesMinMaxSum($req, $4, $5) if ($Detail >= 10); + } elsif ( $ThisLine =~ / \S+ response status for (.*) in ([0-9.]+) seconds (\S+) (\d+) bytes \((.*)\): (\d+)/o ) { + $req .= ":R=" . $6; + $req .= ConvertLoc($1) if ($Detail >= 20); + $req .= ConvertTokens($6) if ($Detail >= 15); + ResponseTimesMinMaxSum($req, $2) if ($Detail >= 10); + ResponseSizesMinMaxSum($req, $3, $4) if ($Detail >= 10); } $Responses{$req}++; } - elsif ( $ThisLine =~ / (\S+) request for/o ) { + elsif ( $ThisLine =~ / (\S+) request for ('[^']+')/o ) { my $req = $1; - if ( $ThisLine =~ / \S+ request for .* with depth '(\d)' received/o ) { + my $loc = $2; + if ( $ThisLine =~ / with depth '(\d)' received/o ) { $req .= ":D=" . $1; } + $req .= ConvertLoc($loc) if ($Detail >= 20); $Requests{$req}++; + + if ( $ThisLine =~ /using ('.*')/o ) { + my $ua = $1; + # remove unexpected chars + $ua =~ s/[\x00-\x1F\x7F-\xFF]//g; + $ua .= ConvertLoc($loc) if ($Detail >= 20); + $UserAgents{$ua}++ if ($Detail >= 18); + } } elsif ( $ThisLine =~ / (Successful login): '([^']+)'/o ) { - $Logins{$2}++ if ($Detail >= 5); + my $login = $2; + $login = ConvertLogin($login) if ($Detail >= 20); + $Logins{$login}++ if ($Detail >= 5); $OtherEvents{$1}++; } elsif ( $ThisLine =~ / (Failed login attempt) /o ) { $OtherEvents{$1}++; } + elsif ( $ThisLine =~ / (Profiling data per request method \S+) /o ) { + my $info = $1; + if ( $ThisLine =~ /(no request seen so far)/o ) { + $OtherEvents{$info . " - " . $1}++; + } else { + $OtherEvents{$info}++; + }; + } + elsif ( $ThisLine =~ / (Profiling data per request \S+) /o ) { + my $info = $1; + if ( $ThisLine =~ /(suppressed because duration below minimum|suppressed because of no data)/o ) { + $OtherEvents{$info . " - " . $1}++; + } else { + $OtherEvents{$info}++; + }; + } elsif ( $ThisLine =~ /\[(DEBUG|INFO)\] /o ) { # skip if DEBUG+INFO } else { # Report any unmatched entries... + if ($ThisLine =~ /^({\'| )/o) { + # skip profiling or raw header data + next; + }; + if ($ThisLine =~ /^$/o) { + # skip empty line + next; + }; $ThisLine =~ s/^\[\d+(\/Thread-\d+)?\] //; # remove process/Thread ID chomp($ThisLine); $OtherList{$ThisLine}++; @@ -114,64 +268,105 @@ if ($Started) { if (keys %Loglevel) { $sum = Sum(\%Loglevel); print "\n**Loglevel counters**\n"; - printf "%-18s | %7s | %5s |\n", "Loglevel", "cnt", "ratio"; - print "-" x38 . "\n"; + printf "%-18s | %7s | %9s |\n", "Loglevel", "cnt", "ratio"; + print "-" x42 . "\n"; foreach my $level (sort keys %Loglevel) { - printf "%-18s | %7d | %3d%% |\n", $level, $Loglevel{$level}, int(($Loglevel{$level} * 100) / $sum); + printf "%-18s | %7d | %7.3f%% |\n", $level, $Loglevel{$level}, (($Loglevel{$level} * 100) / $sum); } - print "-" x38 . "\n"; - printf "%-18s | %7d | %3d%% |\n", "", $sum, 100; -} - -if (keys %Requests) { - $sum = Sum(\%Requests); - print "\n**Request counters (D=)**\n"; - printf "%-18s | %7s | %5s |\n", "Request", "cnt", "ratio"; - print "-" x38 . "\n"; - foreach my $req (sort keys %Requests) { - printf "%-18s | %7d | %3d%% |\n", $req, $Requests{$req}, int(($Requests{$req} * 100) / $sum); - } - print "-" x38 . "\n"; - printf "%-18s | %7d | %3d%% |\n", "", $sum, 100; -} - -if (keys %Responses) { - $sum = Sum(\%Responses); - print "\n**Response result counters ((D= R=)**\n"; - printf "%-18s | %7s | %5s |\n", "Response", "cnt", "ratio"; - print "-" x38 . "\n"; - foreach my $req (sort keys %Responses) { - printf "%-18s | %7d | %3d%% |\n", $req, $Responses{$req}, int(($Responses{$req} * 100) / $sum); - } - print "-" x38 . "\n"; - printf "%-18s | %7d | %3d%% |\n", "", $sum, 100; + print "-" x42 . "\n"; + printf "%-18s | %7d | %7.3f%% |\n", "", $sum, 100; } if (keys %Logins) { $sum = Sum(\%Logins); $length = MaxLength(\%Logins); print "\n**Successful login counters**\n"; - printf "%-" . $length . "s | %7s | %5s |\n", "Login", "cnt", "ratio"; - print "-" x($length + 20) . "\n"; + printf "%-" . $length . "s | %7s | %9s |\n", "Login", "cnt", "ratio"; + print "-" x($length + 24) . "\n"; foreach my $login (sort keys %Logins) { - printf "%-" . $length . "s | %7d | %3d%% |\n", $login, $Logins{$login}, int(($Logins{$login} * 100) / $sum); + printf "%-" . $length . "s | %7d | %7.3f%% |\n", $login, $Logins{$login}, (($Logins{$login} * 100) / $sum); } - print "-" x($length + 20) . "\n"; - printf "%-" . $length . "s | %7d | %3d%% |\n", "", $sum, 100; + print "-" x($length + 24) . "\n"; + printf "%-" . $length . "s | %7d | %7.3d%% |\n", "", $sum, 100; +} + +if (keys %UserAgents) { + $sum = Sum(\%UserAgents); + $length = MaxLength(\%UserAgents); + print "\n**UserAgent Counters**\n"; + print "* Location: L= -> see below L= -> see raw log\n" if (scalar(keys %Locations) > 0); + printf "%-" . $length . "s | %7s | %9s |\n", "UserAgent", "cnt", "ratio"; + print "-" x($length + 24) . "\n"; + foreach my $ua (sort keys %UserAgents) { + printf "%-" . $length . "s | %7d | %7.3f%% |\n", $ua, $UserAgents{$ua}, (($UserAgents{$ua} * 100) / $sum); + } + print "-" x($length + 24) . "\n"; + printf "%-" . $length . "s | %7d | %7.3d%% |\n", "", $sum, 100; +} + +if (keys %Requests) { + $sum = Sum(\%Requests); + $length = MaxLength(\%Requests); + print "\n**Request counters (D=)**\n"; + print "* Location: L= -> see below L= -> see raw log\n" if (scalar(keys %Locations) > 0); + printf "%-" . $length . "s | %7s | %9s |\n", "Request", "cnt", "ratio"; + print "-" x($length + 24) . "\n"; + foreach my $req (sort keys %Requests) { + printf "%-" . $length . "s | %7d | %7.3f%% |\n", $req, $Requests{$req}, (($Requests{$req} * 100) / $sum); + } + print "-" x($length + 24) . "\n"; + printf "%-18s | %7d | %7.3f%% |\n", "", $sum, 100; +} + +if (keys %Responses) { + $sum = Sum(\%Responses); + $length = MaxLength(\%Responses); + print "\n**Response result counters ((D= R=)**\n"; + print "* Flags: ST:sync-token SC:sync-collection GCT:getctag GET:getetag\n" if ($Detail >= 15); + print "* Location: L= -> see below L= -> see raw log\n" if (scalar(keys %Locations) > 0); + printf "%-" . $length . "s | %7s | %9s |\n", "Response", "cnt", "ratio"; + print "-" x($length + 24) . "\n"; + foreach my $req (sort keys %Responses) { + printf "%-" . $length . "s | %7d | %7.3f%% |\n", $req, $Responses{$req}, (($Responses{$req} * 100) / $sum); + } + print "-" x($length + 24) . "\n"; + printf "%-" . $length . "s | %7d | %7.3f%% |\n", "", $sum, 100; } if (keys %ResponseTimes) { - print "\n**Response timings (counts, seconds) (D= R=)**\n"; - printf "%-18s | %7s | %7s | %7s | %7s |\n", "Response", "cnt", "min", "max", "avg"; - print "-" x60 . "\n"; + $length = MaxLength(\%ResponseTimes); + print "\n**Response timings (counts, seconds) (D= R= F=)**\n"; + print "* Flags: ST:sync-token SC:sync-collection GCT:getctag GET:getetag\n" if ($Detail >= 15); + print "* Location: L= -> see below L= -> see raw log\n" if (scalar(keys %Locations) > 0); + printf "%-" . $length . "s | %7s | %7s | %7s | %7s |\n", "Response", "cnt", "min", "max", "avg"; + print "-" x($length + 42) . "\n"; foreach my $req (sort keys %ResponseTimes) { - printf "%-18s | %7d | %7.3f | %7.3f | %7.3f |\n", $req + printf "%-" . $length . "s | %7d | %7.3f | %7.3f | %7.3f |\n", $req , $ResponseTimes{$req}->{'cnt'} , $ResponseTimes{$req}->{'min'} , $ResponseTimes{$req}->{'max'} , $ResponseTimes{$req}->{'sum'} / $ResponseTimes{$req}->{'cnt'}; } - print "-" x60 . "\n"; + print "-" x($length + 42) . "\n"; +} + +if (keys %ResponseSizes) { + for my $type (sort keys %ResponseSizes) { + $length = MaxLength($ResponseSizes{$type}); + print "\n**Response sizes (counts, bytes: $type) (D= R=)**\n"; + print "* Flags: ST:sync-token SC:sync-collection GCT:getctag GET:getetag\n" if ($Detail >= 15); + print "* Location: L= -> see below L= -> see raw log\n" if (scalar(keys %Locations) > 0); + printf "%-" . $length . "s | %7s | %9s | %9s | %9s |\n", "Response", "cnt", "min", "max", "avg"; + print "-" x($length + 48) . "\n"; + foreach my $req (sort keys %{$ResponseSizes{$type}}) { + printf "%-" . $length . "s | %7d | %9d | %9d | %9d |\n", $req + , $ResponseSizes{$type}->{$req}->{'cnt'} + , $ResponseSizes{$type}->{$req}->{'min'} + , $ResponseSizes{$type}->{$req}->{'max'} + , $ResponseSizes{$type}->{$req}->{'sum'} / $ResponseSizes{$type}->{$req}->{'cnt'}; + } + print "-" x($length + 48) . "\n"; + } } if (keys %OtherEvents) { @@ -188,6 +383,28 @@ if (keys %OtherList) { } } +if (scalar(keys %LoginsHash) > 0) { + print "\n**Map of login hashes (REMOVE THIS FOR PRIVACY REASONS before submit)**\n"; + $length = MaxLength(\%LoginsHash); + printf "%-10s | %-" . $length . "s | \n", "Hash", "Login"; + print "-" x($length + 15) . "\n"; + foreach my $login (sort { $LoginsHash{$a} cmp $LoginsHash{$b} } keys %LoginsHash) { + printf "%10s | %-" . $length . "s |\n", $LoginsHash{$login}, $login; + } + print "-" x($length + 15) . "\n"; +} + +if (scalar(keys %Locations) > 0) { + print "\n**Map of location hashes (REMOVE THIS FOR PRIVACY REASONS before submit)**\n"; + $length = MaxLength(\%Locations); + printf "%-10s | %-" . $length . "s | \n", "Hash", "Location"; + print "-" x($length + 15) . "\n"; + foreach my $loc (sort { $Locations{$a} cmp $Locations{$b} } keys %Locations) { + printf "%10s | %-" . $length . "s |\n", $Locations{$loc}, $loc; + } + print "-" x($length + 15) . "\n"; +} + exit(0); # vim: shiftwidth=3 tabstop=3 syntax=perl et smartindent diff --git a/contrib/nginx/radicale.conf b/contrib/nginx/radicale.conf index 990ebe4d..80369e27 100644 --- a/contrib/nginx/radicale.conf +++ b/contrib/nginx/radicale.conf @@ -8,7 +8,7 @@ rewrite ^/.well-known/caldav /radicale/ redirect; ## Base URI: /radicale/ location /radicale/ { - proxy_pass http://localhost:5232/; + proxy_pass http://localhost:5232; proxy_set_header X-Script-Name /radicale; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host; @@ -20,7 +20,7 @@ location /radicale/ { ## Base URI: / #location / { -# proxy_pass http://localhost:5232/; +# proxy_pass http://localhost:5232; # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # proxy_set_header X-Forwarded-Host $host; # proxy_set_header X-Forwarded-Port $server_port; diff --git a/docs/DOCUMENTATION.te.md b/docs/DOCUMENTATION.te.md new file mode 100644 index 00000000..2187594e --- /dev/null +++ b/docs/DOCUMENTATION.te.md @@ -0,0 +1,288 @@ +> Last updated: 2025-10-20 by [@gowtham1412-p](https://github.com/gowtham1412-p) + +> Based on commit: [4fdc78760914040d5f74ece8978013b8836a712e] of [DOCUMENTATION.md](https://github.com/Kozea/Radicale/blob/master/DOCUMENTATION.md) + +\# డాక్యుమెంటేషన్ + + + +\## ప్రారంభించడం + + + +\#### రాడికేల్ గురించి + + + +రాడికేల్ అనేది ఒక చిన్న కానీ శక్తివంతమైన CalDAV (క్యాలెండర్లు, చేయవలసిన జాబితాలు) మరియు CardDAV + +(పరిచయాలు) సర్వర్, ఇది: + + + +\* CalDAV, CardDAV మరియు HTTP ద్వారా క్యాలెండర్లు మరియు పరిచయ జాబితాలను పంచుకుంటుంది. + +\* ఈవెంట్‌లు, టోడోలు, జర్నల్ ఎంట్రీలు మరియు వ్యాపార కార్డులకు మద్దతు ఇస్తుంది. + +\* బాక్స్ వెలుపల పనిచేస్తుంది, సంక్లిష్టమైన సెటప్ లేదా కాన్ఫిగరేషన్ అవసరం లేదు. + +\* సౌకర్యవంతమైన ప్రామాణీకరణ ఎంపికలను అందిస్తుంది. + +\* అధికారం ద్వారా యాక్సెస్‌ను పరిమితం చేయవచ్చు. + +\* TLSతో కనెక్షన్‌లను సురక్షితం చేయవచ్చు. + +\* చాలా మందితో పనిచేస్తుంది + +\[CalDAV మరియు CardDAV క్లయింట్లు](#సపోర్టెడ్-క్లయింట్లు). + +\* ఫైల్ సిస్టమ్‌లోని అన్ని డేటాను సాధారణ ఫోల్డర్ నిర్మాణంలో నిల్వ చేస్తుంది. + +\* ప్లగిన్‌లతో పొడిగించవచ్చు. + +\* GPLv3-లైసెన్స్ పొందిన ఉచిత సాఫ్ట్‌వేర్. + + + +\#### ఇన్‌స్టాలేషన్ + + + +తనిఖీ చేయండి + + + +\* \[ట్యుటోరియల్స్](#ట్యుటోరియల్స్) + +\* \[డాక్యుమెంటేషన్](#డాక్యుమెంటేషన్-1) + +\* \[GitHubలో వికీ](https://github.com/Kozea/Radicale/wiki) + +\* \[GitHubలో చర్చలు](https://github.com/Kozea/Radicale/discussions) + +\* \[GitHubలో తెరిచి ఉన్న మరియు ఇప్పటికే మూసివేయబడిన సమస్యలు](https://github.com/Kozea/Radicale/issues?q=is%3Aissue) + + + +\#### కొత్తగా ఏముంది? + + + +\[GitHubలో చేంజ్‌లాగ్](https://github.com/Kozea/Radicale/blob/master/CHANGELOG.md) చదవండి. + + + +\## ట్యుటోరియల్స్ + + + +\### 5 నిమిషాల సులభమైన సెటప్ + + + +మీరు Radicaleని ప్రయత్నించాలనుకుంటున్నారా కానీ మీ క్యాలెండర్‌లో 5 నిమిషాలు మాత్రమే ఖాళీగా ఉందా? + + + +ఇప్పుడే వెళ్లి Radicaleతో కొంచెం ఆడుదాం! + + + +ఈ విభాగం నుండి సెట్టింగ్‌లతో కాన్ఫిగర్ చేయబడిన సర్వర్, localhost + +కి మాత్రమే బైండ్ అవుతుంది (అంటే ఇది నెట్‌వర్క్ ద్వారా చేరుకోలేరు), మరియు మీరు ఏదైనా వినియోగదారు పేరు మరియు పాస్‌వర్డ్‌తో లాగిన్ అవ్వవచ్చు. + + + +ప్రతిదీ పనిచేసినప్పుడు, మీరు స్థానిక \[client](#supported-clients) + +ని పొందవచ్చు మరియు క్యాలెండర్‌లు మరియు చిరునామా పుస్తకాలను సృష్టించడం ప్రారంభించవచ్చు. + + + +Radicale మీ అవసరాలకు సరిపోతుంటే, రిమోట్ క్లయింట్‌లు మరియు కావలసిన ప్రామాణీకరణ రకానికి మద్దతు ఇవ్వడానికి కొంత \[ప్రాథమిక కాన్ఫిగరేషన్](#basic-configuration) + +కి సమయం కావచ్చు. + + + +మీ ఆపరేటింగ్ సిస్టమ్‌ను బట్టి దిగువన ఉన్న అధ్యాయాలలో ఒకదాన్ని అనుసరించండి. + + + +\#### Linux / \\\*BSD + + + +సూచన: PyPI నుండి డౌన్‌లోడ్ చేయడానికి బదులుగా, మీ \[distribution](#linux-distribution-packages) అందించిన ప్యాకేజీల కోసం చూడండి. + + + +అవి మీ పంపిణీలలో ఇంటిగ్రేట్ చేయబడిన స్టార్టప్ స్క్రిప్ట్‌లను కూడా కలిగి ఉంటాయి, ఇవి Radicaleని డెమోనైజ్ చేయడానికి అనుమతిస్తాయి. + + + +ముందుగా, \*\*python\*\* 3.9 లేదా తరువాత మరియు \*\*pip\*\* ఇన్‌స్టాల్ చేయబడిందని నిర్ధారించుకోండి. చాలా డిస్ట్రిబ్యూషన్లలో ``python3-pip`` ప్యాకేజీని ఇన్‌స్టాల్ చేయడానికి సరిపోతుంది. + + + +\##### సాధారణ వినియోగదారుగా + + + +పరీక్ష కోసం మాత్రమే సిఫార్సు చేయబడింది - కన్సోల్‌ను తెరిచి ఇలా టైప్ చేయండి: + + + +```bash + +\# ప్రస్తుత వినియోగదారు కోసం మాత్రమే ఇన్‌స్టాల్ చేయడానికి కింది ఆదేశాన్ని అమలు చేయండి + +python3 -m pip install --user --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz + +``` + + + +\_install\_ పని చేయకపోతే మరియు బదులుగా `error: externally-managed-environment` ప్రదర్శించబడితే, + +ముందుగానే వర్చువల్ వాతావరణాన్ని సృష్టించండి మరియు సక్రియం చేయండి. + + + +```bash + +python3 -m venv ~/venv + +source ~/venv/bin/activate + +``` + + + +మరియు దీనితో ఇన్‌స్టాల్ చేయడానికి ప్రయత్నించండి + + + +```bash + +python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz + +``` + + + +సేవను మాన్యువల్‌గా ప్రారంభించండి, డేటా ప్రస్తుత వినియోగదారు కోసం మాత్రమే నిల్వ చేయబడుతుంది + + + +```bash + +\# ప్రారంభించు, డేటా ప్రస్తుత వినియోగదారు కోసం మాత్రమే నిల్వ చేయబడుతుంది + +python3 -m radicale --storage-filesystem-folder=~/.var/lib/radicale/collections --auth-type none + +``` + + + +\#### సిస్టమ్ వినియోగదారుగా (లేదా రూట్‌గా) + + + +ప్రత్యామ్నాయంగా, మీరు సిస్టమ్ వినియోగదారుగా లేదా రూట్‌గా ఇన్‌స్టాల్ చేసి అమలు చేయవచ్చు (సిఫార్సు చేయబడలేదు): + + + +```bash + +\# కింది ఆదేశాన్ని రూట్ (సిఫార్సు చేయబడలేదు) లేదా రూట్ కాని వ్యవస్థ వినియోగదారుగా అమలు చేయండి + +\# (డిపెండెన్సీలు లేనప్పుడు తరువాతి వాటికి --user అవసరం కావచ్చు సిస్టమ్-వైడ్ మరియు/లేదా వర్చువల్ ఎన్విరాన్మెంట్ అందుబాటులో ఉంది) + +python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz + +``` + + + +`/var/lib/radicale/collections` కింద సిస్టమ్ ఫోల్డర్‌లో నిల్వ చేయబడిన డేటాతో సేవను మాన్యువల్‌గా ప్రారంభించండి: + + + +```bash + +\# Start, డేటా సిస్టమ్ ఫోల్డర్‌లో నిల్వ చేయబడుతుంది (/var/lib/radicale/collections కు వ్రాయడానికి అనుమతులు అవసరం) + +python3 -m radicale --storage-filesystem-folder=/var/lib/radicale/collections --auth-type none + +``` + + + +\#### Windows + + + +మొదటి దశ పైథాన్‌ను ఇన్‌స్టాల్ చేయడం. + +\[python.org](https://python.org) కు వెళ్లి పైథాన్ 3 యొక్క తాజా వెర్షన్‌ను డౌన్‌లోడ్ చేసుకోండి. + +తర్వాత ఇన్‌స్టాలర్‌ను అమలు చేయండి. + +ఇన్‌స్టాలర్ యొక్క మొదటి విండోలో, "PATH కు పైథాన్‌ను జోడించు" బాక్స్‌ను తనిఖీ చేసి, + +"ఇప్పుడే ఇన్‌స్టాల్ చేయి"పై క్లిక్ చేయండి. రెండు నిమిషాలు వేచి ఉండండి, పూర్తయింది! + + + +కమాండ్ ప్రాంప్ట్‌ను ప్రారంభించి ఇలా టైప్ చేయండి: + + + +```powershell + +python -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz + +python -m radicale --storage-filesystem-folder=~/radicale/collections --auth-type none + +``` + + + +\##### Common + + + +విజయవంతం!!! మీ బ్రౌజర్‌లో తెరవండి! + +ఉదాహరణ ఎంపిక `--auth-type none` ద్వారా ప్రామాణీకరణ అవసరం లేనందున మీరు ఏదైనా వినియోగదారు పేరు మరియు పాస్‌వర్డ్‌తో లాగిన్ అవ్వవచ్చు. + +ఇది \*\*సురక్షితం\*\*, మరిన్ని వివరాల కోసం \[కాన్ఫిగరేషన్/ప్రామాణీకరణ](#auth) చూడండి. + + + +భద్రతా కారణాల దృష్ట్యా డిఫాల్ట్ కాన్ఫిగరేషన్ సర్వర్‌ను `localhost` (IPv4: `127.0.0.1`, IPv6: `::1`) కు బంధిస్తుందని గమనించండి. + + + +మరిన్ని వివరాల కోసం \[చిరునామాలు](#చిరునామాలు) మరియు \[కాన్ఫిగరేషన్/సర్వర్](#సర్వర్) చూడండి. + + + +\### ప్రాథమిక కాన్ఫిగరేషన్ + + + +ఇన్‌స్టాలేషన్ సూచనలను + +\[సరళమైన 5-నిమిషాల సెటప్](#సింపుల్-5-నిమిషాల-సెటప్) ట్యుటోరియల్‌లో చూడవచ్చు. + + + +రాడికేల్ `/etc/radicale/config` మరియు + +`~/.config/radicale/config` నుండి కాన్ఫిగరేషన్ ఫైల్‌లను లోడ్ చేయడానికి ప్రయత్నిస్తుంది. + +Cu + diff --git a/pyproject.toml b/pyproject.toml index 4032a769..80e7fe37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "Radicale" # When the version is updated, a new section in the CHANGELOG.md file must be # added too. readme = "README.md" -version = "3.5.5.dev" +version = "3.6.1.dev" authors = [{name = "Guillaume Ayoub", email = "guillaume.ayoub@kozea.fr"}, {name = "Unrud", email = "unrud@outlook.com"}, {name = "Peter Bieringer", email = "pb@bieringer.de"}] license = {text = "GNU GPL v3"} description = "CalDAV and CardDAV Server" @@ -28,12 +28,14 @@ classifiers = [ ] urls = {Homepage = "https://radicale.org/"} requires-python = ">=3.9.0" +# Hint: if bcyrpt < 5.0.0 is used, passlib(libpass) dependency can be downgraded/reverted by: sed -i 's|libpass[^"]*|passlib|' pyproject.toml dependencies = [ "defusedxml", - "passlib", + "libpass>=1.9.3", "vobject>=0.9.6", "pika>=1.1.0", "requests", + "packaging", ] @@ -104,7 +106,7 @@ radicale = [ [tool.isort] known_standard_library = "_dummy_thread,_thread,abc,aifc,argparse,array,ast,asynchat,asyncio,asyncore,atexit,audioop,base64,bdb,binascii,binhex,bisect,builtins,bz2,cProfile,calendar,cgi,cgitb,chunk,cmath,cmd,code,codecs,codeop,collections,colorsys,compileall,concurrent,configparser,contextlib,contextvars,copy,copyreg,crypt,csv,ctypes,curses,dataclasses,datetime,dbm,decimal,difflib,dis,distutils,doctest,dummy_threading,email,encodings,ensurepip,enum,errno,faulthandler,fcntl,filecmp,fileinput,fnmatch,formatter,fpectl,fractions,ftplib,functools,gc,getopt,getpass,gettext,glob,grp,gzip,hashlib,heapq,hmac,html,http,imaplib,imghdr,imp,importlib,inspect,io,ipaddress,itertools,json,keyword,lib2to3,linecache,locale,logging,lzma,macpath,mailbox,mailcap,marshal,math,mimetypes,mmap,modulefinder,msilib,msvcrt,multiprocessing,netrc,nis,nntplib,ntpath,numbers,operator,optparse,os,ossaudiodev,parser,pathlib,pdb,pickle,pickletools,pipes,pkgutil,platform,plistlib,poplib,posix,posixpath,pprint,profile,pstats,pty,pwd,py_compile,pyclbr,pydoc,queue,quopri,random,re,readline,reprlib,resource,rlcompleter,runpy,sched,secrets,select,selectors,shelve,shlex,shutil,signal,site,smtpd,smtplib,sndhdr,socket,socketserver,spwd,sqlite3,sre,sre_compile,sre_constants,sre_parse,ssl,stat,statistics,string,stringprep,struct,subprocess,sunau,symbol,symtable,sys,sysconfig,syslog,tabnanny,tarfile,telnetlib,tempfile,termios,test,textwrap,threading,time,timeit,tkinter,token,tokenize,trace,traceback,tracemalloc,tty,turtle,turtledemo,types,typing,unicodedata,unittest,urllib,uu,uuid,venv,warnings,wave,weakref,webbrowser,winreg,winsound,wsgiref,xdrlib,xml,xmlrpc,zipapp,zipfile,zipimport,zlib" -known_third_party = "defusedxml,passlib,pkg_resources,pytest,vobject" +known_third_party = "defusedxml,libpass,pkg_resources,pytest,vobject" [tool.mypy] ignore_missing_imports = true diff --git a/radicale/__main__.py b/radicale/__main__.py index b3576a60..e5eb68db 100644 --- a/radicale/__main__.py +++ b/radicale/__main__.py @@ -1,7 +1,7 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2011-2017 Guillaume Ayoub # Copyright © 2017-2022 Unrud -# Copyright © 2024-2024 Peter Bieringer +# Copyright © 2024-2025 Peter Bieringer # # 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 @@ -33,7 +33,7 @@ import sys from types import FrameType from typing import List, Optional, cast -from radicale import VERSION, config, log, server, storage, types +from radicale import VERSION, config, item, log, server, storage, types from radicale.log import logger @@ -65,6 +65,8 @@ def run() -> None: parser.add_argument("--version", action="version", version=VERSION) parser.add_argument("--verify-storage", action="store_true", 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("-C", "--config", help="use specific configuration files", nargs="*") parser.add_argument("-D", "--debug", action="store_const", const="debug", @@ -194,6 +196,19 @@ def run() -> None: sys.exit(1) return + if args_ns.verify_item: + encoding = configuration.get("encoding", "stock") + logger.info("Item verification start using 'stock' encoding: %s", encoding) + try: + if not item.verify(args_ns.verify_item[0], encoding): + logger.critical("Item verification failed") + sys.exit(1) + except Exception as e: + logger.critical("An exception occurred during item " + "verification: %s", e, exc_info=False) + sys.exit(1) + return + # Create a socket pair to notify the server of program shutdown shutdown_socket, shutdown_socket_out = socket.socketpair() diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index b69950b9..54016c81 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -27,15 +27,19 @@ the built-in server (see ``radicale.server`` module). """ import base64 +import cProfile import datetime +import io +import logging import pprint +import pstats import random import time import zlib from http import client from typing import Iterable, List, Mapping, Tuple, Union -from radicale import config, httputils, log, pathutils, types +from radicale import config, httputils, log, pathutils, types, utils from radicale.app.base import ApplicationBase from radicale.app.delete import ApplicationPartDelete from radicale.app.get import ApplicationPartGet @@ -49,11 +53,14 @@ from radicale.app.propfind import ApplicationPartPropfind from radicale.app.proppatch import ApplicationPartProppatch from radicale.app.put import ApplicationPartPut from radicale.app.report import ApplicationPartReport +from radicale.auth import AuthContext from radicale.log import logger # Combination of types.WSGIStartResponse and WSGI application return value _IntermediateResponse = Tuple[str, List[Tuple[str, str]], Iterable[bytes]] +REQUEST_METHODS = ["DELETE", "GET", "HEAD", "MKCALENDAR", "MKCOL", "MOVE", "OPTIONS", "POST", "PROPFIND", "PROPPATCH", "PUT", "REPORT"] + class Application(ApplicationPartDelete, ApplicationPartHead, ApplicationPartGet, ApplicationPartMkcalendar, @@ -67,11 +74,18 @@ class Application(ApplicationPartDelete, ApplicationPartHead, _auth_delay: float _internal_server: bool _max_content_length: int + _max_resource_size: int _auth_realm: str + _auth_type: str + _web_type: str _script_name: str _extra_headers: Mapping[str, str] - _permit_delete_collection: bool - _permit_overwrite_collection: bool + _profiling_per_request: bool = False + _profiling_per_request_method: bool = False + profiler_per_request_method: dict[str, cProfile.Profile] = {} + profiler_per_request_method_counter: dict[str, int] = {} + profiler_per_request_method_starttime: datetime.datetime + profiler_per_request_method_logtime: datetime.datetime def __init__(self, configuration: config.Configuration) -> None: """Initialize Application. @@ -83,10 +97,28 @@ class Application(ApplicationPartDelete, ApplicationPartHead, """ super().__init__(configuration) self._mask_passwords = configuration.get("logging", "mask_passwords") + self._max_content_length = configuration.get("server", "max_content_length") + self._max_resource_size = configuration.get("server", "max_resource_size") + logger.info("max_content_length set to: %d bytes (%sbytes)", self._max_content_length, utils.format_unit(self._max_content_length, binary=True)) + if (self._max_resource_size > (self._max_content_length * 0.8)): + max_resource_size_limited = int(self._max_content_length * 0.8) + logger.warning("max_resource_size set to: %d bytes (%sbytes) (capped from %d to 80%% of max_content_length)", max_resource_size_limited, utils.format_unit(max_resource_size_limited, binary=True), self._max_resource_size) + self._max_resource_size = max_resource_size_limited + else: + logger.info("max_resource_size set to: %d bytes (%sbytes)", self._max_resource_size, utils.format_unit(self._max_resource_size, binary=True)) self._bad_put_request_content = configuration.get("logging", "bad_put_request_content") + logger.info("log bad put request content: %s", self._bad_put_request_content) self._request_header_on_debug = configuration.get("logging", "request_header_on_debug") + self._request_content_on_debug = configuration.get("logging", "request_content_on_debug") + self._response_header_on_debug = configuration.get("logging", "response_header_on_debug") self._response_content_on_debug = configuration.get("logging", "response_content_on_debug") + logger.debug("log request header on debug: %s", self._request_header_on_debug) + logger.debug("log request content on debug: %s", self._request_content_on_debug) + logger.debug("log response header on debug: %s", self._response_header_on_debug) + logger.debug("log response content on debug: %s", self._response_content_on_debug) self._auth_delay = configuration.get("auth", "delay") + self._auth_type = configuration.get("auth", "type") + self._web_type = configuration.get("web", "type") self._internal_server = configuration.get("server", "_internal_server") self._script_name = configuration.get("server", "script_name") if self._script_name: @@ -111,6 +143,59 @@ class Application(ApplicationPartDelete, ApplicationPartHead, self._extra_headers = dict() for key in self.configuration.options("headers"): self._extra_headers[key] = configuration.get("headers", key) + self._strict_preconditions = configuration.get("storage", "strict_preconditions") + logger.info("strict preconditions check: %s", self._strict_preconditions) + # Profiling options + self._profiling = configuration.get("logging", "profiling") + self._profiling_per_request_min_duration = configuration.get("logging", "profiling_per_request_min_duration") + self._profiling_per_request_header = configuration.get("logging", "profiling_per_request_header") + self._profiling_per_request_xml = configuration.get("logging", "profiling_per_request_xml") + self._profiling_per_request_method_interval = configuration.get("logging", "profiling_per_request_method_interval") + self._profiling_top_x_functions = configuration.get("logging", "profiling_top_x_functions") + if self._profiling in config.PROFILING: + logger.info("profiling: %r", self._profiling) + if self._profiling == "per_request": + self._profiling_per_request = True + elif self._profiling == "per_request_method": + self._profiling_per_request_method = True + if self._profiling_per_request or self._profiling_per_request_method: + logger.info("profiling top X functions: %d", self._profiling_top_x_functions) + if self._profiling_per_request: + logger.info("profiling per request minimum duration: %d (below are skipped)", self._profiling_per_request_min_duration) + logger.info("profiling per request header: %s", self._profiling_per_request_header) + logger.info("profiling per request xml : %s", self._profiling_per_request_xml) + if self._profiling_per_request_method: + logger.info("profiling per request method interval: %d seconds", self._profiling_per_request_method_interval) + # Profiling per request method initialization + if self._profiling_per_request_method: + for method in REQUEST_METHODS: + self.profiler_per_request_method[method] = cProfile.Profile() + self.profiler_per_request_method_counter[method] = False + self.profiler_per_request_method_starttime = datetime.datetime.now() + self.profiler_per_request_method_logtime = self.profiler_per_request_method_starttime + + def __del__(self) -> None: + """Shutdown application.""" + if self._profiling_per_request_method: + # Profiling since startup + self._profiler_per_request_method(True) + + def _profiler_per_request_method(self, shutdown: bool = False) -> None: + """Display profiler data per method.""" + profiler_timedelta_start = (datetime.datetime.now() - self.profiler_per_request_method_starttime).total_seconds() + for method in REQUEST_METHODS: + if self.profiler_per_request_method_counter[method] > 0: + s = io.StringIO() + s.write("**Profiling statistics BEGIN**\n") + stats = pstats.Stats(self.profiler_per_request_method[method], stream=s).sort_stats('cumulative') + stats.print_stats(self._profiling_top_x_functions) # Print top X functions + s.write("**Profiling statistics END**\n") + logger.info("Profiling data per request method %s after %d seconds and %d requests:\n%s", method, profiler_timedelta_start, self.profiler_per_request_method_counter[method], utils.textwrap_str(s.getvalue(), -1)) + else: + if shutdown: + logger.info("Profiling data per request method %s after %d seconds: (no request seen so far)", method, profiler_timedelta_start) + else: + logger.debug("Profiling data per request method %s after %d seconds: (no request seen so far)", method, profiler_timedelta_start) def _scrub_headers(self, environ: types.WSGIEnviron) -> types.WSGIEnviron: """Mask passwords and cookies.""" @@ -132,7 +217,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, "%s", environ.get("REQUEST_METHOD", "unknown"), environ.get("PATH_INFO", ""), e, exc_info=True) # Make minimal response - status, raw_headers, raw_answer = ( + status, raw_headers, raw_answer, xml_request = ( httputils.INTERNAL_SERVER_ERROR) assert isinstance(raw_answer, str) answer = raw_answer.encode("ascii") @@ -151,20 +236,29 @@ class Application(ApplicationPartDelete, ApplicationPartHead, request_method = environ["REQUEST_METHOD"].upper() unsafe_path = environ.get("PATH_INFO", "") https = environ.get("HTTPS", "") + profiler = None + profiler_active = False + xml_request = None + + context = AuthContext() """Manage a request.""" def response(status: int, headers: types.WSGIResponseHeaders, - answer: Union[None, str, bytes]) -> _IntermediateResponse: + answer: Union[None, str, bytes], + xml_request: Union[None, str] = None) -> _IntermediateResponse: """Helper to create response from internal types.WSGIResponse""" headers = dict(headers) + content_encoding = "plain" # Set content length answers = [] if answer is not None: if isinstance(answer, str): if self._response_content_on_debug: - logger.debug("Response content:\n%s", answer) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Response content (nonXML):\n%s", utils.textwrap_str(answer)) else: - logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug") + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug") headers["Content-Type"] += "; charset=%s" % self._encoding answer = answer.encode(self._encoding) accept_encoding = [ @@ -176,6 +270,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS) answer = zcomp.compress(answer) + zcomp.flush() headers["Content-Encoding"] = "gzip" + content_encoding = "gzip" headers["Content-Length"] = str(len(answer)) answers.append(answer) @@ -183,13 +278,79 @@ class Application(ApplicationPartDelete, ApplicationPartHead, # Add extra headers set in configuration headers.update(self._extra_headers) + if self._response_header_on_debug: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Response header:\n%s", utils.textwrap_str(pprint.pformat(headers))) + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Response header: suppressed by config/option [logging] response_header_on_debug") + # Start response time_end = datetime.datetime.now() + time_delta_seconds = (time_end - time_begin).total_seconds() status_text = "%d %s" % ( status, client.responses.get(status, "Unknown")) - logger.info("%s response status for %r%s in %.3f seconds: %s", - request_method, unsafe_path, depthinfo, - (time_end - time_begin).total_seconds(), status_text) + flags = [] + if xml_request is not None: + if "" in xml_request: + flags.append("sync-token") + if "" in xml_request: + flags.append("getetag") + if "" in xml_request: + flags.append("getctag") + if " self._profiling_per_request_method_interval: + self._profiler_per_request_method() + self.profiler_per_request_method_logtime = datetime.datetime.now() + # Return response content return status_text, list(headers.items()), answers @@ -197,12 +358,16 @@ class Application(ApplicationPartDelete, ApplicationPartHead, remote_host = "unknown" if environ.get("REMOTE_HOST"): remote_host = repr(environ["REMOTE_HOST"]) - elif environ.get("REMOTE_ADDR"): - remote_host = environ["REMOTE_ADDR"] + if environ.get("REMOTE_ADDR"): + if remote_host == 'unknown': + remote_host = environ["REMOTE_ADDR"] + context.remote_addr = environ["REMOTE_ADDR"] if environ.get("HTTP_X_FORWARDED_FOR"): reverse_proxy = True remote_host = "%s (forwarded for %r)" % ( remote_host, environ["HTTP_X_FORWARDED_FOR"]) + if environ.get("HTTP_X_REMOTE_ADDR"): + context.x_remote_addr = environ["HTTP_X_REMOTE_ADDR"] if environ.get("HTTP_X_FORWARDED_HOST") or environ.get("HTTP_X_FORWARDED_PROTO") or environ.get("HTTP_X_FORWARDED_SERVER"): reverse_proxy = True remote_useragent = "" @@ -220,7 +385,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, remote_host, remote_useragent, https_info) if self._request_header_on_debug: logger.debug("Request header:\n%s", - pprint.pformat(self._scrub_headers(environ))) + utils.textwrap_str(pprint.pformat(self._scrub_headers(environ)))) else: logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug") @@ -257,7 +422,10 @@ class Application(ApplicationPartDelete, ApplicationPartHead, logger.debug("Called by reverse proxy, remove base prefix %r from path: %r => %r", base_prefix, path, path_new) path = path_new else: - logger.warning("Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching", base_prefix, path) + if self._auth_type in ['remote_user', 'http_remote_user', 'http_x_remote_user'] and self._web_type == 'internal': + logger.warning("Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching (may cause authentication issues using internal WebUI)", base_prefix, path) + else: + logger.debug("Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching", base_prefix, path) # Get function corresponding to method function = getattr(self, "do_%s" % request_method, None) @@ -288,7 +456,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, self.configuration, environ, base64.b64decode( authorization.encode("ascii"))).split(":", 1) - (user, info) = self._auth.login(login, password) or ("", "") if login else ("", "") + (user, info) = self._auth.login(login, password, context) or ("", "") if login else ("", "") if self.configuration.get("auth", "type") == "ldap": try: logger.debug("Groups received from LDAP: %r", ",".join(self._auth._ldap_groups)) @@ -323,7 +491,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, if "W" in self._rights.authorization(user, principal_path): with self._storage.acquire_lock("w", user): try: - new_coll = self._storage.create_collection(principal_path) + new_coll, _, _ = self._storage.create_collection(principal_path) if new_coll: jsn_coll = self.configuration.get("storage", "predefined_collections") for (name_coll, props) in jsn_coll.items(): @@ -349,15 +517,42 @@ class Application(ApplicationPartDelete, ApplicationPartHead, return response(*httputils.REQUEST_ENTITY_TOO_LARGE) if not login or user: - status, headers, answer = function( - environ, base_prefix, path, user) - if (status, headers, answer) == httputils.NOT_ALLOWED: + # Profiling + if self._profiling_per_request: + profiler = cProfile.Profile() + try: + profiler.enable() + except ValueError: + profiler_active = False + else: + profiler_active = True + elif self._profiling_per_request_method: + try: + self.profiler_per_request_method[request_method].enable() + except ValueError: + profiler_active = False + else: + profiler_active = True + + status, headers, answer, xml_request = function( + environ, base_prefix, path, user, remote_host, remote_useragent) + + # Profiling + if self._profiling_per_request: + if profiler is not None: + if profiler_active is True: + profiler.disable() + elif self._profiling_per_request_method: + if profiler_active is True: + self.profiler_per_request_method[request_method].disable() + + if (status, headers, answer, xml_request) == httputils.NOT_ALLOWED: logger.info("Access to %r denied for %s", path, repr(user) if user else "anonymous user") else: - status, headers, answer = httputils.NOT_ALLOWED + status, headers, answer, xml_request = httputils.NOT_ALLOWED - if ((status, headers, answer) == httputils.NOT_ALLOWED and not user and + if ((status, headers, answer, xml_request) == httputils.NOT_ALLOWED and not user and not external_login): # Unknown or unauthorized user logger.debug("Asking client for authentication") @@ -367,4 +562,4 @@ class Application(ApplicationPartDelete, ApplicationPartHead, "WWW-Authenticate": "Basic realm=\"%s\"" % self._auth_realm}) - return response(status, headers, answer) + return response(status, headers, answer, xml_request) diff --git a/radicale/app/base.py b/radicale/app/base.py index 28b6f262..aa0af7a2 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -23,7 +23,7 @@ import xml.etree.ElementTree as ET from typing import Optional from radicale import (auth, config, hook, httputils, pathutils, rights, - storage, types, web, xmlutils) + storage, types, utils, web, xmlutils) from radicale.log import logger # HACK: https://github.com/tiran/defusedxml/issues/54 @@ -39,8 +39,10 @@ class ApplicationBase: _rights: rights.BaseRights _web: web.BaseWeb _encoding: str + _max_resource_size: int _permit_delete_collection: bool _permit_overwrite_collection: bool + _strict_preconditions: bool _hook: hook.BaseHook def __init__(self, configuration: config.Configuration) -> None: @@ -70,7 +72,7 @@ class ApplicationBase: if logger.isEnabledFor(logging.DEBUG): if self._request_content_on_debug: logger.debug("Request content (XML):\n%s", - xmlutils.pretty_xml(xml_content)) + utils.textwrap_str(xmlutils.pretty_xml(xml_content))) else: logger.debug("Request content (XML): suppressed by config/option [logging] request_content_on_debug") return xml_content @@ -79,7 +81,7 @@ class ApplicationBase: if logger.isEnabledFor(logging.DEBUG): if self._response_content_on_debug: logger.debug("Response content (XML):\n%s", - xmlutils.pretty_xml(xml_content)) + utils.textwrap_str(xmlutils.pretty_xml(xml_content))) else: logger.debug("Response content (XML): suppressed by config/option [logging] response_content_on_debug") f = io.BytesIO() @@ -92,7 +94,7 @@ class ApplicationBase: """Generate XML error response.""" headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} content = self._xml_response(xmlutils.webdav_error(human_tag)) - return status, headers, content + return status, headers, content, None class Access: diff --git a/radicale/app/delete.py b/radicale/app/delete.py index a111df00..2201e998 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -24,7 +24,7 @@ from typing import Optional from radicale import httputils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase -from radicale.hook import DeleteHookNotificationItem +from radicale.hook import HookNotificationItem, HookNotificationItemTypes from radicale.log import logger @@ -55,7 +55,7 @@ def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection, class ApplicationPartDelete(ApplicationBase): def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage DELETE request.""" access = Access(self._rights, user, path) if not access.check("w"): @@ -82,10 +82,13 @@ class ApplicationPartDelete(ApplicationBase): return httputils.NOT_ALLOWED for i in item.get_all(): hook_notification_item_list.append( - DeleteHookNotificationItem( - access.path, - i.uid, - old_content=item.serialize() # type: ignore + HookNotificationItem( + notification_item_type=HookNotificationItemTypes.DELETE, + path=access.path, + content=i.uid, + uid=i.uid, + old_content=i.serialize(), # type: ignore + new_content=None ) ) xml_answer = xml_delete(base_prefix, path, item) @@ -93,10 +96,13 @@ class ApplicationPartDelete(ApplicationBase): assert item.collection is not None assert item.href is not None hook_notification_item_list.append( - DeleteHookNotificationItem( - access.path, - item.uid, - old_content=item.serialize() # type: ignore + HookNotificationItem( + notification_item_type=HookNotificationItemTypes.DELETE, + path=access.path, + content=item.uid, + uid=item.uid, + old_content=item.serialize(), # type: ignore + new_content=None, ) ) xml_answer = xml_delete( @@ -104,4 +110,4 @@ class ApplicationPartDelete(ApplicationBase): for notification_item in hook_notification_item_list: self._hook.notify(notification_item) headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} - return client.OK, headers, self._xml_response(xml_answer) + return client.OK, headers, self._xml_response(xml_answer), None diff --git a/radicale/app/get.py b/radicale/app/get.py index edd29b75..2eac58f1 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -2,7 +2,8 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2023 Unrud +# Copyright © 2025-2025 Peter Bieringer # # 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 @@ -58,7 +59,7 @@ class ApplicationPartGet(ApplicationBase): return value def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str, - user: str) -> types.WSGIResponse: + user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage GET request.""" # Redirect to /.web if the root path is requested if not pathutils.strip_path(path): @@ -108,4 +109,4 @@ class ApplicationPartGet(ApplicationBase): if content_disposition: headers["Content-Disposition"] = content_disposition answer = item.serialize() - return client.OK, headers, answer + return client.OK, headers, answer, None diff --git a/radicale/app/head.py b/radicale/app/head.py index 5166db2d..eec68bb5 100644 --- a/radicale/app/head.py +++ b/radicale/app/head.py @@ -2,7 +2,8 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2025-2025 Peter Bieringer # # 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 @@ -25,7 +26,7 @@ from radicale.app.get import ApplicationPartGet class ApplicationPartHead(ApplicationPartGet, ApplicationBase): def do_HEAD(self, environ: types.WSGIEnviron, base_prefix: str, path: str, - user: str) -> types.WSGIResponse: + user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage HEAD request.""" # Body is dropped in `Application.__call__` for HEAD requests - return self.do_GET(environ, base_prefix, path, user) + return self.do_GET(environ, base_prefix, path, user, remote_host, remote_useragent) diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index 632d3c38..53abcdbd 100644 --- a/radicale/app/mkcalendar.py +++ b/radicale/app/mkcalendar.py @@ -33,7 +33,7 @@ from radicale.log import logger class ApplicationPartMkcalendar(ApplicationBase): def do_MKCALENDAR(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage MKCALENDAR request.""" if "w" not in self._rights.authorization(user, path): return httputils.NOT_ALLOWED @@ -89,4 +89,4 @@ class ApplicationPartMkcalendar(ApplicationBase): logger.warning( "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST - return client.CREATED, {}, None + return client.CREATED, {}, None, xmlutils.pretty_xml(xml_content) diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index 169cb62c..45ad7c4a 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -33,7 +33,7 @@ from radicale.log import logger class ApplicationPartMkcol(ApplicationBase): def do_MKCOL(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage MKCOL request.""" permissions = self._rights.authorization(user, path) if not rights.intersect(permissions, "Ww"): @@ -94,4 +94,4 @@ class ApplicationPartMkcol(ApplicationBase): "Bad MKCOL request on %r (type:%s): %s", path, collection_type, e, exc_info=True) return httputils.BAD_REQUEST logger.info("MKCOL request %r (type:%s): %s", path, collection_type, "successful") - return client.CREATED, {}, None + return client.CREATED, {}, None, xmlutils.pretty_xml(xml_content) diff --git a/radicale/app/move.py b/radicale/app/move.py index 77e56f3e..168619e3 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -22,7 +22,7 @@ import errno import posixpath import re from http import client -from urllib.parse import urlparse +from urllib.parse import unquote, urlparse from radicale import httputils, pathutils, storage, types from radicale.app.base import Access, ApplicationBase @@ -34,7 +34,7 @@ def get_server_netloc(environ: types.WSGIEnviron, force_port: bool = False): host = environ["HTTP_X_FORWARDED_HOST"] proto = environ.get("HTTP_X_FORWARDED_PROTO") or "http" port = "443" if proto == "https" else "80" - port = environ["HTTP_X_FORWARDED_PORT"] or port + port = environ.get("HTTP_X_FORWARDED_PORT") or port else: host = environ.get("HTTP_HOST") or environ["SERVER_NAME"] proto = environ["wsgi.url_scheme"] @@ -48,18 +48,25 @@ def get_server_netloc(environ: types.WSGIEnviron, force_port: bool = False): class ApplicationPartMove(ApplicationBase): def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage MOVE request.""" raw_dest = environ.get("HTTP_DESTINATION", "") - to_url = urlparse(raw_dest) - to_netloc_with_port = to_url.netloc - if to_url.port is None: - to_netloc_with_port += (":443" if to_url.scheme == "https" - else ":80") - if to_netloc_with_port != get_server_netloc(environ, force_port=True): - logger.info("Unsupported destination address: %r", raw_dest) - # Remote destination server, not supported - return httputils.REMOTE_DESTINATION + + # Decode URL-encoded characters (e.g. %40 -> @) before parsing + raw_dest_decoded = unquote(raw_dest) + to_url = urlparse(raw_dest_decoded) + + # Only check netloc for absolute URLs + if to_url.netloc: + to_netloc_with_port = to_url.netloc + if to_url.port is None: + to_netloc_with_port += (":443" if to_url.scheme == "https" + else ":80") + if to_netloc_with_port != get_server_netloc(environ, force_port=True): + logger.info("Unsupported destination address: %r", raw_dest) + # Remote destination server, not supported + return httputils.REMOTE_DESTINATION + access = Access(self._rights, user, path) if not access.check("w"): return httputils.NOT_ALLOWED @@ -127,4 +134,4 @@ class ApplicationPartMove(ApplicationBase): logger.warning( "Bad MOVE request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST - return client.NO_CONTENT if to_item else client.CREATED, {}, None + return client.NO_CONTENT if to_item else client.CREATED, {}, None, None diff --git a/radicale/app/options.py b/radicale/app/options.py index 6e9053a3..9e347de2 100644 --- a/radicale/app/options.py +++ b/radicale/app/options.py @@ -2,7 +2,8 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2021 Unrud +# Copyright © 2025-2025 Peter Bieringer # # 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 @@ -26,10 +27,10 @@ from radicale.app.base import ApplicationBase class ApplicationPartOptions(ApplicationBase): def do_OPTIONS(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage OPTIONS request.""" headers = { "Allow": ", ".join( name[3:] for name in dir(self) if name.startswith("do_")), "DAV": httputils.DAV_HEADERS} - return client.OK, headers, None + return client.OK, headers, None, None diff --git a/radicale/app/post.py b/radicale/app/post.py index f5367b86..df944499 100644 --- a/radicale/app/post.py +++ b/radicale/app/post.py @@ -2,8 +2,9 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud -# Copyright © 2020 Tom Hacohen +# Copyright © 2017-2021 Unrud +# Copyright © 2020-2020 Tom Hacohen +# Copyright © 2025-2025 Peter Bieringer # # 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 @@ -25,7 +26,7 @@ from radicale.app.base import ApplicationBase class ApplicationPartPost(ApplicationBase): def do_POST(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage POST request.""" if path == "/.web" or path.startswith("/.web/"): return self._web.post(environ, base_prefix, path, user) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 6a3cea6d..62af2949 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -2,7 +2,8 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2021 Unrud +# Copyright © 2025-2025 Peter Bieringer # # 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 @@ -25,7 +26,8 @@ import xml.etree.ElementTree as ET from http import client from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple -from radicale import httputils, pathutils, rights, storage, types, xmlutils +from radicale import (httputils, pathutils, rights, storage, types, utils, + xmlutils) from radicale.app.base import Access, ApplicationBase from radicale.log import logger @@ -33,7 +35,7 @@ from radicale.log import logger def xml_propfind(base_prefix: str, path: str, xml_request: Optional[ET.Element], allowed_items: Iterable[Tuple[types.CollectionOrItem, str]], - user: str, encoding: str) -> Optional[ET.Element]: + user: str, encoding: str, max_resource_size: int) -> Optional[ET.Element]: """Read and answer PROPFIND requests. Read rfc4918-9.1 for info. @@ -70,14 +72,14 @@ def xml_propfind(base_prefix: str, path: str, write = permission == "w" multistatus.append(xml_propfind_response( base_prefix, path, item, props, user, encoding, write=write, - allprop=allprop, propname=propname)) + allprop=allprop, propname=propname, max_resource_size=max_resource_size)) return multistatus def xml_propfind_response( base_prefix: str, path: str, item: types.CollectionOrItem, - props: Sequence[str], user: str, encoding: str, write: bool = False, + props: Sequence[str], user: str, encoding: str, max_resource_size: int, write: bool = False, propname: bool = False, allprop: bool = False) -> ET.Element: """Build and return a PROPFIND response.""" if propname and allprop or (props and (propname or allprop)): @@ -110,6 +112,9 @@ def xml_propfind_response( props.append(xmlutils.make_clark("D:supported-report-set")) props.append(xmlutils.make_clark("D:resourcetype")) props.append(xmlutils.make_clark("D:owner")) + if not allprop: + # RFC4791#5.2.5: SHOULD NOT be returned by a PROPFIND DAV:allprop request + props.append(xmlutils.make_clark("C:max-resource-size")) if is_collection and collection.is_principal: props.append(xmlutils.make_clark("C:calendar-user-address-set")) @@ -131,6 +136,10 @@ def xml_propfind_response( props.append(xmlutils.make_clark("CS:getctag")) props.append( xmlutils.make_clark("C:supported-calendar-component-set")) + if collection.tag == "VADDRESSBOOK": + props.append(xmlutils.make_clark("CS:getctag")) + props.append( + xmlutils.make_clark("CR:supported-address-data")) meta = collection.get_meta() for tag in meta: @@ -184,6 +193,21 @@ def xml_propfind_response( element.append(comp) else: is404 = True + elif tag == xmlutils.make_clark("CR:supported-address-data"): + if is_collection and is_leaf and collection.tag == "VADDRESSBOOK": + # Advertise supported vCard versions per RFC 6352 section 6.2.2 + # vCard 4.0 requires vobject >= 1.0.0 + versions: Sequence[str] = (("4.0", "3.0") + if utils.vobject_supports_vcard4() + else ("3.0",)) + for version in versions: + address_data_type = ET.Element( + xmlutils.make_clark("CR:address-data-type")) + address_data_type.set("content-type", "text/vcard") + address_data_type.set("version", version) + element.append(address_data_type) + else: + is404 = True elif tag == xmlutils.make_clark("D:current-user-principal"): if user: child_element = ET.Element(xmlutils.make_clark("D:href")) @@ -238,6 +262,9 @@ def xml_propfind_response( child_element.text = xmlutils.make_href( base_prefix, "/%s/" % collection.owner) element.append(child_element) + elif tag == xmlutils.make_clark("C:max-resource-size"): + # RFC4791#5.2.5 + element.text = str(max_resource_size) elif is_collection: if tag == xmlutils.make_clark("D:getcontenttype"): if is_leaf: @@ -376,7 +403,7 @@ class ApplicationPartPropfind(ApplicationBase): yield item, permission def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPFIND request.""" access = Access(self._rights, user, path) if not access.check("r"): @@ -406,7 +433,7 @@ class ApplicationPartPropfind(ApplicationBase): headers = {"DAV": httputils.DAV_HEADERS, "Content-Type": "text/xml; charset=%s" % self._encoding} xml_answer = xml_propfind(base_prefix, path, xml_content, - allowed_items, user, self._encoding) + allowed_items, user, self._encoding, max_resource_size=self._max_resource_size) if xml_answer is None: return httputils.NOT_ALLOWED - return client.MULTI_STATUS, headers, self._xml_response(xml_answer) + return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index d2c32811..caaf7b7a 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -73,7 +73,7 @@ def xml_proppatch(base_prefix: str, path: str, class ApplicationPartProppatch(ApplicationBase): def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPPATCH request.""" access = Access(self._rights, user, path) if not access.check("w"): @@ -101,13 +101,17 @@ class ApplicationPartProppatch(ApplicationBase): xml_answer = xml_proppatch(base_prefix, path, xml_content, item) if xml_content is not None: + content = DefusedET.tostring( + xml_content, + encoding=self._encoding + ).decode(encoding=self._encoding) hook_notification_item = HookNotificationItem( - HookNotificationItemTypes.CPATCH, - access.path, - DefusedET.tostring( - xml_content, - encoding=self._encoding - ).decode(encoding=self._encoding) + notification_item_type=HookNotificationItemTypes.CPATCH, + path=access.path, + content=content, + uid=None, + old_content=None, + new_content=content ) self._hook.notify(hook_notification_item) except ValueError as e: @@ -127,4 +131,4 @@ class ApplicationPartProppatch(ApplicationBase): logger.warning( "Bad PROPPATCH request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST - return client.MULTI_STATUS, headers, self._xml_response(xml_answer) + return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) diff --git a/radicale/app/put.py b/radicale/app/put.py index 343f3324..86e863ef 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -46,7 +46,7 @@ PRODID = u"-//Radicale//NONSGML Version " + utils.package_version("radicale") + def prepare(vobject_items: List[vobject.base.Component], path: str, - content_type: str, permission: bool, parent_permission: bool, + content_type: str, permission: bool, parent_permission: bool, max_resource_size: int, tag: Optional[str] = None, write_whole_collection: Optional[bool] = None) -> Tuple[ Iterator[radicale_item.Item], # items @@ -93,24 +93,61 @@ def prepare(vobject_items: List[vobject.base.Component], path: str, logger.debug("Prepare item with UID '%s'", item.uid) try: item.prepare() - except ValueError as e: + except (RuntimeError, ValueError, AttributeError) as e: if logger.isEnabledFor(logging.DEBUG): - logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, item._text) + if item._text is None: + content = vobject_item + else: + content = item._text + logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, utils.textwrap_str(content)) else: logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e) raise + size = len(item.serialize()) + if (size > max_resource_size): + logger.warning("PUT request contains item with UID %r size %d > limit %d: %r", item.uid, size, max_resource_size, path) + # Use OverflowError as flag for max_resource_size + raise OverflowError + else: + logger.debug("PUT request contains item with UID %r size %d <= limit %d: %r", item.uid, size, max_resource_size, path) items.append(item) elif write_whole_collection and tag == "VADDRESSBOOK": for vobject_item in vobject_items: item = radicale_item.Item(collection_path=collection_path, vobject_item=vobject_item) - item.prepare() + logger.debug("Prepare item with UID '%s'", item.uid) + try: + item.prepare() + except (RuntimeError, ValueError, AttributeError) as e: + if logger.isEnabledFor(logging.DEBUG): + if item._text is None: + content = vobject_item + else: + content = item._text + logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, utils.textwrap_str(content)) + else: + logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e) + raise + size = len(item.serialize()) + if (size > max_resource_size): + logger.warning("PUT request contains item with UID %r size %d > limit %d: %r", item.uid, size, max_resource_size, path) + # Use OverflowError as flag for max_resource_size + raise OverflowError + else: + logger.debug("PUT request contains item with UID %r size %d <= limit %d: %r", item.uid, size, max_resource_size, path) items.append(item) elif not write_whole_collection: vobject_item, = vobject_items item = radicale_item.Item(collection_path=collection_path, vobject_item=vobject_item) item.prepare() + size = len(item.serialize()) + if (size > max_resource_size): + logger.warning("PUT request contains item with UID %r size %d above limit %d: %r", item.uid, size, max_resource_size, path) + # Use OverflowError as flag for max_resource_size + raise OverflowError + else: + logger.debug("PUT request contains item with UID %r size %d below limit %d: %r", item.uid, size, max_resource_size, path) items.append(item) if write_whole_collection: @@ -142,7 +179,7 @@ def prepare(vobject_items: List[vobject.base.Component], path: str, class ApplicationPartPut(ApplicationBase): def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PUT request.""" access = Access(self._rights, user, path) if not access.check("w"): @@ -164,7 +201,10 @@ class ApplicationPartPut(ApplicationBase): logger.warning( "Bad PUT request on %r (read_components): %s", path, e, exc_info=True) if self._log_bad_put_request_content: - logger.warning("Bad PUT request content of %r:\n%s", path, content) + logger.warning("Bad PUT request content of %r:\n%s", path, utils.textwrap_str(content)) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Request content (sha256sum): %s", utils.sha256_str(content)) + logger.debug("Request content (hexdump/lines):\n%s", utils.hexdump_lines(content)) else: logger.debug("Bad PUT request content: suppressed by config/option [logging] bad_put_request_content") return httputils.BAD_REQUEST @@ -172,7 +212,8 @@ class ApplicationPartPut(ApplicationBase): prepared_props, prepared_exc_info) = prepare( vobject_items, path, content_type, bool(rights.intersect(access.permissions, "Ww")), - bool(rights.intersect(access.parent_permissions, "w"))) + bool(rights.intersect(access.parent_permissions, "w")), + self._max_resource_size) with self._storage.acquire_lock("w", user, path=path, request="PUT"): item = next(iter(self._storage.discover(path)), None) @@ -207,6 +248,9 @@ class ApplicationPartPut(ApplicationBase): return httputils.NOT_ALLOWED etag = environ.get("HTTP_IF_MATCH", "") + if item and not etag and self._strict_preconditions: + logger.warning("Precondition failed for %r: existing item, no If-Match header, strict mode enabled", path) + return httputils.PRECONDITION_FAILED if not item and etag: # Etag asked but no item found: item has been removed logger.warning("Precondition failed on PUT request for %r (HTTP_IF_MATCH: %s, item not existing)", path, etag) @@ -233,24 +277,46 @@ class ApplicationPartPut(ApplicationBase): vobject_items, path, content_type, bool(rights.intersect(access.permissions, "Ww")), bool(rights.intersect(access.parent_permissions, "w")), + self._max_resource_size, tag, write_whole_collection) props = prepared_props if prepared_exc_info: - logger.warning( - "Bad PUT request on %r (prepare): %s", path, prepared_exc_info[1], - exc_info=prepared_exc_info) - return httputils.BAD_REQUEST + # Use OverflowError as flag for max_resource_size + if prepared_exc_info[0] == OverflowError: + return httputils.PRECONDITION_FAILED + else: + logger.warning( + "Bad PUT request on %r (prepare): %s", path, prepared_exc_info[1], + exc_info=prepared_exc_info) + return httputils.BAD_REQUEST if write_whole_collection: try: - etag = self._storage.create_collection( - path, prepared_items, props).etag + col, replaced_items, new_item_hrefs = self._storage.create_collection( + href=path, + items=prepared_items, + props=props) for item in prepared_items: - hook_notification_item = HookNotificationItem( - HookNotificationItemTypes.UPSERT, - access.path, - item.serialize() - ) + # Try to grab the previously-existing item by href + existing_item = replaced_items.get(item.href, None) # type: ignore + if existing_item: + hook_notification_item = HookNotificationItem( + notification_item_type=HookNotificationItemTypes.UPSERT, + path=access.path, + content=existing_item.serialize(), + uid=None, + old_content=existing_item.serialize(), + new_content=item.serialize() + ) + else: # We assume the item is new because it was not in the replaced_items + hook_notification_item = HookNotificationItem( + notification_item_type=HookNotificationItemTypes.UPSERT, + path=access.path, + content=item.serialize(), + uid=None, + old_content=None, + new_content=item.serialize() + ) self._hook.notify(hook_notification_item) except ValueError as e: logger.warning( @@ -267,11 +333,15 @@ class ApplicationPartPut(ApplicationBase): href = posixpath.basename(pathutils.strip_path(path)) try: - etag = parent_item.upload(href, prepared_item).etag + uploaded_item, replaced_item = parent_item.upload(href, prepared_item) + etag = uploaded_item.etag hook_notification_item = HookNotificationItem( - HookNotificationItemTypes.UPSERT, - access.path, - prepared_item.serialize() + notification_item_type=HookNotificationItemTypes.UPSERT, + path=access.path, + content=prepared_item.serialize(), + uid=None, + old_content=replaced_item.serialize() if replaced_item else None, + new_content=prepared_item.serialize() ) self._hook.notify(hook_notification_item) except ValueError as e: @@ -294,7 +364,7 @@ class ApplicationPartPut(ApplicationBase): if (item and item.uid == prepared_item.uid): logger.debug("PUT request updated existing item %r", path) headers = {"ETag": etag} - return client.NO_CONTENT, headers, None + return client.NO_CONTENT, headers, None, None headers = {"ETag": etag} - return client.CREATED, headers, None + return client.CREATED, headers, None, None diff --git a/radicale/app/report.py b/radicale/app/report.py index 5d41b81b..821d1e60 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -149,13 +149,14 @@ def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Eleme def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], collection: storage.BaseCollection, encoding: str, unlock_storage_fn: Callable[[], None], - max_occurrence: int = 0, + max_occurrence: int = 0, user: str = "", remote_addr: str = "", remote_useragent: str = "" ) -> Tuple[int, ET.Element]: """Read and answer REPORT requests that return XML. Read rfc3253-3.6 for info. """ + logger.debug("TRACE/REPORT/xml_report: base_prefix=%r path=%r", base_prefix, path) multistatus = ET.Element(xmlutils.make_clark("D:multistatus")) if xml_request is None: return client.MULTI_STATUS, multistatus @@ -212,8 +213,8 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], sync_token, names = collection.sync(old_sync_token) except ValueError as e: # Invalid sync token - logger.warning("Client provided invalid sync token %r: %s", - old_sync_token, e, exc_info=True) + logger.warning("Client provided invalid sync token for path %r (user %r from %s%s): %s", + path, user, remote_addr, remote_useragent, e, exc_info=True) # client.CONFLICT doesn't work with some clients (e.g. InfCloud) return (client.FORBIDDEN, xmlutils.webdav_error("D:valid-sync-token")) @@ -239,6 +240,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], filter_copy = copy.deepcopy(filter_) if expand is not None: + logger.debug("TRACE/REPORT/xml_report: expand") for comp_filter in filter_copy.findall(".//" + xmlutils.make_clark("C:comp-filter")): if comp_filter.get("name", "").upper() == "VCALENDAR": continue @@ -275,21 +277,15 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], found_props = [] not_found_props = [] - item_etag: str = "" for prop in props: element = ET.Element(prop.tag) - if prop.tag == xmlutils.make_clark("D:getetag"): - if expand is not None: - item_etag = item.etag - else: - element.text = item.etag - found_props.append(element) - elif prop.tag == xmlutils.make_clark("D:getcontenttype"): + if prop.tag == xmlutils.make_clark("D:getcontenttype"): element.text = xmlutils.get_content_type(item, encoding) found_props.append(element) elif prop.tag in ( xmlutils.make_clark("C:calendar-data"), + xmlutils.make_clark("D:getetag"), xmlutils.make_clark("CR:address-data")): element.text = item.serialize() @@ -326,11 +322,24 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], continue n_vevents += n_vev - found_props.append(expanded_element) + if prop.tag == xmlutils.make_clark("D:getetag"): + if n_vev > 0: + logger.debug("TRACE/REPORT/xml_report: getetag/expanded element") + element.text = item.etag + found_props.append(element) + else: + logger.debug("TRACE/REPORT/xml_report: getetag/no expanded element") + else: + logger.debug("TRACE/REPORT/xml_report: default") + found_props.append(expanded_element) else: - found_props.append(element) - if hasattr(item.vobject_item, "vevent_list"): - n_vevents += len(item.vobject_item.vevent_list) + if prop.tag == xmlutils.make_clark("D:getetag"): + element.text = item.etag + found_props.append(element) + else: + found_props.append(element) + if hasattr(item.vobject_item, "vevent_list"): + n_vevents += len(item.vobject_item.vevent_list) # Avoid DoS with too many events if max_occurrence and n_vevents > max_occurrence: raise ValueError("REPORT occurrences limit of {} hit" @@ -345,7 +354,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], if found_props or not_found_props: multistatus.append(xml_item_response( base_prefix, uri, found_props=found_props, - not_found_props=not_found_props, found_item=True, item_etag=item_etag)) + not_found_props=not_found_props, found_item=True)) return client.MULTI_STATUS, multistatus @@ -475,7 +484,7 @@ def _expand( if not vevent: # Create new instance from recurrence - vevent = copy.deepcopy(base_vevent) + vevent = base_vevent.duplicate(base_vevent) # For all day events, the system timezone may influence the # results, so use recurrence_dt @@ -681,7 +690,7 @@ def _find_overridden( def xml_item_response(base_prefix: str, href: str, found_props: Sequence[ET.Element] = (), not_found_props: Sequence[ET.Element] = (), - found_item: bool = True, item_etag: str = "") -> ET.Element: + found_item: bool = True) -> ET.Element: response = ET.Element(xmlutils.make_clark("D:response")) href_element = ET.Element(xmlutils.make_clark("D:href")) @@ -695,10 +704,6 @@ def xml_item_response(base_prefix: str, href: str, status = ET.Element(xmlutils.make_clark("D:status")) status.text = xmlutils.make_response(code) prop_element = ET.Element(xmlutils.make_clark("D:prop")) - if (item_etag != "") and (code == 200): - prop_etag = ET.Element(xmlutils.make_clark("D:getetag")) - prop_etag.text = item_etag - prop_element.append(prop_etag) for prop in props: prop_element.append(prop) propstat.append(prop_element) @@ -752,6 +757,7 @@ def retrieve_items( else: yield item, False if collection_requested: + logger.debug("TRACE/REPORT/retrieve_items: get_filtered") yield from collection.get_filtered(filters) @@ -787,7 +793,7 @@ def test_filter(collection_tag: str, item: radicale_item.Item, class ApplicationPartReport(ApplicationBase): def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage REPORT request.""" access = Access(self._rights, user, path) if not access.check("r"): @@ -826,15 +832,15 @@ class ApplicationPartReport(ApplicationBase): "Bad REPORT request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding} - return status, headers, str(body) + return status, headers, str(body), xmlutils.pretty_xml(xml_content) else: try: status, xml_answer = xml_report( base_prefix, path, xml_content, collection, self._encoding, - lock_stack.close, max_occurrence) + lock_stack.close, max_occurrence, user, remote_host, remote_useragent) except ValueError as e: logger.warning( "Bad REPORT request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} - return status, headers, self._xml_response(xml_answer) + return status, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) diff --git a/radicale/auth/__init__.py b/radicale/auth/__init__.py index 2de8c4e9..9114a9f0 100644 --- a/radicale/auth/__init__.py +++ b/radicale/auth/__init__.py @@ -23,7 +23,7 @@ Authentication module. Authentication is based on usernames and passwords. If something more advanced is needed an external WSGI server or reverse proxy can be used -(see ``remote_user`` or ``http_x_remote_user`` backend). +(see ``remote_user``, ``http_remote_user`` or ``http_x_remote_user`` backend). Take a look at the class ``BaseAuth`` if you want to implement your own. @@ -40,6 +40,7 @@ from radicale import config, types, utils from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user", + "http_remote_user", "denyall", "htpasswd", "ldap", @@ -59,11 +60,14 @@ CACHE_LOGIN_TYPES: Sequence[str] = ( INSECURE_IF_NO_LOOPBACK_TYPES: Sequence[str] = ( "remote_user", + "http_remote_user", "http_x_remote_user", ) AUTH_SOCKET_FAMILY: Sequence[str] = ("AF_UNIX", "AF_INET", "AF_INET6") +REMOTE_ADDR_SOURCE: Sequence[str] = ("REMOTE_ADDR", "X-Remote-Addr") + def load(configuration: "config.Configuration") -> "BaseAuth": """Load the authentication module chosen in configuration.""" @@ -91,6 +95,15 @@ def load(configuration: "config.Configuration") -> "BaseAuth": configuration) +class AuthContext: + remote_addr: str + x_remote_addr: str + + def __init__(self): + self.remote_addr = None + self.x_remote_addr = None + + class BaseAuth: _ldap_groups: Set[str] = set([]) @@ -129,7 +142,7 @@ class BaseAuth: if self._lc_username is True and self._uc_username is True: raise RuntimeError("auth.lc_username and auth.uc_username cannot be enabled together") self._auth_delay = configuration.get("auth", "delay") - logger.info("auth.delay: %f", self._auth_delay) + logger.info("auth.delay: %f seconds", self._auth_delay) self._failed_auth_delay = 0 self._lock = threading.Lock() # cache_successful_logins @@ -187,6 +200,21 @@ class BaseAuth: raise NotImplementedError + def _login_ext(self, login: str, password: str, context: AuthContext) -> str: + """Check credentials and map login to internal user + + ``login`` the login name + + ``password`` the password + + ``context`` additional data for the login, e.g. IP address used + + Returns the username or ``""`` for invalid credentials. + """ + + # override this method instead of _login() if you want the context + return self._login(login, password) + def _sleep_for_constant_exec_time(self, time_ns_begin: int): """Sleep some time to reach a constant execution time for failed logins @@ -216,7 +244,7 @@ class BaseAuth: time.sleep(sleep) @final - def login(self, login: str, password: str) -> Tuple[str, str]: + def login(self, login: str, password: str, context: AuthContext) -> Tuple[str, str]: time_ns_begin = time.time_ns() result_from_cache = False if self._lc_username: @@ -284,7 +312,7 @@ class BaseAuth: if result == "": # verify login+password via configured backend logger.debug("Login verification for user+password via backend: '%s'", login) - result = self._login(login, password) + result = self._login_ext(login, password, context) if result != "": logger.debug("Login successful for user+password via backend: '%s'", login) if digest == "": @@ -314,7 +342,7 @@ class BaseAuth: return (result, self._type) else: # self._cache_logins is False - result = self._login(login, password) + result = self._login_ext(login, password, context) if result == "": self._sleep_for_constant_exec_time(time_ns_begin) return (result, self._type) diff --git a/radicale/auth/dovecot.py b/radicale/auth/dovecot.py index b3f3fb81..bffed4ed 100644 --- a/radicale/auth/dovecot.py +++ b/radicale/auth/dovecot.py @@ -19,6 +19,7 @@ import base64 import itertools import os +import re import socket from contextlib import closing @@ -32,6 +33,9 @@ class Auth(auth.BaseAuth): self.timeout = 5 self.request_id_gen = itertools.count(1) + remote_ip_source = configuration.get("auth", "remote_ip_source") + self.use_x_remote_addr = remote_ip_source == 'X-Remote-Addr' + config_family = configuration.get("auth", "dovecot_connection_type") if config_family == "AF_UNIX": self.family = socket.AF_UNIX @@ -46,7 +50,7 @@ class Auth(auth.BaseAuth): else: self.family = socket.AF_INET6 - def _login(self, login, password): + def _login_ext(self, login, password, context): """Validate credentials. Check if the ``login``/``password`` pair is valid according to Dovecot. @@ -88,6 +92,7 @@ class Auth(auth.BaseAuth): # Hence, we try to read just once with a buffer big # enough to hold all of it. buf = sock.recv(1024) + version_sent = False while b'\n' in buf and not done: line, buf = buf.split(b'\n', 1) parts = line.split(b'\t') @@ -110,6 +115,10 @@ class Auth(auth.BaseAuth): ) return "" seen_part[0] += 1 + if int(version[1]) >= 3: + sock.send(b'VERSION\t1\t1\n') + buf += sock.recv(1024) + version_sent = True elif first == b'MECH': supported_mechs.append(parts[0]) seen_part[1] += 1 @@ -140,7 +149,8 @@ class Auth(auth.BaseAuth): # Handshake logger.debug("Sending auth handshake") - sock.send(b'VERSION\t1\t1\n') + if not version_sent: + sock.send(b'VERSION\t1\t1\n') sock.send(b'CPID\t%u\n' % os.getpid()) request_id = next(self.request_id_gen) @@ -148,10 +158,19 @@ class Auth(auth.BaseAuth): "Authenticating with request id: '{}'" .format(request_id) ) + rip = b'' + if self.use_x_remote_addr and context.x_remote_addr: + rip = context.x_remote_addr.encode('ascii') + elif context.remote_addr: + rip = context.remote_addr.encode('ascii') + # squash all whitespace - shouldn't be there and auth protocol + # is sensitive to whitespace (in particular \t and \n) + if rip: + rip = b'\trip=' + re.sub(br'\s', b'', rip) sock.send( - b'AUTH\t%u\tPLAIN\tservice=radicale\tresp=%b\n' % + b'AUTH\t%u\tPLAIN\tservice=radicale%s\tresp=%b\n' % ( - request_id, base64.b64encode( + request_id, rip, base64.b64encode( b'\0%b\0%b' % (login.encode(), password.encode()) ) diff --git a/radicale/auth/htpasswd.py b/radicale/auth/htpasswd.py index dd66dfbd..dd27fdec 100644 --- a/radicale/auth/htpasswd.py +++ b/radicale/auth/htpasswd.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2019 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # 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 @@ -43,7 +43,7 @@ out-of-the-box: - SHA256 (htpasswd -2 ...) - SHA512 (htpasswd -5 ...) -When bcrypt is installed: +When bcrypt is installed (bcrypt >= 5.0.0 requires passlib(libpass) >= 1.9.3): - BCRYPT (htpasswd -B ...) -- Requires htpasswd 2.4.x When argon2 is installed: @@ -61,7 +61,7 @@ from typing import Any, Tuple from passlib.hash import apr_md5_crypt, sha256_crypt, sha512_crypt -from radicale import auth, config, logger +from radicale import auth, config, logger, utils class Auth(auth.BaseAuth): @@ -120,12 +120,22 @@ class Auth(auth.BaseAuth): "The htpasswd encryption method 'bcrypt' or 'autodetect' requires " "the bcrypt module (entries found: %d)." % self._htpasswd_bcrypt_use) from e else: - self._has_bcrypt = True + [bcrypt_usable, info] = utils.passlib_libpass_supports_bcrypt() + if bcrypt_usable: + self._has_bcrypt = True + logger.info(info) + else: + logger.warning(info) if self._encryption == "autodetect": if self._htpasswd_bcrypt_use == 0: - logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bycrypt module found, but currently not required", self._encryption) + logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bcrypt module found, but currently not required", self._encryption) else: - logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bycrypt module found (bcrypt entries found: %d)", self._encryption, self._htpasswd_bcrypt_use) + logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bcrypt module found (bcrypt entries found: %d)", self._encryption, self._htpasswd_bcrypt_use) + if not bcrypt_usable: + raise RuntimeError("The htpasswd encryption 'autodetect' requires the bcrypt module but not usuable") + else: + if not bcrypt_usable: + raise RuntimeError("The htpasswd encryption method 'bcrypt' requires the bcrypt module but not usuable") if self._encryption == "bcrypt": self._verify = functools.partial(self._bcrypt, bcrypt) else: @@ -181,37 +191,28 @@ class Auth(auth.BaseAuth): return ("ARGON2", argon2.verify(password, hash_value.strip())) def _md5apr1(self, hash_value: str, password: str) -> tuple[str, bool]: - if self._encryption == "autodetect" and len(hash_value) != 37: - return self._plain_fallback("MD5-APR1", hash_value, password) - else: - return ("MD5-APR1", apr_md5_crypt.verify(password, hash_value.strip())) + return ("MD5-APR1", apr_md5_crypt.verify(password, hash_value.strip())) def _sha256(self, hash_value: str, password: str) -> tuple[str, bool]: - if self._encryption == "autodetect" and len(hash_value) != 63: - return self._plain_fallback("SHA-256", hash_value, password) - else: - return ("SHA-256", sha256_crypt.verify(password, hash_value.strip())) + return ("SHA-256", sha256_crypt.verify(password, hash_value.strip())) def _sha512(self, hash_value: str, password: str) -> tuple[str, bool]: - if self._encryption == "autodetect" and len(hash_value) != 106: - return self._plain_fallback("SHA-512", hash_value, password) - else: - return ("SHA-512", sha512_crypt.verify(password, hash_value.strip())) + return ("SHA-512", sha512_crypt.verify(password, hash_value.strip())) def _autodetect(self, hash_value: str, password: str) -> tuple[str, bool]: - if hash_value.startswith("$apr1$", 0, 6): + if re.match(r"^\$apr1\$[A-Za-z0-9/.]{8}\$[A-Za-z0-9/.]{22}", hash_value): # MD5-APR1 return self._md5apr1(hash_value, password) - elif re.match(r"^\$2(a|b|x|y)?\$", hash_value): + elif re.match(r"^\$2(a|b|x|y)?\$[0-9]{2}\$[A-Za-z0-9/.]{53}", hash_value): # BCRYPT return self._verify_bcrypt(hash_value, password) elif re.match(r"^\$argon2(i|d|id)\$", hash_value): # ARGON2 return self._verify_argon2(hash_value, password) - elif hash_value.startswith("$5$", 0, 3): + elif re.match(r"^\$5\$(rounds=[0-9]+\$)?[A-Za-z0-9/.]{16}\$[A-Za-z0-9/.]{42}", hash_value): # SHA-256 return self._sha256(hash_value, password) - elif hash_value.startswith("$6$", 0, 3): + elif re.match(r"^\$6\$(rounds=[0-9]+\$)?[A-Za-z0-9/.]{16}\$[A-Za-z0-9/.]{85}", hash_value): # SHA-512 return self._sha512(hash_value, password) else: diff --git a/radicale/auth/http_remote_user.py b/radicale/auth/http_remote_user.py new file mode 100644 index 00000000..40695f7b --- /dev/null +++ b/radicale/auth/http_remote_user.py @@ -0,0 +1,36 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2025-2025 Peter Bieringer +# +# 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library 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 Radicale. If not, see . + +""" +Authentication backend that takes the username from the +``HTTP_REMOTE_USER`` header. + +It's intended for use with a reverse proxy. Be aware as this will be insecure +if the reverse proxy is not configured properly. + +""" + +from typing import Tuple, Union + +from radicale import types +from radicale.auth import none + + +class Auth(none.Auth): + + def get_external_login(self, environ: types.WSGIEnviron) -> Union[ + Tuple[()], Tuple[str, str]]: + return environ.get("HTTP_REMOTE_USER", ""), "" diff --git a/radicale/auth/imap.py b/radicale/auth/imap.py index 18ec527b..f0d52b47 100644 --- a/radicale/auth/imap.py +++ b/radicale/auth/imap.py @@ -64,10 +64,18 @@ class Auth(auth.BaseAuth): if self._security == "starttls": connection.starttls(ssl.create_default_context()) try: - connection.authenticate( - "PLAIN", - lambda _: "{0}\x00{0}\x00{1}".format(login, password).encode(), - ) + if "AUTH=PLAIN" in connection.capabilities: + logger.debug("IMAP authentication PLAIN selected for user %r via %s:%d (security: %s)", login, self._host, self._port, self._security) + connection.authenticate( + "PLAIN", + lambda _: "{0}\x00{0}\x00{1}".format(login, password).encode(), + ) + elif "AUTH=LOGIN" in connection.capabilities: + logger.debug("IMAP authentication LOGIN selected for user %r via %s:%d (security: %s)", login, self._host, self._port, self._security) + connection.login(login, password) + else: + logger.error("IMAP server is neither supporting AUTH=PLAIN or AUTH=LOGIN: %s:%d (security: %s)", self._host, self._port, self._security) + return "" except imaplib.IMAP4.error as e: logger.warning("IMAP authentication failed for user %r: %s", login, e, exc_info=False) return "" diff --git a/radicale/auth/ldap.py b/radicale/auth/ldap.py index 2c4d63c3..aadbbf64 100644 --- a/radicale/auth/ldap.py +++ b/radicale/auth/ldap.py @@ -1,6 +1,7 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2022-2024 Peter Varkoly # Copyright © 2024-2024 Peter Bieringer +# Copyright © 2024-2025 Peter Marschall # # 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 @@ -16,20 +17,36 @@ # along with Radicale. If not, see . """ Authentication backend that checks credentials with a LDAP server. -Following parameters are needed in the configuration: - ldap_uri The LDAP URL to the server like ldap://localhost - ldap_base The baseDN of the LDAP server - ldap_reader_dn The DN of a LDAP user with read access to get the user accounts - ldap_secret The password of the ldap_reader_dn - ldap_secret_file The path of the file containing the password of the ldap_reader_dn - ldap_filter The search filter to find the user to authenticate by the username - ldap_user_attribute The attribute to be used as username after authentication - ldap_groups_attribute The attribute containing group memberships in the LDAP user entry -Following parameters controls SSL connections: - ldap_use_ssl If ssl encryption should be used (to be deprecated) - ldap_security The encryption mode to be used: *none*|tls|starttls - ldap_ssl_verify_mode The certificate verification mode. Works for tls and starttls. NONE, OPTIONAL, default is REQUIRED - ldap_ssl_ca_file + The following parameters are needed in the configuration: + ldap_uri URI to the LDAP server + ldap_base Base DN of the LDAP server + ldap_reader_dn DN of an LDAP user with read access to get the user accounts + ldap_secret Password of the 'ldap_reader_dn' + Better: use 'ldap_secret_file'! + ldap_secret_file Path of the file containing the password of the 'ldap_reader_dn' + ldap_filter Search filter to find the user DN to authenticate + The following parameters control TLS connections: + ldap_use_ssl Use ssl on the ldap connection. + Deprecated, use 'ldap_security' instead! + ldap_security Encryption mode to be used, + one of: *none* | tls | starttls + ldap_ssl_verify_mode Certificate verification mode for tls and starttls; + one of: *REQUIRED* | OPTIONAL | NONE + ldap_ssl_ca_file Path to the CA file in PEM format to certify the server certificate + The following parameters are optional: + ldap_user_attribute Attribute to be used as username after authentication, e.g. cn; + if not given, the name used to logon is used. + ldap_groups_attribute Attribute in the user entry to read the user's group memberships from, + e.g. memberof, groupMememberShip. This may even be a non-DN attribute! + ldap_group_base Base DN to search for groups; + only if it differs from 'ldap_base' and if 'ldap_group_members_attribute' is set + ldap_group_filter Search filter to search for groups having the user DN found as member; + only if 'ldap_group_members_attribute' is set + ldap_group_members_attribute Attribute in the group entries to read the group's members from, + e.g. member. + The following parameters are for LDAP servers with oddities + ldap_ignore_attribute_create_modify_timestamp + Ignore modifyTimestamp and createTimestamp attributes. Needed for Authentik LDAP server """ import ssl @@ -47,10 +64,12 @@ class Auth(auth.BaseAuth): _ldap_attributes: list[str] = [] _ldap_user_attr: str _ldap_groups_attr: str + _ldap_group_base: str + _ldap_group_filter: str + _ldap_group_members_attr: str _ldap_module_version: int = 3 - _ldap_use_ssl: bool = False _ldap_security: str = "none" - _ldap_ssl_verify_mode: int = ssl.CERT_REQUIRED + _ldap_ssl_verify_mode: str = "REQUIRED" _ldap_ssl_ca_file: str = "" def __init__(self, configuration: config.Configuration) -> None: @@ -61,16 +80,13 @@ class Auth(auth.BaseAuth): except ImportError: try: import ldap + import ldap.filter self._ldap_module_version = 2 self.ldap = ldap except ImportError as e: - raise RuntimeError("LDAP authentication requires the ldap3 module") from e + raise RuntimeError("LDAP authentication requires the ldap3 or ldap module") from e self._ldap_ignore_attribute_create_modify_timestamp = configuration.get("auth", "ldap_ignore_attribute_create_modify_timestamp") - if self._ldap_ignore_attribute_create_modify_timestamp: - self.ldap3.utils.config._ATTRIBUTES_EXCLUDED_FROM_CHECK.extend(['createTimestamp', 'modifyTimestamp']) - logger.info("auth.ldap_ignore_attribute_create_modify_timestamp applied") - self._ldap_uri = configuration.get("auth", "ldap_uri") self._ldap_base = configuration.get("auth", "ldap_base") self._ldap_reader_dn = configuration.get("auth", "ldap_reader_dn") @@ -78,71 +94,116 @@ class Auth(auth.BaseAuth): self._ldap_filter = configuration.get("auth", "ldap_filter") self._ldap_user_attr = configuration.get("auth", "ldap_user_attribute") self._ldap_groups_attr = configuration.get("auth", "ldap_groups_attribute") + self._ldap_group_base = configuration.get("auth", "ldap_group_base") + self._ldap_group_filter = configuration.get("auth", "ldap_group_filter") + self._ldap_group_members_attr = configuration.get("auth", "ldap_group_members_attribute") ldap_secret_file_path = configuration.get("auth", "ldap_secret_file") if ldap_secret_file_path: with open(ldap_secret_file_path, 'r') as file: self._ldap_secret = file.read().rstrip('\n') - if self._ldap_module_version == 3: - self._ldap_use_ssl = configuration.get("auth", "ldap_use_ssl") - self._ldap_security = configuration.get("auth", "ldap_security") - self._use_encryption = self._ldap_use_ssl or self._ldap_security in ("tls", "starttls") - if self._ldap_use_ssl and self._ldap_security == "starttls": - raise RuntimeError("Cannot set both 'ldap_use_ssl = True' and 'ldap_security' = 'starttls'") - if self._ldap_use_ssl: - logger.warning("Configuration uses soon to be deprecated 'ldap_use_ssl', use 'ldap_security' ('none', 'tls', 'starttls') instead.") - if self._use_encryption: - self._ldap_ssl_ca_file = configuration.get("auth", "ldap_ssl_ca_file") - tmp = configuration.get("auth", "ldap_ssl_verify_mode") - if tmp == "NONE": - self._ldap_ssl_verify_mode = ssl.CERT_NONE - elif tmp == "OPTIONAL": - self._ldap_ssl_verify_mode = ssl.CERT_OPTIONAL + self._ldap_security = configuration.get("auth", "ldap_security") + if self._ldap_security not in ("none", "tls", "starttls"): + raise RuntimeError("Illegal value for config setting ´ldap_security'") + ldap_use_ssl = configuration.get("auth", "ldap_use_ssl") + if ldap_use_ssl: + logger.warning("Configuration uses deprecated 'ldap_use_ssl': use 'ldap_security' ('none', 'tls', 'starttls') instead.") + if self._ldap_security == "starttls": + raise RuntimeError("Deprecated config setting 'ldap_use_ssl = True' conflicts with 'ldap_security' = 'starttls'") + elif self._ldap_security != "tls": + logger.warning("Update configuration: set 'ldap_security = tls' instead of deprecated 'ldap_use_ssl = True'") + self._ldap_security = "tls" + self._ldap_ssl_ca_file = configuration.get("auth", "ldap_ssl_ca_file") + self._ldap_ssl_verify_mode = configuration.get("auth", "ldap_ssl_verify_mode") + if self._ldap_ssl_verify_mode not in ("NONE", "OPTIONAL", "REQUIRED"): + raise RuntimeError("Illegal value for config setting ´ldap_ssl_verify_mode'") - logger.info("auth.ldap_uri : %r" % self._ldap_uri) - logger.info("auth.ldap_base : %r" % self._ldap_base) - logger.info("auth.ldap_reader_dn : %r" % self._ldap_reader_dn) - logger.info("auth.ldap_filter : %r" % self._ldap_filter) + if self._ldap_uri.lower().startswith("ldaps://") and self._ldap_security not in ("tls", "starttls"): + logger.info("Inferring 'ldap_security' = tls from 'ldap_uri' starting with 'ldaps://'") + self._ldap_security = "tls" + if self._ldap_uri.lower().startswith("ldapi://") and self._ldap_ssl_verify_mode != "NONE": + logger.info("Lowering 'ldap_'ldap_ssl_verify_mode' to NONE for 'ldap_uri' starting with 'ldapi://'") + self._ldap_ssl_verify_mode = "NONE" + + if self._ldap_ssl_ca_file == "" and self._ldap_ssl_verify_mode != "NONE" and self._ldap_security in ("tls", "starttls"): + logger.warning("Certificate verification not possible: 'ldap_ssl_ca_file' not set") + if self._ldap_ssl_ca_file and self._ldap_security not in ("tls", "starttls"): + logger.warning("Config setting 'ldap_ssl_ca_file' useless without encrypted LDAP connection") + + logger.info("auth.ldap_uri : %r" % self._ldap_uri) + logger.info("auth.ldap_base : %r" % self._ldap_base) + logger.info("auth.ldap_reader_dn : %r" % self._ldap_reader_dn) + logger.info("auth.ldap_filter : %r" % self._ldap_filter) if self._ldap_user_attr: - logger.info("auth.ldap_user_attribute : %r" % self._ldap_user_attr) + logger.info("auth.ldap_user_attribute : %r" % self._ldap_user_attr) else: - logger.info("auth.ldap_user_attribute : (not provided)") + logger.info("auth.ldap_user_attribute : (not provided)") if self._ldap_groups_attr: - logger.info("auth.ldap_groups_attribute: %r" % self._ldap_groups_attr) + logger.info("auth.ldap_groups_attribute : %r" % self._ldap_groups_attr) else: - logger.info("auth.ldap_groups_attribute: (not provided)") + logger.info("auth.ldap_groups_attribute : (not provided)") + if self._ldap_group_base: + logger.info("auth.ldap_group_base : %r" % self._ldap_group_base) + else: + logger.info("auth.ldap_group_base : (not provided, using ldap_base)") + self._ldap_group_base = self._ldap_base + if self._ldap_group_filter: + logger.info("auth.ldap_group_filter : %r" % self._ldap_group_filter) + else: + logger.info("auth.ldap_group_filter : (not provided)") + if self._ldap_group_members_attr: + logger.info("auth.ldap_group_members_attr: %r" % self._ldap_group_members_attr) + else: + logger.info("auth.ldap_group_members_attr: (not provided)") if ldap_secret_file_path: - logger.info("auth.ldap_secret_file_path: %r" % ldap_secret_file_path) + logger.info("auth.ldap_secret_file_path : %r" % ldap_secret_file_path) if self._ldap_secret: - logger.info("auth.ldap_secret : (from file)") + logger.info("auth.ldap_secret : (from file)") else: - logger.info("auth.ldap_secret_file_path: (not provided)") + logger.info("auth.ldap_secret_file_path : (not provided)") if self._ldap_secret: - logger.info("auth.ldap_secret : (from config)") + logger.info("auth.ldap_secret : (from config)") if self._ldap_reader_dn and not self._ldap_secret: - logger.error("auth.ldap_secret : (not provided)") + logger.error("auth.ldap_secret : (not provided)") raise RuntimeError("LDAP authentication requires ldap_secret for ldap_reader_dn") - logger.info("auth.ldap_use_ssl : %s" % self._ldap_use_ssl) - logger.info("auth.ldap_security : %s" % self._ldap_security) - if self._use_encryption: - logger.info("auth.ldap_ssl_verify_mode : %s" % self._ldap_ssl_verify_mode) - if self._ldap_ssl_ca_file: - logger.info("auth.ldap_ssl_ca_file : %r" % self._ldap_ssl_ca_file) - else: - logger.info("auth.ldap_ssl_ca_file : (not provided)") + logger.info("auth.ldap_use_ssl : %s" % ldap_use_ssl) + logger.info("auth.ldap_security : %s" % self._ldap_security) + logger.info("auth.ldap_ssl_verify_mode : %s" % self._ldap_ssl_verify_mode) + if self._ldap_ssl_ca_file: + logger.info("auth.ldap_ssl_ca_file : %r" % self._ldap_ssl_ca_file) + else: + logger.info("auth.ldap_ssl_ca_file : (not provided)") + if self._ldap_ignore_attribute_create_modify_timestamp: + logger.info("auth.ldap_ignore_attribute_create_modify_timestamp applied (relevant for ldap3 only)") """Extend attributes to to be returned in the user query""" if self._ldap_groups_attr: self._ldap_attributes.append(self._ldap_groups_attr) if self._ldap_user_attr: self._ldap_attributes.append(self._ldap_user_attr) - logger.info("ldap_attributes : %r" % self._ldap_attributes) + logger.info("ldap_attributes : %r" % self._ldap_attributes) def _login2(self, login: str, password: str) -> str: try: """Bind as reader dn""" logger.debug(f"_login2 {self._ldap_uri}, {self._ldap_reader_dn}") conn = self.ldap.initialize(self._ldap_uri) - conn.protocol_version = 3 + conn.protocol_version = self.ldap.VERSION3 conn.set_option(self.ldap.OPT_REFERRALS, 0) + + if self._ldap_security in ("tls", "starttls"): + """certificate validation mode""" + verifyMode = {"NONE": self.ldap.OPT_X_TLS_NEVER, + "OPTIONAL": self.ldap.OPT_X_TLS_ALLOW, + "REQUIRED": self.ldap.OPT_X_TLS_DEMAND} + conn.set_option(self.ldap.OPT_X_TLS_REQUIRE_CERT, verifyMode[self._ldap_ssl_verify_mode]) + """CA file to validate certificate against""" + if self._ldap_ssl_ca_file: + conn.set_option(self.ldap.OPT_X_TLS_CACERTFILE, self._ldap_ssl_ca_file) + """create TLS context- this must be the last TLS setting""" + conn.set_option(self.ldap.OPT_X_TLS_NEWCTX, self.ldap.OPT_ON) + + if self._ldap_security == "starttls": + conn.start_tls_s() + conn.simple_bind_s(self._ldap_reader_dn, self._ldap_secret) """Search for the dn of user to authenticate""" escaped_login = self.ldap.filter.escape_filter_chars(login) @@ -160,34 +221,56 @@ class Auth(auth.BaseAuth): user_entry = res[0] user_dn = user_entry[0] logger.debug(f"_login2 found LDAP user DN {user_dn}") - """Close LDAP connection""" - conn.unbind() + + """Let's collect the groups of the user.""" + groupDNs = [] + if self._ldap_groups_attr: + groupDNs = user_entry[1][self._ldap_groups_attr] + + """Search for all groups having the user_dn found as member.""" + if self._ldap_group_members_attr: + groupDNs = [] + res = conn.search_s( + self._ldap_group_base, + self.ldap.SCOPE_SUBTREE, + filterstr="(&{0}({1}={2}))".format( + self._ldap_group_filter, + self._ldap_group_members_attr, + self.ldap.filter.escape_filter_chars(user_dn)), + attrlist=['1.1'] + ) + """Fill groupDNs with DNs of groups found""" + if len(res) > 0: + groupDNs = [] + for dn, entry in res: + groupDNs.append(dn) + except Exception as e: raise RuntimeError(f"Invalid LDAP configuration:{e}") try: """Bind as user to authenticate""" - conn = self.ldap.initialize(self._ldap_uri) - conn.protocol_version = 3 - conn.set_option(self.ldap.OPT_REFERRALS, 0) conn.simple_bind_s(user_dn, password) - tmp: list[str] = [] - if self._ldap_groups_attr: - tmp = [] - for g in user_entry[1][self._ldap_groups_attr]: - """Get group g's RDN's attribute value""" - try: - rdns = self.ldap.dn.explode_dn(g, notypes=True) - tmp.append(rdns[0]) - except Exception: - tmp.append(g.decode('utf8')) - self._ldap_groups = set(tmp) - logger.debug("_login2 LDAP groups of user: %s", ",".join(self._ldap_groups)) if self._ldap_user_attr: if user_entry[1][self._ldap_user_attr]: - tmplogin = user_entry[1][self._ldap_user_attr][0] - login = tmplogin.decode('utf-8') + login = user_entry[1][self._ldap_user_attr][0] + if isinstance(login, bytes): + login = login.decode('utf-8') logger.debug(f"_login2 user set to: '{login}'") + + """Get RDNs of groups' DNs""" + tmp = [] + for g in groupDNs: + try: + rdns = self.ldap.dn.explode_dn(g, notypes=True) + tmp.append(rdns[0]) + except Exception: + if isinstance(g, bytes): + g = g.decode('utf-8') + tmp.append(g) + self._ldap_groups = set(tmp) + logger.debug("_login2 LDAP groups of user: %s", ",".join(self._ldap_groups)) + conn.unbind() logger.debug(f"_login2 {login} successfully authenticated") return login @@ -195,18 +278,21 @@ class Auth(auth.BaseAuth): return "" def _login3(self, login: str, password: str) -> str: + if self._ldap_ignore_attribute_create_modify_timestamp: + self.ldap3.utils.config._ATTRIBUTES_EXCLUDED_FROM_CHECK.extend(['createTimestamp', 'modifyTimestamp']) + """Connect the server""" try: logger.debug(f"_login3 {self._ldap_uri}, {self._ldap_reader_dn}") - if self._use_encryption: + if self._ldap_security in ("tls", "starttls"): logger.debug("_login3 using encryption (reader)") - tls = self.ldap3.Tls(validate=self._ldap_ssl_verify_mode) + verifyMode = {"NONE": ssl.CERT_NONE, + "OPTIONAL": ssl.CERT_OPTIONAL, + "REQUIRED": ssl.CERT_REQUIRED} + tls = self.ldap3.Tls(validate=verifyMode[self._ldap_ssl_verify_mode]) if self._ldap_ssl_ca_file != "": - tls = self.ldap3.Tls( - validate=self._ldap_ssl_verify_mode, - ca_certs_file=self._ldap_ssl_ca_file - ) - if self._ldap_use_ssl or self._ldap_security == "tls": + tls = self.ldap3.Tls(validate=verifyMode[self._ldap_ssl_verify_mode], ca_certs_file=self._ldap_ssl_ca_file) + if self._ldap_security == "tls": logger.debug("_login3 using ssl (reader)") server = self.ldap3.Server(self._ldap_uri, use_ssl=True, tls=tls) else: @@ -249,9 +335,42 @@ class Auth(auth.BaseAuth): return "" user_entry = conn.response[0] - conn.unbind() user_dn = user_entry['dn'] logger.debug(f"_login3 found LDAP user DN {user_dn}") + + """Let's collect the groups of the user.""" + groupDNs = [] + if self._ldap_groups_attr: + if user_entry['attributes'][self._ldap_groups_attr]: + if isinstance(user_entry['attributes'][self._ldap_groups_attr], list): + groupDNs = user_entry['attributes'][self._ldap_groups_attr] + else: + groupDNs.append(user_entry['attributes'][self._ldap_groups_attr]) + + """Search for all groups having the user_dn found as member.""" + if self._ldap_group_members_attr: + try: + conn.search( + search_base=self._ldap_group_base, + search_filter="(&{0}({1}={2}))".format( + self._ldap_group_filter, + self._ldap_group_members_attr, + self.ldap3.utils.conv.escape_filter_chars(user_dn)), + search_scope=self.ldap3.SUBTREE, + attributes=['1.1'] + ) + except Exception as e: + """LDAP search failed: consider it as non-fatal - only groups missing""" + logger.debug(f"_ldap3: LDAP group search failed: {e}") + else: + """Fill groupDNs with DNs of groups found""" + groupDNs = [] + for group in conn.response: + groupDNs.append(group['dn']) + + """Close LDAP connection""" + conn.unbind() + try: """Try to bind as the user itself""" try: @@ -264,18 +383,18 @@ class Auth(auth.BaseAuth): if not conn.bind(read_server_info=False): logger.debug(f"_login3 user '{login}' cannot be found") return "" - tmp: list[str] = [] - if self._ldap_groups_attr: - tmp = [] - for g in user_entry['attributes'][self._ldap_groups_attr]: - """Get group g's RDN's attribute value""" - try: - rdns = self.ldap3.utils.dn.parse_dn(g) - tmp.append(rdns[0][1]) - except Exception: - tmp.append(g) - self._ldap_groups = set(tmp) - logger.debug("_login3 LDAP groups of user: %s", ",".join(self._ldap_groups)) + + """Get RDNs of groups' DNs""" + tmp = [] + for g in groupDNs: + try: + rdns = self.ldap3.utils.dn.parse_dn(g) + tmp.append(rdns[0][1]) + except Exception: + tmp.append(g) + self._ldap_groups = set(tmp) + logger.debug("_login3 LDAP groups of user: %s", ",".join(self._ldap_groups)) + if self._ldap_user_attr: if user_entry['attributes'][self._ldap_user_attr]: if isinstance(user_entry['attributes'][self._ldap_user_attr], list): diff --git a/radicale/config.py b/radicale/config.py index 63f627b8..519269bc 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -45,6 +45,8 @@ DEFAULT_CONFIG_PATH: str = os.pathsep.join([ "?/etc/radicale/config", "?~/.config/radicale/config"]) +PROFILING: Sequence[str] = ("per_request", "per_request_method", "none") + def positive_int(value: Any) -> int: value = int(value) @@ -70,6 +72,12 @@ def logging_level(value: Any) -> str: return value +def profiling(value: Any) -> str: + if value not in PROFILING: + raise ValueError("unsupported profiling: %r" % value) + return value + + def filepath(value: Any) -> str: if not value: return "" @@ -154,7 +162,11 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "type": positive_int}), ("max_content_length", { "value": "100000000", - "help": "maximum size of request body in bytes", + "help": "maximum size of request body in bytes (default: 100 Mbyte)", + "type": positive_int}), + ("max_resource_size", { + "value": "10000000", + "help": "maximum size of resource (default: 10 Mbyte)", "type": positive_int}), ("timeout", { "value": "30", @@ -253,6 +265,11 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "12345", "help": "dovecot auth port", "type": int}), + ("remote_ip_source", { + "value": "REMOTE_ADDR", + "help": "remote address source for passing it to auth method", + "type": str, + "internal": auth.REMOTE_ADDR_SOURCE}), ("realm", { "value": "Radicale - Password Required", "help": "message displayed when a password is needed", @@ -261,58 +278,70 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "1", "help": "incorrect authentication delay", "type": positive_float}), - ("ldap_ignore_attribute_create_modify_timestamp", { - "value": "false", - "help": "Ignore modifyTimestamp and createTimestamp attributes. Need if Authentik LDAP server is used.", - "type": bool}), ("ldap_uri", { "value": "ldap://localhost", - "help": "URI to the ldap server", + "help": "URI to the LDAP server", "type": str}), ("ldap_base", { "value": "", - "help": "LDAP base DN of the ldap server", + "help": "Base DN of the LDAP server", "type": str}), ("ldap_reader_dn", { "value": "", - "help": "the DN of a ldap user with read access to get the user accounts", + "help": "DN of an LDAP user with read access to users anmd - if defined - groups", "type": str}), ("ldap_secret", { "value": "", - "help": "the password of the ldap_reader_dn", + "help": "Password of ldap_reader_dn (better: use ldap_secret_file)", "type": str}), ("ldap_secret_file", { "value": "", - "help": "path of the file containing the password of the ldap_reader_dn", + "help": "Path to the file containing the password of ldap_reader_dn", "type": str}), ("ldap_filter", { "value": "(cn={0})", - "help": "the search filter to find the user DN to authenticate by the username", + "help": "Filter to search for the LDAP entry of the user to authenticate", "type": str}), ("ldap_user_attribute", { "value": "", - "help": "the attribute to be used as username after authentication", - "type": str}), - ("ldap_groups_attribute", { - "value": "", - "help": "attribute to read the group memberships from", + "help": "Attribute to be used as username after authentication", "type": str}), ("ldap_use_ssl", { "value": "False", - "help": "Use ssl on the ldap connection. Soon to be deprecated, use ldap_security instead", + "help": "Use ssl on the LDAP connection. Deprecated, use ldap_security instead!", "type": bool}), ("ldap_security", { "value": "none", - "help": "the encryption mode to be used: *none*|tls|starttls", + "help": "Encryption mode to be used: *none*|tls|starttls", "type": str}), ("ldap_ssl_verify_mode", { "value": "REQUIRED", - "help": "The certificate verification mode. Works for tls and starttls. NONE, OPTIONAL, default is REQUIRED", + "help": "Certificate verification mode for tls and starttls. NONE, OPTIONAL, default is REQUIRED", "type": str}), ("ldap_ssl_ca_file", { "value": "", - "help": "The path to the CA file in pem format which is used to certificate the server certificate", + "help": "Path to the CA file in PEM format which is used to certify the server certificate", "type": str}), + ("ldap_groups_attribute", { + "value": "", + "help": "Attribute in the user's LDAP entry to read the group memberships from", + "type": str}), + ("ldap_group_members_attribute", { + "value": "", + "help": "Attribute in the group entries to read the group's members from", + "type": str}), + ("ldap_group_base", { + "value": "", + "help": "Base DN to search for groups. Only if it differs from ldap_base and if ldap_group_members_attribute is set", + "type": str}), + ("ldap_group_filter", { + "value": "", + "help": "Search filter to search for groups having the user as member. Only if ldap_group_members_attribute is set", + "type": str}), + ("ldap_ignore_attribute_create_modify_timestamp", { + "value": "false", + "help": "Quirk for Authentik LDAP server: ignore modifyTimestamp and createTimestamp attributes.", + "type": bool}), ("imap_host", { "value": "localhost", "help": "IMAP server hostname: address|address:port|[address]:port|*localhost*", @@ -413,6 +442,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "", "help": "command that is run after changes to storage", "type": str}), + ("strict_preconditions", { + "value": "False", + "help": "strict preconditions check on PUT", + "type": bool}), ("_filesystem_fsync", { "value": "True", "help": "sync all changes to filesystem during requests", @@ -477,7 +510,7 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "False", "help": "Send one email to all attendees, versus one email per attendee", "type": bool}), - ("added_template", { + ("new_or_added_to_event_template", { "value": """Hello $attendee_name, You have been added as an attendee to the following calendar event. @@ -487,20 +520,31 @@ You have been added as an attendee to the following calendar event. $event_location This is an automated message. Please do not reply.""", - "help": "Template for the email sent when an event is added or updated. Select placeholder words prefixed with $ will be replaced", + "help": "Template for the email sent when an event is created or attendee is added. Select placeholder words prefixed with $ will be replaced", "type": str}), - ("removed_template", { + ("deleted_or_removed_from_event_template", { "value": """Hello $attendee_name, -You have been removed as an attendee from the following calendar event. +The following event has been deleted. $event_title $event_start_time - $event_end_time $event_location This is an automated message. Please do not reply.""", - "help": "Template for the email sent when an event is deleted. Select placeholder words prefixed with $ will be replaced", + "help": "Template for the email sent when an event is deleted or attendee is removed. Select placeholder words prefixed with $ will be replaced", "type": str}), + ("updated_event_template", { + "value": """Hello $attendee_name, +The following event has been updated. + $event_title + $event_start_time - $event_end_time + $event_location + +This is an automated message. Please do not reply.""", + "help": "Template for the email sent when an event is updated. Select placeholder words prefixed with $ will be replaced", + "type": str + }) ])), ("web", OrderedDict([ ("type", { @@ -537,6 +581,10 @@ This is an automated message. Please do not reply.""", "value": "False", "help": "log request content on level=debug", "type": bool}), + ("response_header_on_debug", { + "value": "False", + "help": "log response header on level=debug", + "type": bool}), ("response_content_on_debug", { "value": "False", "help": "log response content on level=debug", @@ -549,6 +597,30 @@ This is an automated message. Please do not reply.""", "value": "False", "help": "log storage cache action on level=debug", "type": bool}), + ("profiling", { + "value": "none", + "help": "log profiling data level=info", + "type": profiling}), + ("profiling_per_request_min_duration", { + "value": "3", + "help": "log profiling data per request minimum duration (seconds)", + "type": positive_int}), + ("profiling_per_request_header", { + "value": "False", + "help": "Log profiling request body (if passing minimum duration)", + "type": bool}), + ("profiling_per_request_xml", { + "value": "False", + "help": "Log profiling request XML (if passing minimum duration)", + "type": bool}), + ("profiling_per_request_method_interval", { + "value": "600", + "help": "log profiling data per request method interval (seconds)", + "type": positive_int}), + ("profiling_top_x_functions", { + "value": "10", + "help": "log profiling top X functions (limit)", + "type": positive_int}), ("mask_passwords", { "value": "True", "help": "mask passwords in logs", diff --git a/radicale/hook/__init__.py b/radicale/hook/__init__.py index 835cbe01..009f1b52 100644 --- a/radicale/hook/__init__.py +++ b/radicale/hook/__init__.py @@ -55,21 +55,26 @@ def _cleanup(path): class HookNotificationItem: - def __init__(self, notification_item_type, path, content): + def __init__(self, notification_item_type, path, content=None, uid=None, new_content=None, old_content=None): self.type = notification_item_type.value self.point = _cleanup(path) - self.content = content + self._content_legacy = content + self.uid = uid + self.new_content = new_content + self.old_content = old_content + + @property + def content(self): # For backward compatibility + return self._content_legacy or self.uid or self.new_content or self.old_content + + @property + def replaces_existing_item(self) -> bool: + """Check if this notification item replaces/deletes an existing item.""" + return self.old_content is not None def to_json(self): return json.dumps( - self, - default=lambda o: o.__dict__, + {**self.__dict__, "content": self.content}, sort_keys=True, indent=4 ) - - -class DeleteHookNotificationItem(HookNotificationItem): - def __init__(self, path, uid, old_content=None): - super().__init__(notification_item_type=HookNotificationItemTypes.DELETE, path=path, content=uid) - self.old_content = old_content diff --git a/radicale/hook/email/__init__.py b/radicale/hook/email/__init__.py index 75d043aa..50778503 100644 --- a/radicale/hook/email/__init__.py +++ b/radicale/hook/email/__init__.py @@ -16,6 +16,8 @@ # along with Radicale. If not, see . import enum +import hashlib +import json import re import smtplib import ssl @@ -29,8 +31,8 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple import vobject -from radicale.hook import (BaseHook, DeleteHookNotificationItem, - HookNotificationItem, HookNotificationItemTypes) +from radicale.hook import (BaseHook, HookNotificationItem, + HookNotificationItemTypes) from radicale.log import logger PLUGIN_CONFIG_SCHEMA = { @@ -63,7 +65,7 @@ PLUGIN_CONFIG_SCHEMA = { "value": "", "type": str }, - "added_template": { + "new_or_added_to_event_template": { "value": """Hello $attendee_name, You have been added as an attendee to the following calendar event. @@ -75,10 +77,22 @@ You have been added as an attendee to the following calendar event. This is an automated message. Please do not reply.""", "type": str }, - "removed_template": { + "deleted_or_removed_from_event_template": { "value": """Hello $attendee_name, -You have been removed as an attendee from the following calendar event. +The following event has been deleted. + + $event_title + $event_start_time - $event_end_time + $event_location + +This is an automated message. Please do not reply.""", + "type": str + }, + "updated_event_template": { + "value": """Hello $attendee_name, + +The following event has been updated. $event_title $event_start_time - $event_end_time @@ -143,14 +157,22 @@ SMTP_SSL_VERIFY_MODES: Sequence[str] = (SMTP_SSL_VERIFY_MODE_ENUM.NONE.value, SMTP_SSL_VERIFY_MODE_ENUM.REQUIRED.value) -def ics_contents_contains_invited_event(contents: str): +def read_ics_event(contents: str) -> Optional['Event']: """ - Check if the ICS contents contain an event (versus a VTODO or VJOURNAL). + Read the vobject item from the provided string and create an Event. + """ + v_cal: vobject.base.Component = vobject.readOne(contents) + cal: Calendar = Calendar(vobject_item=v_cal) + return cal.event if cal.event else None + + +def ics_contents_contains_event(contents: str): + """ + Check if the ICS contents contain an event (versus a VADDRESSBOOK, VTODO or VJOURNAL). :param contents: The contents of the ICS file. :return: True if the ICS file contains an event, False otherwise. """ - cal = vobject.readOne(contents) - return cal.vevent is not None + return read_ics_event(contents) is not None def extract_email(value: str) -> Optional[str]: @@ -165,6 +187,64 @@ def extract_email(value: str) -> Optional[str]: return value if "@" in value else None +def determine_added_removed_and_unaltered_attendees(original_event: 'Event', + new_event: 'Event') -> ( + Tuple)[List['Attendee'], List['Attendee'], List['Attendee']]: + """ + Determine the added, removed and unaltered attendees between two events. + """ + original_event_attendees = {attendee.email: attendee for attendee in original_event.attendees} + new_event_attendees = {attendee.email: attendee for attendee in new_event.attendees} + # Added attendees are those who are in the new event but not in the original event + added_attendees = [new_event_attendees[email] for email in new_event_attendees if + email not in original_event_attendees] + # Removed attendees are those who are in the original event but not in the new event + removed_attendees = [original_event_attendees[email] for email in original_event_attendees if + email not in new_event_attendees] + # Unaltered attendees are those who are in both events + unaltered_attendees = [original_event_attendees[email] for email in original_event_attendees if + email in new_event_attendees] + + return added_attendees, removed_attendees, unaltered_attendees + + +def event_details_other_than_attendees_changed(original_event: 'Event', + new_event: 'Event') -> bool: + """ + Check if any details other than attendees and IDs have changed between two events. + """ + + def hash_dict(d: Dict[str, Any]) -> str: + """ + Create a hash of the dictionary to compare contents. + This will ignore None values and empty strings. + """ + return hashlib.sha1(json.dumps(d).encode("utf8")).hexdigest() + + original_event_details = { + "summary": original_event.summary, + "description": original_event.description, + "location": original_event.location, + "datetime_start": original_event.datetime_start.time_string() if original_event.datetime_start else None, + "datetime_end": original_event.datetime_end.time_string() if original_event.datetime_end else None, + "duration": original_event.duration, + "status": original_event.status, + "organizer": original_event.organizer + } + new_event_details = { + "summary": new_event.summary, + "description": new_event.description, + "location": new_event.location, + "datetime_start": new_event.datetime_start.time_string() if new_event.datetime_start else None, + "datetime_end": new_event.datetime_end.time_string() if new_event.datetime_end else None, + "duration": new_event.duration, + "status": new_event.status, + "organizer": new_event.organizer + } + + return hash_dict(original_event_details) != hash_dict(new_event_details) + + class ContentLine: _key: str value: Any @@ -415,6 +495,11 @@ class Event(VComponent): """Return the summary of the event.""" return self._get_content_lines("SUMMARY")[0].value + @property + def description(self) -> Optional[str]: + """Return the description of the event.""" + return self._get_content_lines("DESCRIPTION")[0].value + @property def location(self) -> Optional[str]: """Return the location of the event.""" @@ -611,8 +696,9 @@ class EmailConfig: from_email: str, send_mass_emails: bool, dryrun: bool, - added_template: MessageTemplate, - removed_template: MessageTemplate): + new_or_added_to_event_template: MessageTemplate, + deleted_or_removed_from_event_template: MessageTemplate, + updated_event_template: MessageTemplate): self.host = host self.port = port self.security = SMTP_SECURITY_TYPE_ENUM.from_string(value=security) @@ -622,10 +708,9 @@ class EmailConfig: self.from_email = from_email self.send_mass_emails = send_mass_emails self.dryrun = dryrun - self.added_template = added_template - self.removed_template = removed_template - self.updated_template = added_template # Reuse added template for updated events - self.deleted_template = removed_template # Reuse removed template for deleted events + self.new_or_added_to_event_template = new_or_added_to_event_template + self.deleted_or_removed_from_event_template = deleted_or_removed_from_event_template + self.updated_event_template = updated_event_template def __str__(self) -> str: """ @@ -639,26 +724,17 @@ class EmailConfig: def send_added_email(self, attendees: List[Attendee], event: EmailEvent) -> bool: """ - Send a notification for added attendees. + Send a notification for created events (and/or adding attendees). :param attendees: The attendees to inform. - :param event: The event the attendee is being added to. + :param event: The event being created (or the event the attendee is being added to). :return: True if the email was sent successfully, False otherwise. """ ics_attachment = ICSEmailAttachment(file_content=event.ics_content, file_name=f"{event.file_name}") - return self._prepare_and_send_email(template=self.added_template, attendees=attendees, event=event, + return self._prepare_and_send_email(template=self.new_or_added_to_event_template, attendees=attendees, + event=event, ics_attachment=ics_attachment) - def send_removed_email(self, attendees: List[Attendee], event: EmailEvent) -> bool: - """ - Send a notification for removed attendees. - :param attendees: The attendees to inform. - :param event: The event the attendee is being removed from. - :return: True if the email was sent successfully, False otherwise. - """ - return self._prepare_and_send_email(template=self.removed_template, attendees=attendees, event=event, - ics_attachment=None) - def send_updated_email(self, attendees: List[Attendee], event: EmailEvent) -> bool: """ Send a notification for updated events. @@ -668,17 +744,18 @@ class EmailConfig: """ ics_attachment = ICSEmailAttachment(file_content=event.ics_content, file_name=f"{event.file_name}") - return self._prepare_and_send_email(template=self.updated_template, attendees=attendees, event=event, + return self._prepare_and_send_email(template=self.updated_event_template, attendees=attendees, event=event, ics_attachment=ics_attachment) def send_deleted_email(self, attendees: List[Attendee], event: EmailEvent) -> bool: """ - Send a notification for deleted events. + Send a notification for deleted events (and/or removing attendees). :param attendees: The attendees to inform. - :param event: The event being deleted. + :param event: The event being deleted (or the event the attendee is being removed from). :return: True if the email was sent successfully, False otherwise. """ - return self._prepare_and_send_email(template=self.deleted_template, attendees=attendees, event=event, + return self._prepare_and_send_email(template=self.deleted_or_removed_from_event_template, attendees=attendees, + event=event, ics_attachment=None) def _prepare_and_send_email(self, template: MessageTemplate, attendees: List[Attendee], @@ -825,7 +902,6 @@ def _read_event(vobject_data: str) -> EmailEvent: class Hook(BaseHook): def __init__(self, configuration): super().__init__(configuration) - self.dryrun = self.configuration.get("hook", "dryrun") self.email_config = EmailConfig( host=self.configuration.get("hook", "smtp_server"), port=self.configuration.get("hook", "smtp_port"), @@ -836,14 +912,18 @@ class Hook(BaseHook): from_email=self.configuration.get("hook", "from_email"), send_mass_emails=self.configuration.get("hook", "mass_email"), dryrun=self.configuration.get("hook", "dryrun"), - added_template=MessageTemplate( + new_or_added_to_event_template=MessageTemplate( subject="You have been added to an event", - body=self.configuration.get("hook", "added_template") + body=self.configuration.get("hook", "new_or_added_to_event_template") ), - removed_template=MessageTemplate( - subject="You have been removed from an event", - body=self.configuration.get("hook", "removed_template") + deleted_or_removed_from_event_template=MessageTemplate( + subject="An event you were invited to has been deleted", + body=self.configuration.get("hook", "deleted_or_removed_from_event_template") ), + updated_event_template=MessageTemplate( + subject="An event you are invited to has been updated", + body=self.configuration.get("hook", "updated_event_template") + ) ) logger.info( "Email hook initialized with configuration: %s", @@ -866,7 +946,7 @@ class Hook(BaseHook): :type notification_item: HookNotificationItem :return: None """ - if self.dryrun: + if self.email_config.dryrun: logger.warning("Hook 'email': DRY-RUN received notification_item: %r", vars(notification_item)) else: logger.debug("Received notification_item: %r", vars(notification_item)) @@ -881,50 +961,122 @@ class Hook(BaseHook): return elif notification_type == HookNotificationItemTypes.UPSERT: - # Handle upsert notifications (POST request for new item and PUT for updating existing item) + # Handle upsert notifications - # We don't have access to the original content for a PUT request, just the incoming data + new_item_str: str = notification_item.new_content # type: ignore # A serialized vobject.base.Component + previous_item_str: Optional[str] = notification_item.old_content - item_str: str = notification_item.content # type: ignore # A serialized vobject.base.Component - - if not ics_contents_contains_invited_event(contents=item_str): - # If the ICS file does not contain an event, we do not send any notifications. + if not ics_contents_contains_event(contents=new_item_str): + # If ICS file does not contain an event, do not send any notifications (regardless of previous content). logger.debug("No event found in the ICS file, skipping notification.") return - email_event: EmailEvent = _read_event(vobject_data=item_str) # type: ignore + email_event: EmailEvent = _read_event(vobject_data=new_item_str) # type: ignore + if not email_event: + logger.error("Failed to read event from new content: %s", new_item_str) + return + email_event_event = email_event.event # type: ignore + if not email_event_event: + logger.error("Event could not be parsed from the new content: %s", new_item_str) + return + email_event_end_time = email_event_event.datetime_end # type: ignore + # Skip notification if the event end time is more than 1 minute in the past. + if email_event_end_time and email_event_end_time.time: + event_end = email_event_end_time.time # type: ignore + now = datetime.now( + event_end.tzinfo) if event_end.tzinfo else datetime.now() # Handle timezone-aware datetime + if event_end < (now - timedelta(minutes=1)): + logger.warning("Event end time is in the past, skipping notification for event: %s", + email_event_event.uid) + return - email_success: bool = self.email_config.send_updated_email( # type: ignore - attendees=email_event.event.attendees, - event=email_event - ) - if not email_success: - logger.error("Failed to send some or all email notifications for event: %s", email_event.event.uid) + if not previous_item_str: + # Dealing with a completely new event, no previous content to compare against. + # Email every attendee about the new event. + logger.debug("New event detected, sending notifications to all attendees.") + email_success: bool = self.email_config.send_added_email( # type: ignore + attendees=email_event.event.attendees, + event=email_event + ) + if not email_success: + logger.error("Failed to send some or all added email notifications for event: %s", + email_event.event.uid) + return + + # Dealing with an update to an existing event, compare new and previous content. + new_event: Event = read_ics_event(contents=new_item_str) # type: ignore + previous_event: Optional[Event] = read_ics_event(contents=previous_item_str) + if not previous_event: + # If we cannot parse the previous event for some reason, simply treat it as a new event. + logger.warning("Previous event content could not be parsed, treating as a new event.") + email_success: bool = self.email_config.send_added_email( # type: ignore + attendees=email_event.event.attendees, + event=email_event + ) + if not email_success: + logger.error("Failed to send some or all added email notifications for event: %s", + email_event.event.uid) + return + + # Determine added, removed, and unaltered attendees + added_attendees, removed_attendees, unaltered_attendees = determine_added_removed_and_unaltered_attendees( + original_event=previous_event, new_event=new_event) + + # Notify added attendees as "event created" + if added_attendees: + email_success: bool = self.email_config.send_added_email( # type: ignore + attendees=added_attendees, + event=email_event + ) + if not email_success: + logger.error("Failed to send some or all added email notifications for event: %s", + email_event.event.uid) + + # Notify removed attendees as "event deleted" + if removed_attendees: + email_success: bool = self.email_config.send_deleted_email( # type: ignore + attendees=removed_attendees, + event=email_event + ) + if not email_success: + logger.error("Failed to send some or all removed email notifications for event: %s", + email_event.event.uid) + + # Notify unaltered attendees as "event updated" if details other than attendees have changed + if unaltered_attendees and event_details_other_than_attendees_changed(original_event=previous_event, + new_event=new_event): + email_success: bool = self.email_config.send_updated_email( # type: ignore + attendees=unaltered_attendees, + event=email_event + ) + if not email_success: + logger.error("Failed to send some or all updated email notifications for event: %s", + email_event.event.uid) + + # Skip sending notifications to existing attendees if the only changes made to the event + # were the addition/removal of other attendees. return elif notification_type == HookNotificationItemTypes.DELETE: - # Handle delete notifications (DELETE requests) + # Handle delete notifications - # Ensure it's a delete notification, as we need the old content - if not isinstance(notification_item, DeleteHookNotificationItem): - return + deleted_item_str: str = notification_item.old_content # type: ignore # A serialized vobject.base.Component - item_str: str = notification_item.old_content # type: ignore # A serialized vobject.base.Component - - if not ics_contents_contains_invited_event(contents=item_str): + if not ics_contents_contains_event(contents=deleted_item_str): # If the ICS file does not contain an event, we do not send any notifications. logger.debug("No event found in the ICS file, skipping notification.") return - email_event: EmailEvent = _read_event(vobject_data=item_str) # type: ignore + email_event: EmailEvent = _read_event(vobject_data=deleted_item_str) # type: ignore email_success: bool = self.email_config.send_deleted_email( # type: ignore attendees=email_event.event.attendees, event=email_event ) if not email_success: - logger.error("Failed to send some or all email notifications for event: %s", email_event.event.uid) + logger.error("Failed to send some or all deleted email notifications for event: %s", + email_event.event.uid) return diff --git a/radicale/httputils.py b/radicale/httputils.py index 23f10ec1..81e01715 100644 --- a/radicale/httputils.py +++ b/radicale/httputils.py @@ -24,6 +24,7 @@ Helper functions for HTTP. """ import contextlib +import logging import os import pathlib import sys @@ -31,7 +32,7 @@ import time from http import client from typing import List, Mapping, Union, cast -from radicale import config, pathutils, types +from radicale import config, pathutils, types, utils from radicale.log import logger if sys.version_info < (3, 9): @@ -49,42 +50,42 @@ else: NOT_ALLOWED: types.WSGIResponse = ( client.FORBIDDEN, (("Content-Type", "text/plain"),), - "Access to the requested resource forbidden.") + "Access to the requested resource forbidden.", None) FORBIDDEN: types.WSGIResponse = ( client.FORBIDDEN, (("Content-Type", "text/plain"),), - "Action on the requested resource refused.") + "Action on the requested resource refused.", None) BAD_REQUEST: types.WSGIResponse = ( - client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request") + client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request", None) NOT_FOUND: types.WSGIResponse = ( client.NOT_FOUND, (("Content-Type", "text/plain"),), - "The requested resource could not be found.") + "The requested resource could not be found.", None) CONFLICT: types.WSGIResponse = ( client.CONFLICT, (("Content-Type", "text/plain"),), - "Conflict in the request.") + "Conflict in the request.", None) METHOD_NOT_ALLOWED: types.WSGIResponse = ( client.METHOD_NOT_ALLOWED, (("Content-Type", "text/plain"),), - "The method is not allowed on the requested resource.") + "The method is not allowed on the requested resource.", None) PRECONDITION_FAILED: types.WSGIResponse = ( client.PRECONDITION_FAILED, - (("Content-Type", "text/plain"),), "Precondition failed.") + (("Content-Type", "text/plain"),), "Precondition failed.", None) REQUEST_TIMEOUT: types.WSGIResponse = ( client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),), - "Connection timed out.") + "Connection timed out.", None) REQUEST_ENTITY_TOO_LARGE: types.WSGIResponse = ( client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),), - "Request body too large.") + "Request body too large.", None) REMOTE_DESTINATION: types.WSGIResponse = ( client.BAD_GATEWAY, (("Content-Type", "text/plain"),), - "Remote destination not supported.") + "Remote destination not supported.", None) DIRECTORY_LISTING: types.WSGIResponse = ( client.FORBIDDEN, (("Content-Type", "text/plain"),), - "Directory listings are not supported.") + "Directory listings are not supported.", None) INSUFFICIENT_STORAGE: types.WSGIResponse = ( client.INSUFFICIENT_STORAGE, (("Content-Type", "text/plain"),), - "Insufficient Storage. Please contact the administrator.") + "Insufficient Storage. Please contact the administrator.", None) INTERNAL_SERVER_ERROR: types.WSGIResponse = ( client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),), - "A server error occurred. Please contact the administrator.") + "A server error occurred. Please contact the administrator.", None) DAV_HEADERS: str = "1, 2, 3, calendar-access, addressbook, extended-mkcol" @@ -150,16 +151,19 @@ def read_request_body(configuration: "config.Configuration", content = decode_request(configuration, environ, read_raw_request_body(configuration, environ)) if configuration.get("logging", "request_content_on_debug"): - logger.debug("Request content:\n%s", content) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Request content (sha256sum): %s", utils.sha256_str(content)) + logger.debug("Request content:\n%s", utils.textwrap_str(content)) else: - logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug") + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug") return content def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse: return (status, {"Location": location, "Content-Type": "text/plain"}, - "Redirected to %s" % location) + "Redirected to %s" % location, None) def _serve_traversable( @@ -214,7 +218,7 @@ def _serve_traversable( # adjust on the fly default main.js of InfCloud installation logger.debug("Adjust on-the-fly default InfCloud main.js in served page: %r", path) answer = answer.replace(b"'InfCloud - the open source CalDAV/CardDAV web client'", b"'InfCloud - the open source CalDAV/CardDAV web client - served through Radicale CalDAV/CardDAV server'") - return client.OK, headers, answer + return client.OK, headers, answer, None def serve_resource( diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index a05304ff..48c0bdaa 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -3,7 +3,8 @@ # Copyright © 2008 Pascal Halter # Copyright © 2014 Jean-Marc Martins # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2024-2026 Peter Bieringer # # 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 @@ -37,7 +38,7 @@ from typing import (Any, Callable, List, MutableMapping, Optional, Sequence, import vobject from radicale import storage # noqa:F401 -from radicale import pathutils +from radicale import pathutils, utils from radicale.item import filter as radicale_filter from radicale.log import logger @@ -55,6 +56,8 @@ def read_components(s: str) -> List[vobject.base.Component]: # * 0x0A Line Feed # * 0x0D Carriage Return s = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F]', '', s) + # Workaround delete all empty lines to avoid vobject parsing errors + s = re.sub(r'(?m)^[ \t]*\r?\n', '', s) return list(vobject.readComponents(s, allowQP=True)) @@ -335,6 +338,25 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str return math.floor(start.timestamp()), math.ceil(end.timestamp()) +def verify(file: str, encoding: str): + logger.info("Verifying item: %s", file) + with open(file, "rb") as f: + content_raw = f.read() + content = content_raw.decode(encoding) + logger.info("Verifying item: %s has sha256sum %r", file, utils.sha256_bytes(content_raw)) + try: + vobject_items = read_components(content) # noqa: F841 + except Exception as e: + logger.error("Verifying item: %s problem: %s", file, e) + logger.warning("Item content:\n%s", utils.textwrap_str(content)) + logger.info("Item content (hexdump):\n%s", utils.hexdump_str(content)) + logger.info("Item content (hexdump/lines):\n%s", utils.hexdump_lines(content)) + return False + else: + logger.info("Verifying item: %s successful", file) + return True + + class Item: """Class for address book and calendar entries.""" diff --git a/radicale/item/filter.py b/radicale/item/filter.py index b846023a..a1329988 100644 --- a/radicale/item/filter.py +++ b/radicale/item/filter.py @@ -47,7 +47,7 @@ else: TRIGGER = datetime | None -def date_to_datetime(d: date) -> datetime: +def date_to_datetime(d: date, tzinfo=vobject.icalendar.utc) -> datetime: """Transform any date to a UTC datetime. If ``d`` is a datetime without timezone, return as UTC datetime. If ``d`` @@ -58,7 +58,7 @@ def date_to_datetime(d: date) -> datetime: d = datetime.combine(d, datetime.min.time()) if not d.tzinfo: # NOTE: using vobject's UTC as it wasn't playing well with datetime's. - d = d.replace(tzinfo=vobject.icalendar.utc) + d = d.replace(tzinfo=tzinfo) return d @@ -354,7 +354,10 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str, for child, is_recurrence, recurrences in get_children( vobject_item.vevent_list): # TODO: check if there's a timezone - dtstart = child.dtstart.value + try: + dtstart = child.dtstart.value + except AttributeError: + raise AttributeError("missing DTSTART") if child.rruleset: dtstarts, infinity = getrruleset(child, recurrences) @@ -366,6 +369,21 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str, dtend = getattr(child, "dtend", None) if dtend is not None: dtend = dtend.value + + # Ensure that both datetime.datetime objects have a timezone or + # both do not have one before doing calculations. This is required + # as the library does not support performing mathematical operations + # on timezone-aware and timezone-naive objects. See #1847 + if hasattr(dtstart, 'tzinfo') and hasattr(dtend, 'tzinfo'): + if dtstart.tzinfo is None and dtend.tzinfo is not None: + dtstart_orig = dtstart + dtstart = date_to_datetime(dtstart, dtend.astimezone().tzinfo) + logger.debug("TRACE/ITEM/FILTER/get_children: overtake missing tzinfo on dtstart from dtend: '%s' -> '%s'", dtstart_orig, dtstart) + elif dtstart.tzinfo is not None and dtend.tzinfo is None: + dtend_orig = dtend + dtend = date_to_datetime(dtend, dtstart.astimezone().tzinfo) + logger.debug("TRACE/ITEM/FILTER/get_children: overtake missing tzinfo on dtend from dtstart: '%s' -> '%s'", dtend_orig, dtend) + original_duration = (dtend - dtstart).total_seconds() dtend = date_to_datetime(dtend) diff --git a/radicale/pathutils.py b/radicale/pathutils.py index b204635a..3193e4a2 100644 --- a/radicale/pathutils.py +++ b/radicale/pathutils.py @@ -31,7 +31,7 @@ import threading from tempfile import TemporaryDirectory from typing import Iterator, Type, Union -from radicale import storage, types +from radicale import storage, types, utils if sys.platform == "win32": import ctypes @@ -286,9 +286,10 @@ def path_to_filesystem(root: str, sane_path: str) -> str: safe_path = os.path.join(safe_path, part) # Check for conflicting files (e.g. case-insensitive file systems # or short names on Windows file systems) - if (os.path.lexists(safe_path) and - part not in (e.name for e in os.scandir(safe_path_parent))): - raise CollidingPathError(part) + if os.path.lexists(safe_path): + with os.scandir(safe_path_parent) as entries: + if part not in (e.name for e in entries): + raise CollidingPathError(part) return safe_path @@ -320,13 +321,36 @@ def name_from_path(path: str, collection: "storage.BaseCollection") -> str: def path_permissions(path): path = pathlib.Path(path) - return [path.owner(), path.group(), path.stat().st_mode] + + try: + uid = utils.unknown_if_empty(path.stat().st_uid) + except (KeyError, NotImplementedError): + uid = "UNKNOWN" + + try: + gid = utils.unknown_if_empty(path.stat().st_gid) + except (KeyError, NotImplementedError): + gid = "UNKNOWN" + + try: + mode = utils.unknown_if_empty("%o" % path.stat().st_mode) + except (KeyError, NotImplementedError): + mode = "UNKNOWN" + + try: + owner = utils.unknown_if_empty(path.owner()) + except (KeyError, NotImplementedError): + owner = "UNKNOWN" + + try: + group = utils.unknown_if_empty(path.group()) + except (KeyError, NotImplementedError): + group = "UNKNOWN" + + return [owner, uid, group, gid, mode] def path_permissions_as_string(path): - try: - pp = path_permissions(path) - s = "path=%r owner=%s group=%s mode=%o" % (path, pp[0], pp[1], pp[2]) - except NotImplementedError: - s = "path=%r owner=UNKNOWN(unsupported on this system)" % (path) + pp = path_permissions(path) + s = "path=%r owner=%s(%s) group=%s(%s) mode=%s" % (path, pp[0], pp[1], pp[2], pp[3], pp[4]) return s diff --git a/radicale/server.py b/radicale/server.py index 55e112e2..1dc3dee4 100644 --- a/radicale/server.py +++ b/radicale/server.py @@ -339,6 +339,7 @@ def serve(configuration: config.Configuration, # Fallback to busy waiting. (select(...) blocks SIGINT on Windows.) select_timeout = 1.0 max_connections: int = configuration.get("server", "max_connections") + logger.info("Maximum parallel connections: %d", max_connections) logger.info("Radicale server ready") logger.debug("TRACE: Radicale server ready ('logging/trace_on_debug' is active)") logger.debug("TRACE/SERVER: Radicale server ready ('logging/trace_on_debug' is active - either with 'SERVER' or empty filter)") diff --git a/radicale/storage/__init__.py b/radicale/storage/__init__.py index b9a6864e..ba4d1038 100644 --- a/radicale/storage/__init__.py +++ b/radicale/storage/__init__.py @@ -27,8 +27,8 @@ Take a look at the class ``BaseCollection`` if you want to implement your own. import json import xml.etree.ElementTree as ET from hashlib import sha256 -from typing import (Callable, ContextManager, Iterable, Iterator, Mapping, - Optional, Sequence, Set, Tuple, Union, overload) +from typing import (Callable, ContextManager, Dict, Iterable, Iterator, List, + Mapping, Optional, Sequence, Set, Tuple, Union, overload) import vobject @@ -44,7 +44,8 @@ INTERNAL_TYPES: Sequence[str] = ("multifilesystem", "multifilesystem_nolock",) # NOTE: change only if cache structure is modified to avoid cache invalidation on update CACHE_VERSION_RADICALE = "3.3.1" -CACHE_VERSION: bytes = ("%s=%s;%s=%s;" % ("radicale", CACHE_VERSION_RADICALE, "vobject", utils.package_version("vobject"))).encode() +CACHE_VERSION: bytes = ( + "%s=%s;%s=%s;" % ("radicale", CACHE_VERSION_RADICALE, "vobject", utils.package_version("vobject"))).encode() def load(configuration: "config.Configuration") -> "BaseStorage": @@ -112,17 +113,18 @@ class BaseCollection: invalid. """ + def hrefs_iter() -> Iterator[str]: for item in self.get_all(): assert item.href yield item.href + token = "http://radicale.org/ns/sync/%s" % self.etag.strip("\"") if old_token: raise ValueError("Sync token are not supported") return token, hrefs_iter() - def get_multi(self, hrefs: Iterable[str] - ) -> Iterable[Tuple[str, Optional["radicale_item.Item"]]]: + def get_multi(self, hrefs: Iterable[str]) -> Iterable[Tuple[str, Optional["radicale_item.Item"]]]: """Fetch multiple items. It's not required to return the requested items in the correct order. @@ -175,8 +177,11 @@ class BaseCollection: return False def upload(self, href: str, item: "radicale_item.Item") -> ( - "radicale_item.Item"): - """Upload a new or replace an existing item.""" + Tuple)["radicale_item.Item", Optional["radicale_item.Item"]]: + """Upload a new or replace an existing item. + + Return the uploaded item and the old item if it was replaced. + """ raise NotImplementedError def delete(self, href: Optional[str] = None) -> None: @@ -188,10 +193,12 @@ class BaseCollection: raise NotImplementedError @overload - def get_meta(self, key: None = None) -> Mapping[str, str]: ... + def get_meta(self, key: None = None) -> Mapping[str, str]: + ... @overload - def get_meta(self, key: str) -> Optional[str]: ... + def get_meta(self, key: str) -> Optional[str]: + ... def get_meta(self, key: Optional[str] = None ) -> Union[Mapping[str, str], Optional[str]]: @@ -293,8 +300,7 @@ class BaseStorage: def discover( self, path: str, depth: str = "0", - child_context_manager: Optional[ - Callable[[str, Optional[str]], ContextManager[None]]] = None, + child_context_manager: Optional[Callable[[str, Optional[str]], ContextManager[None]]] = None, user_groups: Set[str] = set([])) -> Iterable["types.CollectionOrItem"]: """Discover a list of collections under the given ``path``. @@ -328,7 +334,8 @@ class BaseStorage: def create_collection( self, href: str, items: Optional[Iterable["radicale_item.Item"]] = None, - props: Optional[Mapping[str, str]] = None) -> BaseCollection: + props: Optional[Mapping[str, str]] = None) -> ( + Tuple)[BaseCollection, Dict[str, "radicale_item.Item"], List[str]]: """Create a collection. ``href`` is the sanitized path. @@ -348,7 +355,7 @@ class BaseStorage: raise NotImplementedError @types.contextmanager - def acquire_lock(self, mode: str, user: str = "") -> Iterator[None]: + def acquire_lock(self, mode: str, user: str = "", *args, **kwargs) -> Iterator[None]: """Set a context manager to lock the whole storage. ``mode`` must either be "r" for shared access or "w" for exclusive diff --git a/radicale/storage/multifilesystem/create_collection.py b/radicale/storage/multifilesystem/create_collection.py index cbbdee53..71aca377 100644 --- a/radicale/storage/multifilesystem/create_collection.py +++ b/radicale/storage/multifilesystem/create_collection.py @@ -19,7 +19,7 @@ import os from tempfile import TemporaryDirectory -from typing import Iterable, Optional, cast +from typing import Dict, Iterable, List, Optional, Tuple, cast import radicale.item as radicale_item from radicale import pathutils @@ -30,9 +30,37 @@ from radicale.storage.multifilesystem.base import StorageBase class StoragePartCreateCollection(StorageBase): + def _discover_existing_items_pre_overwrite(self, + tmp_collection: "multifilesystem.Collection", + dst_path: str) -> Tuple[Dict[str, radicale_item.Item], List[str]]: + """Discover existing items in the collection before overwriting them.""" + existing_items = {} + new_item_hrefs = [] + + existing_collection = self._collection_class( + cast(multifilesystem.Storage, self), + pathutils.unstrip_path(dst_path, True)) + existing_item_hrefs = set(existing_collection._list()) + tmp_collection_hrefs = set(tmp_collection._list()) + for item_href in tmp_collection_hrefs: + if item_href not in existing_item_hrefs: + # Item in temporary collection does not exist in the existing collection (is new) + new_item_hrefs.append(item_href) + continue + # Item exists in both collections, grab the existing item for reference + try: + item = existing_collection._get(item_href, verify_href=False) + if item is not None: + existing_items[item_href] = item + except Exception: + # TODO: Log exception? + continue + + return existing_items, new_item_hrefs + def create_collection(self, href: str, items: Optional[Iterable[radicale_item.Item]] = None, - props=None) -> "multifilesystem.Collection": + props=None) -> Tuple["multifilesystem.Collection", Dict[str, radicale_item.Item], List[str]]: folder = self._get_collection_root_folder() # Path should already be sanitized @@ -44,11 +72,14 @@ class StoragePartCreateCollection(StorageBase): self._makedirs_synced(filesystem_path) return self._collection_class( cast(multifilesystem.Storage, self), - pathutils.unstrip_path(sane_path, True)) + pathutils.unstrip_path(sane_path, True)), {}, [] parent_dir = os.path.dirname(filesystem_path) self._makedirs_synced(parent_dir) + replaced_items: Dict[str, radicale_item.Item] = {} + new_item_hrefs: List[str] = [] + # Create a temporary directory with an unsafe name try: with TemporaryDirectory(prefix=".Radicale.tmp-", dir=parent_dir @@ -68,14 +99,20 @@ class StoragePartCreateCollection(StorageBase): col._upload_all_nonatomic(items, suffix=".vcf") if os.path.lexists(filesystem_path): + replaced_items, new_item_hrefs = self._discover_existing_items_pre_overwrite( + tmp_collection=col, + dst_path=sane_path) pathutils.rename_exchange(tmp_filesystem_path, filesystem_path) else: + # If the destination path does not exist, obviously all items are new + new_item_hrefs = list(col._list()) os.rename(tmp_filesystem_path, filesystem_path) self._sync_directory(parent_dir) except Exception as e: raise ValueError("Failed to create collection %r as %r %s" % (href, filesystem_path, e)) from e + # TODO: Return new-old pairs and just-new items (new vs updated) return self._collection_class( cast(multifilesystem.Storage, self), - pathutils.unstrip_path(sane_path, True)) + pathutils.unstrip_path(sane_path, True)), replaced_items, new_item_hrefs diff --git a/radicale/storage/multifilesystem/get.py b/radicale/storage/multifilesystem/get.py index f74c8fb6..ce162d3a 100644 --- a/radicale/storage/multifilesystem/get.py +++ b/radicale/storage/multifilesystem/get.py @@ -68,8 +68,21 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock, else: path = os.path.join(self._filesystem_path, href) try: - with open(path, "rb") as f: - raw_text = f.read() + if self._storage._use_mtime_and_size_for_item_cache is True: + # try to avoid "open" + if not os.path.isfile(path): + if not os.path.exists(path): + raise FileNotFoundError(path) + if os.path.isdir(path): + raise IsADirectoryError(path) + if not os.access(path, os.R_OK): + raise PermissionError(path) + else: + with open(path, "rb") as f: + # early read of the content + if self._storage._debug_cache_actions is True: + logger.debug("Item cache early read: %r", path) + raw_text = f.read() except (FileNotFoundError, IsADirectoryError): return None except PermissionError: @@ -100,6 +113,12 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock, # Check if another process created the file in the meantime cache_content = self._load_item_cache(href, cache_hash) if cache_content is None: + if self._storage._use_mtime_and_size_for_item_cache is True: + # late read of the content + if self._storage._debug_cache_actions is True: + logger.debug("Item cache late read : %r", path) + with open(path, "rb") as f: + raw_text = f.read() try: vobject_items = radicale_item.read_components( raw_text.decode(self._encoding)) diff --git a/radicale/storage/multifilesystem/upload.py b/radicale/storage/multifilesystem/upload.py index 3814f428..674477c7 100644 --- a/radicale/storage/multifilesystem/upload.py +++ b/radicale/storage/multifilesystem/upload.py @@ -21,7 +21,7 @@ import errno import os import pickle import sys -from typing import Iterable, Iterator, TextIO, cast +from typing import Iterable, Iterator, Optional, TextIO, Tuple, cast import radicale.item as radicale_item from radicale import pathutils @@ -36,10 +36,11 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache, CollectionPartHistory, CollectionBase): def upload(self, href: str, item: radicale_item.Item - ) -> radicale_item.Item: + ) -> Tuple[radicale_item.Item, Optional[radicale_item.Item]]: if not pathutils.is_safe_filesystem_path_component(href): raise pathutils.UnsafePathError(href) path = pathutils.path_to_filesystem(self._filesystem_path, href) + old_item = self._get(href, verify_href=False) try: with self._atomic_write(path, newline="") as fo: # type: ignore f = cast(TextIO, fo) @@ -67,7 +68,7 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache, uploaded_item = self._get(href, verify_href=False) if uploaded_item is None: raise RuntimeError("Storage modified externally") - return uploaded_item + return uploaded_item, old_item def _upload_all_nonatomic(self, items: Iterable[radicale_item.Item], suffix: str = "") -> None: diff --git a/radicale/tests/__init__.py b/radicale/tests/__init__.py index e5ecb1f9..5b637159 100644 --- a/radicale/tests/__init__.py +++ b/radicale/tests/__init__.py @@ -1,6 +1,7 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2012-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2023 Unrud +# Copyright © 2024-2026 Peter Bieringer # # 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 @@ -22,6 +23,8 @@ Tests for Radicale. import base64 import logging +import os +import platform import shutil import sys import tempfile @@ -35,7 +38,7 @@ import defusedxml.ElementTree as DefusedET import vobject import radicale -from radicale import app, config, types, xmlutils +from radicale import app, config, types, utils, xmlutils RESPONSES = Dict[str, Union[int, Dict[str, Tuple[int, ET.Element]], vobject.base.Component]] @@ -51,6 +54,11 @@ class BaseTest: application: app.Application def setup_method(self) -> None: + if os.environ.get("PYTHONPATH"): + info = "with PYTHONPATH=%r " % os.environ.get("PYTHONPATH") + else: + info = "" + logging.info("Testing Radicale %s(%s) as %s on %s", info, utils.packages_version(), utils.user_groups_as_string(), platform.platform()) self.configuration = config.load() self.colpath = tempfile.mkdtemp() self.configure({ @@ -75,6 +83,12 @@ class BaseTest: if login is not None and not isinstance(login, str): raise TypeError("login argument must be %r, not %r" % (str, type(login))) + http_if_match = kwargs.pop("http_if_match", None) + if http_if_match is not None and not isinstance(http_if_match, str): + raise TypeError("http_if_match argument must be %r, not %r" % + (str, type(http_if_match))) + remote_useragent = kwargs.pop("remote_useragent", None) + remote_host = kwargs.pop("remote_host", None) environ: Dict[str, Any] = {k.upper(): v for k, v in kwargs.items()} for k, v in environ.items(): if not isinstance(v, str): @@ -84,6 +98,12 @@ class BaseTest: if login: environ["HTTP_AUTHORIZATION"] = "Basic " + base64.b64encode( login.encode(encoding)).decode() + if http_if_match: + environ["HTTP_IF_MATCH"] = http_if_match + if remote_useragent: + environ["HTTP_USER_AGENT"] = remote_useragent + if remote_host: + environ["REMOTE_ADDR"] = remote_host environ["REQUEST_METHOD"] = method.upper() environ["PATH_INFO"] = path if data is not None: diff --git a/radicale/tests/custom/web.py b/radicale/tests/custom/web.py index 695bbe81..ee8bc6e6 100644 --- a/radicale/tests/custom/web.py +++ b/radicale/tests/custom/web.py @@ -1,5 +1,6 @@ # This file is part of Radicale - CalDAV and CardDAV server -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2021 Unrud +# Copyright © 2025-2025 Peter Bieringer # # 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 @@ -28,9 +29,9 @@ class Web(web.BaseWeb): def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: - return client.OK, {"Content-Type": "text/plain"}, "custom" + return client.OK, {"Content-Type": "text/plain"}, "custom", None def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: content = httputils.read_request_body(self.configuration, environ) - return client.OK, {"Content-Type": "text/plain"}, "echo:" + content + return client.OK, {"Content-Type": "text/plain"}, "echo:" + content, None diff --git a/radicale/tests/static/broken-vcards.vcf b/radicale/tests/static/broken-vcards.vcf new file mode 100644 index 00000000..19bd670b --- /dev/null +++ b/radicale/tests/static/broken-vcards.vcf @@ -0,0 +1,16 @@ +BEGIN:VCARD +VERSION:3.0 +PRODID:-//Inverse inc.//SOGo Connector 1.0//EN +UID:C68582D2-2E60-0001-C2C0-000000000000.vcf +X-MOZILLA-HTML:FALSE +EMAIL;TYPE=work:test-misses-N-or-FN@example.com +X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf +END:VCARD +BEGIN:VCARD +VERSION:3.0 +PRODID:-//Inverse inc.//SOGo Connector 1.0//EN +UID:C68582D2-2E60-0001-C2C0-000000000001.vcf +X-MOZILLA-HTML:FALSE +EMAIL;TYPE=work:test-misses-N-or-FN@example1.com +X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000001.vcf +END:VCARD diff --git a/radicale/tests/static/broken-vcards2-no_uid.vcf b/radicale/tests/static/broken-vcards2-no_uid.vcf new file mode 100644 index 00000000..76a3dfb6 --- /dev/null +++ b/radicale/tests/static/broken-vcards2-no_uid.vcf @@ -0,0 +1,16 @@ +BEGIN:VCARD +VERSION:3.0 +PRODID:-//Inverse inc.//SOGo Connector 1.0//EN +UID:C68582D2-2E60-0001-C2C0-000000000000.vcf +X-MOZILLA-HTML:FALSE +EMAIL;TYPE=work:test-misses-N-or-FN@example.com +FN:Test Example +X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf +END:VCARD +BEGIN:VCARD +VERSION:3.0 +PRODID:-//Inverse inc.//SOGo Connector 1.0//EN +X-MOZILLA-HTML:FALSE +EMAIL;TYPE=work:test-misses-N-or-FN@example1.com +X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000001.vcf +END:VCARD diff --git a/radicale/tests/static/broken-vcards2.vcf b/radicale/tests/static/broken-vcards2.vcf new file mode 100644 index 00000000..ca5fa3ac --- /dev/null +++ b/radicale/tests/static/broken-vcards2.vcf @@ -0,0 +1,17 @@ +BEGIN:VCARD +VERSION:3.0 +PRODID:-//Inverse inc.//SOGo Connector 1.0//EN +UID:C68582D2-2E60-0001-C2C0-000000000000.vcf +X-MOZILLA-HTML:FALSE +EMAIL;TYPE=work:test-misses-N-or-FN@example.com +FN:Test Example +X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf +END:VCARD +BEGIN:VCARD +VERSION:3.0 +PRODID:-//Inverse inc.//SOGo Connector 1.0//EN +UID:C68582D2-2E60-0001-C2C0-000000000001.vcf +X-MOZILLA-HTML:FALSE +EMAIL;TYPE=work:test-misses-N-or-FN@example1.com +X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000001.vcf +END:VCARD diff --git a/radicale/tests/static/broken-vevents.ics b/radicale/tests/static/broken-vevents.ics new file mode 100644 index 00000000..bbfbcd0b --- /dev/null +++ b/radicale/tests/static/broken-vevents.ics @@ -0,0 +1,25 @@ +BEGIN:VCALENDAR +PRODID:-//Radicale//NONSGML Radicale Server//EN +VERSION:2.0 +BEGIN:VEVENT +CREATED:20160725T060147Z +LAST-MODIFIED:20160727T193435Z +DTSTAMP:20160727T193435Z +UID:040000008200E00074C5B7101A82E00800000000 +SUMMARY:Good ICS +STATUS:CONFIRMED +X-MOZ-LASTACK:20160727T193435Z +DTSTART;TZID=Europe/Budapest:20160727T170000 +DTEND;TZID=Europe/Budapest:20160727T223000 +CLASS:PUBLIC +X-LIC-ERROR:No value for LOCATION property. Removing entire property: +END:VEVENT +BEGIN:VEVENT +CREATED:20160725T060147Z +LAST-MODIFIED:20160727T193435Z +DTSTAMP:20160727T193435Z +UID:040000008200E00074C5B7101A82E00800000001 +CLASS:PUBLIC +X-LIC-ERROR:No value for LOCATION property. Removing entire property: +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/contact1_v4.vcf b/radicale/tests/static/contact1_v4.vcf new file mode 100644 index 00000000..5ddb2312 --- /dev/null +++ b/radicale/tests/static/contact1_v4.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:4.0 +UID:contact1 +N:Contact;;;; +FN:Contact +NICKNAME:test +END:VCARD diff --git a/radicale/tests/static/contact_multiple_v4.vcf b/radicale/tests/static/contact_multiple_v4.vcf new file mode 100644 index 00000000..e153ba52 --- /dev/null +++ b/radicale/tests/static/contact_multiple_v4.vcf @@ -0,0 +1,12 @@ +BEGIN:VCARD +VERSION:4.0 +UID:contact1 +N:Contact1;;;; +FN:Contact1 +END:VCARD +BEGIN:VCARD +VERSION:4.0 +UID:contact2 +N:Contact2;;;; +FN:Contact2 +END:VCARD diff --git a/radicale/tests/static/contact_photo_with_data_uri_v4.vcf b/radicale/tests/static/contact_photo_with_data_uri_v4.vcf new file mode 100644 index 00000000..18a2dad3 --- /dev/null +++ b/radicale/tests/static/contact_photo_with_data_uri_v4.vcf @@ -0,0 +1,8 @@ +BEGIN:VCARD +VERSION:4.0 +UID:contact +N:Contact;;;; +FN:Contact +NICKNAME:test +PHOTO:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAD0lEQVQIHQEEAPv/AP///wX+Av4DfRnGAAAAAElFTkSuQmCC +END:VCARD diff --git a/radicale/tests/static/event_issue1812_getetag.ics b/radicale/tests/static/event_issue1812_getetag.ics new file mode 100644 index 00000000..8b9936eb --- /dev/null +++ b/radicale/tests/static/event_issue1812_getetag.ics @@ -0,0 +1,30 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN +BEGIN:VTIMEZONE +TZID:Europe/Paris +LAST-MODIFIED:20250523T094234Z +BEGIN:STANDARD +DTSTART:19701025T030000Z +RRULE:BYDAY=-1SU;BYMONTH=10;FREQ=YEARLY +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19700329T020000Z +RRULE:BYDAY=-1SU;BYMONTH=3;FREQ=YEARLY +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:070a3478-4411-4364-844d-26f3542fc364 +DTSTART;TZID=Europe/Paris;VALUE=DATE:20250716 +DTEND;TZID=Europe/Paris;VALUE=DATE:20250717 +DTSTAMP;VALUE=DATE-TIME:20250723T080354Z +SEQUENCE:1 +SUMMARY:Filtered event +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/event_issue1847_1.ics b/radicale/tests/static/event_issue1847_1.ics new file mode 100644 index 00000000..121c0c6c --- /dev/null +++ b/radicale/tests/static/event_issue1847_1.ics @@ -0,0 +1,14 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN +BEGIN:VEVENT +CREATED:20250814T153429Z +LAST-MODIFIED:20250814T153503Z +DTSTAMP:20250814T153503Z +UID:f91964cb-53ca-4942-8811-c38f076f4328 +SUMMARY:error +DTSTART:20250814T180000 +DTEND;TZID=Europe/Brussels:20250814T190000 +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/event_issue1847_2.ics b/radicale/tests/static/event_issue1847_2.ics new file mode 100644 index 00000000..03d09b49 --- /dev/null +++ b/radicale/tests/static/event_issue1847_2.ics @@ -0,0 +1,14 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN +BEGIN:VEVENT +CREATED:20250814T153429Z +LAST-MODIFIED:20250814T153503Z +DTSTAMP:20250814T153503Z +UID:f91964cb-53ca-4942-8811-c38f076f4328 +SUMMARY:error +DTSTART;TZID=Europe/Brussels:20250814T180000 +DTEND:20250814T190000 +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/event_issue1880_1.ics b/radicale/tests/static/event_issue1880_1.ics new file mode 100644 index 00000000..74484364 --- /dev/null +++ b/radicale/tests/static/event_issue1880_1.ics @@ -0,0 +1,29 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN +BEGIN:VTIMEZONE +TZID:Europe/Paris +LAST-MODIFIED:20250523T094234Z +BEGIN:STANDARD +DTSTART:19701025T030000Z +RRULE:BYDAY=-1SU;BYMONTH=10;FREQ=YEARLY +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19700329T020000Z +RRULE:BYDAY=-1SU;BYMONTH=3;FREQ=YEARLY +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:f5b69821-addc-4010-9ab8-891df1c33c01 +DTSTART;TZID=Europe/Paris;VALUE=DATE-TIME:20250925T093000 +DTEND;TZID=Europe/Paris;VALUE=DATE-TIME:20250925T140000 +DTSTAMP:20250923T114003Z +SUMMARY:event from opencalendar +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/event_issue1880_2.ics b/radicale/tests/static/event_issue1880_2.ics new file mode 100644 index 00000000..791f3d2e --- /dev/null +++ b/radicale/tests/static/event_issue1880_2.ics @@ -0,0 +1,396 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +BEGIN:VTIMEZONE +TZID:Europe/Paris +BEGIN:STANDARD +DTSTART:19110311T000000 +RDATE:19110311T000000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+000921 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19161002T000000 +RDATE:19161002T000000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19171008T000000 +RDATE:19171008T000000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19181007T000000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1MO;UNTIL=19191006T000000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19201024T000000 +RDATE:19201024T000000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19211026T000000 +RDATE:19211026T000000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19221008T000000 +RDATE:19221008T000000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19231007T000000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19321002T000000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19331008T000000 +RDATE:19331008T000000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19341007T000000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19381002T000000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19391119T000000 +RDATE:19391119T000000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19421102T030000 +RDATE:19421102T030000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19431004T030000 +RDATE:19431004T030000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19450916T030000 +RDATE:19450916T030000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19760926T010000 +RDATE:19760926T010000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19770925T030000 +RDATE:19770925T030000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19781001T030000 +RDATE:19781001T030000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19790930T030000 +RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19950924T030000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19961027T030000 +RDATE:19961027T030000 +TZNAME:Europe/Paris(STD) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19971026T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +TZNAME:(STD) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19160614T230000 +RDATE:19160614T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19170324T230000 +RDATE:19170324T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19180309T230000 +RDATE:19180309T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19190301T230000 +RDATE:19190301T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19200214T230000 +RDATE:19200214T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19210314T230000 +RDATE:19210314T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19220325T230000 +RDATE:19220325T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19230526T230000 +RDATE:19230526T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19240329T230000 +RDATE:19240329T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19250404T230000 +RDATE:19250404T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19260417T230000 +RDATE:19260417T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19270409T230000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=2SA;UNTIL=19280414T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19290420T230000 +RDATE:19290420T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19300412T230000 +RDATE:19300412T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19310418T230000 +RDATE:19310418T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19320402T230000 +RDATE:19320402T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19330325T230000 +RDATE:19330325T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19340407T230000 +RDATE:19340407T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19350330T230000 +RDATE:19350330T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19360418T230000 +RDATE:19360418T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19370403T230000 +RDATE:19370403T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19380326T230000 +RDATE:19380326T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19390415T230000 +RDATE:19390415T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19400225T020000 +RDATE:19400225T020000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19400614T230000 +RDATE:19400614T230000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19430329T020000 +RDATE:19430329T020000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19440403T020000 +RDATE:19440403T020000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19440825T000000 +RDATE:19440825T000000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+020000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19441008T010000 +RDATE:19441008T010000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19450402T020000 +RDATE:19450402T020000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19760328T010000 +RDATE:19760328T010000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19770403T020000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=19800406T020000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19810329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19960331T020000 +TZNAME:Europe/Paris(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19970330T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +TZNAME:(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +X-TZINFO:Europe/Paris[2024a] +END:VTIMEZONE +BEGIN:VEVENT +UID:50c08af4-295c-4bea-9ea4-7402b8e82143 +DTSTART;TZID=Europe/Paris:20250924T133000 +DTEND;TZID=Europe/Paris:20250924T143000 +CREATED:20250923T113902Z +DTSTAMP:20250923T113912Z +LAST-MODIFIED:20250923T113912Z +SUMMARY:event from thunderbird +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/event_issue1970_ok.ics b/radicale/tests/static/event_issue1970_ok.ics new file mode 100644 index 00000000..89725608 --- /dev/null +++ b/radicale/tests/static/event_issue1970_ok.ics @@ -0,0 +1,38 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Paris +X-LIC-LOCATION:Europe/Paris +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20130902T150157Z +LAST-MODIFIED:20130902T150158Z +DTSTAMP:20130902T150158Z +UID:event1 +SUMMARY:Event +CATEGORIES:some_category1,another_category2 +DESCRIPTION:Line1 + Line2 + Line3 +ORGANIZER:mailto:unclesam@example.com +ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com +ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com +DTSTART;TZID=Europe/Paris:20130901T180000 +DTEND;TZID=Europe/Paris:20130901T190000 +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/event_issue1970_problem.ics b/radicale/tests/static/event_issue1970_problem.ics new file mode 100644 index 00000000..a1b75e6e --- /dev/null +++ b/radicale/tests/static/event_issue1970_problem.ics @@ -0,0 +1,39 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Paris +X-LIC-LOCATION:Europe/Paris +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20130902T150157Z +LAST-MODIFIED:20130902T150158Z +DTSTAMP:20130902T150158Z +UID:event1 +SUMMARY:Event having description with empty line +CATEGORIES:some_category1,another_category2 +DESCRIPTION:Line1 + Line2 + + Line4 +ORGANIZER:mailto:unclesam@example.com +ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com +ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com +DTSTART;TZID=Europe/Paris:20130901T180000 +DTEND;TZID=Europe/Paris:20130901T190000 +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/event_multiple3.ics b/radicale/tests/static/event_multiple3.ics new file mode 100644 index 00000000..c8275933 --- /dev/null +++ b/radicale/tests/static/event_multiple3.ics @@ -0,0 +1,40 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Paris +X-LIC-LOCATION:Europe/Paris +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +UID:event +SUMMARY:Event +DTSTART;TZID=Europe/Paris:20130901T190000 +DTEND;TZID=Europe/Paris:20130901T200000 +END:VEVENT +BEGIN:VTODO +UID:todo +DTSTART;TZID=Europe/Paris:20130901T220000 +DURATION:PT1H +SUMMARY:Todo +END:VTODO +BEGIN:VEVENT +UID:event2 +SUMMARY:Event-with-longer-description +DTSTART;TZID=Europe/Paris:20130901T190000 +DTEND;TZID=Europe/Paris:20130901T200000 +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/test_auth.py b/radicale/tests/test_auth.py index 88cd3ea4..daf735aa 100644 --- a/radicale/tests/test_auth.py +++ b/radicale/tests/test_auth.py @@ -2,7 +2,7 @@ # Copyright © 2012-2016 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2022 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # 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 @@ -30,7 +30,7 @@ from typing import Iterable, Tuple, Union import pytest -from radicale import xmlutils +from radicale import utils, xmlutils from radicale.tests import BaseTest @@ -114,45 +114,60 @@ class TestBaseAuthRequests(BaseTest): def test_htpasswd_sha256_autodetect(self) -> None: self._test_htpasswd("autodetect", "tmp:$5$i4Ni4TQq6L5FKss5$ilpTjkmnxkwZeV35GB9cYSsDXTALBn6KtWRJAzNlCL/") + def test_htpasswd_sha256_autodetect_with_rounds(self) -> None: + self._test_htpasswd("autodetect", "tmp:$5$rounds=2500$9QD/kpJlV71PCXWy$/AbUzxa6kjDWHJ8BLU1hyQUBN/8wsGEf.rNjuKDHA24") + def test_htpasswd_sha512(self) -> None: self._test_htpasswd("sha512", "tmp:$6$3Qhl8r6FLagYdHYa$UCH9yXCed4A.J9FQsFPYAOXImzZUMfvLa0lwcWOxWYLOF5sE/lF99auQ4jKvHY2vijxmefl7G6kMqZ8JPdhIJ/") def test_htpasswd_sha512_autodetect(self) -> None: self._test_htpasswd("autodetect", "tmp:$6$3Qhl8r6FLagYdHYa$UCH9yXCed4A.J9FQsFPYAOXImzZUMfvLa0lwcWOxWYLOF5sE/lF99auQ4jKvHY2vijxmefl7G6kMqZ8JPdhIJ/") + def test_htpasswd_sha512_autodetect_with_rounds(self) -> None: + self._test_htpasswd("autodetect", "tmp:$6$rounds=2500$A1H/cZUl3CBnsplz$bSKYCDQ/YGR..YhxaZcM1eKmAi/jlnpbENKU8a.9kE95JBIpyUss3.cUyss0xQnhjD4PReN4sAzmdziWmoCsg/") + @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") + @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_2a(self) -> None: self._test_htpasswd("bcrypt", "tmp:$2a$10$Mj4A9vMecAp/K7.0fMKoVOk1SjgR.RBhl06a52nvzXhxlT3HB7Reu") - @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") + @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed or incompatibe") + @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_2a_autodetect(self) -> None: self._test_htpasswd("autodetect", "tmp:$2a$10$Mj4A9vMecAp/K7.0fMKoVOk1SjgR.RBhl06a52nvzXhxlT3HB7Reu") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") + @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_2b(self) -> None: self._test_htpasswd("bcrypt", "tmp:$2b$12$7a4z/fdmXlBIfkz0smvzW.1Nds8wpgC/bo2DVOb4OSQKWCDL1A1wu") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") + @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_2b_autodetect(self) -> None: self._test_htpasswd("autodetect", "tmp:$2b$12$7a4z/fdmXlBIfkz0smvzW.1Nds8wpgC/bo2DVOb4OSQKWCDL1A1wu") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") + @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_2y(self) -> None: self._test_htpasswd("bcrypt", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") + @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_2y_autodetect(self) -> None: self._test_htpasswd("autodetect", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") + @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_C10(self) -> None: self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") + @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_C10_autodetect(self) -> None: self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a") @pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed") + @pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module") def test_htpasswd_bcrypt_unicode(self) -> None: self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK6U9Sqlzr.W1mMVCS8wJUftnW", "unicode") @@ -263,6 +278,23 @@ class TestBaseAuthRequests(BaseTest): href_element = prop.find(xmlutils.make_clark("D:href")) assert href_element is not None and href_element.text == "/test/" + def test_http_remote_user(self) -> None: + self.configure({"auth": {"type": "http_remote_user"}}) + _, responses = self.propfind("/", """\ + + + + + +""", HTTP_REMOTE_USER="test") + assert responses is not None + response = responses["/"] + assert not isinstance(response, int) + status, prop = response["D:current-user-principal"] + assert status == 200 + href_element = prop.find(xmlutils.make_clark("D:href")) + assert href_element is not None and href_element.text == "/test/" + def test_http_x_remote_user(self) -> None: self.configure({"auth": {"type": "http_x_remote_user"}}) _, responses = self.propfind("/", """\ @@ -282,13 +314,23 @@ class TestBaseAuthRequests(BaseTest): @pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows") def _test_dovecot( - self, user, password, expected_status, - response=b'FAIL\n1\n', mech=[b'PLAIN'], broken=None): + self, user, password, expected_status, expected_rip=None, + response=b'FAIL\t1', mech=[b'PLAIN'], broken=None, + extra_config=None, extra_env=None): import socket from unittest.mock import DEFAULT, patch - self.configure({"auth": {"type": "dovecot", - "dovecot_socket": "./dovecot.sock"}}) + if extra_env is None: + extra_env = {} + if extra_config is None: + extra_config = {} + + config = {"auth": {"type": "dovecot", + "dovecot_socket": "./dovecot.sock"}} + for toplvl, entries in extra_config.items(): + for key, val in entries.items(): + config[toplvl][key] = val + self.configure(config) if broken is None: broken = [] @@ -311,10 +353,18 @@ class TestBaseAuthRequests(BaseTest): if "done" not in broken: handshake += b'DONE\n' + sent_rip = None + + def record_sent_data(s, data, flags=None): + nonlocal sent_rip + if b'\trip=' in data: + sent_rip = data.split(b'\trip=')[1].split(b'\t')[0] + return len(data) + with patch.multiple( 'socket.socket', connect=DEFAULT, - send=DEFAULT, + send=record_sent_data, recv=DEFAULT ) as mock_socket: if "socket" in broken: @@ -325,7 +375,9 @@ class TestBaseAuthRequests(BaseTest): status, _, answer = self.request( "PROPFIND", "/", HTTP_AUTHORIZATION="Basic %s" % base64.b64encode( - ("%s:%s" % (user, password)).encode()).decode()) + ("%s:%s" % (user, password)).encode()).decode(), + **extra_env) + assert sent_rip == expected_rip assert status == expected_status @pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows") @@ -392,6 +444,36 @@ class TestBaseAuthRequests(BaseTest): def test_dovecot_auth_id_mismatch(self): self._test_dovecot("user", "password", 401, response=b'OK\t2') + @pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows") + def test_dovecot_remote_addr(self): + self._test_dovecot("user", "password", 401, expected_rip=b'172.17.16.15', + extra_env={ + 'REMOTE_ADDR': '172.17.16.15', + 'HTTP_X_REMOTE_ADDR': '127.0.0.1', + }) + + @pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows") + def test_dovecot_x_remote_addr(self): + self._test_dovecot("user", "password", 401, expected_rip=b'172.17.16.15', + extra_env={ + 'REMOTE_ADDR': '127.0.0.1', + 'HTTP_X_REMOTE_ADDR': '172.17.16.15', + }, + extra_config={ + 'auth': {"remote_ip_source": "X-Remote-Addr"}, + }) + + @pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows") + def test_dovecot_x_remote_addr_whitespace(self): + self._test_dovecot("user", "password", 401, expected_rip=b'172.17.16.15rip=127.0.0.1', + extra_env={ + 'REMOTE_ADDR': '127.0.0.1', + 'HTTP_X_REMOTE_ADDR': '172.17.16.15\trip=127.0.0.1', + }, + extra_config={ + 'auth': {"remote_ip_source": "X-Remote-Addr"}, + }) + def test_custom(self) -> None: """Custom authentication.""" self.configure({"auth": {"type": "radicale.tests.custom.auth"}}) diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index eb25bd1f..f50cf0be 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -1,7 +1,7 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2022 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # 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 @@ -24,12 +24,14 @@ Radicale tests with simple requests. import logging import os import posixpath +import urllib from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple import defusedxml.ElementTree as DefusedET +import pytest import vobject -from radicale import storage, xmlutils +from radicale import storage, utils, xmlutils from radicale.tests import RESPONSES, BaseTest from radicale.tests.helpers import get_file_content @@ -142,6 +144,64 @@ permissions: RrWw""") assert "Event" in answer assert "UID:event" in answer + def test_add_event_with_desc_ok(self) -> None: + """Add an event.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_issue1970_ok.ics") + path = "/calendar.ics/event_issue1970_ok.ics" + self.put(path, event) + _, headers, answer = self.request("GET", path, check=200) + assert "ETag" in headers + assert headers["Content-Type"] == "text/calendar; charset=utf-8" + assert "DESCRIPTION" in answer + assert "VEVENT" in answer + assert "Event" in answer + assert "UID:event" in answer + + def test_add_event_with_desc_problem(self) -> None: + """Add an event.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_issue1970_problem.ics") + path = "/calendar.ics/event_issue1970_problem.ics" + self.put(path, event) + _, headers, answer = self.request("GET", path, check=200) + assert "ETag" in headers + assert headers["Content-Type"] == "text/calendar; charset=utf-8" + assert "DESCRIPTION" in answer + assert "VEVENT" in answer + assert "Event" in answer + assert "UID:event" in answer + + def test_add_event_exceed_size(self) -> None: + """Add an event which is exceeding max-resource-size.""" + self.configure({"server": {"max_resource_size": 20}}) + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + path = "/calendar.ics/event1.ics" + self.put(path, event, check=412) + + def test_add_events_exceed_size(self) -> None: + """Add multipe events where last is exceeding max-resource-size.""" + self.configure({"server": {"max_resource_size": 603}}) + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_multiple3.ics") + path = "/calendar.ics/" + self.put(path, event, check=412) + + def test_add_event_broken(self) -> None: + """Add a broken event.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("broken-vevent.ics") + path = "/calendar.ics/broken-vevent.ics" + self.put(path, event, check=400) + + def test_add_events_broken2(self) -> None: + """Add a broken event (2nd one is broken).""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("broken-vevents.ics") + path = "/calendar.ics/" + self.put(path, event, check=400) + def test_add_event_without_uid(self) -> None: """Add an event without UID.""" self.mkcalendar("/calendar.ics/") @@ -201,6 +261,34 @@ permissions: RrWw""") _, answer = self.get(path) assert "UID:contact1" in answer + def test_add_contact_broken(self) -> None: + """Add a broken contact.""" + self.create_addressbook("/contacts.vcf/") + contact = get_file_content("broken-vcard.vcf") + path = "/contacts.vcf/broken-vcards.vcf" + self.put(path, contact, check=400) + + def test_add_contacts_broken(self) -> None: + """Add broken contacts.""" + self.create_addressbook("/contacts.vcf/") + contact = get_file_content("broken-vcards.vcf") + path = "/contacts.vcf/" + self.put(path, contact, check=400) + + def test_add_contacts_broken2(self) -> None: + """Add broken contacts (only 2nd one is broken).""" + self.create_addressbook("/contacts.vcf/") + contact = get_file_content("broken-vcards2.vcf") + path = "/contacts.vcf/" + self.put(path, contact, check=400) + + def test_add_contacts_broken2_no_uid(self) -> None: + """Add broken contacts (only 2nd one is broken and has no UID).""" + self.create_addressbook("/contacts.vcf/") + contact = get_file_content("broken-vcards2-no_uid.vcf") + path = "/contacts.vcf/" + self.put(path, contact, check=400) + def test_add_contact_photo_with_data_uri(self) -> None: """Test workaround for broken PHOTO data from InfCloud""" self.create_addressbook("/contacts.vcf/") @@ -216,6 +304,48 @@ permissions: RrWw""") path = "/contacts.vcf/contact.vcf" self.put(path, contact, check=400) + def test_add_contact_v3(self) -> None: + """Add a vCard 3.0 contact.""" + self.create_addressbook("/contacts.vcf/") + contact = get_file_content("contact1.vcf") + path = "/contacts.vcf/contact.vcf" + self.put(path, contact) + _, headers, answer = self.request("GET", path, check=200) + assert "ETag" in headers + assert headers["Content-Type"] == "text/vcard; charset=utf-8" + assert "VCARD" in answer + assert "UID:contact1" in answer + assert "VERSION:3.0" in answer + + @pytest.mark.skipif(not utils.vobject_supports_vcard4(), + reason="vobject < 1.0.0 does not support vCard 4.0") + def test_add_contact_v4(self) -> None: + """Add a vCard 4.0 contact (requires vobject >= 1.0.0).""" + self.create_addressbook("/contacts.vcf/") + contact = get_file_content("contact1_v4.vcf") + path = "/contacts.vcf/contact.vcf" + self.put(path, contact) + _, headers, answer = self.request("GET", path, check=200) + assert "ETag" in headers + assert headers["Content-Type"] == "text/vcard; charset=utf-8" + assert "VCARD" in answer + assert "UID:contact1" in answer + assert "VERSION:4.0" in answer + + def test_add_contact_photo_with_data_uri_v3(self) -> None: + """Test vCard 3.0 PHOTO format""" + self.create_addressbook("/contacts.vcf/") + contact = get_file_content("contact_photo_with_data_uri.vcf") + self.put("/contacts.vcf/contact.vcf", contact) + + @pytest.mark.skipif(not utils.vobject_supports_vcard4(), + reason="vobject < 1.0.0 does not support vCard 4.0") + def test_add_contact_photo_with_data_uri_v4(self) -> None: + """Test vCard 4.0 PHOTO data URI format (requires vobject >= 1.0.0)""" + self.create_addressbook("/contacts.vcf/") + contact = get_file_content("contact_photo_with_data_uri_v4.vcf") + self.put("/contacts.vcf/contact.vcf", contact) + def test_update_event(self) -> None: """Update an event.""" self.mkcalendar("/calendar.ics/") @@ -229,6 +359,56 @@ permissions: RrWw""") _, answer = self.get(path) assert "DTSTAMP:20130902T150159Z" in answer + def test_update_event_no_etag_strict_preconditions_true(self) -> None: + """Update an event without serving etag having strict_preconditions enabled (Precondition Failed).""" + self.configure({"storage": {"strict_preconditions": True}}) + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + event_modified = get_file_content("event1_modified.ics") + path = "/calendar.ics/event1.ics" + self.put(path, event, check=201) + self.put(path, event_modified, check=412) + + def test_update_event_with_etag_strict_preconditions_true(self) -> None: + """Update an event with serving equal etag having strict_preconditions enabled (OK).""" + self.configure({"storage": {"strict_preconditions": True}}) + self.configure({"logging": {"response_content_on_debug": True}}) + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + event_modified = get_file_content("event1_modified.ics") + path = "/calendar.ics/event1.ics" + self.put(path, event, check=201) + # get etag + _, responses = self.report("/calendar.ics/", """\ + + + + + +""") + assert len(responses) == 1 + response = responses["/calendar.ics/event1.ics"] + assert not isinstance(response, int) + status, prop = response["D:getetag"] + assert status == 200 and prop.text + self.put(path, event_modified, check=204, http_if_match=prop.text) + + def test_update_event_with_etag_mismatch(self) -> None: + """Update an event with serving mismatch etag (Precondition Failed).""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + event_modified = get_file_content("event1_modified.ics") + path = "/calendar.ics/event1.ics" + self.put(path, event, check=201) + self.put(path, event_modified, check=412, http_if_match="0000") + + def test_add_event_with_etag(self) -> None: + """Add an event with serving etag (Precondition Failed).""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + path = "/calendar.ics/event1.ics" + self.put(path, event, check=412, http_if_match="0000") + def test_update_event_uid_event(self) -> None: """Update an event with a different UID.""" self.mkcalendar("/calendar.ics/") @@ -306,6 +486,22 @@ permissions: RrWw""") for uid2 in uids[i + 1:]: assert uid1 != uid2 + def test_add_event_tz_dtend_only(self) -> None: + """Add an event having TZ only on DTEND.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_issue1847_1.ics") + path = "/calendar.ics/event_issue1847_1.ics" + self.put(path, event) + _, headers, answer = self.request("GET", path, check=200) + + def test_add_event_tz_dtstart_only(self) -> None: + """Add an event having TZ only on DTSTART.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_issue1847_2.ics") + path = "/calendar.ics/event_issue1847_2.ics" + self.put(path, event) + _, headers, answer = self.request("GET", path, check=200) + def test_verify(self) -> None: """Verify the storage.""" contacts = get_file_content("contact_multiple.vcf") @@ -401,6 +597,33 @@ permissions: RrWw""") self.get(path1, check=404) self.get(path2) + def test_move_between_collections_with_at_native(self) -> None: + """Move a item.""" + self.mkcalendar("/calendar1@domain.ics/") + self.mkcalendar("/calendar2@domain.ics/") + event = get_file_content("event1.ics") + path1 = "/calendar1@domain.ics/event1.ics" + path2 = "/calendar2@domain.ics/event2.ics" + self.put(path1, event) + self.request("MOVE", path1, check=201, + HTTP_DESTINATION="http://127.0.0.1/"+path2) + self.get(path1, check=404) + self.get(path2) + + def test_move_between_collections_with_at_encoded(self) -> None: + """Move a item.""" + self.mkcalendar("/calendar1@domain.ics/") + self.mkcalendar("/calendar2@domain.ics/") + event = get_file_content("event1.ics") + path1 = "/calendar1@domain.ics/event1.ics" + path2 = "/calendar2@domain.ics/event2.ics" + path2_encoded = urllib.parse.quote(path2) + self.put(path1, event) + self.request("MOVE", path1, check=201, + HTTP_DESTINATION="http://127.0.0.1/"+path2_encoded) + self.get(path1, check=404) + self.get(path2) + def test_move_between_collections_duplicate_uid(self) -> None: """Move a item to a collection which already contains the UID.""" self.mkcalendar("/calendar1.ics/") @@ -568,11 +791,13 @@ permissions: RrWw""") assert not isinstance(response, int) status, prop = response["D:sync-token"] assert status == 200 and prop.text + assert "C:max-resource-size" not in response _, responses = self.propfind("/calendar.ics/event.ics", propfind) response = responses["/calendar.ics/event.ics"] assert not isinstance(response, int) status, prop = response["D:getetag"] assert status == 200 and prop.text + assert "C:max-resource-size" not in response def test_propfind_nonexistent(self) -> None: """Read a property that does not exist.""" @@ -584,6 +809,87 @@ permissions: RrWw""") status, prop = response["ICAL:calendar-color"] assert status == 404 and not prop.text + def test_propfind_max_resource_size(self) -> None: + """Read property C:max-resource-size""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + self.put("/calendar.ics/event.ics", event) + _, responses = self.propfind("/calendar.ics/", """\ + + + + + + """) + response = responses["/calendar.ics/"] + assert not isinstance(response, int) + status, prop = response["C:max-resource-size"] + assert status == 200 and prop.text + + def test_propfind_getctag(self) -> None: + """Read property CS:getctag""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + self.put("/calendar.ics/event.ics", event) + _, responses = self.propfind("/calendar.ics/", """\ + + + + + +""") + response = responses["/calendar.ics/"] + assert not isinstance(response, int) + status, prop = response["CS:getctag"] + assert status == 200 and prop.text + + def test_propfind_supported_address_data(self) -> None: + """Read property CR:supported-address-data on addressbook""" + self.create_addressbook("/addressbook.vcf/") + contact = get_file_content("contact1.vcf") + self.put("/addressbook.vcf/contact.vcf", contact) + _, responses = self.propfind("/addressbook.vcf/", """\ + + + + + +""") + response = responses["/addressbook.vcf/"] + assert not isinstance(response, int) + status, prop = response["CR:supported-address-data"] + assert status == 200 + # Should have at least one address-data-type element + address_data_types = prop.findall( + xmlutils.make_clark("CR:address-data-type")) + assert len(address_data_types) >= 1 + # Check that 3.0 is always supported + versions = [e.get("version") for e in address_data_types] + assert "3.0" in versions + # Check content-type is text/vcard for all + for e in address_data_types: + assert e.get("content-type") == "text/vcard" + # If vobject >= 1.0.0, should also support 4.0 + if utils.vobject_supports_vcard4(): + assert "4.0" in versions + # vCard 4.0 should be listed first (preferred) + assert versions[0] == "4.0" + + def test_propfind_supported_address_data_on_calendar(self) -> None: + """Read property CR:supported-address-data on calendar (should 404)""" + self.mkcalendar("/calendar.ics/") + _, responses = self.propfind("/calendar.ics/", """\ + + + + + +""") + response = responses["/calendar.ics/"] + assert not isinstance(response, int) + status, prop = response["CR:supported-address-data"] + assert status == 404 + def test_proppatch(self) -> None: """Set/Remove a property and read it back.""" self.mkcalendar("/calendar.ics/") @@ -1621,7 +1927,7 @@ permissions: RrWw""") """, 400, is_xml=False) def _report_sync_token( - self, calendar_path: str, sync_token: Optional[str] = None + self, calendar_path: str, sync_token: Optional[str] = None, **kwargs ) -> Tuple[str, RESPONSES]: sync_token_xml = ( "" % sync_token @@ -1633,7 +1939,7 @@ permissions: RrWw""") %s -""" % sync_token_xml) +""" % sync_token_xml, **kwargs) xml = DefusedET.fromstring(answer) if status in (403, 409): assert xml.tag == xmlutils.make_clark("D:error") @@ -1781,6 +2087,15 @@ permissions: RrWw""") calendar_path, "http://radicale.org/ns/sync/INVALID") assert not sync_token + def test_report_sync_collection_invalid_sync_token_with_user(self) -> None: + """Test sync-collection report with an invalid sync token and user+host+useragent""" + self.configure({"auth": {"type": "none"}}) + calendar_path = "/calendar.ics/" + self.mkcalendar(calendar_path) + sync_token, _ = self._report_sync_token( + calendar_path, "http://radicale.org/ns/sync/INVALID", login="testuser:", remote_host="192.0.2.1", remote_useragent="Testclient/1.0") + assert not sync_token + def test_propfind_sync_token(self) -> None: """Retrieve the sync-token with a propfind request""" calendar_path = "/calendar.ics/" diff --git a/radicale/tests/test_expand.py b/radicale/tests/test_expand.py index 2cc4a49f..9783abeb 100644 --- a/radicale/tests/test_expand.py +++ b/radicale/tests/test_expand.py @@ -3,6 +3,7 @@ # Copyright © 2017-2019 Unrud # Copyright © 2024 Pieter Hijma # Copyright © 2025 David Greaves +# Copyright © 2025 Peter Bieringer # # 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 @@ -512,3 +513,188 @@ permissions: RrWw""") status, event2_calendar_data = responses["/test/event2.ics"]["C:calendar-data"] assert event2_calendar_data.text assert "UID:c6be8b2c-3d72-453c-b698-4f25cdf1569e" in event2_calendar_data.text + + def test_report_getetag_expand_filter(self) -> None: + """Test getetag with time-range filter and expand (example from #1880).""" + self.mkcalendar("/test/") + self.put("/test/event_issue1880_1.ics", get_file_content("event_issue1880_1.ics")) + self.put("/test/event_issue1880_2.ics", get_file_content("event_issue1880_2.ics")) + + request = """ + + + + + + + + + + + + + + + """ + status, responses = self.report("/test", request) + assert status == 207 + assert len(responses) == 2 + assert isinstance(responses["/test/event_issue1880_1.ics"], dict) + assert isinstance(responses["/test/event_issue1880_2.ics"], dict) + assert "D:getetag" in responses["/test/event_issue1880_1.ics"] + assert "D:getetag" in responses["/test/event_issue1880_2.ics"] + + def test_report_getetag_expand_filter_positive1(self) -> None: + """Test getetag with time-range filter and expand (not applicable), should return as matching filter range (example from #1812).""" + self.mkcalendar("/test/") + self.put("/test/event_issue1812_getetag.ics", get_file_content("event_issue1812_getetag.ics")) + + request = """ + + + + + + + + + + + + + + + """ + status, responses = self.report("/test", request) + assert status == 207 + assert len(responses) == 1 + assert isinstance(responses["/test/event_issue1812_getetag.ics"], dict) + assert "D:getetag" in responses["/test/event_issue1812_getetag.ics"] + + def test_report_getetag_expand_filter_positive2(self) -> None: + """Test getetag with time-range filter and expand, should return as matching filter range (example from #1812).""" + self.mkcalendar("/test/") + self.put("/test/event_issue1812.ics", get_file_content("event_issue1812.ics")) + + request = """ + + + + + + + + + + + + + + + """ + status, responses = self.report("/test", request) + assert status == 207 + assert len(responses) == 1 + assert isinstance(responses["/test/event_issue1812.ics"], dict) + assert "D:getetag" in responses["/test/event_issue1812.ics"] + + def test_report_getetag_expand_filter_negative1(self) -> None: + """Test getetag with time-range filter and expand, should not return anything (example from #1812).""" + self.mkcalendar("/test/") + self.put("/test/event_issue1812_getetag.ics", get_file_content("event_issue1812_getetag.ics")) + + request = """ + + + + + + + + + + + + + + + """ + status, responses = self.report("/test", request) + assert status == 207 + assert len(responses) == 0 + + def test_report_getetag_expand_filter_negative2(self) -> None: + """Test getetag with time-range filter and expand, should not return anything (example from #1812).""" + self.mkcalendar("/test/") + self.put("/test/event_issue1812_getetag.ics", get_file_content("event_issue1812_getetag.ics")) + + request = """ + + + + + + + + + + + + + + + + """ + status, responses = self.report("/test", request) + assert status == 207 + assert len(responses) == 0 + + def test_report_getetag_expand_filter_negative3(self) -> None: + """Test getetag with time-range filter and expand, should not return anything (example from #1812).""" + self.mkcalendar("/test/") + self.put("/test/event_issue1812_getetag.ics", get_file_content("event_issue1812_getetag.ics")) + + request = """ + + + + + + + + + + + + + + + """ + status, responses = self.report("/test", request) + assert status == 207 + assert len(responses) == 0 + + def test_report_getetag_expand_filter_negative4(self) -> None: + """Test getetag with time-range filter and expand, nothing returned as filter is not matching (example from #1812).""" + self.mkcalendar("/test/") + self.put("/test/event_issue1812.ics", get_file_content("event_issue1812.ics")) + + request = """ + + + + + + + + + + + + + + + """ + status, responses = self.report("/test", request) + assert status == 207 + assert len(responses) == 0 diff --git a/radicale/tests/test_hook_email.py b/radicale/tests/test_hook_email.py index 74674589..b7fef935 100644 --- a/radicale/tests/test_hook_email.py +++ b/radicale/tests/test_hook_email.py @@ -21,6 +21,8 @@ Radicale tests related to hook 'email' import logging import os +import re +from datetime import datetime, timedelta from radicale.tests import BaseTest from radicale.tests.helpers import get_file_content @@ -63,11 +65,26 @@ permissions: RrWw""") self.configure({"hook": {"type": "email", "dryrun": "True"}}) - def test_add_event(self, caplog) -> None: + def _future_date_timestamp(self) -> str: + """Return a date timestamp for a future date.""" + future_date = datetime.now() + timedelta(days=1) + return future_date.strftime("%Y%m%dT%H%M%S") + + def _past_date_timestamp(self) -> str: + past_date = datetime.now() - timedelta(days=1) + return past_date.strftime("%Y%m%dT%H%M%S") + + def _replace_end_date_in_event(self, event: str, new_date: str) -> str: + """Replace the end date in an event string.""" + return re.sub(r"DTEND;TZID=Europe/Paris:\d{8}T\d{6}", + f"DTEND;TZID=Europe/Paris:{new_date}", event) + + def test_add_event_with_future_end_date(self, caplog) -> None: caplog.set_level(logging.WARNING) """Add an event.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") + event = self._replace_end_date_in_event(event, self._future_date_timestamp()) path = "/calendar.ics/event1.ics" self.put(path, event) _, headers, answer = self.request("GET", path, check=200) @@ -76,35 +93,68 @@ permissions: RrWw""") assert "VEVENT" in answer assert "Event" in answer assert "UID:event" in answer - found = 0 - for line in caplog.messages: - if line.find("notification_item: {'type': 'upsert'") != -1: - found = found | 1 - if line.find("to_addresses=['janedoe@example.com']") != -1: - found = found | 2 - if line.find("to_addresses=['johndoe@example.com']") != -1: - found = found | 4 - if (found != 7): - raise ValueError("Logging misses expected log lines, found=%d", found) - def test_delete_event(self, caplog) -> None: + logs = caplog.messages + # Should have a log saying the notification item was received + assert len([log for log in logs if "received notification_item: {'type': 'upsert'," in log]) == 1 + # Should NOT have a log saying that no email is sent (email won't actually be sent due to dryrun) + assert len([log for log in logs if "skipping notification for event: event1" in log]) == 0 + + def test_add_event_with_past_end_date(self, caplog) -> None: + caplog.set_level(logging.WARNING) + """Add an event.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + event = self._replace_end_date_in_event(event, self._past_date_timestamp()) + path = "/calendar.ics/event1.ics" + self.put(path, event) + _, headers, answer = self.request("GET", path, check=200) + assert "ETag" in headers + assert headers["Content-Type"] == "text/calendar; charset=utf-8" + assert "VEVENT" in answer + assert "Event" in answer + assert "UID:event" in answer + + logs = caplog.messages + # Should have a log saying the notification item was received + assert len([log for log in logs if "received notification_item: {'type': 'upsert'," in log]) == 1 + # Should have a log saying that no email is sent due to past end date + assert len([log for log in logs if "Event end time is in the past, skipping notification for event: event1" in log]) == 1 + + def test_delete_event_with_future_end_date(self, caplog) -> None: caplog.set_level(logging.WARNING) """Delete an event.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") + event = self._replace_end_date_in_event(event, self._future_date_timestamp()) path = "/calendar.ics/event1.ics" self.put(path, event) _, responses = self.delete(path) assert responses[path] == 200 _, answer = self.get("/calendar.ics/") assert "VEVENT" not in answer - found = 0 - for line in caplog.messages: - if line.find("notification_item: {'type': 'delete'") != -1: - found = found | 1 - if line.find("to_addresses=['janedoe@example.com']") != -1: - found = found | 2 - if line.find("to_addresses=['johndoe@example.com']") != -1: - found = found | 4 - if (found != 7): - raise ValueError("Logging misses expected log lines, found=%d", found) + + logs = caplog.messages + # Should have a log saying the notification item was received + assert len([log for log in logs if "received notification_item: {'type': 'delete'," in log]) == 1 + # Should NOT have a log saying that no email is sent (email won't actually be sent due to dryrun) + assert len([log for log in logs if "skipping notification for event: event1" in log]) == 0 + + def test_delete_event_with_past_end_date(self, caplog) -> None: + caplog.set_level(logging.WARNING) + """Delete an event.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + event = self._replace_end_date_in_event(event, self._past_date_timestamp()) + path = "/calendar.ics/event1.ics" + self.put(path, event) + _, responses = self.delete(path) + assert responses[path] == 200 + _, answer = self.get("/calendar.ics/") + assert "VEVENT" not in answer + + logs = caplog.messages + # Should have a log saying the notification item was received + assert len([log for log in logs if "received notification_item: {'type': 'delete'," in log]) == 1 + # Should have a log saying that no email is sent due to past end date + assert len([log for log in logs if "Event end time is in the past, skipping notification for event: event1" in log]) == 1 diff --git a/radicale/tests/test_hook_rabbitmq.py b/radicale/tests/test_hook_rabbitmq.py index 42cedfce..80abb55c 100644 --- a/radicale/tests/test_hook_rabbitmq.py +++ b/radicale/tests/test_hook_rabbitmq.py @@ -22,6 +22,8 @@ Radicale tests related to hook 'rabbitmq' import logging import os +import pytest + from radicale.tests import BaseTest from radicale.tests.helpers import get_file_content @@ -29,6 +31,14 @@ from radicale.tests.helpers import get_file_content class TestHooks(BaseTest): """Tests with hooks.""" + # test for available pika module + try: + import pika + except ImportError: + has_pika = 0 + else: + has_pika = 1 + def setup_method(self) -> None: BaseTest.setup_method(self) rights_file_path = os.path.join(self.colpath, "rights") @@ -63,6 +73,7 @@ permissions: RrWw""") self.configure({"hook": {"type": "rabbitmq", "dryrun": "True"}}) + @pytest.mark.skipif(has_pika == 0, reason="No pika module installed") def test_add_event(self, caplog) -> None: caplog.set_level(logging.WARNING) """Add an event.""" @@ -83,6 +94,7 @@ permissions: RrWw""") if (found is False): raise ValueError("Logging misses expected log line") + @pytest.mark.skipif(has_pika == 0, reason="No pika module installed") def test_delete_event(self, caplog) -> None: caplog.set_level(logging.WARNING) """Delete an event.""" diff --git a/radicale/tests/test_pathutils.py b/radicale/tests/test_pathutils.py new file mode 100644 index 00000000..ebe92de8 --- /dev/null +++ b/radicale/tests/test_pathutils.py @@ -0,0 +1,91 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2025 Tobias Brox +# +# 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library 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 Radicale. If not, see . + +""" +Tests for pathutils module. + +""" + +import gc +import os +import tempfile + +import pytest + +from radicale import pathutils + + +class TestPathToFilesystem: + """Tests for path_to_filesystem function.""" + + @pytest.mark.filterwarnings("error::ResourceWarning") + @pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning") + def test_scandir_iterator_closed(self) -> None: + """Verify that os.scandir iterator is properly closed. + + This test catches ResourceWarning: unclosed scandir iterator + which occurs when os.scandir() is used without a context manager. + See: https://github.com/Kozea/Radicale/issues/1972 + + The ResourceWarning is emitted during garbage collection when an + unclosed scandir iterator is finalized. We use pytest.mark.filterwarnings + to convert both ResourceWarning and PytestUnraisableExceptionWarning + to errors. + """ + with tempfile.TemporaryDirectory() as tmpdir: + # Create a subdirectory so path_to_filesystem has something + # to scan (the scandir check is for case-insensitive filesystems) + subdir = os.path.join(tmpdir, "testdir") + os.makedirs(subdir) + + # Call path_to_filesystem - if scandir iterator is not closed, + # a ResourceWarning will be emitted during garbage collection + result = pathutils.path_to_filesystem(tmpdir, "testdir") + assert result == subdir + + # Force garbage collection to trigger any ResourceWarning + # from unclosed iterators + gc.collect() + + def test_path_to_filesystem_basic(self) -> None: + """Test basic path_to_filesystem functionality.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Test empty path + result = pathutils.path_to_filesystem(tmpdir, "") + assert result == tmpdir + + # Test single component + subdir = os.path.join(tmpdir, "test") + os.makedirs(subdir) + result = pathutils.path_to_filesystem(tmpdir, "test") + assert result == subdir + + # Test nested path + nested = os.path.join(subdir, "nested") + os.makedirs(nested) + result = pathutils.path_to_filesystem(tmpdir, "test/nested") + assert result == nested + + def test_unsafe_path_raises(self) -> None: + """Test that unsafe path components raise UnsafePathError.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Hidden files (starting with .) are not safe + with pytest.raises(pathutils.UnsafePathError): + pathutils.path_to_filesystem(tmpdir, ".hidden") + + # Backup files (ending with ~) are not safe + with pytest.raises(pathutils.UnsafePathError): + pathutils.path_to_filesystem(tmpdir, "backup~") diff --git a/radicale/types.py b/radicale/types.py index 6899a755..175869d7 100644 --- a/radicale/types.py +++ b/radicale/types.py @@ -1,5 +1,6 @@ # This file is part of Radicale - CalDAV and CardDAV server -# Copyright © 2020 Unrud +# Copyright © 2020-2023 Unrud +# Copyright © 2024-2025 Peter Bieringer # # 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 @@ -20,7 +21,7 @@ from typing import (Any, Callable, ContextManager, Iterator, List, Mapping, runtime_checkable) WSGIResponseHeaders = Union[Mapping[str, str], Sequence[Tuple[str, str]]] -WSGIResponse = Tuple[int, WSGIResponseHeaders, Union[None, str, bytes]] +WSGIResponse = Tuple[int, WSGIResponseHeaders, Union[None, str, bytes], Union[None, str]] WSGIEnviron = Mapping[str, Any] WSGIStartResponse = Callable[[str, List[Tuple[str, str]]], Any] diff --git a/radicale/utils.py b/radicale/utils.py index 096864b6..54b80913 100644 --- a/radicale/utils.py +++ b/radicale/utils.py @@ -2,7 +2,7 @@ # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2018 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # 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 @@ -21,9 +21,14 @@ import datetime import os import ssl import sys +import textwrap +from hashlib import sha256 from importlib import import_module, metadata +from string import ascii_letters, digits, punctuation from typing import Callable, Sequence, Tuple, Type, TypeVar, Union +from packaging.version import Version + from radicale import config from radicale.log import logger @@ -47,8 +52,18 @@ ADDRESS_TYPE = Union[Tuple[Union[str, bytes, bytearray], int], Tuple[str, int, int, int]] -# Max YEAR in datetime in unixtime +# Max/Min YEAR in datetime in unixtime DATETIME_MAX_UNIXTIME: int = (datetime.MAXYEAR - 1970) * 365 * 24 * 60 * 60 +DATETIME_MIN_UNIXTIME: int = (datetime.MINYEAR - 1970) * 365 * 24 * 60 * 60 + + +# Number units +UNIT_g: int = (1000 * 1000 * 1000) +UNIT_m: int = (1000 * 1000) +UNIT_k: int = (1000) +UNIT_G: int = (1024 * 1024 * 1024) +UNIT_M: int = (1024 * 1024) +UNIT_K: int = (1024) def load_plugin(internal_types: Sequence[str], module_name: str, @@ -72,9 +87,48 @@ def load_plugin(internal_types: Sequence[str], module_name: str, def package_version(name): + if name == "passlib": + # passlib(libpass) requires special handling as module name is unchanged, but metadata has new name + import passlib + return passlib.__version__ return metadata.version(name) +def vobject_supports_vcard4() -> bool: + """Check if vobject supports vCard 4.0 (requires version >= 1.0.0).""" + try: + version = package_version("vobject") + parts = version.split(".") + major = int(parts[0]) + return major >= 1 + except Exception: + return False + + +def passlib_libpass_supports_bcrypt() -> Tuple[bool, str]: + """Check if passlib(libpass) version supports bcrypt version.""" + info = "" + try: + version_bcrypt = package_version("bcrypt") + version_bcrypt_check = "5.0.0" + version_passlib = package_version("passlib") + version_passlib_check = "1.9.3" + if Version(version_bcrypt) >= Version(version_bcrypt_check): + # bcrypt >= 5.0.0 has issues with passlib(libpass) < 1.9.3 + if Version(version_passlib) < Version(version_passlib_check): + info = "bcrypt module version %r >= %r and passlib(libpass) module version %r < %r found => incompatible, downgrade bcrypt or upgrade passlib(libpass)" % (version_bcrypt, version_bcrypt_check, version_passlib, version_passlib_check) + return (False, info) + else: + info = "bcrypt module version %r >= %r and passlib(libpass) module version %r >= %r found => ok" % (version_bcrypt, version_bcrypt_check, version_passlib, version_passlib_check) + return (True, info) + else: + info = "bcrypt module version %r < %r and passlib(libpass) module version %r found => ok" % (version_bcrypt, version_bcrypt_check, version_passlib) + return (True, info) + except Exception: + info = "bcrypt module version or passlib(libpass) module version %r not found => problem" + return (False, info) + + def packages_version(): versions = [] versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])) @@ -226,25 +280,49 @@ def ssl_get_protocols(context): return protocols +def unknown_if_empty(value): + if value == "": + return "UNKNOWN" + else: + return value + + def user_groups_as_string(): if sys.platform != "win32": euid = os.geteuid() - egid = os.getegid() try: username = pwd.getpwuid(euid)[0] + user = "%s(%d)" % (unknown_if_empty(username), euid) except Exception: # name of user not found - s = "user=(%d) group=(%d)" % (euid, egid) - return s - gids = os.getgrouplist(username, egid) + user = "UNKNOWN(%d)" % euid + + egid = os.getegid() groups = [] - for gid in gids: + try: + gids = os.getgrouplist(username, egid) + for gid in gids: + try: + gi = grp.getgrgid(gid) + groups.append("%s(%d)" % (unknown_if_empty(gi.gr_name), gid)) + except Exception: + groups.append("UNKNOWN(%d)" % gid) + except Exception: try: - gi = grp.getgrgid(gid) - groups.append("%s(%d)" % (gi.gr_name, gid)) + groups.append("%s(%d)" % (grp.getgrnam(egid)[0], egid)) except Exception: - groups.append("%s(%d)" % (gid, gid)) - s = "user=%s(%d) groups=%s" % (username, euid, ','.join(groups)) + # workaround to get groupid by name + groups_all = grp.getgrall() + found = False + for entry in groups_all: + if entry[2] == egid: + groups.append("%s(%d)" % (unknown_if_empty(entry[0]), egid)) + found = True + break + if not found: + groups.append("UNKNOWN(%d)" % egid) + + s = "user=%s groups=%s" % (user, ','.join(groups)) else: username = os.getlogin() s = "user=%s" % (username) @@ -255,12 +333,175 @@ def format_ut(unixtime: int) -> str: if sys.platform == "win32": # TODO check how to support this better return str(unixtime) - if unixtime < DATETIME_MAX_UNIXTIME: + if unixtime <= DATETIME_MIN_UNIXTIME: + r = str(unixtime) + "(<=MIN:" + str(DATETIME_MIN_UNIXTIME) + ")" + elif unixtime >= DATETIME_MAX_UNIXTIME: + r = str(unixtime) + "(>=MAX:" + str(DATETIME_MAX_UNIXTIME) + ")" + else: if sys.version_info < (3, 11): dt = datetime.datetime.utcfromtimestamp(unixtime) else: dt = datetime.datetime.fromtimestamp(unixtime, datetime.UTC) r = str(unixtime) + "(" + dt.strftime('%Y-%m-%dT%H:%M:%SZ') + ")" - else: - r = str(unixtime) + "(>MAX:" + str(DATETIME_MAX_UNIXTIME) + ")" return r + + +def format_unit(value: float, binary: bool = False) -> str: + if binary: + if value > UNIT_G: + value = value / UNIT_G + unit = "G" + elif value > UNIT_M: + value = value / UNIT_M + unit = "M" + elif value > UNIT_K: + value = value / UNIT_K + unit = "K" + else: + unit = "" + else: + if value > UNIT_g: + value = value / UNIT_g + unit = "g" + elif value > UNIT_m: + value = value / UNIT_m + unit = "m" + elif value > UNIT_k: + value = value / UNIT_k + unit = "k" + else: + unit = "" + return ("%.1f %s" % (value, unit)) + + +def limit_str(content: str, limit: int) -> str: + length = len(content) + if limit > 0 and length >= limit: + return content[:limit] + ("...(shortened because original length %d > limit %d)" % (length, limit)) + else: + return content + + +def textwrap_str(content: str, limit: int = 2000) -> str: + # TODO: add support for config option and prefix + return textwrap.indent(limit_str(content, limit), " ", lambda line: True) + + +def dataToHex(data, count): + result = '' + for item in range(count): + if ((item > 0) and ((item % 8) == 0)): + result += ' ' + if (item < len(data)): + result += '%02x' % data[item] + ' ' + else: + result += ' ' + return result + + +def dataToAscii(data, count): + result = '' + for item in range(count): + if (item < len(data)): + char = chr(data[item]) + if char in ascii_letters or \ + char in digits or \ + char in punctuation or \ + char == ' ': + result += char + else: + result += '.' + return result + + +def dataToSpecial(data, count): + result = '' + for item in range(count): + if (item < len(data)): + char = chr(data[item]) + if char == '\r': + result += 'C' + elif char == '\n': + result += 'L' + elif (ord(char) & 0xf8) == 0xf0: # assuming UTF-8 + result += '4' + elif (ord(char) & 0xf0) == 0xf0: # assuming UTF-8 + result += '3' + elif (ord(char) & 0xe0) == 0xe0: # assuming UTF-8 + result += '2' + else: + result += '.' + return result + + +def hexdump_str(content: str, limit: int = 2000) -> str: + result = "Hexdump of string: index | | |\n" + index = 0 + size = 16 + bytestring = content.encode("utf-8") # assuming UTF-8 + length = len(bytestring) + + while (index < length) and (index < limit): + data = bytestring[index:index+size] + hex = dataToHex(data, size) + ascii = dataToAscii(data, size) + special = dataToSpecial(data, size) + result += '%08x ' % index + result += hex + result += '|' + result += '%-16s' % ascii + result += '|' + result += '%-16s' % special + result += '|' + result += '\n' + index += size + + return result + + +def hexdump_line(line: str, limit: int = 200) -> str: + result = "" + length_str = len(line) + bytestring = line.encode("utf-8") # assuming UTF-8 + length = len(bytestring) + size = length + if (size > limit): + size = limit + + hex = dataToHex(bytestring, size) + ascii = dataToAscii(bytestring, size) + special = dataToSpecial(bytestring, size) + result += '%3d/%3d' % (length_str, length) + result += ': ' + result += hex + result += '|' + result += ascii + result += '|' + result += special + result += '|' + result += '\n' + + return result + + +def hexdump_lines(lines: str, limit: int = 200) -> str: + result = "Hexdump of lines: nr chars/bytes: | | |\n" + counter = 0 + for line in lines.splitlines(True): + result += '% 4d ' % counter + result += hexdump_line(line) + counter += 1 + + return result + + +def sha256_str(content: str) -> str: + _hash = sha256() + _hash.update(content.encode("utf-8")) # assuming UTF-8 + return _hash.hexdigest() + + +def sha256_bytes(content: bytes) -> str: + _hash = sha256() + _hash.update(content) + return _hash.hexdigest() diff --git a/radicale/web/none.py b/radicale/web/none.py index 263992ec..59cc341e 100644 --- a/radicale/web/none.py +++ b/radicale/web/none.py @@ -1,5 +1,6 @@ # This file is part of Radicale - CalDAV and CardDAV server -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2025-2025 Peter Bieringer # # 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 @@ -32,4 +33,4 @@ class Web(web.BaseWeb): assert pathutils.sanitize_path(path) == path if path != "/.web": return httputils.redirect(base_prefix + "/.web") - return client.OK, {"Content-Type": "text/plain"}, "Radicale works!" + return client.OK, {"Content-Type": "text/plain"}, "Radicale works!", None diff --git a/radicale/xmlutils.py b/radicale/xmlutils.py index 4b9c51bf..4c31bbb1 100644 --- a/radicale/xmlutils.py +++ b/radicale/xmlutils.py @@ -2,7 +2,8 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2015 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2021 Unrud +# Copyright © 2025-2025 Peter Bieringer # # 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 @@ -26,7 +27,7 @@ import copy import xml.etree.ElementTree as ET from collections import OrderedDict from http import client -from typing import Dict, Mapping, Optional +from typing import Dict, Mapping, Optional, Union from urllib.parse import quote from radicale import item, pathutils @@ -56,7 +57,7 @@ for short, url in NAMESPACES.items(): ET.register_namespace("" if short == "D" else short, url) -def pretty_xml(element: ET.Element) -> str: +def pretty_xml(element: Union[ET.Element, None]) -> str: """Indent an ElementTree ``element`` and its children.""" def pretty_xml_recursive(element: ET.Element, level: int) -> None: indent = "\n" + level * " " @@ -71,6 +72,9 @@ def pretty_xml(element: ET.Element) -> str: sub_element.tail = indent elif level > 0 and not (element.tail or "").strip(): element.tail = indent + + if element is None: + return "" element = copy.deepcopy(element) pretty_xml_recursive(element, 0) return '\n%s' % ET.tostring(element, "unicode") diff --git a/setup.cfg.legacy b/setup.cfg.legacy index e27241b4..9eb9f2ca 100644 --- a/setup.cfg.legacy +++ b/setup.cfg.legacy @@ -29,13 +29,31 @@ skip_install = True [tool:isort] known_standard_library = _dummy_thread,_thread,abc,aifc,argparse,array,ast,asynchat,asyncio,asyncore,atexit,audioop,base64,bdb,binascii,binhex,bisect,builtins,bz2,cProfile,calendar,cgi,cgitb,chunk,cmath,cmd,code,codecs,codeop,collections,colorsys,compileall,concurrent,configparser,contextlib,contextvars,copy,copyreg,crypt,csv,ctypes,curses,dataclasses,datetime,dbm,decimal,difflib,dis,distutils,doctest,dummy_threading,email,encodings,ensurepip,enum,errno,faulthandler,fcntl,filecmp,fileinput,fnmatch,formatter,fpectl,fractions,ftplib,functools,gc,getopt,getpass,gettext,glob,grp,gzip,hashlib,heapq,hmac,html,http,imaplib,imghdr,imp,importlib,inspect,io,ipaddress,itertools,json,keyword,lib2to3,linecache,locale,logging,lzma,macpath,mailbox,mailcap,marshal,math,mimetypes,mmap,modulefinder,msilib,msvcrt,multiprocessing,netrc,nis,nntplib,ntpath,numbers,operator,optparse,os,ossaudiodev,parser,pathlib,pdb,pickle,pickletools,pipes,pkgutil,platform,plistlib,poplib,posix,posixpath,pprint,profile,pstats,pty,pwd,py_compile,pyclbr,pydoc,queue,quopri,random,re,readline,reprlib,resource,rlcompleter,runpy,sched,secrets,select,selectors,shelve,shlex,shutil,signal,site,smtpd,smtplib,sndhdr,socket,socketserver,spwd,sqlite3,sre,sre_compile,sre_constants,sre_parse,ssl,stat,statistics,string,stringprep,struct,subprocess,sunau,symbol,symtable,sys,sysconfig,syslog,tabnanny,tarfile,telnetlib,tempfile,termios,test,textwrap,threading,time,timeit,tkinter,token,tokenize,trace,traceback,tracemalloc,tty,turtle,turtledemo,types,typing,unicodedata,unittest,urllib,uu,uuid,venv,warnings,wave,weakref,webbrowser,winreg,winsound,wsgiref,xdrlib,xml,xmlrpc,zipapp,zipfile,zipimport,zlib -known_third_party = defusedxml,passlib,pkg_resources,pytest,vobject +known_third_party = defusedxml,libpass,pkg_resources,pytest,vobject [flake8] # Only enable default tests (https://github.com/PyCQA/flake8/issues/790#issuecomment-812823398) # DNE: DOES-NOT-EXIST select = E,F,W,C90,DNE000 -ignore = E121,E123,E126,E226,E24,E704,W503,W504,DNE000,E501 +ignore = E121,E123,E126,E226,E24,E704,W503,W504,DNE000,E501,E261 +exclude = .git, + __pycache__, + build, + dist, + *.egg, + *.egg-info, + *.eggs, + *.pyc, + *.pyo, + *.pyd, + .tox, + venv, + venv3, + .venv, + .venv3, + .env, + .mypy_cache, + .pytest_cache extend-exclude = build [mypy] diff --git a/setup.py.legacy b/setup.py.legacy index 1c44d272..27957903 100644 --- a/setup.py.legacy +++ b/setup.py.legacy @@ -1,7 +1,7 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2009-2017 Guillaume Ayoub # Copyright © 2017-2018 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # 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 @@ -20,7 +20,7 @@ from setuptools import find_packages, setup # When the version is updated, a new section in the CHANGELOG.md file must be # added too. -VERSION = "3.5.5.dev" +VERSION = "3.6.1.dev" with open("README.md", encoding="utf-8") as f: long_description = f.read() @@ -36,9 +36,11 @@ web_files = ["web/internal_data/css/icon.png", "web/internal_data/fn.js", "web/internal_data/index.html"] -install_requires = ["defusedxml", "passlib", "vobject>=0.9.6", +# Hint: if bcyrpt < 5.0.0 is used, passlib(libpass) dependency can be downgraded/reverted by: sed -i 's|libpass[^"]*|passlib|' setup.py.legacy +install_requires = ["defusedxml", "libpass>=1.9.3", "vobject>=0.9.6", "pika>=1.1.0", "requests", + "packaging", ] bcrypt_requires = ["bcrypt"] argon2_requires = ["argon2-cffi"]