From 9600363ff78685ee7d73d5af5829536ad308ecf2 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 28 Oct 2024 07:20:19 +0100 Subject: [PATCH] resync with master --- .github/workflows/generate-documentation.yml | 4 +- .github/workflows/pypi-publish.yml | 11 +- .github/workflows/test.yml | 24 +- CHANGELOG.md | 74 +++ DOCUMENTATION.md | 355 ++++++++++++-- Dockerfile | 31 +- Dockerfile.dev | 32 ++ README.md | 6 +- config | 110 ++++- contrib/apache/radicale.conf | 252 ++++++++++ contrib/nginx/radicale.conf | 21 + pyproject.toml | 128 +++++ radicale/__init__.py | 12 +- radicale/__main__.py | 19 +- radicale/app/__init__.py | 39 +- radicale/app/base.py | 28 +- radicale/app/delete.py | 29 ++ radicale/app/get.py | 6 +- radicale/app/mkcol.py | 11 +- radicale/app/move.py | 23 +- radicale/app/propfind.py | 28 +- radicale/app/proppatch.py | 13 + radicale/app/put.py | 37 +- radicale/app/report.py | 288 ++++++++++- radicale/auth/__init__.py | 29 +- radicale/auth/denyall.py | 30 ++ radicale/auth/htpasswd.py | 76 ++- radicale/auth/ldap.py | 184 +++++++ radicale/auth/none.py | 2 +- radicale/config.py | 137 +++++- radicale/hook/__init__.py | 69 +++ radicale/hook/none.py | 6 + radicale/hook/rabbitmq/__init__.py | 50 ++ radicale/httputils.py | 8 +- radicale/item/__init__.py | 16 +- radicale/item/filter.py | 101 ++-- radicale/log.py | 147 +++++- radicale/rights/__init__.py | 4 +- radicale/rights/from_file.py | 58 ++- radicale/server.py | 61 +-- radicale/storage/__init__.py | 11 +- radicale/storage/multifilesystem/base.py | 7 +- radicale/storage/multifilesystem/cache.py | 3 +- radicale/storage/multifilesystem/discover.py | 19 +- radicale/storage/multifilesystem/get.py | 17 +- radicale/storage/multifilesystem/meta.py | 3 +- radicale/storage/multifilesystem/sync.py | 3 +- radicale/storage/multifilesystem/upload.py | 3 +- radicale/storage/multifilesystem/verify.py | 20 +- radicale/tests/__init__.py | 41 +- radicale/tests/static/event1.ics | 1 + radicale/tests/static/event10.ics | 36 ++ radicale/tests/static/event_daily_rrule.ics | 28 ++ .../tests/static/event_full_day_rrule.ics | 31 ++ radicale/tests/test_auth.py | 27 +- radicale/tests/test_base.py | 340 ++++++++++++- radicale/tests/test_config.py | 4 +- radicale/tests/test_server.py | 18 +- radicale/tests/test_storage.py | 16 +- radicale/types.py | 25 +- radicale/utils.py | 10 +- .../web/internal_data/css/icons/delete.svg | 1 + .../web/internal_data/css/icons/download.svg | 1 + radicale/web/internal_data/css/icons/edit.svg | 1 + radicale/web/internal_data/css/icons/new.svg | 1 + .../web/internal_data/css/icons/upload.svg | 1 + radicale/web/internal_data/css/loading.svg | 72 +++ radicale/web/internal_data/css/logo.svg | 10 + radicale/web/internal_data/css/main.css | 429 ++++++++++++++++- radicale/web/internal_data/fn.js | 451 ++++++++++++++---- radicale/web/internal_data/index.html | 286 ++++++----- radicale/xmlutils.py | 6 +- rights | 24 + setup.cfg | 56 +-- setup.cfg.legacy | 62 +++ setup.py.legacy | 81 ++++ 76 files changed, 4080 insertions(+), 624 deletions(-) create mode 100644 Dockerfile.dev create mode 100644 contrib/apache/radicale.conf create mode 100644 contrib/nginx/radicale.conf create mode 100644 pyproject.toml create mode 100644 radicale/auth/denyall.py create mode 100644 radicale/auth/ldap.py create mode 100644 radicale/hook/__init__.py create mode 100644 radicale/hook/none.py create mode 100644 radicale/hook/rabbitmq/__init__.py create mode 100644 radicale/tests/static/event10.ics create mode 100644 radicale/tests/static/event_daily_rrule.ics create mode 100644 radicale/tests/static/event_full_day_rrule.ics create mode 100644 radicale/web/internal_data/css/icons/delete.svg create mode 100644 radicale/web/internal_data/css/icons/download.svg create mode 100644 radicale/web/internal_data/css/icons/edit.svg create mode 100644 radicale/web/internal_data/css/icons/new.svg create mode 100644 radicale/web/internal_data/css/icons/upload.svg create mode 100644 radicale/web/internal_data/css/loading.svg create mode 100644 radicale/web/internal_data/css/logo.svg create mode 100644 setup.cfg.legacy create mode 100644 setup.py.legacy diff --git a/.github/workflows/generate-documentation.yml b/.github/workflows/generate-documentation.yml index bdc6394da..d766a834d 100644 --- a/.github/workflows/generate-documentation.yml +++ b/.github/workflows/generate-documentation.yml @@ -6,9 +6,9 @@ on: jobs: generate: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: ref: gh-pages - name: Run generator diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index a6bd720f0..be98f3bbf 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -5,10 +5,12 @@ on: jobs: publish: + permissions: + id-token: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: 3.x - name: Install Build dependencies @@ -16,7 +18,4 @@ jobs: - name: Build run: python -m build --sdist --wheel - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@master - with: - user: __token__ - password: ${{ secrets.pypi_password }} + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4b82fa583..32961e866 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,24 +6,22 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', pypy-3.7, pypy-3.8, pypy-3.9] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12.3', '3.13.0-beta.4', pypy-3.8, pypy-3.9] exclude: - - os: windows-latest - python-version: pypy-3.7 - os: windows-latest python-version: pypy-3.8 - os: windows-latest python-version: pypy-3.9 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - 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 - run: tox + run: tox -e py - name: Install Coveralls if: github.event_name == 'push' run: pip install coveralls @@ -39,7 +37,7 @@ jobs: if: github.event_name == 'push' runs-on: ubuntu-latest steps: - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v5 with: python-version: 3.x - name: Install Coveralls @@ -48,3 +46,15 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: coveralls --service=github --finish + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install tox + run: pip install tox + - name: Lint + run: tox -e flake8,mypy,isort diff --git a/CHANGELOG.md b/CHANGELOG.md index eed645846..965b9f53b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,79 @@ # Changelog +## 3.3.0 + +* Adjustment: option [auth] htpasswd_encryption change default from "md5" to "autodetect" +* Add: option [auth] type=ldap with (group) rights management via LDAP/LDAPS +* Enhancement: permit_delete_collection can be now controlled also per collection by rights 'D' or 'd' +* Add: option [rights] permit_overwrite_collection (default=True) which can be also controlled per collection by rights 'O' or 'o' +* Fix: only expand VEVENT on REPORT request containing 'expand' +* Adjustment: switch from setup.py to pyproject.toml (but keep files for legacy packaging) +* Adjustment: 'rights' file is now read only during startup +* Cleanup: Python 3.7 leftovers + +## 3.2.3 +* Add: support for Python 3.13 +* 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: 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 +* Drop: remove unused requirement "typeguard" +* Improve: Refactored some date parsing code + +## 3.2.2 +* Enhancement: add support for auth.type=denyall (will be default for security reasons in upcoming releases) +* Enhancement: display warning in case only default config is active +* Enhancement: display warning in case no user authentication is active +* Enhancement: add option to skip broken item to avoid triggering exception (default: enabled) +* Enhancement: add support for predefined collections for new users +* Enhancement: add options to enable several parts in debug log like backtrace, request_header, request_content, response_content (default: disabled) +* Enhancement: rights/from_file: display resulting permission of a match in debug log +* Enhancement: add Apache config file example (see contrib directory) +* Fix: "verify-collection" skips non-collection directories, logging improved + +## 3.2.1 + +* Enhancement: add option for logging bad PUT request content +* Enhancement: extend logging with step where bad PUT request failed +* Fix: support for recurrence "full day" +* Fix: list of web_files related to HTML pages +* Test: update/adjustments for workflows (pytest>=7, typeguard<4.3) + +## 3.2.0 + +* Enhancement: add hook support for event changes+deletion hooks (initial support: "rabbitmq") +* Dependency: pika >= 1.1.0 +* Enhancement: add support for webcal subscriptions +* Enhancement: major update of WebUI (design+features) +* Adjust: change default loglevel to "info" +* Enhancement: support "expand-property" on REPORT request +* Drop: support for Python 3.7 (EOSL, can't be tested anymore) +* Fix: allow quoted-printable encoding for vObjects + +## 3.1.9 + +* Add: support for Python 3.11 + 3.12 +* Drop: support for Python 3.6 +* Fix: MOVE in case listen on non-standard ports or behind reverse proxy +* Fix: stricter requirements of Python 3.11 +* Fix: HTML pages +* Fix: Main Component is missing when only recurrence id exists +* Fix: passlib don't support bcrypt>=4.1 +* Fix: web login now proper encodes passwords containing %XX (hexdigits) +* Enhancement: user-selectable log formats +* Enhancement: autodetect logging to systemd journal +* Enhancement: test code +* Enhancement: option for global permit to delete collection +* Enhancement: auth type 'htpasswd' supports now 'htpasswd_encryption' sha256/sha512 and "autodetect" for smooth transition +* Improve: Dockerfiles +* Improve: server socket listen code + address format in log +* Update: documentations + examples +* Dependency: limit typegard version < 3 +* General: code cosmetics + ## 3.1.8 * Fix setuptools requirement if installing wheel diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index f82e77dcb..ac8d33e89 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -23,8 +23,8 @@ Radicale is a small but powerful CalDAV (calendars, to-do lists) and CardDAV Radicale is really easy to install and works out-of-the-box. ```bash -python3 -m pip install --upgrade radicale -python3 -m radicale --storage-filesystem-folder=~/.var/lib/radicale/collections +python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz +python3 -m radicale --logging-level info --storage-filesystem-folder=~/.var/lib/radicale/collections ``` When the server is launched, open in your browser! @@ -36,7 +36,7 @@ Want more? Check the [tutorials](#tutorials) and the #### What's New? Read the -[changelog on GitHub.](https://github.com/Kozea/Radicale/blob/v3/CHANGELOG.md) +[changelog on GitHub.](https://github.com/Kozea/Radicale/blob/master/CHANGELOG.md) ## Tutorials @@ -55,8 +55,7 @@ Follow one of the chapters below depending on your operating system. #### Linux / \*BSD -First, make sure that **python** 3.5 or later (**python** ≥ 3.6 is -recommended) and **pip** are installed. On most distributions it should be +First, make sure that **python** 3.8 or later and **pip** are installed. On most distributions it should be enough to install the package ``python3-pip``. Then open a console and type: @@ -64,7 +63,7 @@ Then open a console and type: ```bash # Run the following command as root or # add the --user argument to only install for the current user -$ python3 -m pip install --upgrade radicale +$ python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz $ python3 -m radicale --storage-filesystem-folder=~/.var/lib/radicale/collections ``` @@ -82,7 +81,7 @@ click on "Install now". Wait a couple of minutes, it's done! Launch a command prompt and type: ```powershell -python -m pip install --upgrade radicale +python -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz python -m radicale --storage-filesystem-folder=~/radicale/collections ``` @@ -122,12 +121,12 @@ The `users` file can be created and managed with [htpasswd](https://httpd.apache.org/docs/current/programs/htpasswd.html): ```bash -# Create a new htpasswd file with the user "user1" -$ htpasswd -c /path/to/users user1 +# Create a new htpasswd file with the user "user1" using SHA-512 as hash method +$ htpasswd -5 -c /path/to/users user1 New password: Re-type new password: # Add another user -$ htpasswd /path/to/users user2 +$ htpasswd -5 /path/to/users user2 New password: Re-type new password: ``` @@ -138,8 +137,7 @@ Authentication can be enabled with the following configuration: [auth] type = htpasswd htpasswd_filename = /path/to/users -# encryption method used in the htpasswd file -htpasswd_encryption = md5 +htpasswd_encryption = autodetect ``` ##### The simple but insecure way @@ -216,6 +214,8 @@ requirements. #### Linux with systemd system-wide +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 @@ -328,9 +328,13 @@ start the **Radicale** service. ### Reverse Proxy -When a reverse proxy is used, the path at which Radicale is available must -be provided via the `X-Script-Name` header. The proxy must remove the location -from the URL path that is forwarded to Radicale. +When a reverse proxy is used, and Radicale should be made available at a path +below the root (such as `/radicale/`), then this path must be provided via +the `X-Script-Name` header (without a trailing `/`). The proxy must remove +the location from the URL path that is forwarded to Radicale. If Radicale +should be made available at the root of the web server (in the nginx case +using `location /`), then the setting of the `X-Script-Name` header should be +removed from the example below. Example **nginx** configuration: @@ -344,6 +348,17 @@ location /radicale/ { # The trailing / is important! } ``` +Example **Caddy** configuration: + +``` +handle_path /radicale/* { + uri strip_prefix /radicale + reverse_proxy localhost:5232 { + header_up X-Script-Name /radicale + } +} +``` + Example **Apache** configuration: ```apache @@ -354,6 +369,11 @@ RewriteRule ^/radicale$ /radicale/ [R,L] ProxyPass http://localhost:5232/ retry=0 ProxyPassReverse http://localhost:5232/ RequestHeader set X-Script-Name /radicale + RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" + RequestHeader unset X-Forwarded-Proto + + RequestHeader set X-Forwarded-Proto "https" + ``` @@ -366,6 +386,28 @@ RewriteRule ^(.*)$ http://localhost:5232/$1 [P,L] # Set to directory of .htaccess file: RequestHeader set X-Script-Name /radicale +RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" +RequestHeader unset X-Forwarded-Proto + +RequestHeader set X-Forwarded-Proto "https" + +``` + +Example **lighttpd** configuration: + +```lighttpd +server.modules += ( "mod_proxy" , "mod_setenv", "mod_rewrite" ) + +$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" ) +} ``` Be reminded that Radicale's default configuration enforces limits on the @@ -393,6 +435,21 @@ location /radicale/ { } ``` +Example **Caddy** configuration: + +``` +handle_path /radicale/* { + uri strip_prefix /radicale + basicauth { + USER HASH + } + reverse_proxy localhost:5232 { + header_up X-Script-Name /radicale + header_up X-remote-user {http.auth.user.id} + } +} +``` + Example **Apache** configuration: ```apache @@ -458,6 +515,15 @@ 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: + +```ini +[server] +ssl = True +certificate = /etc/letsencrypt/live/{Your Domain}/fullchain.pem +key = /etc/letsencrypt/live/{Your Domain}/privkey.pem +``` + Example **nginx** configuration: ```nginx @@ -522,12 +588,22 @@ The configuration option `hook` in the `storage` section must be set to the following command: ```bash -git add -A && (git diff --cached --quiet || git commit -m "Changes by "%(user)s) +git add -A && (git diff --cached --quiet || git commit -m "Changes by \"%(user)s\"") ``` The command gets executed after every change to the storage and commits the changes into the **git** repository. +For the hook to not cause errors either **git** user details need to be set and match the owner of the collections directory or the repository needs to be marked as safe. + +When using the systemd unit file from the [Running as a service](#running-as-a-service) section this **cannot** be done via a `.gitconfig` file in the users home directory, as Radicale won't have read permissions! + +In `/var/lib/radicale/collections/.git` run: +```bash +git config user.name "radicale" +git config user.email "radicale@example.com" +``` + ## Documentation ### Configuration @@ -545,7 +621,7 @@ hosts = 0.0.0.0:5232, [::]:5232 [auth] type = htpasswd htpasswd_filename = ~/.config/radicale/users -htpasswd_encryption = md5 +htpasswd_encryption = autodetect [storage] filesystem_folder = ~/.var/lib/radicale/collections @@ -563,7 +639,7 @@ The same example configuration via command line arguments looks like: ```bash python3 -m radicale --server-hosts 0.0.0.0:5232,[::]:5232 \ --auth-type htpasswd --auth-htpasswd-filename ~/.config/radicale/users \ - --auth-htpasswd-encryption md5 + --auth-htpasswd-encryption autodetect ``` Add the argument `--config ""` to stop Radicale from loading the default @@ -667,6 +743,9 @@ Available backends: authentication. This can be used to provide the username from a reverse proxy. +`ldap` +: Use a LDAP or AD server to authenticate users. + Default: `none` ##### htpasswd_filename @@ -694,12 +773,21 @@ Available methods: `bcrypt` : This uses a modified version of the Blowfish stream cipher. It's very secure. - The installation of **radicale[bcrypt]** is required for this. + The installation of **bcrypt** is required for this. `md5` -: This uses an iterated md5 digest of the password with a salt. +: This uses an iterated MD5 digest of the password with a salt (nowadays insecure). + +`sha256` +: This uses an iterated SHA-256 digest of the password with a salt. -Default: `md5` +`sha512` +: This uses an iterated SHA-512 digest of the password with a salt. + +`autodetect` +: This selects autodetection of method per entry. + +Default: `autodetect` ##### delay @@ -713,6 +801,76 @@ Message displayed in the client when a password is needed. Default: `Radicale - Password Required` +##### ldap_uri + +The URI to the ldap server + +Default: `ldap://localhost` + +##### ldap_base + +LDAP base DN of the ldap server. This parameter must be provided if auth type is ldap. + +Default: + +##### ldap_reader_dn + +The DN of a ldap user with read access to get the user accounts. This parameter must be provided if auth type is ldap. + +Default: + +##### ldap_secret + +The password of the ldap_reader_dn. This parameter must be provided if auth type is ldap. + +Default: + +##### ldap_filter + +The search filter to find the user DN to authenticate by the username. User '{0}' as placeholder for the user name. + +Default: `(cn={0})` + +##### ldap_load_groups + +Load the ldap groups of the authenticated user. These groups 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 calneder folders will not be created automaticaly. This must be created manualy. [Here](https://github.com/Kozea/Radicale/wiki/LDAP-authentication) you can find a script to create group calneder folders https://github.com/Kozea/Radicale/wiki/LDAP-authentication + +Default: False + +##### ldap_use_ssl + +Use ssl on the ldap connection + +Default: False + +##### ldap_ssl_verify_mode + +The certifikat verification mode. NONE, OPTIONAL or REQUIRED + +Default: REQUIRED + +##### ldap_ssl_ca_file + +The path to the CA file in pem format which is used to certificate the server certificate + +Default: + +##### lc_username + +Сonvert username to lowercase, must be true for case-insensitive auth +providers like ldap, kerberos + +Default: `False` + +##### strip_domain + +Strip domain from username + +Default: `False` + #### rights ##### type @@ -748,6 +906,24 @@ Default: `owner_only` File for the rights backend `from_file`. See the [Rights](#authentication-and-rights) section. +##### permit_delete_collection + +(New since 3.1.9) + +Global control of permission to delete complete collection (default: True) + +If False it can be permitted by permissions per section with: D +If True it can be forbidden by permissions per section with: d + +##### permit_overwrite_collection + +(New since 3.3.0) + +Global control of permission to overwrite complete collection (default: True) + +If False it can be permitted by permissions per section with: O +If True it can be forbidden by permissions per section with: o + #### storage ##### type @@ -777,6 +953,12 @@ Delete sync-token that are older than the specified time. (seconds) Default: `2592000` +##### skip_broken_item + +Skip broken item instead of triggering an exception + +Default: `True` + ##### hook Command that is run after changes to storage. Take a look at the @@ -784,6 +966,26 @@ Command that is run after changes to storage. Take a look at the Default: +##### predefined_collections + +Create predefined user collections + + Example: + + { + "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 ##### type @@ -816,6 +1018,42 @@ Don't include passwords in logs. Default: `True` +##### bad_put_request_content + +Log bad PUT request content (for further diagnostics) + +Default: `False` + +##### backtrace_on_debug + +Log backtrace on level=debug + +Default: `False` + +##### request_header_on_debug + +Log request on level=debug + +Default: `False` + +##### request_content_on_debug + +Log request on level=debug + +Default: `False` + +##### response_content_on_debug = True + +Log response on level=debug + +Default: `False` + +##### rights_rule_doesnt_match_on_debug = True + +Log rights rule which doesn't match on level=debug + +Default: `False` + #### headers In this section additional HTTP headers that are sent to clients can be @@ -827,7 +1065,53 @@ An example to relax the same-origin policy: Access-Control-Allow-Origin = * ``` -### Supported Clients +#### hook +##### type + +Hook binding for event changes and deletion notifications. + +Available types: + +`none` +: Disabled. Nothing will be notified. + +`rabbitmq` +: Push the message to the rabbitmq server. + +Default: `none` + +#### rabbitmq_endpoint + +End-point address for rabbitmq server. +Ex: amqp://user:password@localhost:5672/ + +Default: + +#### rabbitmq_topic + +RabbitMQ topic to publish message. + +Default: + +#### rabbitmq_queue_type + +RabbitMQ queue type for the topic. + +Default: classic + +#### reporting +##### max_freebusy_occurrence + +When returning a free-busy report, a list of busy time occurrences are +generated based on a given time frame. Large time frames could +generate a lot of occurrences based on the time frame supplied. This +setting limits the lookup to prevent potential denial of service +attacks on large time frames. If the limit is reached, an HTTP error +is thrown instead of returning the results. + +Default: 10000 + +## Supported Clients Radicale has been tested with: @@ -858,16 +1142,21 @@ 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. -#### GNOME Calendar, Contacts and Evolution +#### GNOME Calendar, Contacts + +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. -**GNOME Calendar** and **Contacts** do not support adding WebDAV calendars -and address books directly, but you can add them in **Evolution**. +#### Evolution In **Evolution** add a new calendar and address book respectively with WebDAV. 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. + #### Thunderbird Add a new calendar on the network. Enter your username and the URL of the @@ -954,6 +1243,8 @@ Delete the collections by running something like: curl -u user -X DELETE 'http://localhost:5232/user/calendar' ``` +Note: requires config/option `permit_delete_collection = True` + ### Authentication and Rights This section describes the format of the rights file for the `from_file` @@ -973,7 +1264,7 @@ An example rights file: [root] user: .+ collection: -permissions: R +permissions: r # Allow reading and writing principal collection (same as username) [principal] @@ -1015,6 +1306,10 @@ 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 +* **d:** forbid delete of collection in case permit_delete_collection=True +* **O:** permit overwrite of collection in case permit_overwrite_collection=False +* **o:** forbid overwrite of collection in case permit_overwrite_collection=True ### Storage @@ -1330,10 +1625,6 @@ The module must contain a class `Storage` that extends ## Contribute -#### Chat with Us on IRC - -Want to say something? Join our IRC room: `##kozea` on Freenode. - #### Report Bugs Found a bug? Want a new feature? Report a new issue on the @@ -1348,7 +1639,7 @@ add new features, fix bugs or update the documentation. #### Documentation To change or complement the documentation create a pull request to -[DOCUMENTATION.md](https://github.com/Kozea/Radicale/blob/v3/DOCUMENTATION.md). +[DOCUMENTATION.md](https://github.com/Kozea/Radicale/blob/master/DOCUMENTATION.md). ## Download @@ -1388,7 +1679,7 @@ Radicale has been packaged for: * [Debian](http://packages.debian.org/radicale) by Jonas Smedegaard * [Gentoo](https://packages.gentoo.org/packages/www-apps/radicale) by René Neumann, Maxim Koltsov and Manuel Rüger -* [Fedora/RHEL/CentOS](https://src.fedoraproject.org/rpms/radicale) by Jorti +* [Fedora/EnterpriseLinux](https://src.fedoraproject.org/rpms/radicale) by Jorti and Peter Bieringer * [Mageia](http://madb.mageia.org/package/show/application/0/name/radicale) by Jani Välimaa diff --git a/Dockerfile b/Dockerfile index cb14a5896..902595083 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,34 @@ # This file is intended to be used apart from the containing source code tree. +FROM python:3-alpine AS builder + +# Version of Radicale (e.g. v3) +ARG VERSION=master + +# Optional dependencies (e.g. bcrypt) +ARG DEPENDENCIES=bcrypt + +RUN apk add --no-cache --virtual gcc libffi-dev musl-dev \ + && python -m venv /app/venv \ + && /app/venv/bin/pip install --no-cache-dir "Radicale[${DEPENDENCIES}] @ https://github.com/Kozea/Radicale/archive/${VERSION}.tar.gz" + + FROM python:3-alpine -# Version of Radicale -ARG VERSION=v3 +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 + +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 -CMD ["radicale", "--hosts", "0.0.0.0:5232"] +ENTRYPOINT [ "/app/bin/python", "/app/bin/radicale"] +CMD ["--hosts", "0.0.0.0:5232,[::]:5232"] -RUN apk add --no-cache ca-certificates openssl \ - && apk add --no-cache --virtual .build-deps gcc libffi-dev musl-dev \ - && pip install --no-cache-dir "Radicale[bcrypt] @ https://github.com/Kozea/Radicale/archive/${VERSION}.tar.gz" \ - && apk del .build-deps +USER radicale diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..65e00fcd6 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,32 @@ +FROM python:3-alpine AS builder + +# Optional dependencies (e.g. bcrypt) +ARG DEPENDENCIES=bcrypt + +COPY . /app + +WORKDIR /app + +RUN apk add --no-cache --virtual gcc libffi-dev musl-dev \ + && python -m venv /app/venv \ + && /app/venv/bin/pip install --no-cache-dir .[${DEPENDENCIES}] + +FROM python:3-alpine + +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 + +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"] + +USER radicale diff --git a/README.md b/README.md index 59ab4682c..acbde25b1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Radicale -[![Test](https://github.com/Kozea/Radicale/actions/workflows/test.yml/badge.svg?branch=v3)](https://github.com/Kozea/Radicale/actions/workflows/test.yml) -[![Coverage Status](https://coveralls.io/repos/github/Kozea/Radicale/badge.svg?branch=v3)](https://coveralls.io/github/Kozea/Radicale?branch=v3) +[![Test](https://github.com/Kozea/Radicale/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/Kozea/Radicale/actions/workflows/test.yml) +[![Coverage Status](https://coveralls.io/repos/github/Kozea/Radicale/badge.svg?branch=master)](https://coveralls.io/github/Kozea/Radicale?branch=master) Radicale is a small but powerful CalDAV (calendars, to-do lists) and CardDAV (contacts) server, that: @@ -17,7 +17,7 @@ Radicale is a small but powerful CalDAV (calendars, to-do lists) and CardDAV * Is GPLv3-licensed free software. For the complete documentation, please visit -[Radicale v3 Documentation](https://radicale.org/v3.html). +[Radicale master Documentation](https://radicale.org/master.html). Additional hints can be found * [Radicale Wiki](https://github.com/Kozea/Radicale/wiki) diff --git a/config b/config index 7c77f5f94..041fa9ce6 100644 --- a/config +++ b/config @@ -14,7 +14,8 @@ # CalDAV server hostnames separated by a comma # IPv4 syntax: address:port # IPv6 syntax: [address]:port -# For example: 0.0.0.0:9999, [::]:9999 +# Hostname syntax (using "getaddrinfo" to resolve to IPv4/IPv6 adress(es)): hostname:port +# For example: 0.0.0.0:9999, [::]:9999, localhost:9999 #hosts = localhost:5232 # Max parallel connections @@ -52,16 +53,43 @@ [auth] # Authentication method -# Value: none | htpasswd | remote_user | http_x_remote_user +# Value: none | htpasswd | remote_user | http_x_remote_user | ldap | denyall #type = none +# URI to the LDAP server +#ldap_uri = ldap://localhost + +# The base DN where the user accounts have to be searched +#ldap_base = ##BASE_DN## + +# The reader DN of the LDAP server +#ldap_reader_dn = CN=ldapreader,CN=Users,##BASE_DN## + +# Password of the reader DN +#ldap_secret = ldapreader-secret + +# If the ldap groups of the user need to be loaded +#ldap_load_groups = True + +# The filter to find the DN of the user. This filter must contain a python-style placeholder for the login +#ldap_filter = (&(objectClass=person)(uid={0})) + +# Use ssl on the ldap connection +#ldap_use_ssl = False + +# The certifikat verification mode. NONE, OPTIONAL, default is REQUIRED +#ldap_ssl_verify_mode = REQUIRED + +# The path to the CA file in pem format which is used to certificate the server certificate +#ldap_ssl_ca_file = + # Htpasswd filename #htpasswd_filename = /etc/radicale/users # Htpasswd encryption method -# Value: plain | bcrypt | md5 -# bcrypt requires the installation of radicale[bcrypt]. -#htpasswd_encryption = md5 +# Value: plain | bcrypt | md5 | sha256 | sha512 | autodetect +# bcrypt requires the installation of 'bcrypt' module. +#htpasswd_encryption = autodetect # Incorrect authentication delay (seconds) #delay = 1 @@ -69,6 +97,12 @@ # Message displayed in the client when a password is needed #realm = Radicale - Password Required +# Convert username to lowercase, must be true for case-insensitive auth providers +#lc_username = False + +# Strip domain name from username +#strip_domain = False + [rights] @@ -79,6 +113,12 @@ # File for rights management from_file #file = /etc/radicale/rights +# Permit delete of a collection (global) +#permit_delete_collection = True + +# Permit overwrite of a collection (global) +#permit_overwrite_collection = True + [storage] @@ -92,10 +132,31 @@ # Delete sync token that are older (seconds) #max_sync_token_age = 2592000 +# Skip broken item instead of triggering an exception +#skip_broken_item = True + # Command that is run after changes to storage -# Example: ([ -d .git ] || git init) && git add -A && (git diff --cached --quiet || git commit -m "Changes by "%(user)s) +# Example: ([ -d .git ] || git init) && git add -A && (git diff --cached --quiet || git commit -m "Changes by \"%(user)s\"") #hook = +# Create predefined user collections +# +# json format: +# +# { +# "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" +# } +# } +# +#predefined_collections = + [web] @@ -108,13 +169,48 @@ # Threshold for the logger # Value: debug | info | warning | error | critical -#level = warning +#level = info # Don't include passwords in logs #mask_passwords = True +# Log bad PUT request content +#bad_put_request_content = False + +# Log backtrace on level=debug +#backtrace_on_debug = False + +# Log request header on level=debug +#request_header_on_debug = False + +# Log request content on level=debug +#request_content_on_debug = False + +# Log response content on level=debug +#response_content_on_debug = False + +# Log rights rule which doesn't match on level=debug +#rights_rule_doesnt_match_on_debug = False + [headers] # Additional HTTP headers #Access-Control-Allow-Origin = * + + +[hook] + +# Hook types +# Value: none | rabbitmq +#type = none +#rabbitmq_endpoint = +#rabbitmq_topic = +#rabbitmq_queue_type = classic + + +[reporting] + +# When returning a free-busy report, limit the number of returned +# occurences per event to prevent DOS attacks. +#max_freebusy_occurrence = 10000 diff --git a/contrib/apache/radicale.conf b/contrib/apache/radicale.conf new file mode 100644 index 000000000..98a25a72c --- /dev/null +++ b/contrib/apache/radicale.conf @@ -0,0 +1,252 @@ +### Define how Apache should serve "radicale" +## !!! Do not enable both at the same time !!! + +## Apache acting as reverse proxy and forward requests via ProxyPass to a running "radicale" server +# SELinux WARNING: To use this correctly, you will need to set: +# setsebool -P httpd_can_network_connect=1 +#Define RADICALE_SERVER_REVERSE_PROXY + + +## Apache starting WSGI server running with "radicale" application +# MAY CONFLICT with other WSG servers on same system -> use then inside a VirtualHost +# SELinux WARNING: To use this correctly, you will need to set: +# setsebool -P httpd_can_read_write_radicale=1 +#Define RADICALE_SERVER_WSGI + + +### Extra options +## Apache starting a dedicated VHOST with SSL +#Define RADICALE_SERVER_VHOST_SSL + + +### permit public access to "radicale" +#Define RADICALE_PERMIT_PUBLIC_ACCESS + + +### enforce SSL on default host +#Define RADICALE_ENFORCE_SSL + + +### Particular configuration EXAMPLES, adjust/extend/override to your needs + +########################## +### default host +########################## + + +## RADICALE_SERVER_REVERSE_PROXY + + RewriteEngine On + RewriteRule ^/radicale$ /radicale/ [R,L] + + + RequestHeader set X-Script-Name /radicale + + RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" + RequestHeader unset X-Forwarded-Proto + + RequestHeader set X-Forwarded-Proto "https" + + + ProxyPass http://localhost:5232/ retry=0 + ProxyPassReverse http://localhost:5232/ + + ## User authentication handled by "radicale" + Require local + + Require all granted + + + ## You may want to use apache's authentication (config: [auth] type = http_x_remote_user) + ## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser + #AuthBasicProvider file + #AuthType Basic + #AuthName "Enter your credentials" + #AuthUserFile /etc/httpd/conf/htpasswd-radicale + #AuthGroupFile /dev/null + #Require valid-user + #RequestHeader set X-Remote-User expr=%{REMOTE_USER} + + + + Error "RADICALE_ENFORCE_SSL selected but ssl module not loaded/enabled" + + SSLRequireSSL + + + + + +## RADICALE_SERVER_WSGI +# For more information, visit: +# http://radicale.org/user_documentation/#idapache-and-mod-wsgi + + + + + SetHandler wsgi-script + + Require local + + Require all granted + + + + WSGIDaemonProcess radicale user=radicale group=radicale threads=1 umask=0027 + WSGIProcessGroup radicale + WSGIApplicationGroup %{GLOBAL} + WSGIPassAuthorization On + + WSGIScriptAlias /radicale /usr/share/radicale/radicale.wsgi + + + RequestHeader set X-Script-Name /radicale + + ## User authentication handled by "radicale" + Require local + + Require all granted + + + ## You may want to use apache's authentication (config: [auth] type = http_x_remote_user) + ## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser + #AuthBasicProvider file + #AuthType Basic + #AuthName "Enter your credentials" + #AuthUserFile /etc/httpd/conf/htpasswd-radicale + #AuthGroupFile /dev/null + #Require valid-user + #RequestHeader set X-Remote-User expr=%{REMOTE_USER} + + + + Error "RADICALE_ENFORCE_SSL selected but ssl module not loaded/enabled" + + SSLRequireSSL + + + + + Error "RADICALE_SERVER_WSGI selected but wsgi module not loaded/enabled" + + + + + + +########################## +### VHOST with SSL +########################## + + + +Listen 8443 https + + +## taken from ssl.conf + +#ServerName www.example.com:443 +ErrorLog logs/ssl_error_log +TransferLog logs/ssl_access_log +LogLevel warn +SSLEngine on +SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 +SSLProxyProtocol all -SSLv3 -TLSv1 -TLSv1.1 +SSLHonorCipherOrder on +SSLCipherSuite PROFILE=SYSTEM +SSLProxyCipherSuite PROFILE=SYSTEM +SSLCertificateFile /etc/pki/tls/certs/localhost.crt +SSLCertificateKeyFile /etc/pki/tls/private/localhost.key +#SSLCertificateChainFile /etc/pki/tls/certs/server-chain.crt +#SSLCACertificateFile /etc/pki/tls/certs/ca-bundle.crt +#SSLVerifyClient require +#SSLVerifyDepth 10 +#SSLOptions +FakeBasicAuth +ExportCertData +StrictRequire +BrowserMatch "MSIE [2-5]" \ nokeepalive ssl-unclean-shutdown \ downgrade-1.0 force-response-1.0 +CustomLog logs/ssl_request_log "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b" + + +## RADICALE_SERVER_REVERSE_PROXY + + + RequestHeader set X-Script-Name / + + RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" + RequestHeader set X-Forwarded-Proto "https" + + ProxyPass http://localhost:5232/ retry=0 + ProxyPassReverse http://localhost:5232/ + + ## User authentication handled by "radicale" + Require local + + Require all granted + + + ## You may want to use apache's authentication (config: [auth] type = http_x_remote_user) + ## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser + #AuthBasicProvider file + #AuthType Basic + #AuthName "Enter your credentials" + #AuthUserFile /etc/httpd/conf/htpasswd-radicale + #AuthGroupFile /dev/null + #Require valid-user + + + + +## RADICALE_SERVER_WSGI +# For more information, visit: +# http://radicale.org/user_documentation/#idapache-and-mod-wsgi + + + + + SetHandler wsgi-script + + Require local + + Require all granted + + + + WSGIDaemonProcess radicale user=radicale group=radicale threads=1 umask=0027 + WSGIProcessGroup radicale + WSGIApplicationGroup %{GLOBAL} + WSGIPassAuthorization On + + WSGIScriptAlias / /usr/share/radicale/radicale.wsgi + + + RequestHeader set X-Script-Name / + + ## User authentication handled by "radicale" + Require local + + Require all granted + + + ## You may want to use apache's authentication (config: [auth] type = http_x_remote_user) + ## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser + #AuthBasicProvider file + #AuthType Basic + #AuthName "Enter your credentials" + #AuthUserFile /etc/httpd/conf/htpasswd-radicale + #AuthGroupFile /dev/null + #Require valid-user + + + + Error "RADICALE_SERVER_WSGI selected but wsgi module not loaded/enabled" + + + + + + + + + Error "RADICALE_SERVER_VHOST_SSL selected but ssl module not loaded/enabled" + + + diff --git a/contrib/nginx/radicale.conf b/contrib/nginx/radicale.conf new file mode 100644 index 000000000..fea82d03f --- /dev/null +++ b/contrib/nginx/radicale.conf @@ -0,0 +1,21 @@ +### Proxy Forward to local running "radicale" server +### +### Usual configuration file location: /etc/nginx/default.d/ + +## Base URI: /radicale/ +#location /radicale/ { +# 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 Host $http_host; +# proxy_pass_header Authorization; +#} + +## Base URI: / +#location / { +# 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 Host $http_host; +# proxy_pass_header Authorization; +#} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..4e8e2dd0d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,128 @@ +[project] +name = "Radicale" +# When the version is updated, a new section in the CHANGELOG.md file must be +# added too. +readme = "README.md" +version = "3.3.0" +authors = [{name = "Guillaume Ayoub", email = "guillaume.ayoub@kozea.fr"}] +license = {text = "GNU GPL v3"} +description = "CalDAV and CardDAV Server" +keywords = ["calendar", "addressbook", "CalDAV", "CardDAV"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Environment :: Web Environment", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: GNU General Public License (GPL)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Office/Business :: Groupware", +] +urls = {Homepage = "https://radicale.org/"} +requires-python = ">=3.8.0" +dependencies = [ + "defusedxml", + "passlib", + "vobject>=0.9.6", + "python-dateutil>=2.7.3", + "pika>=1.1.0", +] + + +[project.optional-dependencies] +test = ["pytest>=7", "waitress", "bcrypt"] +bcrypt = ["bcrypt"] + +[project.scripts] +radicale = "radicale.__main__:run" + +[build-system] +requires = ["setuptools>=61.2"] +build-backend = "setuptools.build_meta" + +[tool.tox] +min_version = "4.0" +envlist = ["py", "flake8", "isort", "mypy"] + +[tool.tox.env.py] +extras = ["test"] +deps = [ + "pytest", + "pytest-cov" +] +commands = [["pytest", "-r", "s", "--cov", "--cov-report=term", "--cov-report=xml", "."]] + +[tool.tox.env.flake8] +deps = ["flake8==7.1.0"] +commands = [["flake8", "."]] +skip_install = true + +[tool.tox.env.isort] +deps = ["isort==5.13.2"] +commands = [["isort", "--check", "--diff", "."]] +skip_install = true + +[tool.tox.env.mypy] +deps = ["mypy==1.11.0"] +commands = [["mypy", "."]] +skip_install = true + + +[tool.setuptools] +platforms = ["Any"] +include-package-data = false + +[tool.setuptools.packages.find] +exclude = ["*.tests"] # *.tests.*; tests.*; tests +namespaces = false + +[tool.setuptools.package-data] +radicale = [ + "web/internal_data/css/icon.png", + "web/internal_data/css/loading.svg", + "web/internal_data/css/logo.svg", + "web/internal_data/css/main.css", + "web/internal_data/css/icons/delete.svg", + "web/internal_data/css/icons/download.svg", + "web/internal_data/css/icons/edit.svg", + "web/internal_data/css/icons/new.svg", + "web/internal_data/css/icons/upload.svg", + "web/internal_data/fn.js", + "web/internal_data/index.html", + "py.typed", +] + +[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" + +[tool.mypy] +ignore_missing_imports = true +show_error_codes = true +exclude = "(^|/)build($|/)" + +[tool.coverage.run] +branch = true +source = ["radicale"] +omit = ["tests/*", "*/tests/*"] + +[tool.coverage.report] +# Regexes for lines to exclude from consideration +exclude_lines = [ + # Have to re-enable the standard pragma + "pragma: no cover", + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + # Don't complain if non-runnable code isn't run: + "if __name__ == .__main__.:", +] diff --git a/radicale/__init__.py b/radicale/__init__.py index 870bf3692..2554e5b25 100644 --- a/radicale/__init__.py +++ b/radicale/__init__.py @@ -2,7 +2,8 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2019 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2024-2024 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 @@ -51,11 +52,16 @@ def _get_application_instance(config_path: str, wsgi_errors: types.ErrorStream configuration = config.load(config.parse_compound_paths( config.DEFAULT_CONFIG_PATH, config_path)) - log.set_level(cast(str, configuration.get("logging", "level"))) + log.set_level(cast(str, configuration.get("logging", "level")), configuration.get("logging", "backtrace_on_debug")) # Log configuration after logger is configured + default_config_active = True for source, miss in configuration.sources(): - logger.info("%s %s", "Skipped missing" if miss + logger.info("%s %s", "Skipped missing/unreadable" if miss else "Loaded", source) + if not miss and source != "default config": + default_config_active = False + if default_config_active: + logger.warning("%s", "No config file found/readable - only default config is active") _application_instance = Application(configuration) if _application_config_path != config_path: raise ValueError("RADICALE_CONFIG must not change: %r != %r" % diff --git a/radicale/__main__.py b/radicale/__main__.py index 209348f13..25d2b8538 100644 --- a/radicale/__main__.py +++ b/radicale/__main__.py @@ -1,6 +1,7 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2011-2017 Guillaume Ayoub -# Copyright © 2017-2019 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2024-2024 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 @@ -141,7 +142,7 @@ def exit_signal_handler(signal_number: int, # Preliminary configure logging with contextlib.suppress(ValueError): log.set_level(config.DEFAULT_CONFIG_SCHEMA["logging"]["level"]["type"]( - vars(args_ns).get("c:logging:level", ""))) + vars(args_ns).get("c:logging:level", "")), True) # Update Radicale configuration according to arguments arguments_config: types.MUTABLE_CONFIG = {} @@ -164,11 +165,17 @@ def exit_signal_handler(signal_number: int, sys.exit(1) # Configure logging - log.set_level(cast(str, configuration.get("logging", "level"))) + log.set_level(cast(str, configuration.get("logging", "level")), configuration.get("logging", "backtrace_on_debug")) # Log configuration after logger is configured + default_config_active = True for source, miss in configuration.sources(): - logger.info("%s %s", "Skipped missing" if miss else "Loaded", source) + logger.info("%s %s", "Skipped missing/unreadable" if miss else "Loaded", source) + if not miss and source != "default config": + default_config_active = False + + if default_config_active: + logger.warning("%s", "No config file found/readable - only default config is active") if args_ns.verify_storage: logger.info("Verifying storage") @@ -176,7 +183,7 @@ def exit_signal_handler(signal_number: int, storage_ = storage.load(configuration) with storage_.acquire_lock("r"): if not storage_.verify(): - logger.critical("Storage verifcation failed") + logger.critical("Storage verification failed") sys.exit(1) except Exception as e: logger.critical("An exception occurred during storage " @@ -198,7 +205,7 @@ def shutdown_signal_handler(signal_number: int, server.serve(configuration, shutdown_socket_out) except Exception as e: logger.critical("An exception occurred during server startup: %s", e, - exc_info=True) + exc_info=False) sys.exit(1) diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index 6896bc701..4f11ad3f8 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -3,6 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2019 Unrud +# Copyright © 2024-2024 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 @@ -68,6 +69,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead, _max_content_length: int _auth_realm: str _extra_headers: Mapping[str, str] + _permit_delete_collection: bool + _permit_overwrite_collection: bool def __init__(self, configuration: config.Configuration) -> None: """Initialize Application. @@ -79,11 +82,18 @@ def __init__(self, configuration: config.Configuration) -> None: """ super().__init__(configuration) self._mask_passwords = configuration.get("logging", "mask_passwords") + self._bad_put_request_content = configuration.get("logging", "bad_put_request_content") + self._request_header_on_debug = configuration.get("logging", "request_header_on_debug") + self._response_content_on_debug = configuration.get("logging", "response_content_on_debug") self._auth_delay = configuration.get("auth", "delay") self._internal_server = configuration.get("server", "_internal_server") self._max_content_length = configuration.get( "server", "max_content_length") self._auth_realm = configuration.get("auth", "realm") + self._permit_delete_collection = configuration.get("rights", "permit_delete_collection") + logger.info("permit delete of collection: %s", self._permit_delete_collection) + self._permit_overwrite_collection = configuration.get("rights", "permit_overwrite_collection") + logger.info("permit overwrite of collection: %s", self._permit_overwrite_collection) self._extra_headers = dict() for key in self.configuration.options("headers"): self._extra_headers[key] = configuration.get("headers", key) @@ -136,7 +146,10 @@ def response(status: int, headers: types.WSGIResponseHeaders, answers = [] if answer is not None: if isinstance(answer, str): - logger.debug("Response content:\n%s", answer) + if self._response_content_on_debug: + logger.debug("Response content:\n%s", answer) + else: + 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 = [ @@ -182,8 +195,11 @@ def response(status: int, headers: types.WSGIResponseHeaders, logger.info("%s request for %r%s received from %s%s", request_method, unsafe_path, depthinfo, remote_host, remote_useragent) - logger.debug("Request headers:\n%s", - pprint.pformat(self._scrub_headers(environ))) + if self._request_header_on_debug: + logger.debug("Request header:\n%s", + pprint.pformat(self._scrub_headers(environ))) + else: + logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug") # SCRIPT_NAME is already removed from PATH_INFO, according to the # WSGI specification. @@ -219,7 +235,7 @@ def response(status: int, headers: types.WSGIResponseHeaders, path.rstrip("/").endswith("/.well-known/carddav")): return response(*httputils.redirect( base_prefix + "/", client.MOVED_PERMANENTLY)) - # Return NOT FOUND for all other paths containing ".well-knwon" + # Return NOT FOUND for all other paths containing ".well-known" if path.endswith("/.well-known") or "/.well-known/" in path: return response(*httputils.NOT_FOUND) @@ -237,6 +253,12 @@ def response(status: int, headers: types.WSGIResponseHeaders, authorization.encode("ascii"))).split(":", 1) user = self._auth.login(login, password) or "" if login else "" + if self.configuration.get("auth", "type") == "ldap": + try: + logger.debug("Groups %r", ",".join(self._auth._ldap_groups)) + self._rights._user_groups = self._auth._ldap_groups + except AttributeError: + pass if user and login == user: logger.info("Successful login: %r", user) elif user: @@ -265,7 +287,14 @@ def response(status: int, headers: types.WSGIResponseHeaders, if "W" in self._rights.authorization(user, principal_path): with self._storage.acquire_lock("w", user): try: - 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(): + try: + self._storage.create_collection(principal_path + name_coll, props=props) + except ValueError as e: + logger.warning("Failed to create predefined collection %r: %s", name_coll, e) except ValueError as e: logger.warning("Failed to create principal " "collection %r: %s", user, e) diff --git a/radicale/app/base.py b/radicale/app/base.py index 4316117d1..28b6f2628 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -1,5 +1,6 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2020 Unrud +# Copyright © 2024-2024 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,8 +22,8 @@ import xml.etree.ElementTree as ET from typing import Optional -from radicale import (auth, config, httputils, pathutils, rights, storage, - types, web, xmlutils) +from radicale import (auth, config, hook, httputils, pathutils, rights, + storage, types, web, xmlutils) from radicale.log import logger # HACK: https://github.com/tiran/defusedxml/issues/54 @@ -38,6 +39,9 @@ class ApplicationBase: _rights: rights.BaseRights _web: web.BaseWeb _encoding: str + _permit_delete_collection: bool + _permit_overwrite_collection: bool + _hook: hook.BaseHook def __init__(self, configuration: config.Configuration) -> None: self.configuration = configuration @@ -46,6 +50,10 @@ def __init__(self, configuration: config.Configuration) -> None: self._rights = rights.load(configuration) self._web = web.load(configuration) self._encoding = configuration.get("encoding", "request") + self._log_bad_put_request_content = configuration.get("logging", "bad_put_request_content") + self._response_content_on_debug = configuration.get("logging", "response_content_on_debug") + self._request_content_on_debug = configuration.get("logging", "request_content_on_debug") + self._hook = hook.load(configuration) def _read_xml_request_body(self, environ: types.WSGIEnviron ) -> Optional[ET.Element]: @@ -60,14 +68,20 @@ def _read_xml_request_body(self, environ: types.WSGIEnviron logger.debug("Request content (Invalid XML):\n%s", content) raise RuntimeError("Failed to parse XML: %s" % e) from e if logger.isEnabledFor(logging.DEBUG): - logger.debug("Request content:\n%s", - xmlutils.pretty_xml(xml_content)) + if self._request_content_on_debug: + logger.debug("Request content (XML):\n%s", + xmlutils.pretty_xml(xml_content)) + else: + logger.debug("Request content (XML): suppressed by config/option [logging] request_content_on_debug") return xml_content def _xml_response(self, xml_content: ET.Element) -> bytes: if logger.isEnabledFor(logging.DEBUG): - logger.debug("Response content:\n%s", - xmlutils.pretty_xml(xml_content)) + if self._response_content_on_debug: + logger.debug("Response content (XML):\n%s", + xmlutils.pretty_xml(xml_content)) + else: + logger.debug("Response content (XML): suppressed by config/option [logging] response_content_on_debug") f = io.BytesIO() ET.ElementTree(xml_content).write(f, encoding=self._encoding, xml_declaration=True) @@ -112,7 +126,7 @@ def parent_permissions(self) -> str: def check(self, permission: str, item: Optional[types.CollectionOrItem] = None) -> bool: - if permission not in "rw": + if permission not in "rwdDoO": raise ValueError("Invalid permission argument: %r" % permission) if not item: permissions = permission + permission.upper() diff --git a/radicale/app/delete.py b/radicale/app/delete.py index 69ae5ab40..ee7550ff4 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -3,6 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2018 Unrud +# Copyright © 2024-2024 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 @@ -23,6 +24,8 @@ from radicale import httputils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase +from radicale.hook import HookNotificationItem, HookNotificationItemTypes +from radicale.log import logger def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection, @@ -67,12 +70,38 @@ def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str, if if_match not in ("*", item.etag): # ETag precondition not verified, do not delete item return httputils.PRECONDITION_FAILED + hook_notification_item_list = [] if isinstance(item, storage.BaseCollection): + if self._permit_delete_collection: + if access.check("d", item): + logger.info("delete of collection is permitted by config/option [rights] permit_delete_collection but explicit forbidden by permission 'd': %s", path) + return httputils.NOT_ALLOWED + else: + if not access.check("D", item): + logger.info("delete of collection is prevented by config/option [rights] permit_delete_collection and not explicit allowed by permission 'D': %s", path) + return httputils.NOT_ALLOWED + for i in item.get_all(): + hook_notification_item_list.append( + HookNotificationItem( + HookNotificationItemTypes.DELETE, + access.path, + i.uid + ) + ) xml_answer = xml_delete(base_prefix, path, item) else: assert item.collection is not None assert item.href is not None + hook_notification_item_list.append( + HookNotificationItem( + HookNotificationItemTypes.DELETE, + access.path, + item.uid + ) + ) xml_answer = xml_delete( base_prefix, path, item.collection, item.href) + 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) diff --git a/radicale/app/get.py b/radicale/app/get.py index 7e5feeb47..d8b015207 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -45,8 +45,8 @@ def propose_filename(collection: storage.BaseCollection) -> str: class ApplicationPartGet(ApplicationBase): - def _content_disposition_attachement(self, filename: str) -> str: - value = "attachement" + def _content_disposition_attachment(self, filename: str) -> str: + value = "attachment" try: encoded_filename = quote(filename, encoding=self._encoding) except UnicodeEncodeError: @@ -91,7 +91,7 @@ def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str, return (httputils.NOT_ALLOWED if limited_access else httputils.DIRECTORY_LISTING) content_type = xmlutils.MIMETYPES[item.tag] - content_disposition = self._content_disposition_attachement( + content_disposition = self._content_disposition_attachment( propose_filename(item)) elif limited_access: return httputils.NOT_ALLOWED diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index 94207e32b..5bccc50c8 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -52,8 +52,12 @@ def do_MKCOL(self, environ: types.WSGIEnviron, base_prefix: str, logger.warning( "Bad MKCOL request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST - if (props.get("tag") and "w" not in permissions or - not props.get("tag") and "W" not in permissions): + collection_type = props.get("tag") or "UNKNOWN" + if props.get("tag") and "w" not in permissions: + logger.warning("MKCOL request %r (type:%s): %s", path, collection_type, "rejected because of missing rights 'w'") + return httputils.NOT_ALLOWED + if not props.get("tag") and "W" not in permissions: + logger.warning("MKCOL request %r (type:%s): %s", path, collection_type, "rejected because of missing rights 'W'") return httputils.NOT_ALLOWED with self._storage.acquire_lock("w", user): item = next(iter(self._storage.discover(path)), None) @@ -71,6 +75,7 @@ def do_MKCOL(self, environ: types.WSGIEnviron, base_prefix: str, self._storage.create_collection(path, props=props) except ValueError as e: logger.warning( - "Bad MKCOL request on %r: %s", path, e, exc_info=True) + "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 diff --git a/radicale/app/move.py b/radicale/app/move.py index fda85257c..5bd8a5793 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -18,6 +18,7 @@ # along with Radicale. If not, see . import posixpath +import re from http import client from urllib.parse import urlparse @@ -26,6 +27,22 @@ from radicale.log import logger +def get_server_netloc(environ: types.WSGIEnviron, force_port: bool = False): + if environ.get("HTTP_X_FORWARDED_HOST"): + 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 + else: + host = environ.get("HTTP_HOST") or environ["SERVER_NAME"] + proto = environ["wsgi.url_scheme"] + port = environ["SERVER_PORT"] + if (not force_port and port == ("443" if proto == "https" else "80") or + re.search(r":\d+$", host)): + return host + return host + ":" + port + + class ApplicationPartMove(ApplicationBase): def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, @@ -33,7 +50,11 @@ def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, """Manage MOVE request.""" raw_dest = environ.get("HTTP_DESTINATION", "") to_url = urlparse(raw_dest) - if to_url.netloc != environ["HTTP_HOST"]: + 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 diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 52d0b00b3..6a3cea6d9 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -85,7 +85,7 @@ def xml_propfind_response( if isinstance(item, storage.BaseCollection): is_collection = True - is_leaf = item.tag in ("VADDRESSBOOK", "VCALENDAR") + is_leaf = item.tag in ("VADDRESSBOOK", "VCALENDAR", "VSUBSCRIBED") collection = item # Some clients expect collections to end with `/` uri = pathutils.unstrip_path(item.path, True) @@ -259,6 +259,10 @@ def xml_propfind_response( child_element = ET.Element( xmlutils.make_clark("C:calendar")) element.append(child_element) + elif collection.tag == "VSUBSCRIBED": + child_element = ET.Element( + xmlutils.make_clark("CS:subscribed")) + element.append(child_element) child_element = ET.Element(xmlutils.make_clark("D:collection")) element.append(child_element) elif tag == xmlutils.make_clark("RADICALE:displayname"): @@ -268,6 +272,12 @@ def xml_propfind_response( element.text = displayname else: is404 = True + elif tag == xmlutils.make_clark("RADICALE:getcontentcount"): + # Only for internal use by the web interface + if isinstance(item, storage.BaseCollection) and not collection.is_principal: + element.text = str(sum(1 for x in item.get_all())) + else: + is404 = True elif tag == xmlutils.make_clark("D:displayname"): displayname = collection.get_meta("D:displayname") if not displayname and is_leaf: @@ -286,6 +296,13 @@ def xml_propfind_response( element.text, _ = collection.sync() else: is404 = True + elif tag == xmlutils.make_clark("CS:source"): + if is_leaf: + child_element = ET.Element(xmlutils.make_clark("D:href")) + child_element.text = collection.get_meta('CS:source') + element.append(child_element) + else: + is404 = True else: human_tag = xmlutils.make_human_tag(tag) tag_text = collection.get_meta(human_tag) @@ -305,13 +322,13 @@ def xml_propfind_response( responses[404 if is404 else 200].append(element) - for status_code, childs in responses.items(): - if not childs: + for status_code, children in responses.items(): + if not children: continue propstat = ET.Element(xmlutils.make_clark("D:propstat")) response.append(propstat) prop = ET.Element(xmlutils.make_clark("D:prop")) - prop.extend(childs) + prop.extend(children) propstat.append(prop) status = ET.Element(xmlutils.make_clark("D:status")) status.text = xmlutils.make_response(status_code) @@ -375,7 +392,8 @@ def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, return httputils.REQUEST_TIMEOUT with self._storage.acquire_lock("r", user): items_iter = iter(self._storage.discover( - path, environ.get("HTTP_DEPTH", "0"))) + path, environ.get("HTTP_DEPTH", "0"), + None, self._rights._user_groups)) # take root item for rights checking item = next(items_iter, None) if not item: diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index 934f53b71..c15fddfe1 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -22,9 +22,12 @@ from http import client from typing import Dict, Optional, cast +import defusedxml.ElementTree as DefusedET + import radicale.item as radicale_item from radicale import httputils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase +from radicale.hook import HookNotificationItem, HookNotificationItemTypes from radicale.log import logger @@ -93,6 +96,16 @@ def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str, try: xml_answer = xml_proppatch(base_prefix, path, xml_content, item) + if xml_content is not None: + hook_notification_item = HookNotificationItem( + HookNotificationItemTypes.CPATCH, + access.path, + DefusedET.tostring( + xml_content, + encoding=self._encoding + ).decode(encoding=self._encoding) + ) + self._hook.notify(hook_notification_item) except ValueError as e: logger.warning( "Bad PROPPATCH request on %r: %s", path, e, exc_info=True) diff --git a/radicale/app/put.py b/radicale/app/put.py index ec495878b..710e44356 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -3,6 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2018 Unrud +# Copyright © 2024-2024 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,6 +31,7 @@ import radicale.item as radicale_item from radicale import httputils, pathutils, rights, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase +from radicale.hook import HookNotificationItem, HookNotificationItemTypes from radicale.log import logger MIMETYPE_TAGS: Mapping[str, str] = {value: key for key, value in @@ -132,7 +134,7 @@ def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, try: content = httputils.read_request_body(self.configuration, environ) except RuntimeError as e: - logger.warning("Bad PUT request on %r: %s", path, e, exc_info=True) + logger.warning("Bad PUT request on %r (read_request_body): %s", path, e, exc_info=True) return httputils.BAD_REQUEST except socket.timeout: logger.debug("Client timed out", exc_info=True) @@ -144,7 +146,11 @@ def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, vobject_items = radicale_item.read_components(content or "") except Exception as e: logger.warning( - "Bad PUT request on %r: %s", path, e, exc_info=True) + "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) + else: + logger.debug("Bad PUT request content: suppressed by config/option [logging] bad_put_request_content") return httputils.BAD_REQUEST (prepared_items, prepared_tag, prepared_write_whole_collection, prepared_props, prepared_exc_info) = prepare( @@ -171,6 +177,14 @@ def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, if write_whole_collection: if ("w" if tag else "W") not in access.permissions: return httputils.NOT_ALLOWED + if not self._permit_overwrite_collection: + if ("O") not in access.permissions: + logger.info("overwrite of collection is prevented by config/option [rights] permit_overwrite_collection and not explicit allowed by permssion 'O': %s", path) + return httputils.NOT_ALLOWED + else: + if ("o") in access.permissions: + logger.info("overwrite of collection is allowed by config/option [rights] permit_overwrite_collection but explicit forbidden by permission 'o': %s", path) + return httputils.NOT_ALLOWED elif "w" not in access.parent_permissions: return httputils.NOT_ALLOWED @@ -198,7 +212,7 @@ def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, props = prepared_props if prepared_exc_info: logger.warning( - "Bad PUT request on %r: %s", path, prepared_exc_info[1], + "Bad PUT request on %r (prepare): %s", path, prepared_exc_info[1], exc_info=prepared_exc_info) return httputils.BAD_REQUEST @@ -206,9 +220,16 @@ def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, try: etag = self._storage.create_collection( path, prepared_items, props).etag + for item in prepared_items: + hook_notification_item = HookNotificationItem( + HookNotificationItemTypes.UPSERT, + access.path, + item.serialize() + ) + self._hook.notify(hook_notification_item) except ValueError as e: logger.warning( - "Bad PUT request on %r: %s", path, e, exc_info=True) + "Bad PUT request on %r (create_collection): %s", path, e, exc_info=True) return httputils.BAD_REQUEST else: assert not isinstance(item, storage.BaseCollection) @@ -222,9 +243,15 @@ def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, href = posixpath.basename(pathutils.strip_path(path)) try: etag = parent_item.upload(href, prepared_item).etag + hook_notification_item = HookNotificationItem( + HookNotificationItemTypes.UPSERT, + access.path, + prepared_item.serialize() + ) + self._hook.notify(hook_notification_item) except ValueError as e: logger.warning( - "Bad PUT request on %r: %s", path, e, exc_info=True) + "Bad PUT request on %r (upload): %s", path, e, exc_info=True) return httputils.BAD_REQUEST headers = {"ETag": etag} diff --git a/radicale/app/report.py b/radicale/app/report.py index 5807f6e6d..d5092db15 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -18,13 +18,20 @@ # along with Radicale. If not, see . import contextlib +import copy +import datetime import posixpath import socket import xml.etree.ElementTree as ET from http import client -from typing import Callable, Iterable, Iterator, Optional, Sequence, Tuple +from typing import (Any, Callable, Iterable, Iterator, List, Optional, + Sequence, Tuple, Union) from urllib.parse import unquote, urlparse +import vobject +import vobject.base +from vobject.base import ContentLine + import radicale.item as radicale_item from radicale import httputils, pathutils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase @@ -32,11 +39,110 @@ from radicale.log import logger +def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], + collection: storage.BaseCollection, encoding: str, + unlock_storage_fn: Callable[[], None], + max_occurrence: int + ) -> Tuple[int, Union[ET.Element, str]]: + # NOTE: this function returns both an Element and a string because + # free-busy reports are an edge-case on the return type according + # to the spec. + + multistatus = ET.Element(xmlutils.make_clark("D:multistatus")) + if xml_request is None: + return client.MULTI_STATUS, multistatus + root = xml_request + if (root.tag == xmlutils.make_clark("C:free-busy-query") and + collection.tag != "VCALENDAR"): + logger.warning("Invalid REPORT method %r on %r requested", + xmlutils.make_human_tag(root.tag), path) + return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report") + + time_range_element = root.find(xmlutils.make_clark("C:time-range")) + assert isinstance(time_range_element, ET.Element) + + # Build a single filter from the free busy query for retrieval + # TODO: filter for VFREEBUSY in additional to VEVENT but + # test_filter doesn't support that yet. + vevent_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"), + attrib={'name': 'VEVENT'}) + vevent_cf_element.append(time_range_element) + vcalendar_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"), + attrib={'name': 'VCALENDAR'}) + vcalendar_cf_element.append(vevent_cf_element) + filter_element = ET.Element(xmlutils.make_clark("C:filter")) + filter_element.append(vcalendar_cf_element) + filters = (filter_element,) + + # First pull from storage + retrieved_items = list(collection.get_filtered(filters)) + # !!! Don't access storage after this !!! + unlock_storage_fn() + + cal = vobject.iCalendar() + collection_tag = collection.tag + while retrieved_items: + # Second filtering before evaluating occurrences. + # ``item.vobject_item`` might be accessed during filtering. + # Don't keep reference to ``item``, because VObject requires a lot of + # memory. + item, filter_matched = retrieved_items.pop(0) + if not filter_matched: + try: + if not test_filter(collection_tag, item, filter_element): + continue + except ValueError as e: + raise ValueError("Failed to free-busy filter item %r from %r: %s" % + (item.href, collection.path, e)) from e + except Exception as e: + raise RuntimeError("Failed to free-busy filter item %r from %r: %s" % + (item.href, collection.path, e)) from e + + fbtype = None + if item.component_name == 'VEVENT': + transp = getattr(item.vobject_item.vevent, 'transp', None) + if transp and transp.value != 'OPAQUE': + continue + + status = getattr(item.vobject_item.vevent, 'status', None) + if not status or status.value == 'CONFIRMED': + fbtype = 'BUSY' + elif status.value == 'CANCELLED': + fbtype = 'FREE' + elif status.value == 'TENTATIVE': + fbtype = 'BUSY-TENTATIVE' + else: + # Could do fbtype = status.value for x-name, I prefer this + fbtype = 'BUSY' + + # TODO: coalesce overlapping periods + + if max_occurrence > 0: + n_occurrences = max_occurrence+1 + else: + n_occurrences = 0 + occurrences = radicale_filter.time_range_fill(item.vobject_item, + time_range_element, + "VEVENT", + n=n_occurrences) + if len(occurrences) >= max_occurrence: + raise ValueError("FREEBUSY occurrences limit of {} hit" + .format(max_occurrence)) + + for occurrence in occurrences: + vfb = cal.add('vfreebusy') + vfb.add('dtstamp').value = item.vobject_item.vevent.dtstamp.value + vfb.add('dtstart').value, vfb.add('dtend').value = occurrence + if fbtype: + vfb.add('fbtype').value = fbtype + return (client.OK, cal.serialize()) + + def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], collection: storage.BaseCollection, encoding: str, unlock_storage_fn: Callable[[], None] ) -> Tuple[int, ET.Element]: - """Read and answer REPORT requests. + """Read and answer REPORT requests that return XML. Read rfc3253-3.6 for info. @@ -64,9 +170,8 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], logger.warning("Invalid REPORT method %r on %r requested", xmlutils.make_human_tag(root.tag), path) return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report") - prop_element = root.find(xmlutils.make_clark("D:prop")) - props = ([prop.tag for prop in prop_element] - if prop_element is not None else []) + + props: Union[ET.Element, List] = root.find(xmlutils.make_clark("D:prop")) or [] hreferences: Iterable[str] if root.tag in ( @@ -138,19 +243,40 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], found_props = [] not_found_props = [] - for tag in props: - element = ET.Element(tag) - if tag == xmlutils.make_clark("D:getetag"): + for prop in props: + element = ET.Element(prop.tag) + if prop.tag == xmlutils.make_clark("D:getetag"): element.text = item.etag found_props.append(element) - elif tag == xmlutils.make_clark("D:getcontenttype"): + elif prop.tag == xmlutils.make_clark("D:getcontenttype"): element.text = xmlutils.get_content_type(item, encoding) found_props.append(element) - elif tag in ( + elif prop.tag in ( xmlutils.make_clark("C:calendar-data"), xmlutils.make_clark("CR:address-data")): element.text = item.serialize() - found_props.append(element) + + expand = prop.find(xmlutils.make_clark("C:expand")) + if expand is not None and item.component_name == 'VEVENT': + start = expand.get('start') + end = expand.get('end') + + if (start is None) or (end is None): + return client.FORBIDDEN, \ + xmlutils.webdav_error("C:expand") + + start = datetime.datetime.strptime( + start, '%Y%m%dT%H%M%SZ' + ).replace(tzinfo=datetime.timezone.utc) + end = datetime.datetime.strptime( + end, '%Y%m%dT%H%M%SZ' + ).replace(tzinfo=datetime.timezone.utc) + + expanded_element = _expand( + element, copy.copy(item), start, end) + found_props.append(expanded_element) + else: + found_props.append(element) else: not_found_props.append(element) @@ -164,6 +290,111 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], return client.MULTI_STATUS, multistatus +def _expand( + element: ET.Element, + item: radicale_item.Item, + start: datetime.datetime, + end: datetime.datetime, +) -> ET.Element: + dt_format = '%Y%m%dT%H%M%SZ' + + if type(item.vobject_item.vevent.dtstart.value) is datetime.date: + # If an event comes to us with a dt_start specified as a date + # then in the response we return the date, not datetime + dt_format = '%Y%m%d' + + expanded_item, rruleset = _make_vobject_expanded_item(item, dt_format) + + if rruleset: + recurrences = rruleset.between(start, end, inc=True) + + expanded: vobject.base.Component = copy.copy(expanded_item.vobject_item) + is_expanded_filled: bool = False + + for recurrence_dt in recurrences: + recurrence_utc = recurrence_dt.astimezone(datetime.timezone.utc) + + vevent = copy.deepcopy(expanded.vevent) + vevent.recurrence_id = ContentLine( + name='RECURRENCE-ID', + value=recurrence_utc.strftime(dt_format), params={} + ) + + if is_expanded_filled is False: + expanded.vevent = vevent + is_expanded_filled = True + else: + expanded.add(vevent) + + element.text = expanded.serialize() + else: + element.text = expanded_item.vobject_item.serialize() + + return element + + +def _make_vobject_expanded_item( + item: radicale_item.Item, + dt_format: str, +) -> Tuple[radicale_item.Item, Optional[Any]]: + # https://www.rfc-editor.org/rfc/rfc4791#section-9.6.5 + # The returned calendar components MUST NOT use recurrence + # properties (i.e., EXDATE, EXRULE, RDATE, and RRULE) and MUST NOT + # have reference to or include VTIMEZONE components. Date and local + # time with reference to time zone information MUST be converted + # into date with UTC time. + + item = copy.copy(item) + vevent = item.vobject_item.vevent + + if type(vevent.dtstart.value) is datetime.date: + start_utc = datetime.datetime.fromordinal( + vevent.dtstart.value.toordinal() + ).replace(tzinfo=datetime.timezone.utc) + else: + start_utc = vevent.dtstart.value.astimezone(datetime.timezone.utc) + + vevent.dtstart = ContentLine(name='DTSTART', value=start_utc, params=[]) + + dt_end = getattr(vevent, 'dtend', None) + if dt_end is not None: + if type(vevent.dtend.value) is datetime.date: + end_utc = datetime.datetime.fromordinal( + dt_end.value.toordinal() + ).replace(tzinfo=datetime.timezone.utc) + else: + end_utc = dt_end.value.astimezone(datetime.timezone.utc) + + vevent.dtend = ContentLine(name='DTEND', value=end_utc, params={}) + + rruleset = None + if hasattr(item.vobject_item.vevent, 'rrule'): + rruleset = vevent.getrruleset() + + # There is something strange behaviour during serialization native datetime, so converting manually + vevent.dtstart.value = vevent.dtstart.value.strftime(dt_format) + if dt_end is not None: + vevent.dtend.value = vevent.dtend.value.strftime(dt_format) + + timezones_to_remove = [] + for component in item.vobject_item.components(): + if component.name == 'VTIMEZONE': + timezones_to_remove.append(component) + + for timezone in timezones_to_remove: + item.vobject_item.remove(timezone) + + try: + delattr(item.vobject_item.vevent, 'rrule') + delattr(item.vobject_item.vevent, 'exdate') + delattr(item.vobject_item.vevent, 'exrule') + delattr(item.vobject_item.vevent, 'rdate') + except AttributeError: + pass + + return item, rruleset + + def xml_item_response(base_prefix: str, href: str, found_props: Sequence[ET.Element] = (), not_found_props: Sequence[ET.Element] = (), @@ -295,13 +526,28 @@ def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str, else: assert item.collection is not None collection = item.collection - try: - status, xml_answer = xml_report( - base_prefix, path, xml_content, collection, self._encoding, - lock_stack.close) - 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) + + if xml_content is not None and \ + xml_content.tag == xmlutils.make_clark("C:free-busy-query"): + max_occurrence = self.configuration.get("reporting", "max_freebusy_occurrence") + try: + status, body = free_busy_report( + base_prefix, path, xml_content, collection, self._encoding, + lock_stack.close, max_occurrence) + 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/calendar; charset=%s" % self._encoding} + return status, headers, str(body) + else: + try: + status, xml_answer = xml_report( + base_prefix, path, xml_content, collection, self._encoding, + lock_stack.close) + 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) diff --git a/radicale/auth/__init__.py b/radicale/auth/__init__.py index 9c4dd1c04..623b20645 100644 --- a/radicale/auth/__init__.py +++ b/radicale/auth/__init__.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 © 2024-2024 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,22 +29,33 @@ """ -from typing import Sequence, Tuple, Union +from typing import Sequence, Set, Tuple, Union from radicale import config, types, utils +from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user", - "htpasswd") + "denyall", + "htpasswd", + "ldap") def load(configuration: "config.Configuration") -> "BaseAuth": """Load the authentication module chosen in configuration.""" + if configuration.get("auth", "type") == "none": + logger.warning("No user authentication is selected: '[auth] type=none' (insecure)") + if configuration.get("auth", "type") == "denyall": + logger.warning("All access is blocked by: '[auth] type=denyall'") return utils.load_plugin(INTERNAL_TYPES, "auth", "Auth", BaseAuth, configuration) class BaseAuth: + _ldap_groups: Set[str] = set([]) + _lc_username: bool + _strip_domain: bool + def __init__(self, configuration: "config.Configuration") -> None: """Initialize BaseAuth. @@ -53,6 +65,8 @@ def __init__(self, configuration: "config.Configuration") -> None: """ self.configuration = configuration + self._lc_username = configuration.get("auth", "lc_username") + self._strip_domain = configuration.get("auth", "strip_domain") def get_external_login(self, environ: types.WSGIEnviron) -> Union[ Tuple[()], Tuple[str, str]]: @@ -67,7 +81,7 @@ def get_external_login(self, environ: types.WSGIEnviron) -> Union[ """ return () - def login(self, login: str, password: str) -> str: + def _login(self, login: str, password: str) -> str: """Check credentials and map login to internal user ``login`` the login name @@ -79,3 +93,10 @@ def login(self, login: str, password: str) -> str: """ raise NotImplementedError + + def login(self, login: str, password: str) -> str: + if self._lc_username: + login = login.lower() + if self._strip_domain: + login = login.split('@')[0] + return self._login(login, password) diff --git a/radicale/auth/denyall.py b/radicale/auth/denyall.py new file mode 100644 index 000000000..5a047e358 --- /dev/null +++ b/radicale/auth/denyall.py @@ -0,0 +1,30 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2024-2024 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 . + +""" +A dummy backend that denies any username and password. + +Used as default for security reasons. + +""" + +from radicale import auth + + +class Auth(auth.BaseAuth): + + def _login(self, login: str, password: str) -> str: + return "" diff --git a/radicale/auth/htpasswd.py b/radicale/auth/htpasswd.py index 872f7277e..7422e16df 100644 --- a/radicale/auth/htpasswd.py +++ b/radicale/auth/htpasswd.py @@ -3,6 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2019 Unrud +# Copyright © 2024 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,12 +23,12 @@ Apache's htpasswd command (httpd.apache.org/docs/programs/htpasswd.html) manages a file for storing user credentials. It can encrypt passwords using -different the methods BCRYPT or MD5-APR1 (a version of MD5 modified for -Apache). MD5-APR1 provides medium security as of 2015. Only BCRYPT can be +different the methods BCRYPT/SHA256/SHA512 or MD5-APR1 (a version of MD5 modified for +Apache). MD5-APR1 provides medium security as of 2015. Only BCRYPT/SHA256/SHA512 can be considered secure by current standards. MD5-APR1-encrypted credentials can be written by all versions of htpasswd (it -is the default, in fact), whereas BCRYPT requires htpasswd 2.4.x or newer. +is the default, in fact), whereas BCRYPT/SHA256/SHA512 requires htpasswd 2.4.x or newer. The `is_authenticated(user, password)` function provided by this module verifies the user-given credentials by parsing the htpasswd credential file @@ -35,15 +36,15 @@ the password encryption method specified via the ``htpasswd_encryption`` configuration value. -The following htpasswd password encrpytion methods are supported by Radicale +The following htpasswd password encryption methods are supported by Radicale out-of-the-box: + - plain-text (created by htpasswd -p ...) -- INSECURE + - MD5-APR1 (htpasswd -m ...) -- htpasswd's default method, INSECURE + - SHA256 (htpasswd -2 ...) + - SHA512 (htpasswd -5 ...) - - plain-text (created by htpasswd -p...) -- INSECURE - - MD5-APR1 (htpasswd -m...) -- htpasswd's default method - -When passlib[bcrypt] is installed: - - - BCRYPT (htpasswd -B...) -- Requires htpasswd 2.4.x +When bcrypt is installed: + - BCRYPT (htpasswd -B ...) -- Requires htpasswd 2.4.x """ @@ -51,9 +52,9 @@ import hmac from typing import Any -from passlib.hash import apr_md5_crypt +from passlib.hash import apr_md5_crypt, sha256_crypt, sha512_crypt -from radicale import auth, config +from radicale import auth, config, logger class Auth(auth.BaseAuth): @@ -67,22 +68,28 @@ def __init__(self, configuration: config.Configuration) -> None: self._encoding = configuration.get("encoding", "stock") encryption: str = configuration.get("auth", "htpasswd_encryption") + logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s'", encryption) + if encryption == "plain": self._verify = self._plain elif encryption == "md5": self._verify = self._md5apr1 - elif encryption == "bcrypt": + elif encryption == "sha256": + self._verify = self._sha256 + elif encryption == "sha512": + self._verify = self._sha512 + elif encryption == "bcrypt" or encryption == "autodetect": try: - from passlib.hash import bcrypt + import bcrypt except ImportError as e: raise RuntimeError( - "The htpasswd encryption method 'bcrypt' requires " - "the passlib[bcrypt] module.") from e - # A call to `encrypt` raises passlib.exc.MissingBackendError with a - # good error message if bcrypt backend is not available. Trigger - # this here. - bcrypt.hash("test-bcrypt-backend") - self._verify = functools.partial(self._bcrypt, bcrypt) + "The htpasswd encryption method 'bcrypt' or 'autodetect' requires " + "the bcrypt module.") from e + if encryption == "bcrypt": + self._verify = functools.partial(self._bcrypt, bcrypt) + else: + self._verify = self._autodetect + self._verify_bcrypt = functools.partial(self._bcrypt, bcrypt) else: raise RuntimeError("The htpasswd encryption method %r is not " "supported." % encryption) @@ -92,12 +99,35 @@ def _plain(self, hash_value: str, password: str) -> bool: return hmac.compare_digest(hash_value.encode(), password.encode()) def _bcrypt(self, bcrypt: Any, hash_value: str, password: str) -> bool: - return bcrypt.verify(password, hash_value.strip()) + return bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hash_value.encode()) def _md5apr1(self, hash_value: str, password: str) -> bool: return apr_md5_crypt.verify(password, hash_value.strip()) - def login(self, login: str, password: str) -> str: + def _sha256(self, hash_value: str, password: str) -> bool: + return sha256_crypt.verify(password, hash_value.strip()) + + def _sha512(self, hash_value: str, password: str) -> bool: + return sha512_crypt.verify(password, hash_value.strip()) + + def _autodetect(self, hash_value: str, password: str) -> bool: + if hash_value.startswith("$apr1$", 0, 6) and len(hash_value) == 37: + # MD5-APR1 + return self._md5apr1(hash_value, password) + elif hash_value.startswith("$2y$", 0, 4) and len(hash_value) == 60: + # BCRYPT + return self._verify_bcrypt(hash_value, password) + elif hash_value.startswith("$5$", 0, 3) and len(hash_value) == 63: + # SHA-256 + return self._sha256(hash_value, password) + elif hash_value.startswith("$6$", 0, 3) and len(hash_value) == 106: + # SHA-512 + return self._sha512(hash_value, password) + else: + # assumed plaintext + return self._plain(hash_value, password) + + def _login(self, login: str, password: str) -> str: """Validate credentials. Iterate through htpasswd credential file until login matches, extract diff --git a/radicale/auth/ldap.py b/radicale/auth/ldap.py new file mode 100644 index 000000000..3c87561e2 --- /dev/null +++ b/radicale/auth/ldap.py @@ -0,0 +1,184 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright 2022 Peter Varkoly +# +# 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 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_filter The search filter to find the user to authenticate by the username + ldap_load_groups If the groups of the authenticated users need to be loaded +Following parameters controls SSL connections: + ldap_use_ssl If the connection + ldap_ssl_verify_mode The certifikat verification mode. NONE, OPTIONAL, default is REQUIRED + ldap_ssl_ca_file + +""" +import ssl + +from radicale import auth, config +from radicale.log import logger + + +class Auth(auth.BaseAuth): + _ldap_uri: str + _ldap_base: str + _ldap_reader_dn: str + _ldap_secret: str + _ldap_filter: str + _ldap_load_groups: bool + _ldap_version: int = 3 + _ldap_use_ssl: bool = False + _ldap_ssl_verify_mode: int = ssl.CERT_REQUIRED + _ldap_ssl_ca_file: str = "" + + def __init__(self, configuration: config.Configuration) -> None: + super().__init__(configuration) + try: + import ldap3 + self.ldap3 = ldap3 + except ImportError: + try: + import ldap + self._ldap_version = 2 + self.ldap = ldap + except ImportError as e: + raise RuntimeError("LDAP authentication requires the ldap3 module") from e + 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") + self._ldap_load_groups = configuration.get("auth", "ldap_load_groups") + self._ldap_secret = configuration.get("auth", "ldap_secret") + self._ldap_filter = configuration.get("auth", "ldap_filter") + if self._ldap_version == 3: + self._ldap_use_ssl = configuration.get("auth", "ldap_use_ssl") + if self._ldap_use_ssl: + 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 + + 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.set_option(self.ldap.OPT_REFERRALS, 0) + conn.simple_bind_s(self._ldap_reader_dn, self._ldap_secret) + """Search for the dn of user to authenticate""" + res = conn.search_s(self._ldap_base, self.ldap.SCOPE_SUBTREE, filterstr=self._ldap_filter.format(login), attrlist=['memberOf']) + if len(res) == 0: + """User could not be find""" + return "" + user_dn = res[0][0] + logger.debug("LDAP Auth user: %s", user_dn) + """Close ldap connection""" + conn.unbind() + 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_load_groups: + tmp = [] + for t in res[0][1]['memberOf']: + tmp.append(t.decode('utf-8').split(',')[0][3:]) + self._ldap_groups = set(tmp) + logger.debug("LDAP Auth groups of user: %s", ",".join(self._ldap_groups)) + conn.unbind() + return login + except self.ldap.INVALID_CREDENTIALS: + return "" + + def _login3(self, login: str, password: str) -> str: + """Connect the server""" + try: + logger.debug(f"_login3 {self._ldap_uri}, {self._ldap_reader_dn}") + if self._ldap_use_ssl: + tls = self.ldap3.Tls(validate=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 + ) + server = self.ldap3.Server(self._ldap_uri, use_ssl=True, tls=tls) + else: + server = self.ldap3.Server(self._ldap_uri) + conn = self.ldap3.Connection(server, self._ldap_reader_dn, password=self._ldap_secret) + except self.ldap3.core.exceptions.LDAPSocketOpenError: + raise RuntimeError("Unable to reach ldap server") + except Exception as e: + logger.debug(f"_login3 error 1 {e}") + pass + + if not conn.bind(): + logger.debug("_login3 can not bind") + raise RuntimeError("Unable to read from ldap server") + + logger.debug(f"_login3 bind as {self._ldap_reader_dn}") + """Search the user dn""" + conn.search( + search_base=self._ldap_base, + search_filter=self._ldap_filter.format(login), + search_scope=self.ldap3.SUBTREE, + attributes=['memberOf'] + ) + if len(conn.entries) == 0: + logger.debug(f"_login3 user '{login}' can not be find") + """User could not be find""" + return "" + + user_entry = conn.response[0] + conn.unbind() + user_dn = user_entry['dn'] + logger.debug(f"_login3 found user_dn {user_dn}") + try: + """Try to bind as the user itself""" + conn = self.ldap3.Connection(server, user_dn, password=password) + if not conn.bind(): + logger.debug(f"_login3 user '{login}' can not be find") + return "" + if self._ldap_load_groups: + tmp = [] + for g in user_entry['attributes']['memberOf']: + tmp.append(g.split(',')[0][3:]) + self._ldap_groups = set(tmp) + conn.unbind() + logger.debug(f"_login3 {login} successfully authorized") + return login + except Exception as e: + logger.debug(f"_login3 error 2 {e}") + pass + return "" + + def login(self, login: str, password: str) -> str: + """Validate credentials. + In first step we make a connection to the ldap server with the ldap_reader_dn credential. + In next step the DN of the user to authenticate will be searched. + In the last step the authentication of the user will be proceeded. + """ + if self._ldap_version == 2: + return self._login2(login, password) + return self._login3(login, password) diff --git a/radicale/auth/none.py b/radicale/auth/none.py index ce2b1c869..be451feba 100644 --- a/radicale/auth/none.py +++ b/radicale/auth/none.py @@ -27,5 +27,5 @@ class Auth(auth.BaseAuth): - def login(self, login: str, password: str) -> str: + def _login(self, login: str, password: str) -> str: return login diff --git a/radicale/config.py b/radicale/config.py index 02a0b3814..241f6380a 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -2,7 +2,8 @@ # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter -# Copyright © 2017-2019 Unrud +# Copyright © 2017-2020 Unrud +# Copyright © 2024-2024 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,6 +27,7 @@ """ import contextlib +import json import math import os import string @@ -35,7 +37,8 @@ from typing import (Any, Callable, ClassVar, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union) -from radicale import auth, rights, storage, types, web +from radicale import auth, hook, rights, storage, types, web +from radicale.item import check_and_sanitize_props DEFAULT_CONFIG_PATH: str = os.pathsep.join([ "?/etc/radicale/config", @@ -101,6 +104,16 @@ def _convert_to_bool(value: Any) -> bool: return RawConfigParser.BOOLEAN_STATES[value.lower()] +def json_str(value: Any) -> dict: + if not value: + return {} + ret = json.loads(value) + for (name_coll, props) in ret.items(): + checked_props = check_and_sanitize_props(props) + ret[name_coll] = checked_props + return ret + + INTERNAL_OPTIONS: Sequence[str] = ("_allow_extra",) # Default configuration DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ @@ -167,7 +180,7 @@ def _convert_to_bool(value: Any) -> bool: "help": "htpasswd filename", "type": filepath}), ("htpasswd_encryption", { - "value": "md5", + "value": "autodetect", "help": "htpasswd encryption method", "type": str}), ("realm", { @@ -177,13 +190,65 @@ def _convert_to_bool(value: Any) -> bool: ("delay", { "value": "1", "help": "incorrect authentication delay", - "type": positive_float})])), + "type": positive_float}), + ("ldap_uri", { + "value": "ldap://localhost", + "help": "URI to the ldap server", + "type": str}), + ("ldap_base", { + "value": "none", + "help": "LDAP base DN of the ldap server", + "type": str}), + ("ldap_reader_dn", { + "value": "none", + "help": "the DN of a ldap user with read access to get the user accounts", + "type": str}), + ("ldap_secret", { + "value": "none", + "help": "the password of the ldap_reader_dn", + "type": str}), + ("ldap_filter", { + "value": "(cn={0})", + "help": "the search filter to find the user DN to authenticate by the username", + "type": str}), + ("ldap_load_groups", { + "value": "False", + "help": "load the ldap groups of the authenticated user", + "type": bool}), + ("ldap_use_ssl", { + "value": "False", + "help": "Use ssl on the ldap connection", + "type": bool}), + ("ldap_ssl_verify_mode", { + "value": "REQUIRED", + "help": "The certifikat verification mode. 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", + "type": str}), + ("strip_domain", { + "value": "False", + "help": "strip domain from username", + "type": bool}), + ("lc_username", { + "value": "False", + "help": "convert username to lowercase, must be true for case-insensitive auth providers", + "type": bool})])), ("rights", OrderedDict([ ("type", { "value": "owner_only", "help": "rights backend", "type": str_or_callable, "internal": rights.INTERNAL_TYPES}), + ("permit_delete_collection", { + "value": "True", + "help": "permit delete of a collection", + "type": bool}), + ("permit_overwrite_collection", { + "value": "True", + "help": "permit overwrite of a collection", + "type": bool}), ("file", { "value": "/etc/radicale/rights", "help": "file for rights management from_file", @@ -202,6 +267,10 @@ def _convert_to_bool(value: Any) -> bool: "value": "2592000", # 30 days "help": "delete sync token that are older", "type": positive_int}), + ("skip_broken_item", { + "value": "True", + "help": "skip broken item instead of triggering exception", + "type": bool}), ("hook", { "value": "", "help": "command that is run after changes to storage", @@ -209,7 +278,29 @@ def _convert_to_bool(value: Any) -> bool: ("_filesystem_fsync", { "value": "True", "help": "sync all changes to filesystem during requests", - "type": bool})])), + "type": bool}), + ("predefined_collections", { + "value": "", + "help": "predefined user collections", + "type": json_str})])), + ("hook", OrderedDict([ + ("type", { + "value": "none", + "help": "hook backend", + "type": str, + "internal": hook.INTERNAL_TYPES}), + ("rabbitmq_endpoint", { + "value": "", + "help": "endpoint where rabbitmq server is running", + "type": str}), + ("rabbitmq_topic", { + "value": "", + "help": "topic to declare queue", + "type": str}), + ("rabbitmq_queue_type", { + "value": "", + "help": "queue type for topic declaration", + "type": str})])), ("web", OrderedDict([ ("type", { "value": "internal", @@ -218,15 +309,45 @@ def _convert_to_bool(value: Any) -> bool: "internal": web.INTERNAL_TYPES})])), ("logging", OrderedDict([ ("level", { - "value": "warning", + "value": "info", "help": "threshold for the logger", "type": logging_level}), + ("bad_put_request_content", { + "value": "False", + "help": "log bad PUT request content", + "type": bool}), + ("backtrace_on_debug", { + "value": "False", + "help": "log backtrace on level=debug", + "type": bool}), + ("request_header_on_debug", { + "value": "False", + "help": "log request header on level=debug", + "type": bool}), + ("request_content_on_debug", { + "value": "False", + "help": "log request content on level=debug", + "type": bool}), + ("response_content_on_debug", { + "value": "False", + "help": "log response content on level=debug", + "type": bool}), + ("rights_rule_doesnt_match_on_debug", { + "value": "False", + "help": "log rights rules which doesn't match on level=debug", + "type": bool}), ("mask_passwords", { "value": "True", "help": "mask passwords in logs", "type": bool})])), ("headers", OrderedDict([ - ("_allow_extra", str)]))]) + ("_allow_extra", str)])), + ("reporting", OrderedDict([ + ("max_freebusy_occurrence", { + "value": "10000", + "help": "number of occurrences per event when reporting", + "type": positive_int})])) + ]) def parse_compound_paths(*compound_paths: Optional[str] @@ -278,7 +399,7 @@ def load(paths: Optional[Iterable[Tuple[str, bool]]] = None config_source = "config file %r" % path config: types.CONFIG try: - with open(path, "r") as f: + with open(path) as f: parser.read_file(f) config = {s: {o: parser[s][o] for o in parser.options(s)} for s in parser.sections()} diff --git a/radicale/hook/__init__.py b/radicale/hook/__init__.py new file mode 100644 index 000000000..1f39c9e1a --- /dev/null +++ b/radicale/hook/__init__.py @@ -0,0 +1,69 @@ +import json +from enum import Enum +from typing import Sequence + +from radicale import pathutils, utils +from radicale.log import logger + +INTERNAL_TYPES: Sequence[str] = ("none", "rabbitmq") + + +def load(configuration): + """Load the storage module chosen in configuration.""" + try: + return utils.load_plugin( + INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration) + except Exception as e: + logger.warning(e) + logger.warning("Hook \"%s\" failed to load, falling back to \"none\"." % configuration.get("hook", "type")) + configuration = configuration.copy() + configuration.update({"hook": {"type": "none"}}, "hook", privileged=True) + return utils.load_plugin( + INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration) + + +class BaseHook: + def __init__(self, configuration): + """Initialize BaseHook. + + ``configuration`` see ``radicale.config`` module. + The ``configuration`` must not change during the lifetime of + this object, it is kept as an internal reference. + + """ + self.configuration = configuration + + def notify(self, notification_item): + """Upload a new or replace an existing item.""" + raise NotImplementedError + + +class HookNotificationItemTypes(Enum): + CPATCH = "cpatch" + UPSERT = "upsert" + DELETE = "delete" + + +def _cleanup(path): + sane_path = pathutils.strip_path(path) + attributes = sane_path.split("/") if sane_path else [] + + if len(attributes) < 2: + return "" + return attributes[0] + "/" + attributes[1] + + +class HookNotificationItem: + + def __init__(self, notification_item_type, path, content): + self.type = notification_item_type.value + self.point = _cleanup(path) + self.content = content + + def to_json(self): + return json.dumps( + self, + default=lambda o: o.__dict__, + sort_keys=True, + indent=4 + ) diff --git a/radicale/hook/none.py b/radicale/hook/none.py new file mode 100644 index 000000000..b770ab67b --- /dev/null +++ b/radicale/hook/none.py @@ -0,0 +1,6 @@ +from radicale import hook + + +class Hook(hook.BaseHook): + def notify(self, notification_item): + """Notify nothing. Empty hook.""" diff --git a/radicale/hook/rabbitmq/__init__.py b/radicale/hook/rabbitmq/__init__.py new file mode 100644 index 000000000..2323ed43c --- /dev/null +++ b/radicale/hook/rabbitmq/__init__.py @@ -0,0 +1,50 @@ +import pika +from pika.exceptions import ChannelWrongStateError, StreamLostError + +from radicale import hook +from radicale.hook import HookNotificationItem +from radicale.log import logger + + +class Hook(hook.BaseHook): + + def __init__(self, configuration): + super().__init__(configuration) + self._endpoint = configuration.get("hook", "rabbitmq_endpoint") + self._topic = configuration.get("hook", "rabbitmq_topic") + self._queue_type = configuration.get("hook", "rabbitmq_queue_type") + self._encoding = configuration.get("encoding", "stock") + + self._make_connection_synced() + self._make_declare_queue_synced() + + def _make_connection_synced(self): + parameters = pika.URLParameters(self._endpoint) + connection = pika.BlockingConnection(parameters) + self._channel = connection.channel() + + def _make_declare_queue_synced(self): + self._channel.queue_declare(queue=self._topic, durable=True, arguments={"x-queue-type": self._queue_type}) + + def notify(self, notification_item): + if isinstance(notification_item, HookNotificationItem): + self._notify(notification_item, True) + + def _notify(self, notification_item, recall): + try: + self._channel.basic_publish( + exchange='', + routing_key=self._topic, + body=notification_item.to_json().encode( + encoding=self._encoding + ) + ) + except Exception as e: + if (isinstance(e, ChannelWrongStateError) or + isinstance(e, StreamLostError)) and recall: + self._make_connection_synced() + self._notify(notification_item, False) + return + logger.error("An exception occurred during " + "publishing hook notification item: %s", + e, exc_info=True) diff --git a/radicale/httputils.py b/radicale/httputils.py index 8255615ff..04898b40e 100644 --- a/radicale/httputils.py +++ b/radicale/httputils.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 © 2024-2024 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 @@ -142,7 +143,10 @@ def read_request_body(configuration: "config.Configuration", environ: types.WSGIEnviron) -> str: content = decode_request(configuration, environ, read_raw_request_body(configuration, environ)) - logger.debug("Request content:\n%s", content) + if configuration.get("logging", "request_content_on_debug"): + logger.debug("Request content:\n%s", content) + else: + logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug") return content diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 4a3fc22e6..a05304ffc 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -49,7 +49,13 @@ def read_components(s: str) -> List[vobject.base.Component]: s = re.sub(r"^(PHOTO(?:;[^:\r\n]*)?;ENCODING=b(?:;[^:\r\n]*)?:)" r"data:[^;,\r\n]*;base64,", r"\1", s, flags=re.MULTILINE | re.IGNORECASE) - return list(vobject.readComponents(s)) + # Workaround for bug with malformed ICS files containing control codes + # Filter out all control codes except those we expect to find: + # * 0x09 Horizontal Tab + # * 0x0A Line Feed + # * 0x0D Carriage Return + s = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F]', '', s) + return list(vobject.readComponents(s, allowQP=True)) def predict_tag_of_parent_collection( @@ -91,7 +97,7 @@ def check_and_sanitize_items( The ``tag`` of the collection. """ - if tag and tag not in ("VCALENDAR", "VADDRESSBOOK"): + if tag and tag not in ("VCALENDAR", "VADDRESSBOOK", "VSUBSCRIBED"): raise ValueError("Unsupported collection tag: %r" % tag) if not is_collection and len(vobject_items) != 1: raise ValueError("Item contains %d components" % len(vobject_items)) @@ -164,7 +170,7 @@ def check_and_sanitize_items( ref_value_param = component.dtstart.params.get("VALUE") for dates in chain(component.contents.get("exdate", []), component.contents.get("rdate", [])): - if all(type(d) == type(ref_date) for d in dates.value): + if all(type(d) is type(ref_date) for d in dates.value): continue for i, date in enumerate(dates.value): dates.value[i] = ref_date.replace( @@ -230,7 +236,7 @@ def check_and_sanitize_props(props: MutableMapping[Any, Any] raise ValueError("Value of %r must be %r not %r: %r" % ( k, str.__name__, type(v).__name__, v)) if k == "tag": - if v not in ("", "VCALENDAR", "VADDRESSBOOK"): + if v not in ("", "VCALENDAR", "VADDRESSBOOK", "VSUBSCRIBED"): raise ValueError("Unsupported collection tag: %r" % v) return props @@ -298,7 +304,7 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str Returns a tuple (``start``, ``end``) where ``start`` and ``end`` are POSIX timestamps. - This is intened to be used for matching against simplified prefilters. + This is intended to be used for matching against simplified prefilters. """ if not tag: diff --git a/radicale/item/filter.py b/radicale/item/filter.py index 587dc3671..cb3e8cdbd 100644 --- a/radicale/item/filter.py +++ b/radicale/item/filter.py @@ -48,10 +48,34 @@ def date_to_datetime(d: date) -> datetime: if not isinstance(d, datetime): d = datetime.combine(d, datetime.min.time()) if not d.tzinfo: - d = d.replace(tzinfo=timezone.utc) + # NOTE: using vobject's UTC as it wasn't playing well with datetime's. + d = d.replace(tzinfo=vobject.icalendar.utc) return d +def parse_time_range(time_filter: ET.Element) -> Tuple[datetime, datetime]: + start_text = time_filter.get("start") + end_text = time_filter.get("end") + if start_text: + start = datetime.strptime( + start_text, "%Y%m%dT%H%M%SZ").replace( + tzinfo=timezone.utc) + else: + start = DATETIME_MIN + if end_text: + end = datetime.strptime( + end_text, "%Y%m%dT%H%M%SZ").replace( + tzinfo=timezone.utc) + else: + end = DATETIME_MAX + return start, end + + +def time_range_timestamps(time_filter: ET.Element) -> Tuple[int, int]: + start, end = parse_time_range(time_filter) + return (math.floor(start.timestamp()), math.ceil(end.timestamp())) + + def comp_match(item: "item.Item", filter_: ET.Element, level: int = 0) -> bool: """Check whether the ``item`` matches the comp ``filter_``. @@ -147,21 +171,10 @@ def time_range_match(vobject_item: vobject.base.Component, """Check whether the component/property ``child_name`` of ``vobject_item`` matches the time-range ``filter_``.""" - start_text = filter_.get("start") - end_text = filter_.get("end") - if not start_text and not end_text: + if not filter_.get("start") and not filter_.get("end"): return False - if start_text: - start = datetime.strptime(start_text, "%Y%m%dT%H%M%SZ") - else: - start = datetime.min - if end_text: - end = datetime.strptime(end_text, "%Y%m%dT%H%M%SZ") - else: - end = datetime.max - start = start.replace(tzinfo=timezone.utc) - end = end.replace(tzinfo=timezone.utc) + start, end = parse_time_range(filter_) matched = False def range_fn(range_start: datetime, range_end: datetime, @@ -181,6 +194,35 @@ def infinity_fn(start: datetime) -> bool: return matched +def time_range_fill(vobject_item: vobject.base.Component, + filter_: ET.Element, child_name: str, n: int = 1 + ) -> List[Tuple[datetime, datetime]]: + """Create a list of ``n`` occurances from the component/property ``child_name`` + of ``vobject_item``.""" + if not filter_.get("start") and not filter_.get("end"): + return [] + + start, end = parse_time_range(filter_) + ranges: List[Tuple[datetime, datetime]] = [] + + def range_fn(range_start: datetime, range_end: datetime, + is_recurrence: bool) -> bool: + nonlocal ranges + if start < range_end and range_start < end: + ranges.append((range_start, range_end)) + if n > 0 and len(ranges) >= n: + return True + if end < range_start and not is_recurrence: + return True + return False + + def infinity_fn(range_start: datetime) -> bool: + return False + + visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn) + return ranges + + def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str, range_fn: Callable[[datetime, datetime, bool], bool], infinity_fn: Callable[[datetime], bool]) -> None: @@ -199,7 +241,7 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str, """ - # HACK: According to rfc5545-3.8.4.4 an recurrance that is resheduled + # HACK: According to rfc5545-3.8.4.4 a recurrence that is rescheduled # with Recurrence ID affects the recurrence itself and all following # recurrences too. This is not respected and client don't seem to bother # either. @@ -225,6 +267,7 @@ def getrruleset(child: vobject.base.Component, ignore: Sequence[date] def get_children(components: Iterable[vobject.base.Component]) -> Iterator[ Tuple[vobject.base.Component, bool, List[date]]]: main = None + rec_main = None recurrences = [] for comp in components: if hasattr(comp, "recurrence_id") and comp.recurrence_id.value: @@ -232,11 +275,14 @@ def get_children(components: Iterable[vobject.base.Component]) -> Iterator[ if comp.rruleset: # Prevent possible infinite loop raise ValueError("Overwritten recurrence with RRULESET") + rec_main = comp yield comp, True, [] else: if main is not None: raise ValueError("Multiple main components") main = comp + if main is None and len(recurrences) == 1: + main = rec_main if main is None: raise ValueError("Main component missing") yield main, False, recurrences @@ -468,7 +514,15 @@ def match(value: str) -> bool: match(attrib) for child in children for attrib in child.params.get(attrib_name, [])) else: - condition = any(match(child.value) for child in children) + res = [] + for child in children: + # Some filters such as CATEGORIES provide a list in child.value + if type(child.value) is list: + for value in child.value: + res.append(match(value)) + else: + res.append(match(child.value)) + condition = any(res) if filter_.get("negate-condition") == "yes": return not condition return condition @@ -531,20 +585,7 @@ def simplify_prefilters(filters: Iterable[ET.Element], collection_tag: str if time_filter.tag != xmlutils.make_clark("C:time-range"): simple = False continue - start_text = time_filter.get("start") - end_text = time_filter.get("end") - if start_text: - start = math.floor(datetime.strptime( - start_text, "%Y%m%dT%H%M%SZ").replace( - tzinfo=timezone.utc).timestamp()) - else: - start = TIMESTAMP_MIN - if end_text: - end = math.ceil(datetime.strptime( - end_text, "%Y%m%dT%H%M%SZ").replace( - tzinfo=timezone.utc).timestamp()) - else: - end = TIMESTAMP_MAX + start, end = time_range_timestamps(time_filter) return tag, start, end, simple return tag, TIMESTAMP_MIN, TIMESTAMP_MAX, simple return None, TIMESTAMP_MIN, TIMESTAMP_MAX, simple diff --git a/radicale/log.py b/radicale/log.py index eaa842bfb..313b4933b 100644 --- a/radicale/log.py +++ b/radicale/log.py @@ -1,6 +1,7 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2011-2017 Guillaume Ayoub -# Copyright © 2017-2019 Unrud +# Copyright © 2017-2023 Unrud +# Copyright © 2024-2024 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,16 +26,25 @@ """ +import contextlib +import io import logging import os +import socket +import struct import sys import threading -from typing import Any, Callable, ClassVar, Dict, Iterator, Union +import time +from typing import (Any, Callable, ClassVar, Dict, Iterator, Mapping, Optional, + Tuple, Union, cast) from radicale import types LOGGER_NAME: str = "radicale" -LOGGER_FORMAT: str = "[%(asctime)s] [%(ident)s] [%(levelname)s] %(message)s" +LOGGER_FORMATS: Mapping[str, str] = { + "verbose": "[%(asctime)s] [%(ident)s] [%(levelname)s] %(message)s", + "journal": "[%(ident)s] [%(levelname)s] %(message)s", +} DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S %z" logger: logging.Logger = logging.getLogger(LOGGER_NAME) @@ -59,12 +69,17 @@ def __init__(self, upstream_factory: Callable[..., logging.LogRecord] def __call__(self, *args: Any, **kwargs: Any) -> logging.LogRecord: record = self._upstream_factory(*args, **kwargs) - ident = "%d" % os.getpid() - main_thread = threading.main_thread() - current_thread = threading.current_thread() - if current_thread.name and main_thread != current_thread: - ident += "/%s" % current_thread.name + ident = ("%d" % record.process if record.process is not None + else record.processName or "unknown") + tid = None + if record.thread is not None: + if record.thread != threading.main_thread().ident: + ident += "/%s" % (record.threadName or "unknown") + if (sys.version_info >= (3, 8) and + record.thread == threading.get_ident()): + tid = threading.get_native_id() record.ident = ident # type:ignore[attr-defined] + record.tid = tid # type:ignore[attr-defined] return record @@ -75,19 +90,102 @@ class ThreadedStreamHandler(logging.Handler): terminator: ClassVar[str] = "\n" _streams: Dict[int, types.ErrorStream] + _journal_stream_id: Optional[Tuple[int, int]] + _journal_socket: Optional[socket.socket] + _journal_socket_failed: bool + _formatters: Mapping[str, logging.Formatter] + _formatter: Optional[logging.Formatter] - def __init__(self) -> None: + def __init__(self, format_name: Optional[str] = None) -> None: super().__init__() self._streams = {} + self._journal_stream_id = None + with contextlib.suppress(TypeError, ValueError): + dev, inode = os.environ.get("JOURNAL_STREAM", "").split(":", 1) + self._journal_stream_id = (int(dev), int(inode)) + self._journal_socket = None + self._journal_socket_failed = False + self._formatters = {name: logging.Formatter(fmt, DATE_FORMAT) + for name, fmt in LOGGER_FORMATS.items()} + self._formatter = (self._formatters[format_name] + if format_name is not None else None) + + def _get_formatter(self, default_format_name: str) -> logging.Formatter: + return self._formatter or self._formatters[default_format_name] + + def _detect_journal(self, stream: types.ErrorStream) -> bool: + if not self._journal_stream_id or not isinstance(stream, io.IOBase): + return False + try: + stat = os.fstat(stream.fileno()) + except OSError: + return False + return self._journal_stream_id == (stat.st_dev, stat.st_ino) + + @staticmethod + def _encode_journal(data: Mapping[str, Optional[Union[str, int]]] + ) -> bytes: + msg = b"" + for key, value in data.items(): + if value is None: + continue + keyb = key.encode() + valueb = str(value).encode() + if b"\n" in valueb: + msg += (keyb + b"\n" + + struct.pack(" bool: + if not self._journal_socket: + # Try to connect to systemd journal socket + if self._journal_socket_failed or not hasattr(socket, "AF_UNIX"): + return False + journal_socket = None + try: + journal_socket = socket.socket( + socket.AF_UNIX, socket.SOCK_DGRAM) + journal_socket.connect("/run/systemd/journal/socket") + except OSError as e: + self._journal_socket_failed = True + if journal_socket: + journal_socket.close() + # Log after setting `_journal_socket_failed` to prevent loop! + logger.error("Failed to connect to systemd journal: %s", + e, exc_info=True) + return False + self._journal_socket = journal_socket + + priority = {"DEBUG": 7, + "INFO": 6, + "WARNING": 4, + "ERROR": 3, + "CRITICAL": 2}.get(record.levelname, 4) + timestamp = time.strftime("%Y-%m-%dT%H:%M:%S.%%03dZ", + time.gmtime(record.created)) % record.msecs + data = {"PRIORITY": priority, + "TID": cast(Optional[int], getattr(record, "tid", None)), + "SYSLOG_IDENTIFIER": record.name, + "SYSLOG_FACILITY": 1, + "SYSLOG_PID": record.process, + "SYSLOG_TIMESTAMP": timestamp, + "CODE_FILE": record.pathname, + "CODE_LINE": record.lineno, + "CODE_FUNC": record.funcName, + "MESSAGE": self._get_formatter("journal").format(record)} + self._journal_socket.sendall(self._encode_journal(data)) + return True def emit(self, record: logging.LogRecord) -> None: try: stream = self._streams.get(threading.get_ident(), sys.stderr) - msg = self.format(record) - stream.write(msg) - stream.write(self.terminator) - if hasattr(stream, "flush"): - stream.flush() + if self._detect_journal(stream) and self._try_emit_journal(record): + return + msg = self._get_formatter("verbose").format(record) + stream.write(msg + self.terminator) + stream.flush() except Exception: self.handleError(record) @@ -111,21 +209,30 @@ def register_stream(stream: types.ErrorStream) -> Iterator[None]: def setup() -> None: """Set global logging up.""" global register_stream - handler = ThreadedStreamHandler() - logging.basicConfig(format=LOGGER_FORMAT, datefmt=DATE_FORMAT, - handlers=[handler]) + format_name = os.environ.get("RADICALE_LOG_FORMAT") or None + sane_format_name = format_name if format_name in LOGGER_FORMATS else None + handler = ThreadedStreamHandler(sane_format_name) + logging.basicConfig(handlers=[handler]) register_stream = handler.register_stream log_record_factory = IdentLogRecordFactory(logging.getLogRecordFactory()) logging.setLogRecordFactory(log_record_factory) - set_level(logging.WARNING) + set_level(logging.INFO, True) + if format_name != sane_format_name: + logger.error("Invalid RADICALE_LOG_FORMAT: %r", format_name) -def set_level(level: Union[int, str]) -> None: +def set_level(level: Union[int, str], backtrace_on_debug: bool) -> None: """Set logging level for global logger.""" if isinstance(level, str): level = getattr(logging, level.upper()) assert isinstance(level, int) logger.setLevel(level) - logger.removeFilter(REMOVE_TRACEBACK_FILTER) if level > logging.DEBUG: + logger.info("Logging of backtrace is disabled in this loglevel") logger.addFilter(REMOVE_TRACEBACK_FILTER) + else: + if not backtrace_on_debug: + logger.debug("Logging of backtrace is disabled by option in this loglevel") + logger.addFilter(REMOVE_TRACEBACK_FILTER) + else: + logger.removeFilter(REMOVE_TRACEBACK_FILTER) diff --git a/radicale/rights/__init__.py b/radicale/rights/__init__.py index 1b8986592..7aec9d4e2 100644 --- a/radicale/rights/__init__.py +++ b/radicale/rights/__init__.py @@ -32,7 +32,7 @@ """ -from typing import Sequence +from typing import Sequence, Set from radicale import config, utils @@ -57,6 +57,8 @@ def intersect(a: str, b: str) -> str: class BaseRights: + _user_groups: Set[str] = set([]) + def __init__(self, configuration: "config.Configuration") -> None: """Initialize BaseRights. diff --git a/radicale/rights/from_file.py b/radicale/rights/from_file.py index 01fa2fb76..20928a643 100644 --- a/radicale/rights/from_file.py +++ b/radicale/rights/from_file.py @@ -22,7 +22,7 @@ The login is matched against the "user" key, and the collection path is matched against the "collection" key. In the "collection" regex you can use `{user}` and get groups from the "user" regex with `{0}`, `{1}`, etc. -In consequence of the parameter subsitution you have to write `{{` and `}}` +In consequence of the parameter substitution you have to write `{{` and `}}` if you want to use regular curly braces in the "user" and "collection" regexes. For example, for the "user" key, ".+" means "authenticated user" and ".*" @@ -48,39 +48,63 @@ class Rights(rights.BaseRights): def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) self._filename = configuration.get("rights", "file") + self._log_rights_rule_doesnt_match_on_debug = configuration.get("logging", "rights_rule_doesnt_match_on_debug") + self._rights_config = configparser.ConfigParser() + try: + with open(self._filename, "r") as f: + self._rights_config.read_file(f) + logger.debug("Read rights file") + except Exception as e: + raise RuntimeError("Failed to load rights file %r: %s" % + (self._filename, e)) from e def authorization(self, user: str, path: str) -> str: user = user or "" sane_path = pathutils.strip_path(path) # Prevent "regex injection" escaped_user = re.escape(user) - rights_config = configparser.ConfigParser() - try: - with open(self._filename, "r") as f: - rights_config.read_file(f) - except Exception as e: - raise RuntimeError("Failed to load rights file %r: %s" % - (self._filename, e)) from e - for section in rights_config.sections(): + if not self._log_rights_rule_doesnt_match_on_debug: + logger.debug("logging of rules which doesn't match suppressed by config/option [logging] rights_rule_doesnt_match_on_debug") + for section in self._rights_config.sections(): + group_match = None + user_match = None try: - user_pattern = rights_config.get(section, "user") - collection_pattern = rights_config.get(section, "collection") + user_pattern = self._rights_config.get(section, "user", fallback="") + collection_pattern = self._rights_config.get(section, "collection") + allowed_groups = self._rights_config.get(section, "groups", fallback="").split(",") + try: + group_match = len(self._user_groups.intersection(allowed_groups)) > 0 + except Exception: + pass # Use empty format() for harmonized handling of curly braces - user_match = re.fullmatch(user_pattern.format(), user) - collection_match = user_match and re.fullmatch( + if user_pattern != "": + user_match = re.fullmatch(user_pattern.format(), user) + user_collection_match = user_match and re.fullmatch( collection_pattern.format( *(re.escape(s) for s in user_match.groups()), user=escaped_user), sane_path) + group_collection_match = re.fullmatch(collection_pattern.format(user=escaped_user), sane_path) except Exception as e: raise RuntimeError("Error in section %r of rights file %r: " "%s" % (section, self._filename, e)) from e - if user_match and collection_match: - logger.debug("Rule %r:%r matches %r:%r from section %r", + if user_match and user_collection_match: + permission = self._rights_config.get(section, "permissions") + logger.debug("Rule %r:%r matches %r:%r from section %r permission %r", + user, sane_path, user_pattern, + collection_pattern, section, permission) + return permission + if group_match and group_collection_match: + permission = self._rights_config.get(section, "permissions") + logger.debug("Rule %r:%r matches %r:%r from section %r permission %r by group membership", user, sane_path, user_pattern, - collection_pattern, section) - return rights_config.get(section, "permissions") + collection_pattern, section, permission) + return permission logger.debug("Rule %r:%r doesn't match %r:%r from section %r", user, sane_path, user_pattern, collection_pattern, section) + if self._log_rights_rule_doesnt_match_on_debug: + logger.debug("Rule %r:%r doesn't match %r:%r from section %r", + user, sane_path, user_pattern, collection_pattern, + section) logger.info("Rights: %r:%r doesn't match any section", user, sane_path) return "" diff --git a/radicale/server.py b/radicale/server.py index 6cb4c7b4b..2f03837cf 100644 --- a/radicale/server.py +++ b/radicale/server.py @@ -3,6 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2019 Unrud +# Copyright © 2024-2024 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,7 +23,6 @@ """ -import errno import http import select import socket @@ -58,11 +58,19 @@ # IPv4 (host, port) and IPv6 (host, port, flowinfo, scopeid) -ADDRESS_TYPE = Union[Tuple[str, int], Tuple[str, int, int, int]] +ADDRESS_TYPE = Union[Tuple[Union[str, bytes, bytearray], int], + Tuple[str, int, int, int]] def format_address(address: ADDRESS_TYPE) -> str: - return "[%s]:%d" % address[:2] + host, port, *_ = address + if not isinstance(host, str): + raise NotImplementedError("Unsupported address format: %r" % + (address,)) + if host.find(":") == -1: + return "%s:%d" % (host, port) + else: + return "[%s]:%d" % (host, port) class ParallelHTTPServer(socketserver.ThreadingMixIn, @@ -168,7 +176,7 @@ def server_bind(self) -> None: if name == "certificate_authority" and not filename: continue try: - open(filename, "r").close() + open(filename).close() except OSError as e: raise RuntimeError( "Invalid %s value for option %r in section %r in %s: %r " @@ -278,41 +286,22 @@ def serve(configuration: config.Configuration, servers = {} try: hosts: List[Tuple[str, int]] = configuration.get("server", "hosts") - for address in hosts: - # Try to bind sockets for IPv4 and IPv6 - possible_families = (socket.AF_INET, socket.AF_INET6) - bind_ok = False - for i, family in enumerate(possible_families): - is_last = i == len(possible_families) - 1 + for address_port in hosts: + # retrieve IPv4/IPv6 address of address + try: + getaddrinfo = socket.getaddrinfo(address_port[0], address_port[1], 0, socket.SOCK_STREAM, socket.IPPROTO_TCP) + except OSError as e: + logger.warning("cannot retrieve IPv4 or IPv6 address of '%s': %s" % (format_address(address_port), e)) + continue + logger.debug("getaddrinfo of '%s': %s" % (format_address(address_port), getaddrinfo)) + for (address_family, socket_kind, socket_proto, socket_flags, socket_address) in getaddrinfo: + logger.debug("try to create server socket on '%s'" % (format_address(socket_address))) try: - server = server_class(configuration, family, address, - RequestHandler) + server = server_class(configuration, address_family, (socket_address[0], socket_address[1]), RequestHandler) except OSError as e: - # Ignore unsupported families (only one must work) - if ((bind_ok or not is_last) and ( - isinstance(e, socket.gaierror) and ( - # Hostname does not exist or doesn't have - # address for address family - # macOS: IPv6 address for INET address family - e.errno == socket.EAI_NONAME or - # Address not for address family - e.errno == COMPAT_EAI_ADDRFAMILY or - e.errno == COMPAT_EAI_NODATA) or - # Workaround for PyPy - str(e) == "address family mismatched" or - # Address family not available (e.g. IPv6 disabled) - # macOS: IPv4 address for INET6 address family with - # IPV6_V6ONLY set - e.errno == errno.EADDRNOTAVAIL or - # Address family not supported - e.errno == errno.EAFNOSUPPORT or - # Protocol not supported - e.errno == errno.EPROTONOSUPPORT)): - continue - raise RuntimeError("Failed to start server %r: %s" % ( - format_address(address), e)) from e + logger.warning("cannot create server socket on '%s': %s" % (format_address(socket_address), e)) + continue servers[server.socket] = server - bind_ok = True server.set_app(application) logger.info("Listening on %r%s", format_address(server.server_address), diff --git a/radicale/storage/__init__.py b/radicale/storage/__init__.py index 6946f59b4..73cf77b99 100644 --- a/radicale/storage/__init__.py +++ b/radicale/storage/__init__.py @@ -26,8 +26,8 @@ import json import xml.etree.ElementTree as ET from hashlib import sha256 -from typing import (Iterable, Iterator, Mapping, Optional, Sequence, Set, - Tuple, Union, overload) +from typing import (Callable, ContextManager, Iterable, Iterator, Mapping, + Optional, Sequence, Set, Tuple, Union, overload) import vobject @@ -282,8 +282,11 @@ def __init__(self, configuration: "config.Configuration") -> None: """ self.configuration = configuration - def discover(self, path: str, depth: str = "0") -> Iterable[ - "types.CollectionOrItem"]: + def discover( + self, path: str, depth: str = "0", + 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``. ``path`` is sanitized. diff --git a/radicale/storage/multifilesystem/base.py b/radicale/storage/multifilesystem/base.py index 7b1b7d286..a7cc0bee3 100644 --- a/radicale/storage/multifilesystem/base.py +++ b/radicale/storage/multifilesystem/base.py @@ -1,7 +1,8 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub -# Copyright © 2017-2019 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2024-2024 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 @@ -40,11 +41,13 @@ def __init__(self, storage_: "multifilesystem.Storage", path: str, # Path should already be sanitized self._path = pathutils.strip_path(path) self._encoding = storage_.configuration.get("encoding", "stock") + self._skip_broken_item = storage_.configuration.get("storage", "skip_broken_item") if filesystem_path is None: filesystem_path = pathutils.path_to_filesystem(folder, self.path) self._filesystem_path = filesystem_path - @types.contextmanager + # TODO: better fix for "mypy" + @types.contextmanager # type: ignore def _atomic_write(self, path: str, mode: str = "w", newline: Optional[str] = None) -> Iterator[IO[AnyStr]]: # TODO: Overload with Literal when dropping support for Python < 3.8 diff --git a/radicale/storage/multifilesystem/cache.py b/radicale/storage/multifilesystem/cache.py index 9cb4dda6d..31ab47154 100644 --- a/radicale/storage/multifilesystem/cache.py +++ b/radicale/storage/multifilesystem/cache.py @@ -86,7 +86,8 @@ def _store_item_cache(self, href: str, item: radicale_item.Item, content = self._item_cache_content(item) self._storage._makedirs_synced(cache_folder) # Race: Other processes might have created and locked the file. - with contextlib.suppress(PermissionError), self._atomic_write( + # TODO: better fix for "mypy" + with contextlib.suppress(PermissionError), self._atomic_write( # type: ignore os.path.join(cache_folder, href), "wb") as fo: fb = cast(BinaryIO, fo) pickle.dump((cache_hash, *content), fb) diff --git a/radicale/storage/multifilesystem/discover.py b/radicale/storage/multifilesystem/discover.py index 00316141e..a635906a5 100644 --- a/radicale/storage/multifilesystem/discover.py +++ b/radicale/storage/multifilesystem/discover.py @@ -16,9 +16,10 @@ # You should have received a copy of the GNU General Public License # along with Radicale. If not, see . +import base64 import os import posixpath -from typing import Callable, ContextManager, Iterator, Optional, cast +from typing import Callable, ContextManager, Iterator, Optional, Set, cast from radicale import pathutils, types from radicale.log import logger @@ -35,8 +36,10 @@ def _null_child_context_manager(path: str, class StoragePartDiscover(StorageBase): def discover( - self, path: str, depth: str = "0", child_context_manager: Optional[ - Callable[[str, Optional[str]], ContextManager[None]]] = None + self, path: str, depth: str = "0", + child_context_manager: Optional[ + Callable[[str, Optional[str]], ContextManager[None]]] = None, + user_groups: Set[str] = set([]) ) -> Iterator[types.CollectionOrItem]: # assert isinstance(self, multifilesystem.Storage) if child_context_manager is None: @@ -102,3 +105,13 @@ def discover( with child_context_manager(sane_child_path, None): yield self._collection_class( cast(multifilesystem.Storage, self), child_path) + for group in user_groups: + href = base64.b64encode(group.encode('utf-8')).decode('ascii') + logger.debug(f"searching for group calendar {group} {href}") + sane_child_path = f"GROUPS/{href}" + if not os.path.isdir(pathutils.path_to_filesystem(folder, sane_child_path)): + continue + child_path = f"/GROUPS/{href}/" + with child_context_manager(sane_child_path, None): + yield self._collection_class( + cast(multifilesystem.Storage, self), child_path) diff --git a/radicale/storage/multifilesystem/get.py b/radicale/storage/multifilesystem/get.py index 0a1fd73f6..f5d258161 100644 --- a/radicale/storage/multifilesystem/get.py +++ b/radicale/storage/multifilesystem/get.py @@ -1,7 +1,8 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2024-2024 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 @@ -83,7 +84,7 @@ def _get(self, href: str, verify_href: bool = True cache_content = self._load_item_cache(href, cache_hash) if cache_content is None: with self._acquire_cache_lock("item"): - # Lock the item cache to prevent multpile processes from + # Lock the item cache to prevent multiple processes from # generating the same data in parallel. # This improves the performance for multiple requests. if self._storage._lock.locked == "r": @@ -101,8 +102,12 @@ def _get(self, href: str, verify_href: bool = True cache_content = self._store_item_cache( href, temp_item, cache_hash) except Exception as e: - raise RuntimeError("Failed to load item %r in %r: %s" % - (href, self.path, e)) from e + if self._skip_broken_item: + logger.warning("Skip broken item %r in %r: %s", href, self.path, e) + return None + else: + raise RuntimeError("Failed to load item %r in %r: %s" % + (href, self.path, e)) from e # Clean cache entries once after the data in the file # system was edited externally. if not self._item_cache_cleaned: @@ -122,7 +127,7 @@ def _get(self, href: str, verify_href: bool = True def get_multi(self, hrefs: Iterable[str] ) -> Iterator[Tuple[str, Optional[radicale_item.Item]]]: - # It's faster to check for file name collissions here, because + # It's faster to check for file name collisions here, because # we only need to call os.listdir once. files = None for href in hrefs: @@ -141,7 +146,7 @@ def get_multi(self, hrefs: Iterable[str] def get_all(self) -> Iterator[radicale_item.Item]: for href in self._list(): - # We don't need to check for collissions, because the file names + # We don't need to check for collisions, because the file names # are from os.listdir. item = self._get(href, verify_href=False) if item is not None: diff --git a/radicale/storage/multifilesystem/meta.py b/radicale/storage/multifilesystem/meta.py index edce65136..b95fb162b 100644 --- a/radicale/storage/multifilesystem/meta.py +++ b/radicale/storage/multifilesystem/meta.py @@ -61,6 +61,7 @@ def get_meta(self, key: Optional[str] = None) -> Union[Mapping[str, str], return self._meta_cache if key is None else self._meta_cache.get(key) def set_meta(self, props: Mapping[str, str]) -> None: - with self._atomic_write(self._props_path, "w") as fo: + # TODO: better fix for "mypy" + with self._atomic_write(self._props_path, "w") as fo: # type: ignore f = cast(TextIO, fo) json.dump(props, f, sort_keys=True) diff --git a/radicale/storage/multifilesystem/sync.py b/radicale/storage/multifilesystem/sync.py index 83cbe2a00..ae703c91f 100644 --- a/radicale/storage/multifilesystem/sync.py +++ b/radicale/storage/multifilesystem/sync.py @@ -95,7 +95,8 @@ def check_token_name(token_name: str) -> bool: self._storage._makedirs_synced(token_folder) try: # Race: Other processes might have created and locked the file. - with self._atomic_write(token_path, "wb") as fo: + # TODO: better fix for "mypy" + with self._atomic_write(token_path, "wb") as fo: # type: ignore fb = cast(BinaryIO, fo) pickle.dump(state, fb) except PermissionError: diff --git a/radicale/storage/multifilesystem/upload.py b/radicale/storage/multifilesystem/upload.py index 730e4cb21..a9fcdc2cc 100644 --- a/radicale/storage/multifilesystem/upload.py +++ b/radicale/storage/multifilesystem/upload.py @@ -43,7 +43,8 @@ def upload(self, href: str, item: radicale_item.Item raise ValueError("Failed to store item %r in collection %r: %s" % (href, self.path, e)) from e path = pathutils.path_to_filesystem(self._filesystem_path, href) - with self._atomic_write(path, newline="") as fo: + # TODO: better fix for "mypy" + with self._atomic_write(path, newline="") as fo: # type: ignore f = cast(TextIO, fo) f.write(item.serialize()) # Clean the cache after the actual item is stored, or the cache entry diff --git a/radicale/storage/multifilesystem/verify.py b/radicale/storage/multifilesystem/verify.py index d25d4fe76..776f1bfd7 100644 --- a/radicale/storage/multifilesystem/verify.py +++ b/radicale/storage/multifilesystem/verify.py @@ -1,7 +1,8 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2021 Unrud +# Copyright © 2024-2024 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 @@ -48,7 +49,9 @@ def exception_cm(sane_path: str, href: Optional[str] while remaining_sane_paths: sane_path = remaining_sane_paths.pop(0) path = pathutils.unstrip_path(sane_path, True) - logger.debug("Verifying collection %r", sane_path) + logger.info("Verifying path %r", sane_path) + count = 0 + is_collection = True with exception_cm(sane_path, None): saved_item_errors = item_errors collection: Optional[storage.BaseCollection] = None @@ -59,6 +62,9 @@ def exception_cm(sane_path: str, href: Optional[str] assert isinstance(item, storage.BaseCollection) collection = item collection.get_meta() + if not collection.tag: + is_collection = False + logger.info("Skip !collection %r", sane_path) continue if isinstance(item, storage.BaseCollection): has_child_collections = True @@ -68,13 +74,17 @@ def exception_cm(sane_path: str, href: Optional[str] item.href, sane_path, item.uid) else: uids.add(item.uid) - logger.debug("Verified item %r in %r", - item.href, sane_path) + count += 1 + logger.debug("Verified in %r item %r", + sane_path, item.href) assert collection if item_errors == saved_item_errors: - collection.sync() + if is_collection: + collection.sync() if has_child_collections and collection.tag: logger.error("Invalid collection %r: %r must not have " "child collections", sane_path, collection.tag) + if is_collection: + logger.info("Verified collect %r (items: %d)", sane_path, count) return item_errors == 0 and collection_errors == 0 diff --git a/radicale/tests/__init__.py b/radicale/tests/__init__.py index 2e132560a..ceb155b4c 100644 --- a/radicale/tests/__init__.py +++ b/radicale/tests/__init__.py @@ -25,16 +25,18 @@ import shutil import sys import tempfile +import wsgiref.util import xml.etree.ElementTree as ET from io import BytesIO from typing import Any, Dict, List, Optional, Tuple, Union import defusedxml.ElementTree as DefusedET +import vobject import radicale from radicale import app, config, types, xmlutils -RESPONSES = Dict[str, Union[int, Dict[str, Tuple[int, ET.Element]]]] +RESPONSES = Dict[str, Union[int, Dict[str, Tuple[int, ET.Element]], vobject.base.Component]] # Enable debug output radicale.log.logger.setLevel(logging.DEBUG) @@ -47,7 +49,7 @@ class BaseTest: configuration: config.Configuration application: app.Application - def setup(self) -> None: + def setup_method(self) -> None: self.configuration = config.load() self.colpath = tempfile.mkdtemp() self.configure({ @@ -61,7 +63,7 @@ def configure(self, config_: types.CONFIG) -> None: self.configuration.update(config_, "test", privileged=True) self.application = app.Application(self.configuration) - def teardown(self) -> None: + def teardown_method(self) -> None: shutil.rmtree(self.colpath) def request(self, method: str, path: str, data: Optional[str] = None, @@ -83,11 +85,12 @@ def request(self, method: str, path: str, data: Optional[str] = None, login.encode(encoding)).decode() environ["REQUEST_METHOD"] = method.upper() environ["PATH_INFO"] = path - if data: + if data is not None: data_bytes = data.encode(encoding) environ["wsgi.input"] = BytesIO(data_bytes) environ["CONTENT_LENGTH"] = str(len(data_bytes)) environ["wsgi.errors"] = sys.stderr + wsgiref.util.setup_testing_defaults(environ) status = headers = None def start_response(status_: str, headers_: List[Tuple[str, str]] @@ -105,12 +108,11 @@ def start_response(status_: str, headers_: List[Tuple[str, str]] def parse_responses(text: str) -> RESPONSES: xml = DefusedET.fromstring(text) assert xml.tag == xmlutils.make_clark("D:multistatus") - path_responses: Dict[str, Union[ - int, Dict[str, Tuple[int, ET.Element]]]] = {} + path_responses: RESPONSES = {} for response in xml.findall(xmlutils.make_clark("D:response")): href = response.find(xmlutils.make_clark("D:href")) assert href.text not in path_responses - prop_respones: Dict[str, Tuple[int, ET.Element]] = {} + prop_responses: Dict[str, Tuple[int, ET.Element]] = {} for propstat in response.findall( xmlutils.make_clark("D:propstat")): status = propstat.find(xmlutils.make_clark("D:status")) @@ -119,16 +121,22 @@ def parse_responses(text: str) -> RESPONSES: for element in propstat.findall( "./%s/*" % xmlutils.make_clark("D:prop")): human_tag = xmlutils.make_human_tag(element.tag) - assert human_tag not in prop_respones - prop_respones[human_tag] = (status_code, element) + assert human_tag not in prop_responses + prop_responses[human_tag] = (status_code, element) status = response.find(xmlutils.make_clark("D:status")) if status is not None: - assert not prop_respones + assert not prop_responses assert status.text.startswith("HTTP/1.1 ") status_code = int(status.text.split(" ")[1]) path_responses[href.text] = status_code else: - path_responses[href.text] = prop_respones + path_responses[href.text] = prop_responses + return path_responses + + @staticmethod + def parse_free_busy(text: str) -> RESPONSES: + path_responses: RESPONSES = {} + path_responses[""] = vobject.readOne(text) return path_responses def get(self, path: str, check: Optional[int] = 200, **kwargs @@ -137,8 +145,8 @@ def get(self, path: str, check: Optional[int] = 200, **kwargs status, _, answer = self.request("GET", path, check=check, **kwargs) return status, answer - def post(self, path: str, data: str = None, check: Optional[int] = 200, - **kwargs) -> Tuple[int, str]: + def post(self, path: str, data: Optional[str] = None, + check: Optional[int] = 200, **kwargs) -> Tuple[int, str]: status, _, answer = self.request("POST", path, data, check=check, **kwargs) return status, answer @@ -175,13 +183,18 @@ def proppatch(self, path: str, data: Optional[str] = None, return status, responses def report(self, path: str, data: str, check: Optional[int] = 207, + is_xml: Optional[bool] = True, **kwargs) -> Tuple[int, RESPONSES]: status, _, answer = self.request("REPORT", path, data, check=check, **kwargs) if status < 200 or 300 <= status: return status, {} assert answer is not None - return status, self.parse_responses(answer) + if is_xml: + parsed = self.parse_responses(answer) + else: + parsed = self.parse_free_busy(answer) + return status, parsed def delete(self, path: str, check: Optional[int] = 200, **kwargs ) -> Tuple[int, RESPONSES]: diff --git a/radicale/tests/static/event1.ics b/radicale/tests/static/event1.ics index bc04d80ab..4e669175f 100644 --- a/radicale/tests/static/event1.ics +++ b/radicale/tests/static/event1.ics @@ -25,6 +25,7 @@ LAST-MODIFIED:20130902T150158Z DTSTAMP:20130902T150158Z UID:event1 SUMMARY:Event +CATEGORIES:some_category1,another_category2 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 diff --git a/radicale/tests/static/event10.ics b/radicale/tests/static/event10.ics new file mode 100644 index 000000000..3faa034d2 --- /dev/null +++ b/radicale/tests/static/event10.ics @@ -0,0 +1,36 @@ +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:event10 +SUMMARY:Event +CATEGORIES:some_category1,another_category2 +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 +STATUS:CANCELLED +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/event_daily_rrule.ics b/radicale/tests/static/event_daily_rrule.ics new file mode 100644 index 000000000..362a18e4e --- /dev/null +++ b/radicale/tests/static/event_daily_rrule.ics @@ -0,0 +1,28 @@ +BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060102T120000 +DURATION:PT1H +RRULE:FREQ=DAILY;COUNT=5 +SUMMARY:Recurring event +UID:event_daily_rrule +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/event_full_day_rrule.ics b/radicale/tests/static/event_full_day_rrule.ics new file mode 100644 index 000000000..88f81c7d9 --- /dev/null +++ b/radicale/tests/static/event_full_day_rrule.ics @@ -0,0 +1,31 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060102 +DTEND;TZID=US/Eastern:20060103 +RRULE:FREQ=DAILY;COUNT=5 +SUMMARY:Recurring event +UID:event_full_day_rrule +DTSTAMP:20060102T094829Z +END:VEVENT +END:VCALENDAR + diff --git a/radicale/tests/test_auth.py b/radicale/tests/test_auth.py index f33780b6e..3604e2f9c 100644 --- a/radicale/tests/test_auth.py +++ b/radicale/tests/test_auth.py @@ -1,7 +1,8 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2012-2016 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub -# Copyright © 2017-2019 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2024-2024 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 @@ -82,6 +83,12 @@ def test_htpasswd_md5_unicode(self): self._test_htpasswd( "md5", "😀:$apr1$w4ev89r1$29xO8EvJmS2HEAadQ5qy11", "unicode") + def test_htpasswd_sha256(self) -> None: + self._test_htpasswd("sha256", "tmp:$5$i4Ni4TQq6L5FKss5$ilpTjkmnxkwZeV35GB9cYSsDXTALBn6KtWRJAzNlCL/") + + def test_htpasswd_sha512(self) -> None: + self._test_htpasswd("sha512", "tmp:$6$3Qhl8r6FLagYdHYa$UCH9yXCed4A.J9FQsFPYAOXImzZUMfvLa0lwcWOxWYLOF5sE/lF99auQ4jKvHY2vijxmefl7G6kMqZ8JPdhIJ/") + def test_htpasswd_bcrypt(self) -> None: self._test_htpasswd("bcrypt", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3V" "NTRI3w5KDnj8NTUKJNWfVpvRq") @@ -108,6 +115,16 @@ def test_htpasswd_whitespace_password(self) -> None: def test_htpasswd_comment(self) -> None: self._test_htpasswd("plain", "#comment\n #comment\n \ntmp:bepo\n\n") + def test_htpasswd_lc_username(self) -> None: + self.configure({"auth": {"lc_username": "True"}}) + self._test_htpasswd("plain", "tmp:bepo", ( + ("tmp", "bepo", True), ("TMP", "bepo", True), ("tmp1", "bepo", False))) + + def test_htpasswd_strip_domain(self) -> None: + self.configure({"auth": {"strip_domain": "True"}}) + self._test_htpasswd("plain", "tmp:bepo", ( + ("tmp", "bepo", True), ("tmp@domain.example", "bepo", True), ("tmp1", "bepo", False))) + def test_remote_user(self) -> None: self.configure({"auth": {"type": "remote_user"}}) _, responses = self.propfind("/", """\ @@ -146,3 +163,11 @@ def test_custom(self) -> None: """Custom authentication.""" self.configure({"auth": {"type": "radicale.tests.custom.auth"}}) self.propfind("/tmp/", login="tmp:") + + def test_none(self) -> None: + self.configure({"auth": {"type": "none"}}) + self.propfind("/tmp/", login="tmp:") + + def test_denyall(self) -> None: + self.configure({"auth": {"type": "denyall"}}) + self.propfind("/tmp/", login="tmp:", check=401) diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index 5ea37bfb1..c47df7201 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -25,6 +25,7 @@ from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple import defusedxml.ElementTree as DefusedET +import vobject from radicale import storage, xmlutils from radicale.tests import RESPONSES, BaseTest @@ -37,11 +38,31 @@ class TestBaseRequests(BaseTest): # Allow skipping sync-token tests, when not fully supported by the backend full_sync_token_support: ClassVar[bool] = True - def setup(self) -> None: - BaseTest.setup(self) + def setup_method(self) -> None: + BaseTest.setup_method(self) rights_file_path = os.path.join(self.colpath, "rights") with open(rights_file_path, "w") as f: f.write("""\ +[permit delete collection] +user: .* +collection: test-permit-delete +permissions: RrWwD + +[forbid delete collection] +user: .* +collection: test-forbid-delete +permissions: RrWwd + +[permit overwrite collection] +user: .* +collection: test-permit-overwrite +permissions: RrWwO + +[forbid overwrite collection] +user: .* +collection: test-forbid-overwrite +permissions: RrWwo + [allow all] user: .* collection: .* @@ -355,11 +376,11 @@ def test_move(self) -> None: path2 = "/calendar.ics/event2.ics" self.put(path1, event) self.request("MOVE", path1, check=201, - HTTP_DESTINATION=path2, HTTP_HOST="") + HTTP_DESTINATION="http://127.0.0.1/"+path2) self.get(path1, check=404) self.get(path2) - def test_move_between_colections(self) -> None: + def test_move_between_collections(self) -> None: """Move a item.""" self.mkcalendar("/calendar1.ics/") self.mkcalendar("/calendar2.ics/") @@ -368,11 +389,11 @@ def test_move_between_colections(self) -> None: path2 = "/calendar2.ics/event2.ics" self.put(path1, event) self.request("MOVE", path1, check=201, - HTTP_DESTINATION=path2, HTTP_HOST="") + HTTP_DESTINATION="http://127.0.0.1/"+path2) self.get(path1, check=404) self.get(path2) - def test_move_between_colections_duplicate_uid(self) -> None: + def test_move_between_collections_duplicate_uid(self) -> None: """Move a item to a collection which already contains the UID.""" self.mkcalendar("/calendar1.ics/") self.mkcalendar("/calendar2.ics/") @@ -382,13 +403,13 @@ def test_move_between_colections_duplicate_uid(self) -> None: self.put(path1, event) self.put("/calendar2.ics/event1.ics", event) status, _, answer = self.request( - "MOVE", path1, HTTP_DESTINATION=path2, HTTP_HOST="") + "MOVE", path1, HTTP_DESTINATION="http://127.0.0.1/"+path2) assert status in (403, 409) xml = DefusedET.fromstring(answer) assert xml.tag == xmlutils.make_clark("D:error") assert xml.find(xmlutils.make_clark("C:no-uid-conflict")) is not None - def test_move_between_colections_overwrite(self) -> None: + def test_move_between_collections_overwrite(self) -> None: """Move a item to a collection which already contains the item.""" self.mkcalendar("/calendar1.ics/") self.mkcalendar("/calendar2.ics/") @@ -398,12 +419,12 @@ def test_move_between_colections_overwrite(self) -> None: self.put(path1, event) self.put(path2, event) self.request("MOVE", path1, check=412, - HTTP_DESTINATION=path2, HTTP_HOST="") - self.request("MOVE", path1, check=204, - HTTP_DESTINATION=path2, HTTP_HOST="", HTTP_OVERWRITE="T") + HTTP_DESTINATION="http://127.0.0.1/"+path2) + self.request("MOVE", path1, check=204, HTTP_OVERWRITE="T", + HTTP_DESTINATION="http://127.0.0.1/"+path2) - def test_move_between_colections_overwrite_uid_conflict(self) -> None: - """Move a item to a collection which already contains the item with + def test_move_between_collections_overwrite_uid_conflict(self) -> None: + """Move an item to a collection which already contains the item with a different UID.""" self.mkcalendar("/calendar1.ics/") self.mkcalendar("/calendar2.ics/") @@ -413,8 +434,9 @@ def test_move_between_colections_overwrite_uid_conflict(self) -> None: path2 = "/calendar2.ics/event2.ics" self.put(path1, event1) self.put(path2, event2) - status, _, answer = self.request("MOVE", path1, HTTP_DESTINATION=path2, - HTTP_HOST="", HTTP_OVERWRITE="T") + status, _, answer = self.request( + "MOVE", path1, HTTP_OVERWRITE="T", + HTTP_DESTINATION="http://127.0.0.1/"+path2) assert status in (403, 409) xml = DefusedET.fromstring(answer) assert xml.tag == xmlutils.make_clark("D:error") @@ -437,6 +459,33 @@ def test_delete_collection(self) -> None: assert responses["/calendar.ics/"] == 200 self.get("/calendar.ics/", check=404) + def test_delete_collection_global_forbid(self) -> None: + """Delete a collection (expect forbidden).""" + self.configure({"rights": {"permit_delete_collection": False}}) + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + self.put("/calendar.ics/event1.ics", event) + _, responses = self.delete("/calendar.ics/", check=401) + self.get("/calendar.ics/", check=200) + + def test_delete_collection_global_forbid_explicit_permit(self) -> None: + """Delete a collection with permitted path (expect permit).""" + self.configure({"rights": {"permit_delete_collection": False}}) + self.mkcalendar("/test-permit-delete/") + event = get_file_content("event1.ics") + self.put("/test-permit-delete/event1.ics", event) + _, responses = self.delete("/test-permit-delete/", check=200) + self.get("/test-permit-delete/", check=404) + + def test_delete_collection_global_permit_explicit_forbid(self) -> None: + """Delete a collection with permitted path (expect forbid).""" + self.configure({"rights": {"permit_delete_collection": True}}) + self.mkcalendar("/test-forbid-delete/") + event = get_file_content("event1.ics") + self.put("/test-forbid-delete/event1.ics", event) + _, responses = self.delete("/test-forbid-delete/", check=401) + self.get("/test-forbid-delete/", check=200) + def test_delete_root_collection(self) -> None: """Delete the root collection.""" self.mkcalendar("/calendar.ics/") @@ -448,6 +497,30 @@ def test_delete_root_collection(self) -> None: self.get("/calendar.ics/", check=404) self.get("/event1.ics", 404) + def test_overwrite_collection_global_forbid(self) -> None: + """Overwrite a collection (expect forbid).""" + self.configure({"rights": {"permit_overwrite_collection": False}}) + event = get_file_content("event1.ics") + self.put("/calender.ics/", event, check=401) + + def test_overwrite_collection_global_forbid_explict_permit(self) -> None: + """Overwrite a collection with permitted path (expect permit).""" + self.configure({"rights": {"permit_overwrite_collection": False}}) + event = get_file_content("event1.ics") + self.put("/test-permit-overwrite/", event, check=201) + + def test_overwrite_collection_global_permit(self) -> None: + """Overwrite a collection (expect permit).""" + self.configure({"rights": {"permit_overwrite_collection": True}}) + event = get_file_content("event1.ics") + self.put("/calender.ics/", event, check=201) + + def test_overwrite_collection_global_permit_explict_forbid(self) -> None: + """Overwrite a collection with forbidden path (expect forbid).""" + self.configure({"rights": {"permit_overwrite_collection": True}}) + event = get_file_content("event1.ics") + self.put("/test-forbid-overwrite/", event, check=401) + def test_propfind(self) -> None: calendar_path = "/calendar.ics/" self.mkcalendar("/calendar.ics/") @@ -916,6 +989,22 @@ def test_text_match_filter(self) -> None: event +"""]) + assert "/calendar.ics/event1.ics" in self._test_filter(["""\ + + + + some_category1 + + +"""]) + assert "/calendar.ics/event1.ics" in self._test_filter(["""\ + + + + some_category1 + + """]) assert "/calendar.ics/event1.ics" not in self._test_filter(["""\ @@ -1343,10 +1432,45 @@ def test_report_item(self) -> None: """) assert len(responses) == 1 response = responses[event_path] - assert not isinstance(response, int) + assert isinstance(response, dict) status, prop = response["D:getetag"] assert status == 200 and prop.text + def test_report_free_busy(self) -> None: + """Test free busy report on a few items""" + calendar_path = "/calendar.ics/" + self.mkcalendar(calendar_path) + for i in (1, 2, 10): + filename = "event{}.ics".format(i) + event = get_file_content(filename) + self.put(posixpath.join(calendar_path, filename), event) + code, responses = self.report(calendar_path, """\ + + + +""", 200, is_xml=False) + for response in responses.values(): + assert isinstance(response, vobject.base.Component) + assert len(responses) == 1 + vcalendar = list(responses.values())[0] + assert isinstance(vcalendar, vobject.base.Component) + assert len(vcalendar.vfreebusy_list) == 3 + types = {} + for vfb in vcalendar.vfreebusy_list: + fbtype_val = vfb.fbtype.value + if fbtype_val not in types: + types[fbtype_val] = 0 + types[fbtype_val] += 1 + assert types == {'BUSY': 2, 'FREE': 1} + + # Test max_freebusy_occurrence limit + self.configure({"reporting": {"max_freebusy_occurrence": 1}}) + code, responses = self.report(calendar_path, """\ + + + +""", 400, is_xml=False) + def _report_sync_token( self, calendar_path: str, sync_token: Optional[str] = None ) -> Tuple[str, RESPONSES]: @@ -1471,7 +1595,7 @@ def test_report_sync_collection_move(self) -> None: sync_token, responses = self._report_sync_token(calendar_path) assert len(responses) == 1 and responses[event1_path] == 200 self.request("MOVE", event1_path, check=201, - HTTP_DESTINATION=event2_path, HTTP_HOST="") + HTTP_DESTINATION="http://127.0.0.1/"+event2_path) sync_token, responses = self._report_sync_token( calendar_path, sync_token) if not self.full_sync_token_support and not sync_token: @@ -1490,9 +1614,9 @@ def test_report_sync_collection_move_undo(self) -> None: sync_token, responses = self._report_sync_token(calendar_path) assert len(responses) == 1 and responses[event1_path] == 200 self.request("MOVE", event1_path, check=201, - HTTP_DESTINATION=event2_path, HTTP_HOST="") + HTTP_DESTINATION="http://127.0.0.1/"+event2_path) self.request("MOVE", event2_path, check=201, - HTTP_DESTINATION=event1_path, HTTP_HOST="") + HTTP_DESTINATION="http://127.0.0.1/"+event1_path) sync_token, responses = self._report_sync_token( calendar_path, sync_token) if not self.full_sync_token_support and not sync_token: @@ -1508,6 +1632,184 @@ def test_report_sync_collection_invalid_sync_token(self) -> None: calendar_path, "http://radicale.org/ns/sync/INVALID") assert not sync_token + def test_report_with_expand_property(self) -> None: + """Test report with expand property""" + self.put("/calendar.ics/", get_file_content("event_daily_rrule.ics")) + req_body_without_expand = \ + """ + + + + + + + + + + + + + + """ + _, responses = self.report("/calendar.ics/", req_body_without_expand) + assert len(responses) == 1 + + response_without_expand = responses['/calendar.ics/event_daily_rrule.ics'] + assert not isinstance(response_without_expand, int) + status, element = response_without_expand["C:calendar-data"] + + assert status == 200 and element.text + + assert "RRULE" in element.text + assert "BEGIN:VTIMEZONE" in element.text + assert "RECURRENCE-ID" not in element.text + + uids: List[str] = [] + for line in element.text.split("\n"): + if line.startswith("UID:"): + uid = line[len("UID:"):] + assert uid == "event_daily_rrule" + uids.append(uid) + + assert len(uids) == 1 + + req_body_with_expand = \ + """ + + + + + + + + + + + + + + + """ + + _, responses = self.report("/calendar.ics/", req_body_with_expand) + + assert len(responses) == 1 + + response_with_expand = responses['/calendar.ics/event_daily_rrule.ics'] + assert not isinstance(response_with_expand, int) + status, element = response_with_expand["C:calendar-data"] + + assert status == 200 and element.text + assert "RRULE" not in element.text + assert "BEGIN:VTIMEZONE" not in element.text + + uids = [] + recurrence_ids = [] + for line in element.text.split("\n"): + if line.startswith("UID:"): + assert line == "UID:event_daily_rrule" + uids.append(line) + + if line.startswith("RECURRENCE-ID:"): + assert line in ["RECURRENCE-ID:20060103T170000Z", "RECURRENCE-ID:20060104T170000Z"] + recurrence_ids.append(line) + + if line.startswith("DTSTART:"): + assert line == "DTSTART:20060102T170000Z" + + assert len(uids) == 2 + assert len(set(recurrence_ids)) == 2 + + def test_report_with_expand_property_all_day_event(self) -> None: + """Test report with expand property""" + self.put("/calendar.ics/", get_file_content("event_full_day_rrule.ics")) + req_body_without_expand = \ + """ + + + + + + + + + + + + + + """ + _, responses = self.report("/calendar.ics/", req_body_without_expand) + assert len(responses) == 1 + + response_without_expand = responses['/calendar.ics/event_full_day_rrule.ics'] + assert not isinstance(response_without_expand, int) + status, element = response_without_expand["C:calendar-data"] + + assert status == 200 and element.text + + assert "RRULE" in element.text + assert "RECURRENCE-ID" not in element.text + + uids: List[str] = [] + for line in element.text.split("\n"): + if line.startswith("UID:"): + uid = line[len("UID:"):] + assert uid == "event_full_day_rrule" + uids.append(uid) + + assert len(uids) == 1 + + req_body_with_expand = \ + """ + + + + + + + + + + + + + + + """ + + _, responses = self.report("/calendar.ics/", req_body_with_expand) + + assert len(responses) == 1 + + response_with_expand = responses['/calendar.ics/event_full_day_rrule.ics'] + assert not isinstance(response_with_expand, int) + status, element = response_with_expand["C:calendar-data"] + + assert status == 200 and element.text + assert "RRULE" not in element.text + assert "BEGIN:VTIMEZONE" not in element.text + + uids = [] + recurrence_ids = [] + for line in element.text.split("\n"): + if line.startswith("UID:"): + assert line == "UID:event_full_day_rrule" + uids.append(line) + + if line.startswith("RECURRENCE-ID:"): + assert line in ["RECURRENCE-ID:20060103", "RECURRENCE-ID:20060104", "RECURRENCE-ID:20060105"] + recurrence_ids.append(line) + + if line.startswith("DTSTART:"): + assert line == "DTSTART:20060102" + + if line.startswith("DTEND:"): + assert line == "DTEND:20060103" + + assert len(uids) == 3 + assert len(set(recurrence_ids)) == 3 + 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_config.py b/radicale/tests/test_config.py index 32a87ec21..92ece9a62 100644 --- a/radicale/tests/test_config.py +++ b/radicale/tests/test_config.py @@ -31,10 +31,10 @@ class TestConfig: colpath: str - def setup(self) -> None: + def setup_method(self) -> None: self.colpath = tempfile.mkdtemp() - def teardown(self) -> None: + def teardown_method(self) -> None: shutil.rmtree(self.colpath) def _write_config(self, config_dict: types.CONFIG, name: str) -> str: diff --git a/radicale/tests/test_server.py b/radicale/tests/test_server.py index af3cf29b7..ecc493a40 100644 --- a/radicale/tests/test_server.py +++ b/radicale/tests/test_server.py @@ -54,14 +54,15 @@ class TestBaseServerRequests(BaseTest): thread: threading.Thread opener: request.OpenerDirector - def setup(self) -> None: - super().setup() + def setup_method(self) -> None: + super().setup_method() self.shutdown_socket, shutdown_socket_out = socket.socketpair() with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: # Find available port sock.bind(("127.0.0.1", 0)) + self.sockfamily = socket.AF_INET self.sockname = sock.getsockname() - self.configure({"server": {"hosts": "[%s]:%d" % self.sockname}, + self.configure({"server": {"hosts": "%s:%d" % self.sockname}, # Enable debugging for new processes "logging": {"level": "debug"}}) self.thread = threading.Thread(target=server.serve, args=( @@ -73,13 +74,13 @@ def setup(self) -> None: request.HTTPSHandler(context=ssl_context), DisabledRedirectHandler) - def teardown(self) -> None: + def teardown_method(self) -> None: self.shutdown_socket.close() try: self.thread.join() except RuntimeError: # Thread never started pass - super().teardown() + super().teardown_method() def request(self, method: str, path: str, data: Optional[str] = None, check: Optional[int] = None, **kwargs @@ -105,8 +106,12 @@ def request(self, method: str, path: str, data: Optional[str] = None, data_bytes = None if data: data_bytes = data.encode(encoding) + if self.sockfamily == socket.AF_INET6: + req_host = ("[%s]" % self.sockname[0]) + else: + req_host = self.sockname[0] req = request.Request( - "%s://[%s]:%d%s" % (scheme, *self.sockname, path), + "%s://%s:%d%s" % (scheme, req_host, self.sockname[1], path), data=data_bytes, headers=headers, method=method) while True: assert is_alive_fn() @@ -161,6 +166,7 @@ def test_ipv6(self) -> None: server.COMPAT_IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) # Find available port sock.bind(("::1", 0)) + self.sockfamily = socket.AF_INET6 self.sockname = sock.getsockname()[:2] except OSError as e: if e.errno in (errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT, diff --git a/radicale/tests/test_storage.py b/radicale/tests/test_storage.py index 35479e986..9072a354b 100644 --- a/radicale/tests/test_storage.py +++ b/radicale/tests/test_storage.py @@ -35,8 +35,8 @@ class TestMultiFileSystem(BaseTest): """Tests for multifilesystem.""" - def setup(self) -> None: - _TestBaseRequests.setup(cast(_TestBaseRequests, self)) + def setup_method(self) -> None: + _TestBaseRequests.setup_method(cast(_TestBaseRequests, self)) self.configure({"storage": {"type": "multifilesystem"}}) def test_folder_creation(self) -> None: @@ -150,8 +150,8 @@ def test_put_whole_addressbook_random_uids_used_as_file_names( class TestMultiFileSystemNoLock(BaseTest): """Tests for multifilesystem_nolock.""" - def setup(self) -> None: - _TestBaseRequests.setup(cast(_TestBaseRequests, self)) + def setup_method(self) -> None: + _TestBaseRequests.setup_method(cast(_TestBaseRequests, self)) self.configure({"storage": {"type": "multifilesystem_nolock"}}) test_add_event = _TestBaseRequests.test_add_event @@ -161,8 +161,8 @@ def setup(self) -> None: class TestCustomStorageSystem(BaseTest): """Test custom backend loading.""" - def setup(self) -> None: - _TestBaseRequests.setup(cast(_TestBaseRequests, self)) + def setup_method(self) -> None: + _TestBaseRequests.setup_method(cast(_TestBaseRequests, self)) self.configure({"storage": { "type": "radicale.tests.custom.storage_simple_sync"}}) @@ -181,8 +181,8 @@ def setup(self) -> None: class TestCustomStorageSystemCallable(BaseTest): """Test custom backend loading with ``callable``.""" - def setup(self) -> None: - _TestBaseRequests.setup(cast(_TestBaseRequests, self)) + def setup_method(self) -> None: + _TestBaseRequests.setup_method(cast(_TestBaseRequests, self)) self.configure({"storage": { "type": radicale.tests.custom.storage_simple_sync.Storage}}) diff --git a/radicale/types.py b/radicale/types.py index 0eb3fd6a7..6899a7553 100644 --- a/radicale/types.py +++ b/radicale/types.py @@ -15,9 +15,9 @@ # along with Radicale. If not, see . import contextlib -import sys from typing import (Any, Callable, ContextManager, Iterator, List, Mapping, - MutableMapping, Sequence, Tuple, TypeVar, Union) + MutableMapping, Protocol, Sequence, Tuple, TypeVar, Union, + runtime_checkable) WSGIResponseHeaders = Union[Mapping[str, str], Sequence[Tuple[str, str]]] WSGIResponse = Tuple[int, WSGIResponseHeaders, Union[None, str, bytes]] @@ -41,20 +41,17 @@ def contextmanager(func: Callable[..., Iterator[_T]] return result -if sys.version_info >= (3, 8): - from typing import Protocol, runtime_checkable +@runtime_checkable +class InputStream(Protocol): + def read(self, size: int = ...) -> bytes: ... - @runtime_checkable - class InputStream(Protocol): - def read(self, size: int = ...) -> bytes: ... - @runtime_checkable - class ErrorStream(Protocol): - def flush(self) -> None: ... - def write(self, s: str) -> None: ... -else: - ErrorStream = Any - InputStream = Any +@runtime_checkable +class ErrorStream(Protocol): + def flush(self) -> object: ... + + def write(self, s: str) -> object: ... + from radicale import item, storage # noqa:E402 isort:skip diff --git a/radicale/utils.py b/radicale/utils.py index 6125792a5..a65126464 100644 --- a/radicale/utils.py +++ b/radicale/utils.py @@ -16,18 +16,12 @@ # You should have received a copy of the GNU General Public License # along with Radicale. If not, see . -import sys -from importlib import import_module +from importlib import import_module, metadata from typing import Callable, Sequence, Type, TypeVar, Union from radicale import config from radicale.log import logger -if sys.version_info < (3, 8): - import pkg_resources -else: - from importlib import metadata - _T_co = TypeVar("_T_co", covariant=True) @@ -52,6 +46,4 @@ def load_plugin(internal_types: Sequence[str], module_name: str, def package_version(name): - if sys.version_info < (3, 8): - return pkg_resources.get_distribution(name).version return metadata.version(name) diff --git a/radicale/web/internal_data/css/icons/delete.svg b/radicale/web/internal_data/css/icons/delete.svg new file mode 100644 index 000000000..f8aa78561 --- /dev/null +++ b/radicale/web/internal_data/css/icons/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/download.svg b/radicale/web/internal_data/css/icons/download.svg new file mode 100644 index 000000000..1ee311b51 --- /dev/null +++ b/radicale/web/internal_data/css/icons/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/edit.svg b/radicale/web/internal_data/css/icons/edit.svg new file mode 100644 index 000000000..0cfe935e1 --- /dev/null +++ b/radicale/web/internal_data/css/icons/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/new.svg b/radicale/web/internal_data/css/icons/new.svg new file mode 100644 index 000000000..d8448b8e6 --- /dev/null +++ b/radicale/web/internal_data/css/icons/new.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/upload.svg b/radicale/web/internal_data/css/icons/upload.svg new file mode 100644 index 000000000..2e05b18ca --- /dev/null +++ b/radicale/web/internal_data/css/icons/upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/loading.svg b/radicale/web/internal_data/css/loading.svg new file mode 100644 index 000000000..3513ff672 --- /dev/null +++ b/radicale/web/internal_data/css/loading.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/radicale/web/internal_data/css/logo.svg b/radicale/web/internal_data/css/logo.svg new file mode 100644 index 000000000..546d3d10d --- /dev/null +++ b/radicale/web/internal_data/css/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css index 726b9a19b..a6d7da72c 100644 --- a/radicale/web/internal_data/css/main.css +++ b/radicale/web/internal_data/css/main.css @@ -1 +1,428 @@ -body{background:#e4e9f6;color:#424247;display:flex;flex-direction:column;font-family:sans;font-size:14pt;line-height:1.4;margin:0;min-height:100vh}a{color:inherit}nav,footer{background:#a40000;color:#fff;padding:0 20%}nav ul,footer ul{display:flex;flex-wrap:wrap;margin:0;padding:0}nav ul li,footer ul li{display:block;padding:0 1em 0 0}nav ul li a,footer ul li a{color:inherit;display:block;padding:1em .5em 1em 0;text-decoration:inherit;transition:.2s}nav ul li a:hover,nav ul li a:focus,footer ul li a:hover,footer ul li a:focus{color:#000;outline:none}header{background:url(logo.svg),linear-gradient(to bottom right, #050a02, #000);background-position:22% 45%;background-repeat:no-repeat;color:#efdddd;font-size:1.5em;min-height:250px;overflow:auto;padding:3em 22%;text-shadow:.2em .2em .2em rgba(0,0,0,0.5)}header>*{padding-left:220px}header h1{font-size:2.5em;font-weight:lighter;margin:.5em 0}main{flex:1}section{padding:0 20% 2em}section:not(:last-child){border-bottom:1px dashed #ccc}section h1{background:linear-gradient(to bottom right, #050a02, #000);color:#e5dddd;font-size:2.5em;margin:0 -33.33% 1em;padding:1em 33.33%}section h2,section h3,section h4{font-weight:lighter;margin:1.5em 0 1em}article{border-top:1px solid transparent;position:relative;margin:3em 0}article aside{box-sizing:border-box;color:#aaa;font-size:.8em;right:-30%;top:.5em;position:absolute}article:before{border-top:1px dashed #ccc;content:"";display:block;left:-33.33%;position:absolute;right:-33.33%}pre{border-radius:3px;background:#000;color:#d3d5db;margin:0 -1em;overflow-x:auto;padding:1em}table{border-collapse:collapse;font-size:.8em;margin:auto}table td{border:1px solid #ccc;padding:.5em}dl dt{margin-bottom:.5em;margin-top:1em}p>code,li>code,dt>code{background:#d1daf0}@media (max-width: 800px){body{font-size:12pt}header,section{padding-left:2em;padding-right:2em}nav,footer{padding-left:0;padding-right:0}nav ul,footer ul{justify-content:center}nav ul li,footer ul li{padding:0 .5em}nav ul li a,footer ul li a{padding:1em 0}header{background-position:50% 30px,0 0;padding-bottom:0;padding-top:330px;text-align:center}header>*{margin:0;padding-left:0}section h1{margin:0 -.8em 1.3em;padding:.5em 0;text-align:center}article aside{top:.5em;right:-1.5em}article:before{left:-2em;right:-2em}} +body{ + background: #ffffff; + color: #424247; + font-family: sans-serif; + font-size: 14pt; + margin: 0; + min-height: 100vh; + display: flex; + flex-wrap: wrap; + flex-direction: row; + align-content: center; + align-items: flex-start; + justify-content: space-around; +} + +main{ + width: 100%; +} + +.container{ + height: auto; + min-height: 450px; + width: 350px; + transition: .2s; + overflow: hidden; + padding: 20px 40px; + background: #fff; + border: 1px solid #dadce0; + border-radius: 8px; + display: block; + flex-shrink: 0; + margin: 0 auto; +} + +.container h1{ + margin: 0; + width: 100%; + text-align: center; + color: #484848; +} + +#loginscene input{ +} + + +#loginscene .logocontainer{ + width: 100%; + text-align: center; +} + +#loginscene .logocontainer img{ + width: 75px; +} + +#loginscene h1{ + text-align: center; + font-family: sans-serif; + font-weight: normal; +} + +#loginscene button{ + float: right; +} + +#loadingscene{ + width: 100%; + height: 100%; + background: rgb(237 237 237); + position: absolute; + top: 0; + left: 0; + display: flex; + flex-wrap: nowrap; + justify-content: center; + align-items: center; + flex-direction: column; + overflow: hidden; + z-index: 999; +} + +#loadingscene h2{ + font-size: 2em; + font-weight: bold; +} + +#logoutview{ + width: 100%; + display: block; + background: white; + text-align: center; + padding: 10px 0px; + color: #666; + border-bottom: 2px solid #dadce0; + position: fixed; +} + +#logoutview span{ + width: calc(100% - 60px); + display: inline-block; +} + +#logoutview a{ + color: white; + text-decoration: none; + padding: 3px 10px; + position: relative; + border-radius: 4px; +} + +#logoutview a[data-name=logout]{ + right: 25px; + float: right; +} + +#logoutview a[data-name=refresh]{ + left: 25px; + float: left; +} + +#collectionsscene{ + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-content: flex-start; + align-items: center; + margin-top: 50px; + width: 100%; + height: 100vh; +} + +#collectionsscene article{ + width: 275px; + background: rgb(250, 250, 250); + border-radius: 8px; + box-shadow: 2px 2px 3px #0000001a; + border: 1px solid #dadce0; + padding: 5px 10px; + padding-top: 0; + margin: 10px; + float: left; + min-height: 375px; + overflow: hidden; +} + +#collectionsscene article .colorbar{ + width: 500%; + height: 15px; + margin: 0px -100%; + background: #000000; +} + +#collectionsscene article .title{ + width: 100%; + text-align: center; + font-size: 1.5em; + display: block; + padding: 10px 0; + margin: 0; +} + +#collectionsscene article small{ + font-size: 15px; + float: left; + font-weight: normal; + font-style: italic; + padding-bottom: 10px; + width: 100%; + text-align: center; +} + +#collectionsscene article input[type=text]{ + margin-bottom: 0 !important; +} + +#collectionsscene article p{ + font-size: 1em; + max-height: 130px; + overflow: overlay; +} + +#collectionsscene article:hover ul{ + visibility: visible; +} + +#collectionsscene ul{ + visibility: hidden; + display: flex; + justify-content: space-evenly; + width: 60%; + margin: 0 20%; + padding: 0; +} + +#collectionsscene li{ + list-style: none; + display: block; +} + +#collectionsscene li a{ + text-decoration: none !important; + padding: 5px; + float: left; + border-radius: 5px; + width: 25px; + height: 25px; + text-align: center; +} + +#collectionsscene article small[data-name=contentcount]{ + font-weight: bold; + font-style: normal; +} + +#editcollectionscene p span{ + word-wrap:break-word; + font-weight: bold; + color: #4e9a06; +} + +#deletecollectionscene p span{ + word-wrap:break-word; + font-weight: bold; + color: #a40000; +} + +#uploadcollectionscene ul{ + margin: 10px -30px; + max-height: 600px; + overflow-y: scroll; +} + +#uploadcollectionscene li{ + border-bottom: 1px dashed #d5d5d5; + margin-bottom: 10px; + padding-bottom: 10px; +} + +#uploadcollectionscene div[data-name=pending]{ + width: 100%; + text-align: center; +} + +#uploadcollectionscene .successmessage{ + color: #4e9a06; + width: 100%; + text-align: center; + display: block; + margin-top: 15px; +} + +.deleteconfirmationtxt{ + text-align: center; + font-size: 1em; + font-weight: bold; +} + +.fabcontainer{ + display: flex; + flex-direction: column-reverse; + position: fixed; + bottom: 5px; + right: 0; +} + +.fabcontainer a{ + width: 30px; + height: 30px; + text-decoration: none; + color: white; + border: none !important; + border-radius: 100%; + margin: 5px 10px; + background: black; + text-align: center; + display: flex; + align-content: center; + justify-content: center; + align-items: center; + font-size: 30px; + padding: 10px; + box-shadow: 2px 2px 7px #000000d6; +} + +.title{ + word-wrap: break-word; + font-weight: bold; +} + +.icon{ + width: 100%; + height: 100%; + filter: invert(1); +} + +.smalltext{ + font-size: 75% !important; +} + +.error{ + width: 100%; + display: block; + text-align: center; + color: rgb(217,48,37); + font-family: sans-serif; + clear: both; + padding-top: 15px; +} + +img.loading{ + width: 150px; + height: 150px; +} + +.error::before{ + content: "!"; + height: 1em; + color: white; + background: rgb(217,48,37); + font-weight: bold; + border-radius: 100%; + display: inline-block; + width: 1.1em; + margin-right: 5px; + font-size: 1em; + text-align: center; +} + +button{ + font-size: 1em; + padding: 7px 21px; + color: white; + border-radius: 4px; + float: right; + margin-left: 10px; + background: black; + cursor: pointer; +} + +input, select{ + width: 100%; + height: 3em; + border-style: solid; + border-color: #e6e6e6; + border-width: 1px; + border-radius: 7px; + margin-bottom: 25px; + padding-left: 15px; + padding-right: 15px; + outline: none !important; +} + +input[type=text], input[type=password]{ + width: calc(100% - 30px); +} + +input:active, input:focus, input:focus-visible{ + border-color: #2494fe !important; + border-width: 1px !important; +} + +p.red, span.red{ + color: #b50202; +} + +button.red, a.red{ + background: #b50202; + border: 1px solid #a40000; +} + +button.red:hover, a.red:hover{ + background: #a40000; +} + +button.red:active, a.red:active{ + background: #8f0000; +} + +button.green, a.green{ + background: #4e9a06; + border: 1px solid #377200; +} + +button.green:hover, a.green:hover{ + background: #377200; +} + +button.green:active, a.green:active{ + background: #285200; +} + +button.blue, a.blue{ + background: #2494fe; + border: 1px solid #055fb5; +} + +button.blue:hover, a.blue:hover{ + background: #1578d6; + cursor: pointer !important; +} + +button.blue:active, a.blue:active{ + background: #055fb5; + cursor: pointer !important; +} + +@media only screen and (max-width: 600px) { + #collectionsscene{ + flex-direction: column !important; + flex-wrap: nowrap; + } + + #collectionsscene article{ + height: auto; + min-height: 375px; + } + + .container{ + max-width: 280px !important; + } + + #collectionsscene ul{ + visibility: visible !important; + } + + #logoutview span{ + padding: 0 5px; + } +} diff --git a/radicale/web/internal_data/fn.js b/radicale/web/internal_data/fn.js index 82651a36f..af13ad0a6 100644 --- a/radicale/web/internal_data/fn.js +++ b/radicale/web/internal_data/fn.js @@ -1,6 +1,7 @@ /** * This file is part of Radicale Server - Calendar Server - * Copyright © 2017-2018 Unrud + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -28,7 +29,7 @@ const SERVER = location.origin; * @const * @type {string} */ -const ROOT_PATH = (new URL("..", location.href)).pathname; +const ROOT_PATH = location.pathname.replace(new RegExp("/+[^/]+/*(/index\\.html?)?$"), "") + '/'; /** * Regex to match and normalize color @@ -36,6 +37,13 @@ const ROOT_PATH = (new URL("..", location.href)).pathname; */ const COLOR_RE = new RegExp("^(#[0-9A-Fa-f]{6})(?:[0-9A-Fa-f]{2})?$"); + +/** + * The text needed to confirm deleting a collection + * @const + */ +const DELETE_CONFIRMATION_TEXT = "DELETE"; + /** * Escape string for usage in XML * @param {string} s @@ -63,6 +71,7 @@ const CollectionType = { CALENDAR: "CALENDAR", JOURNAL: "JOURNAL", TASKS: "TASKS", + WEBCAL: "WEBCAL", is_subset: function(a, b) { let components = a.split("_"); for (let i = 0; i < components.length; i++) { @@ -89,7 +98,27 @@ const CollectionType = { if (a.search(this.TASKS) !== -1 || b.search(this.TASKS) !== -1) { union.push(this.TASKS); } + if (a.search(this.WEBCAL) !== -1 || b.search(this.WEBCAL) !== -1) { + union.push(this.WEBCAL); + } return union.join("_"); + }, + valid_options_for_type: function(a){ + a = a.trim().toUpperCase(); + switch(a){ + case CollectionType.CALENDAR_JOURNAL_TASKS: + case CollectionType.CALENDAR_JOURNAL: + case CollectionType.CALENDAR_TASKS: + case CollectionType.JOURNAL_TASKS: + case CollectionType.CALENDAR: + case CollectionType.JOURNAL: + case CollectionType.TASKS: + return [CollectionType.CALENDAR_JOURNAL_TASKS, CollectionType.CALENDAR_JOURNAL, CollectionType.CALENDAR_TASKS, CollectionType.JOURNAL_TASKS, CollectionType.CALENDAR, CollectionType.JOURNAL, CollectionType.TASKS]; + case CollectionType.ADDRESSBOOK: + case CollectionType.WEBCAL: + default: + return [a]; + } } }; @@ -102,12 +131,15 @@ const CollectionType = { * @param {string} description * @param {string} color */ -function Collection(href, type, displayname, description, color) { +function Collection(href, type, displayname, description, color, contentcount, size, source) { this.href = href; this.type = type; this.displayname = displayname; this.color = color; this.description = description; + this.source = source; + this.contentcount = contentcount; + this.size = size; } /** @@ -119,7 +151,7 @@ function Collection(href, type, displayname, description, color) { */ function get_principal(user, password, callback) { let request = new XMLHttpRequest(); - request.open("PROPFIND", SERVER + ROOT_PATH, true, user, password); + request.open("PROPFIND", SERVER + ROOT_PATH, true, user, encodeURIComponent(password)); request.onreadystatechange = function() { if (request.readyState !== 4) { return; @@ -134,6 +166,7 @@ function get_principal(user, password, callback) { CollectionType.PRINCIPAL, displayname_element ? displayname_element.textContent : "", "", + 0, ""), null); } else { callback(null, "Internal error"); @@ -162,7 +195,7 @@ function get_principal(user, password, callback) { */ function get_collections(user, password, collection, callback) { let request = new XMLHttpRequest(); - request.open("PROPFIND", SERVER + collection.href, true, user, password); + request.open("PROPFIND", SERVER + collection.href, true, user, encodeURIComponent(password)); request.setRequestHeader("depth", "1"); request.onreadystatechange = function() { if (request.readyState !== 4) { @@ -183,6 +216,9 @@ function get_collections(user, password, collection, callback) { let addressbookcolor_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-color"); let calendardesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|calendar-description"); let addressbookdesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-description"); + let contentcount_element = response.querySelector(response_query + " > *|propstat > *|prop > *|getcontentcount"); + let contentlength_element = response.querySelector(response_query + " > *|propstat > *|prop > *|getcontentlength"); + let webcalsource_element = response.querySelector(response_query + " > *|propstat > *|prop > *|source"); let components_query = response_query + " > *|propstat > *|prop > *|supported-calendar-component-set"; let components_element = response.querySelector(components_query); let href = href_element ? href_element.textContent : ""; @@ -190,11 +226,21 @@ function get_collections(user, password, collection, callback) { let type = ""; let color = ""; let description = ""; + let source = ""; + let count = 0; + let size = 0; if (resourcetype_element) { if (resourcetype_element.querySelector(resourcetype_query + " > *|addressbook")) { type = CollectionType.ADDRESSBOOK; color = addressbookcolor_element ? addressbookcolor_element.textContent : ""; description = addressbookdesc_element ? addressbookdesc_element.textContent : ""; + count = contentcount_element ? parseInt(contentcount_element.textContent) : 0; + size = contentlength_element ? parseInt(contentlength_element.textContent) : 0; + } else if (resourcetype_element.querySelector(resourcetype_query + " > *|subscribed")) { + type = CollectionType.WEBCAL; + source = webcalsource_element ? webcalsource_element.textContent : ""; + color = calendarcolor_element ? calendarcolor_element.textContent : ""; + description = calendardesc_element ? calendardesc_element.textContent : ""; } else if (resourcetype_element.querySelector(resourcetype_query + " > *|calendar")) { if (components_element) { if (components_element.querySelector(components_query + " > *|comp[name=VEVENT]")) { @@ -209,6 +255,8 @@ function get_collections(user, password, collection, callback) { } color = calendarcolor_element ? calendarcolor_element.textContent : ""; description = calendardesc_element ? calendardesc_element.textContent : ""; + count = contentcount_element ? parseInt(contentcount_element.textContent) : 0; + size = contentlength_element ? parseInt(contentlength_element.textContent) : 0; } } let sane_color = color.trim(); @@ -221,7 +269,7 @@ function get_collections(user, password, collection, callback) { } } if (href.substr(-1) === "/" && href !== collection.href && type) { - collections.push(new Collection(href, type, displayname, description, sane_color)); + collections.push(new Collection(href, type, displayname, description, sane_color, count, size, source)); } } collections.sort(function(a, b) { @@ -235,11 +283,15 @@ function get_collections(user, password, collection, callback) { } }; request.send('' + - '' + + 'xmlns:RADICALE="http://radicale.org/ns/"' + + '>' + '' + '' + '' + @@ -248,6 +300,9 @@ function get_collections(user, password, collection, callback) { '' + '' + '' + + '' + + '' + + '' + '' + ''); return request; @@ -263,7 +318,7 @@ function get_collections(user, password, collection, callback) { */ function upload_collection(user, password, collection_href, file, callback) { let request = new XMLHttpRequest(); - request.open("PUT", SERVER + collection_href, true, user, password); + request.open("PUT", SERVER + collection_href, true, user, encodeURIComponent(password)); request.onreadystatechange = function() { if (request.readyState !== 4) { return; @@ -288,7 +343,7 @@ function upload_collection(user, password, collection_href, file, callback) { */ function delete_collection(user, password, collection, callback) { let request = new XMLHttpRequest(); - request.open("DELETE", SERVER + collection.href, true, user, password); + request.open("DELETE", SERVER + collection.href, true, user, encodeURIComponent(password)); request.onreadystatechange = function() { if (request.readyState !== 4) { return; @@ -313,7 +368,7 @@ function delete_collection(user, password, collection, callback) { */ function create_edit_collection(user, password, collection, create, callback) { let request = new XMLHttpRequest(); - request.open(create ? "MKCOL" : "PROPPATCH", SERVER + collection.href, true, user, password); + request.open(create ? "MKCOL" : "PROPPATCH", SERVER + collection.href, true, user, encodeURIComponent(password)); request.onreadystatechange = function() { if (request.readyState !== 4) { return; @@ -329,12 +384,18 @@ function create_edit_collection(user, password, collection, create, callback) { let addressbook_color = ""; let calendar_description = ""; let addressbook_description = ""; + let calendar_source = ""; let resourcetype; let components = ""; if (collection.type === CollectionType.ADDRESSBOOK) { addressbook_color = escape_xml(collection.color + (collection.color ? "ff" : "")); addressbook_description = escape_xml(collection.description); resourcetype = ''; + } else if (collection.type === CollectionType.WEBCAL) { + calendar_color = escape_xml(collection.color + (collection.color ? "ff" : "")); + calendar_description = escape_xml(collection.description); + resourcetype = ''; + calendar_source = escape_xml(collection.source); } else { calendar_color = escape_xml(collection.color + (collection.color ? "ff" : "")); calendar_description = escape_xml(collection.description); @@ -351,7 +412,7 @@ function create_edit_collection(user, password, collection, create, callback) { } let xml_request = create ? "mkcol" : "propertyupdate"; request.send('' + - '<' + xml_request + ' xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:I="http://apple.com/ns/ical/" xmlns:INF="http://inf-it.com/ns/ab/">' + + '<' + xml_request + ' xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:I="http://apple.com/ns/ical/" xmlns:INF="http://inf-it.com/ns/ab/">' + '' + '' + (create ? '' + resourcetype + '' : '') + @@ -361,6 +422,7 @@ function create_edit_collection(user, password, collection, create, callback) { (addressbook_color ? '' + addressbook_color + '' : '') + (addressbook_description ? '' + addressbook_description + '' : '') + (calendar_description ? '' + calendar_description + '' : '') + + (calendar_source ? '' + calendar_source + '' : '') + '' + '' + (!create ? ('' + @@ -481,7 +543,8 @@ function LoginScene() { let error_form = html_scene.querySelector("[data-name=error]"); let logout_view = document.getElementById("logoutview"); let logout_user_form = logout_view.querySelector("[data-name=user]"); - let logout_btn = logout_view.querySelector("[data-name=link]"); + let logout_btn = logout_view.querySelector("[data-name=logout]"); + let refresh_btn = logout_view.querySelector("[data-name=refresh]"); /** @type {?number} */ let scene_index = null; let user = ""; @@ -495,7 +558,12 @@ function LoginScene() { function fill_form() { user_form.value = user; password_form.value = ""; - error_form.textContent = error ? "Error: " + error : ""; + if(error){ + error_form.textContent = "Error: " + error; + error_form.classList.remove("hidden"); + }else{ + error_form.classList.add("hidden"); + } } function onlogin() { @@ -507,7 +575,8 @@ function LoginScene() { // setup logout logout_view.classList.remove("hidden"); logout_btn.onclick = onlogout; - logout_user_form.textContent = user; + refresh_btn.onclick = refresh; + logout_user_form.textContent = user + "'s Collections"; // Fetch principal let loading_scene = new LoadingScene(); push_scene(loading_scene, false); @@ -557,9 +626,17 @@ function LoginScene() { function remove_logout() { logout_view.classList.add("hidden"); logout_btn.onclick = null; + refresh_btn.onclick = null; logout_user_form.textContent = ""; } + function refresh(){ + //The easiest way to refresh is to push a LoadingScene onto the stack and then pop it + //forcing the scene below it, the Collections Scene to refresh itself. + push_scene(new LoadingScene(), false); + pop_scene(scene_stack.length-2); + } + this.show = function() { remove_logout(); fill_form(); @@ -618,12 +695,6 @@ function CollectionsScene(user, password, collection, onerror) { /** @type {?XMLHttpRequest} */ let collections_req = null; /** @type {?Array} */ let collections = null; /** @type {Array} */ let nodes = []; - let filesInput = document.createElement("input"); - filesInput.setAttribute("type", "file"); - filesInput.setAttribute("accept", ".ics, .vcf"); - filesInput.setAttribute("multiple", ""); - let filesInputForm = document.createElement("form"); - filesInputForm.appendChild(filesInput); function onnew() { try { @@ -636,17 +707,9 @@ function CollectionsScene(user, password, collection, onerror) { } function onupload() { - filesInput.click(); - return false; - } - - function onfileschange() { try { - let files = filesInput.files; - if (files.length > 0) { - let upload_scene = new UploadCollectionScene(user, password, collection, files); - push_scene(upload_scene); - } + let upload_scene = new UploadCollectionScene(user, password, collection); + push_scene(upload_scene); } catch(err) { console.error(err); } @@ -674,21 +737,24 @@ function CollectionsScene(user, password, collection, onerror) { } function show_collections(collections) { + let heightOfNavBar = document.querySelector("#logoutview").offsetHeight + "px"; + html_scene.style.marginTop = heightOfNavBar; + html_scene.style.height = "calc(100vh - " + heightOfNavBar +")"; collections.forEach(function (collection) { let node = template.cloneNode(true); node.classList.remove("hidden"); let title_form = node.querySelector("[data-name=title]"); let description_form = node.querySelector("[data-name=description]"); + let contentcount_form = node.querySelector("[data-name=contentcount]"); let url_form = node.querySelector("[data-name=url]"); let color_form = node.querySelector("[data-name=color]"); let delete_btn = node.querySelector("[data-name=delete]"); let edit_btn = node.querySelector("[data-name=edit]"); + let download_btn = node.querySelector("[data-name=download]"); if (collection.color) { - color_form.style.color = collection.color; - } else { - color_form.classList.add("hidden"); + color_form.style.background = collection.color; } - let possible_types = [CollectionType.ADDRESSBOOK]; + let possible_types = [CollectionType.ADDRESSBOOK, CollectionType.WEBCAL]; [CollectionType.CALENDAR, ""].forEach(function(e) { [CollectionType.union(e, CollectionType.JOURNAL), e].forEach(function(e) { [CollectionType.union(e, CollectionType.TASKS), e].forEach(function(e) { @@ -704,10 +770,26 @@ function CollectionsScene(user, password, collection, onerror) { } }); title_form.textContent = collection.displayname || collection.href; + if(title_form.textContent.length > 30){ + title_form.classList.add("smalltext"); + } description_form.textContent = collection.description; + if(description_form.textContent.length > 150){ + description_form.classList.add("smalltext"); + } + if(collection.type != CollectionType.WEBCAL){ + let contentcount_form_txt = (collection.contentcount > 0 ? Number(collection.contentcount).toLocaleString() : "No") + " item" + (collection.contentcount == 1 ? "" : "s") + " in collection"; + if(collection.contentcount > 0){ + contentcount_form_txt += " (" + bytesToHumanReadable(collection.size) + ")"; + } + contentcount_form.textContent = contentcount_form_txt; + } let href = SERVER + collection.href; - url_form.href = href; - url_form.textContent = href; + url_form.value = href; + download_btn.href = href; + if(collection.type == CollectionType.WEBCAL){ + download_btn.parentElement.classList.add("hidden"); + } delete_btn.onclick = function() {return ondelete(collection);}; edit_btn.onclick = function() {return onedit(collection);}; node.classList.remove("hidden"); @@ -738,8 +820,6 @@ function CollectionsScene(user, password, collection, onerror) { html_scene.classList.remove("hidden"); new_btn.onclick = onnew; upload_btn.onclick = onupload; - filesInputForm.reset(); - filesInput.onchange = onfileschange; if (collections === null) { update(); } else { @@ -752,7 +832,6 @@ function CollectionsScene(user, password, collection, onerror) { scene_index = scene_stack.length - 1; new_btn.onclick = null; upload_btn.onclick = null; - filesInput.onchange = null; collections = null; // remove collection nodes.forEach(function(node) { @@ -767,7 +846,6 @@ function CollectionsScene(user, password, collection, onerror) { collections_req = null; } collections = null; - filesInputForm.reset(); }; } @@ -779,41 +857,87 @@ function CollectionsScene(user, password, collection, onerror) { * @param {Collection} collection parent collection * @param {Array} files */ -function UploadCollectionScene(user, password, collection, files) { +function UploadCollectionScene(user, password, collection) { let html_scene = document.getElementById("uploadcollectionscene"); let template = html_scene.querySelector("[data-name=filetemplate]"); + let upload_btn = html_scene.querySelector("[data-name=submit]"); let close_btn = html_scene.querySelector("[data-name=close]"); + let uploadfile_form = html_scene.querySelector("[data-name=uploadfile]"); + let uploadfile_lbl = html_scene.querySelector("label[for=uploadfile]"); + let href_form = html_scene.querySelector("[data-name=href]"); + let href_label = html_scene.querySelector("label[for=href]"); + let hreflimitmsg_html = html_scene.querySelector("[data-name=hreflimitmsg]"); + let pending_html = html_scene.querySelector("[data-name=pending]"); + + let files = uploadfile_form.files; + href_form.addEventListener("keydown", cleanHREFinput); + upload_btn.onclick = upload_start; + uploadfile_form.onchange = onfileschange; + + let href = random_uuid(); + href_form.value = href; /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let upload_req = null; - /** @type {Array} */ let errors = []; + /** @type {Array} */ let results = []; /** @type {?Array} */ let nodes = null; - function upload_next() { + function upload_start() { try { - if (files.length === errors.length) { - if (errors.every(error => error === null)) { - pop_scene(scene_index - 1); - } else { - close_btn.classList.remove("hidden"); - } + if(!read_form()){ + return false; + } + uploadfile_form.classList.add("hidden"); + uploadfile_lbl.classList.add("hidden"); + href_form.classList.add("hidden"); + href_label.classList.add("hidden"); + hreflimitmsg_html.classList.add("hidden"); + upload_btn.classList.add("hidden"); + close_btn.classList.add("hidden"); + + pending_html.classList.remove("hidden"); + + nodes = []; + for (let i = 0; i < files.length; i++) { + let file = files[i]; + let node = template.cloneNode(true); + node.classList.remove("hidden"); + let name_form = node.querySelector("[data-name=name]"); + name_form.textContent = file.name; + node.classList.remove("hidden"); + nodes.push(node); + updateFileStatus(i); + template.parentNode.insertBefore(node, template); + } + upload_next(); + } catch(err) { + console.error(err); + } + return false; + } + + function upload_next(){ + try{ + if (files.length === results.length) { + pending_html.classList.add("hidden"); + close_btn.classList.remove("hidden"); + return; } else { - let file = files[errors.length]; - let upload_href = collection.href + random_uuid() + "/"; - upload_req = upload_collection(user, password, upload_href, file, function(error) { - if (scene_index === null) { - return; - } + let file = files[results.length]; + if(files.length > 1 || href.length == 0){ + href = random_uuid(); + } + let upload_href = collection.href + "/" + href + "/"; + upload_req = upload_collection(user, password, upload_href, file, function(result) { upload_req = null; - errors.push(error); - updateFileStatus(errors.length - 1); + results.push(result); + updateFileStatus(results.length - 1); upload_next(); }); } - } catch(err) { + }catch(err){ console.error(err); } - return false; } function onclose() { @@ -829,54 +953,77 @@ function UploadCollectionScene(user, password, collection, files) { if (nodes === null) { return; } - let pending_form = nodes[i].querySelector("[data-name=pending]"); let success_form = nodes[i].querySelector("[data-name=success]"); let error_form = nodes[i].querySelector("[data-name=error]"); - if (errors.length > i) { - pending_form.classList.add("hidden"); - if (errors[i]) { + if (results.length > i) { + if (results[i]) { success_form.classList.add("hidden"); - error_form.textContent = "Error: " + errors[i]; + error_form.textContent = "Error: " + results[i]; error_form.classList.remove("hidden"); } else { success_form.classList.remove("hidden"); error_form.classList.add("hidden"); } } else { - pending_form.classList.remove("hidden"); success_form.classList.add("hidden"); error_form.classList.add("hidden"); } } - this.show = function() { - html_scene.classList.remove("hidden"); - if (errors.length < files.length) { - close_btn.classList.add("hidden"); + function read_form() { + cleanHREFinput(href_form); + let newhreftxtvalue = href_form.value.trim().toLowerCase(); + if(!isValidHREF(newhreftxtvalue)){ + alert("You must enter a valid HREF"); + return false; } - close_btn.onclick = onclose; - nodes = []; - for (let i = 0; i < files.length; i++) { - let file = files[i]; - let node = template.cloneNode(true); - node.classList.remove("hidden"); - let name_form = node.querySelector("[data-name=name]"); - name_form.textContent = file.name; - node.classList.remove("hidden"); - nodes.push(node); - updateFileStatus(i); - template.parentNode.insertBefore(node, template); + href = newhreftxtvalue; + + if(uploadfile_form.files.length == 0){ + alert("You must select at least one file to upload"); + return false; } - if (scene_index === null) { - scene_index = scene_stack.length - 1; - upload_next(); + files = uploadfile_form.files; + return true; + } + + function onfileschange() { + files = uploadfile_form.files; + if(files.length > 1){ + hreflimitmsg_html.classList.remove("hidden"); + href_form.classList.add("hidden"); + href_label.classList.add("hidden"); + }else{ + hreflimitmsg_html.classList.add("hidden"); + href_form.classList.remove("hidden"); + href_label.classList.remove("hidden"); } + return false; + } + + this.show = function() { + scene_index = scene_stack.length - 1; + html_scene.classList.remove("hidden"); + close_btn.onclick = onclose; }; this.hide = function() { html_scene.classList.add("hidden"); close_btn.classList.remove("hidden"); + upload_btn.classList.remove("hidden"); + uploadfile_form.classList.remove("hidden"); + uploadfile_lbl.classList.remove("hidden"); + href_form.classList.remove("hidden"); + href_label.classList.remove("hidden"); + hreflimitmsg_html.classList.add("hidden"); + pending_html.classList.add("hidden"); close_btn.onclick = null; + upload_btn.onclick = null; + href_form.value = ""; + uploadfile_form.value = ""; + if(nodes == null){ + return; + } nodes.forEach(function(node) { node.parentNode.removeChild(node); }); @@ -902,14 +1049,25 @@ function DeleteCollectionScene(user, password, collection) { let html_scene = document.getElementById("deletecollectionscene"); let title_form = html_scene.querySelector("[data-name=title]"); let error_form = html_scene.querySelector("[data-name=error]"); + let confirmation_txt = html_scene.querySelector("[data-name=confirmationtxt]"); + let delete_confirmation_lbl = html_scene.querySelector("[data-name=deleteconfirmationtext]"); let delete_btn = html_scene.querySelector("[data-name=delete]"); let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + delete_confirmation_lbl.innerHTML = DELETE_CONFIRMATION_TEXT; + confirmation_txt.value = ""; + confirmation_txt.addEventListener("keydown", onkeydown); + /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let delete_req = null; let error = ""; function ondelete() { + let confirmation_text_value = confirmation_txt.value; + if(confirmation_text_value != DELETE_CONFIRMATION_TEXT){ + alert("Please type the confirmation text to delete this collection."); + return; + } try { let loading_scene = new LoadingScene(); push_scene(loading_scene); @@ -940,14 +1098,27 @@ function DeleteCollectionScene(user, password, collection) { return false; } + function onkeydown(event){ + if (event.keyCode !== 13) { + return; + } + ondelete(); + } + this.show = function() { this.release(); scene_index = scene_stack.length - 1; html_scene.classList.remove("hidden"); title_form.textContent = collection.displayname || collection.href; - error_form.textContent = error ? "Error: " + error : ""; delete_btn.onclick = ondelete; cancel_btn.onclick = oncancel; + if(error){ + error_form.textContent = "Error: " + error; + error_form.classList.remove("hidden"); + }else{ + error_form.classList.add("hidden"); + } + }; this.hide = function() { html_scene.classList.add("hidden"); @@ -988,13 +1159,22 @@ function CreateEditCollectionScene(user, password, collection) { let html_scene = document.getElementById(edit ? "editcollectionscene" : "createcollectionscene"); let title_form = edit ? html_scene.querySelector("[data-name=title]") : null; let error_form = html_scene.querySelector("[data-name=error]"); + let href_form = html_scene.querySelector("[data-name=href]"); + let href_label = html_scene.querySelector("label[for=href]"); let displayname_form = html_scene.querySelector("[data-name=displayname]"); + let displayname_label = html_scene.querySelector("label[for=displayname]"); let description_form = html_scene.querySelector("[data-name=description]"); + let description_label = html_scene.querySelector("label[for=description]"); + let source_form = html_scene.querySelector("[data-name=source]"); + let source_label = html_scene.querySelector("label[for=source]"); let type_form = html_scene.querySelector("[data-name=type]"); + let type_label = html_scene.querySelector("label[for=type]"); let color_form = html_scene.querySelector("[data-name=color]"); + let color_label = html_scene.querySelector("label[for=color]"); let submit_btn = html_scene.querySelector("[data-name=submit]"); let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let create_edit_req = null; let error = ""; @@ -1003,40 +1183,69 @@ function CreateEditCollectionScene(user, password, collection) { let href = edit ? collection.href : collection.href + random_uuid() + "/"; let displayname = edit ? collection.displayname : ""; let description = edit ? collection.description : ""; + let source = edit ? collection.source : ""; let type = edit ? collection.type : CollectionType.CALENDAR_JOURNAL_TASKS; let color = edit && collection.color ? collection.color : "#" + random_hex(6); + if(!edit){ + href_form.addEventListener("keydown", cleanHREFinput); + } + function remove_invalid_types() { if (!edit) { return; } /** @type {HTMLOptionsCollection} */ let options = type_form.options; // remove all options that are not supersets + let valid_type_options = CollectionType.valid_options_for_type(type); for (let i = options.length - 1; i >= 0; i--) { - if (!CollectionType.is_subset(type, options[i].value)) { + if (valid_type_options.indexOf(options[i].value) < 0) { options.remove(i); } } } function read_form() { + if(!edit){ + cleanHREFinput(href_form); + let newhreftxtvalue = href_form.value.trim().toLowerCase(); + if(!isValidHREF(newhreftxtvalue)){ + alert("You must enter a valid HREF"); + return false; + } + href = collection.href + "/" + newhreftxtvalue + "/"; + } displayname = displayname_form.value; description = description_form.value; + source = source_form.value; type = type_form.value; color = color_form.value; + return true; } function fill_form() { + if(!edit){ + href_form.value = random_uuid(); + } displayname_form.value = displayname; description_form.value = description; + source_form.value = source; type_form.value = type; color_form.value = color; - error_form.textContent = error ? "Error: " + error : ""; + if(error){ + error_form.textContent = "Error: " + error; + error_form.classList.remove("hidden"); + } + error_form.classList.add("hidden"); + onTypeChange(); + type_form.addEventListener("change", onTypeChange); } function onsubmit() { try { - read_form(); + if(!read_form()){ + return false; + } let sane_color = color.trim(); if (sane_color) { let color_match = COLOR_RE.exec(sane_color); @@ -1049,7 +1258,7 @@ function CreateEditCollectionScene(user, password, collection) { } let loading_scene = new LoadingScene(); push_scene(loading_scene); - let collection = new Collection(href, type, displayname, description, sane_color); + let collection = new Collection(href, type, displayname, description, sane_color, 0, 0, source); let callback = function(error1) { if (scene_index === null) { return; @@ -1082,6 +1291,17 @@ function CreateEditCollectionScene(user, password, collection) { return false; } + + function onTypeChange(e){ + if(type_form.value == CollectionType.WEBCAL){ + source_label.classList.remove("hidden"); + source_form.classList.remove("hidden"); + }else{ + source_label.classList.add("hidden"); + source_form.classList.add("hidden"); + } + } + this.show = function() { this.release(); scene_index = scene_stack.length - 1; @@ -1117,6 +1337,57 @@ function CreateEditCollectionScene(user, password, collection) { }; } +/** + * Removed invalid HREF characters for a collection HREF. + * + * @param a A valid Input element or an onchange Event of an Input element. + */ +function cleanHREFinput(a) { + let href_form = a; + if (a.target) { + href_form = a.target; + } + let currentTxtVal = href_form.value.trim().toLowerCase(); + //Clean the HREF to remove non lowercase letters and dashes + currentTxtVal = currentTxtVal.replace(/(?![0-9a-z\-\_])./g, ''); + href_form.value = currentTxtVal; +} + +/** + * Checks if a proposed HREF for a collection has a valid format and syntax. + * + * @param href String of the porposed HREF. + * + * @return Boolean results if the HREF is valid. + */ +function isValidHREF(href) { + if (href.length < 1) { + return false; + } + if (href.indexOf("/") != -1) { + return false; + } + + return true; +} + +/** + * Format bytes to human-readable text. + * + * @param bytes Number of bytes. + * + * @return Formatted string. + */ +function bytesToHumanReadable(bytes, dp=1) { + let isNumber = !isNaN(parseFloat(bytes)) && !isNaN(bytes - 0); + if(!isNumber){ + return ""; + } + var i = bytes == 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(dp) * 1 + ' ' + ['b', 'kb', 'mb', 'gb', 'tb'][i]; +} + + function main() { // Hide startup loading message document.getElementById("loadingscene").classList.add("hidden"); diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index c526195ab..7806765f1 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -1,66 +1,97 @@ + + + + + Radicale Web Interface + + + + + - - - - -Radicale Web Interface - - - + + - +
+
+ Loading... +

Loading

+

Please wait...

+ +
-
-

Loading

-

Please wait...

- -
+ - + - - - + + + + + + + + + + +
+ +
+ + + + - + + + - + - +
+ + diff --git a/radicale/xmlutils.py b/radicale/xmlutils.py index 09508d9c4..4b9c51bfc 100644 --- a/radicale/xmlutils.py +++ b/radicale/xmlutils.py @@ -33,7 +33,8 @@ MIMETYPES: Mapping[str, str] = { "VADDRESSBOOK": "text/vcard", - "VCALENDAR": "text/calendar"} + "VCALENDAR": "text/calendar", + "VSUBSCRIBED": "text/calendar"} OBJECT_MIMETYPES: Mapping[str, str] = { "VCARD": "text/vcard", @@ -177,6 +178,9 @@ def props_from_request(xml_request: Optional[ET.Element] if resource_type.tag == make_clark("C:calendar"): value = "VCALENDAR" break + if resource_type.tag == make_clark("CS:subscribed"): + value = "VSUBSCRIBED" + break if resource_type.tag == make_clark("CR:addressbook"): value = "VADDRESSBOOK" break diff --git a/rights b/rights index 1425003e4..834d2b7c2 100644 --- a/rights +++ b/rights @@ -1,5 +1,29 @@ # -*- mode: conf -*- # vim:ft=cfg +# Allow all rights for the Administrator +#[root] +#user: Administrator +#collection: .* +#permissions: RW + +# Allow reading principal collection (same as username) +#[principal] +#user: .+ +#collection: {user} +#permissions: R + +# Allow reading and writing private collection (same as username) +#[private] +#user: .+ +#collection: {user}/private/ +#permissions: RW + +# Allow reading calendars and address books that are direct +# children of the principal collection for other users +#[calendarsReader] +#user: .+ +#collection: {user}/[^/]+ +#permissions: r # Rights management file for Radicale - A simple calendar server # diff --git a/setup.cfg b/setup.cfg index a77b43bc9..10786e2a4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,56 +1,6 @@ -[tool:pytest] -addopts = --typeguard-packages=radicale - -[tox:tox] - -[testenv] -extras = test -deps = - flake8 - isort - # mypy installation fails with pypy<3.9 - mypy; implementation_name!='pypy' or python_version>='3.9' - types-setuptools - pytest-cov -commands = - flake8 . - isort --check --diff . - # Run mypy if it's installed - python -c 'import importlib.util, subprocess, sys; \ - importlib.util.find_spec("mypy") \ - and sys.exit(subprocess.run(["mypy", "."]).returncode) \ - or print("Skipped: mypy is not installed")' - pytest -r s --cov --cov-report=term --cov-report=xml . - -[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 - [flake8] # Only enable default tests (https://github.com/PyCQA/flake8/issues/790#issuecomment-812823398) -select = E,F,W,C90,DOES-NOT-EXIST -ignore = E121,E123,E126,E226,E24,E704,W503,W504,DOES-NOT-EXIST +# DNE: DOES-NOT-EXIST +select = E,F,W,C90,DNE000 +ignore = E121,E123,E126,E226,E24,E704,W503,W504,DNE000,E501 extend-exclude = build - -[mypy] -ignore_missing_imports = True -show_error_codes = True -exclude = (^|/)build($|/) - -[coverage:run] -branch = True -source = radicale -omit = tests/*,*/tests/* - -[coverage:report] -# Regexes for lines to exclude from consideration -exclude_lines = - # Have to re-enable the standard pragma - pragma: no cover - - # Don't complain if tests don't hit defensive assertion code: - raise AssertionError - raise NotImplementedError - - # Don't complain if non-runnable code isn't run: - if __name__ == .__main__.: diff --git a/setup.cfg.legacy b/setup.cfg.legacy new file mode 100644 index 000000000..94a39915d --- /dev/null +++ b/setup.cfg.legacy @@ -0,0 +1,62 @@ +[tool:pytest] + +[tox:tox] +min_version = 4.0 +envlist = py, flake8, isort, mypy + +[testenv] +extras = + test +deps = + pytest + pytest-cov +commands = pytest -r s --cov --cov-report=term --cov-report=xml . + +[testenv:flake8] +deps = flake8==7.1.0 +commands = flake8 . +skip_install = True + +[testenv:isort] +deps = isort==5.13.2 +commands = isort --check --diff . +skip_install = True + +[testenv:mypy] +deps = mypy==1.11.0 +commands = mypy . +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 + +[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 +extend-exclude = build + +[mypy] +ignore_missing_imports = True +show_error_codes = True +exclude = (^|/)build($|/) + +[coverage:run] +branch = True +source = radicale +omit = tests/*,*/tests/* + +[coverage:report] +# Regexes for lines to exclude from consideration +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain if tests don't hit defensive assertion code: + raise AssertionError + raise NotImplementedError + + # Don't complain if non-runnable code isn't run: + if __name__ == .__main__.: diff --git a/setup.py.legacy b/setup.py.legacy new file mode 100644 index 000000000..95717e6a8 --- /dev/null +++ b/setup.py.legacy @@ -0,0 +1,81 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2009-2017 Guillaume Ayoub +# Copyright © 2017-2018 Unrud +# +# 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 . + +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.3.0" + +with open("README.md", encoding="utf-8") as f: + long_description = f.read() +web_files = ["web/internal_data/css/icon.png", + "web/internal_data/css/loading.svg", + "web/internal_data/css/logo.svg", + "web/internal_data/css/main.css", + "web/internal_data/css/icons/delete.svg", + "web/internal_data/css/icons/download.svg", + "web/internal_data/css/icons/edit.svg", + "web/internal_data/css/icons/new.svg", + "web/internal_data/css/icons/upload.svg", + "web/internal_data/fn.js", + "web/internal_data/index.html"] + +install_requires = ["defusedxml", "passlib", "vobject>=0.9.6", + "python-dateutil>=2.7.3", + "pika>=1.1.0", + ] +bcrypt_requires = ["bcrypt"] +test_requires = ["pytest>=7", "waitress", *bcrypt_requires] + +setup( + name="Radicale", + version=VERSION, + description="CalDAV and CardDAV Server", + long_description=long_description, + long_description_content_type="text/markdown", + author="Guillaume Ayoub", + author_email="guillaume.ayoub@kozea.fr", + url="https://radicale.org/", + license="GNU GPL v3", + platforms="Any", + packages=find_packages( + exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), + package_data={"radicale": [*web_files, "py.typed"]}, + entry_points={"console_scripts": ["radicale = radicale.__main__:run"]}, + install_requires=install_requires, + extras_require={"test": test_requires, "bcrypt": bcrypt_requires}, + keywords=["calendar", "addressbook", "CalDAV", "CardDAV"], + python_requires=">=3.8.0", + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Environment :: Web Environment", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: GNU General Public License (GPL)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Office/Business :: Groupware"])