pax_global_header00006660000000000000000000000064142640755600014523gustar00rootroot0000000000000052 comment=78c7225e8d5f805c23d90517736aa6c5b0f32860 Radicale-3.1.8/000077500000000000000000000000001426407556000132405ustar00rootroot00000000000000Radicale-3.1.8/.github/000077500000000000000000000000001426407556000146005ustar00rootroot00000000000000Radicale-3.1.8/.github/workflows/000077500000000000000000000000001426407556000166355ustar00rootroot00000000000000Radicale-3.1.8/.github/workflows/generate-documentation.yml000066400000000000000000000004241426407556000240210ustar00rootroot00000000000000name: Generate documentation on: push: paths: - DOCUMENTATION.md jobs: generate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 with: ref: gh-pages - name: Run generator run: documentation-generator/run.py Radicale-3.1.8/.github/workflows/pypi-publish.yml000066400000000000000000000010261426407556000220040ustar00rootroot00000000000000name: PyPI publish on: release: types: [published] jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: 3.x - name: Install Build dependencies run: pip install build - 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 }} Radicale-3.1.8/.github/workflows/test.yml000066400000000000000000000027171426407556000203460ustar00rootroot00000000000000name: Test on: [push, pull_request] jobs: test: 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] 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 with: python-version: ${{ matrix.python-version }} - name: Install Test dependencies run: pip install tox - name: Test run: tox - name: Install Coveralls if: github.event_name == 'push' run: pip install coveralls - name: Upload coverage to Coveralls if: github.event_name == 'push' env: COVERALLS_PARALLEL: true GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: coveralls --service=github coveralls-finish: needs: test if: github.event_name == 'push' runs-on: ubuntu-latest steps: - uses: actions/setup-python@v2 with: python-version: 3.x - name: Install Coveralls run: pip install coveralls - name: Finish Coveralls parallel builds env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: coveralls --service=github --finish Radicale-3.1.8/.gitignore000066400000000000000000000003311426407556000152250ustar00rootroot00000000000000*~ .*.swp *.pyc __pycache__ /MANIFEST /build /dist /*.egg-info /_site coverage.xml .pytest_cache .cache .coverage .coverage.* .eggs .mypy_cache .project .pydevproject .settings .tox .vscode .sass-cache Gemfile.lock Radicale-3.1.8/.mdl.style000066400000000000000000000001241426407556000151510ustar00rootroot00000000000000all rule 'MD026', :punctuation => '.,;:!' exclude_rule 'MD001' exclude_rule 'MD024' Radicale-3.1.8/.mdlrc000066400000000000000000000000661426407556000143440ustar00rootroot00000000000000style File.join(File.dirname(__FILE__), '.mdl.style') Radicale-3.1.8/CHANGELOG.md000066400000000000000000000427331426407556000150620ustar00rootroot00000000000000# Changelog ## 3.1.8 * Fix setuptools requirement if installing wheel * Tests: Switch from `python setup.py test` to `tox` * Small changes to build system configuration and tests ## 3.1.7 * Fix random href fallback ## 3.1.6 * Ignore `Not a directory` error for optional config paths * Fix upload of whole address book/calendar with UIDs that collide on case-insensitive filesystem * Remove runtime dependency on setuptools for Python>=3.9 * Windows: Block ADS paths ## 3.1.5 * Ignore configuration file if access is denied * Use F_FULLFSYNC with PyPy on MacOS * Fallback if F_FULLFSYNC is not supported by the filesystem ## 3.1.4 * Fallback if RENAME_EXCHANGE is not supported by the filesystem * Assume POSIX compatibility if `sys.platform` is not `win32` ## 3.1.3 * Redirect '…/.well-known/caldav' and '…/.well-known/carddav' to base prefix * Warning instead of error when base prefix ends with '/' ## 3.1.2 * Verify that base prefix starts with '/' but doesn't end with '/' * Improve base prefix log message * Never send body for HEAD requests (again) ## 3.1.1 * Workaround for contact photo bug in InfCloud * Redirect GET and HEAD requests under `/.web` to sanitized path * Set `Content-Length` header for HEAD requests * Never send body for HEAD requests * Improve error messages for `from_file` rights backend * Don't sanitize WSGI script name ## 3.1.0 * Single `` element in PROPPATCH response * Allow multiple `` and `` elements * Improve log messages * Fix date filter * Improve sanitization of collection properties * Cancel mkcalendar request on error * Use **renameat2** on Linux for atomic overwriting of collections * Command Line Parser * Disallow abbreviated arguments * Support backend specific options and HTTP headers * Optional argument for boolean options * Load no config file for `--config` without argument * Allow float for server->timeout setting * Fix **is-not-defined** filter in **addressbook-query** report * Add python type hints * Add **multifilesystem_nolock** storage * Add support for Python 3.9 and 3.10 * Drop support for Python 3.5 * Fix compatibility with Evolution (Exceptions from recurrence rules) ## 3.0.6 * Allow web plugins to handle POST requests ## 3.0.5 * Start storage hook in own process group * Kill storage hook on error or exit * Try to kill child processes of storage hook * Internal Server: Exit immediately when signal is received (do not wait for clients or storage hook to finish) ## 3.0.4 * Fix internal server on FreeBSD ## 3.0.3 * Fix internal server on OpenBSD ## 3.0.2 * Use 403 response for supported-report and valid-sync-token errors * Internal server: Handle missing IPv6 support ## 3.0.1 * Fix XML error messages ## 3.0.0 This release is incompatible with previous releases. See the upgrade checklist below. * Parallel write requests * Support PyPy * Protect against XML denial-of-service attacks * Check for duplicated UIDs in calendars/address books * Only add missing UIDs for uploaded whole calendars/address books * Switch from md5 to sha256 for UIDs and tokens * Code cleanup: * All plugin interfaces were simplified and are incompatible with old plugins * Major refactor * Never sanitize paths multiple times (check if they are sanitized) * Config * Multiple configuration files separated by `:` (resp. `;` on Windows) * Optional configuration files by prepending file path with `?` * Check validity of every configuration file and command line arguments separately * Report the source of invalid configuration parameters in error messages * Code cleanup: * Store configuration as parsed values * Use Schema that describes configuration and allow plugins to apply their own schemas * Mark internal settings with `_` * Internal server * Bind to IPv4 and IPv6 address, when both are available for hostname * Set default address to `localhost:5232` * Remove settings for SSL ciphers and protocol versions (enforce safe defaults instead) * Remove settings for file locking because they are of little use * Remove daemonization (should be handled by service managers) * Logging * Replace complex Python logger configuration with simple `logging.level` setting * Write PID and `threadName` instead of cryptic id's in log messages * Use `wsgi.errors` for logging (as required by the WSGI spec) * Code cleanup: * Don't pass logger object around (use `logging.getLogger()` instead) * Auth * Use `md5` as default for `htpasswd_encryption` setting * Move setting `realm` from section `server` to `auth` * Rights * Use permissions `RW` for non-leaf collections and `rw` for address books/calendars * New permission `i` that only allows access with HTTP method GET (CalDAV/CardDAV is susceptible to expensive search requests) * Web * Add upload dialog for calendars/address books from file * Show startup loading message * Show warning if JavaScript is disabled * Pass HTML Validator * Storage * Check for missing UIDs in items * Check for child collections in address books and calendars * Code cleanup: * Split BaseCollection in BaseStorage and BaseCollection ## Upgrade checklist * Config * Some settings were removed * The default of `auth.htpasswd_encryption` changed to `md5` * The setting `server.realm` moved to `auth.realm` * The setting `logging.debug` was replaced by `logging.level` * The format of the `rights.file` configuration file changed: * Permission `r` replaced by `Rr` * Permission `w` replaced by `Ww` * New permission `i` added as subset of `r` * Replaced variable `%(login)s` by `{user}` * Removed variable `%(path)s` * `{` must be escaped as `{{` and `}` as `}}` in regexes * File system storage * The storage format is compatible with Radicale 2.x.x * Run `radicale --verify-storage` to check for errors * Custom plugins: * `auth` and `web` plugins require minor adjustments * `rights` plugins must be adapted to the new permission model * `storage` plugins require major changes ## 2.1.10 - Wild Radish This release is compatible with version 2.0.0. * Update required versions for dependencies * Get `RADICALE_CONFIG` from WSGI environ * Improve HTTP status codes * Fix race condition in storage lock creation * Raise default limits for content length and timeout * Log output from hook ## 2.1.9 - Wild Radish This release is compatible with version 2.0.0. * Specify versions for dependencies * Move WSGI initialization into module * Check if `REPORT` method is actually supported * Include `rights` file in source distribution * Specify `md5` and `bcrypt` as extras * Improve logging messages * Windows: Fix crash when item path is a directory ## 2.1.8 - Wild Radish This release is compatible with version 2.0.0. * Flush files before fsync'ing ## 2.1.7 - Wild Radish This release is compatible with version 2.0.0. * Don't print warning when cache format changes * Add documentation for `BaseAuth` * Add `is_authenticated2(login, user, password)` to `BaseAuth` * Fix names of custom properties in PROPFIND requests with `D:propname` or `D:allprop` * Return all properties in PROPFIND requests with `D:propname` or `D:allprop` * Allow `D:displayname` property on all collections * Answer with `D:unauthenticated` for `D:current-user-principal` property when not logged in * Remove non-existing `ICAL:calendar-color` and `C:calendar-timezone` properties from PROPFIND requests with `D:propname` or `D:allprop` * Add `D:owner` property to calendar and address book objects * Remove `D:getetag` and `D:getlastmodified` properties from regular collections ## 2.1.6 - Wild Radish This release is compatible with version 2.0.0. * Fix content-type of VLIST * Specify correct COMPONENT in content-type of VCALENDAR * Cache COMPONENT of calendar objects (improves speed with some clients) * Stricter parsing of filters * Improve support for CardDAV filter * Fix some smaller bugs in CalDAV filter * Add X-WR-CALNAME and X-WR-CALDESC to calendars downloaded via HTTP/WebDAV * Use X-WR-CALNAME and X-WR-CALDESC from calendars published via WebDAV ## 2.1.5 - Wild Radish This release is compatible with version 2.0.0. * Add `--verify-storage` command-line argument * Allow comments in the htpasswd file * Don't strip whitespaces from user names and passwords in the htpasswd file * Remove cookies from logging output * Allow uploads of whole collections with many components * Show warning message if server.timeout is used with Python < 3.5.2 ## 2.1.4 - Wild Radish This release is compatible with version 2.0.0. * Fix incorrect time range matching and calculation for some edge-cases with rescheduled recurrences * Fix owner property ## 2.1.3 - Wild Radish This release is compatible with version 2.0.0. * Enable timeout for SSL handshakes and move them out of the main thread * Create cache entries during upload of items * Stop built-in server on Windows when Ctrl+C is pressed * Prevent slow down when multiple requests hit a collection during cache warm-up ## 2.1.2 - Wild Radish This release is compatible with version 2.0.0. * Remove workarounds for bugs in VObject < 0.9.5 * Error checking of collection tags and associated components * Improve error checking of uploaded collections and components * Don't delete empty collection properties implicitly * Improve logging of VObject serialization ## 2.1.1 - Wild Radish Again This release is compatible with version 2.0.0. * Add missing UIDs instead of failing * Improve error checking of calendar and address book objects * Fix upload of whole address books ## 2.1.0 - Wild Radish This release is compatible with version 2.0.0. * Built-in web interface for creating and managing address books and calendars * can be extended with web plugins * Much faster storage backend * Significant reduction in memory usage * Improved logging * Include paths (of invalid items / requests) in log messages * Include configuration values causing problems in log messages * Log warning message for invalid requests by clients * Log error message for invalid files in the storage backend * No stack traces unless debugging is enabled * Time range filter also regards overwritten recurrences * Items that couldn't be filtered because of bugs in VObject are always returned (and a warning message is logged) * Basic error checking of configuration files * File system locking isn't disabled implicitly anymore, instead a new configuration option gets introduced * The permissions of the lock file are not changed anymore * Support for sync-token * Support for client-side SSL certificates * Rights plugins can decide if access to an item is granted explicitly * Respond with 403 instead of 404 for principal collections of non-existing users when `owner_only` plugin is used (information leakage) * Authentication plugins can provide the login and password from the environment * new `remote_user` plugin, that gets the login from the `REMOTE_USER` environment variable (for WSGI server) * new `http_x_remote_user` plugin, that gets the login from the `X-Remote-User` HTTP header (for reverse proxies) ## 2.0.0 - Little Big Radish This feature is not compatible with the 1.x.x versions. Follow our [migration guide](https://radicale.org/2.1.html#documentation/migration-from-1xx-to-2xx) if you want to switch from 1.x.x to 2.0.0. * Support Python 3.3+ only, Python 2 is not supported anymore * Keep only one simple filesystem-based storage system * Remove built-in Git support * Remove built-in authentication modules * Keep the WSGI interface, use Python HTTP server by default * Use a real iCal parser, rely on the "vobject" external module * Add a solid calendar discovery * Respect the difference between "files" and "folders", don't rely on slashes * Remove the calendar creation with GET requests * Be stateless * Use a file locker * Add threading * Get atomic writes * Support new filters * Support read-only permissions * Allow External plugins for authentication, rights management, storage and version control ## 1.1.4 - Fifth Law of Nature * Use `shutil.move` for `--export-storage` ## 1.1.3 - Fourth Law of Nature * Add a `--export-storage=FOLDER` command-line argument (by Unrud, see #606) ## 1.1.2 - Third Law of Nature * **Security fix**: Add a random timer to avoid timing oracles and simple bruteforce attacks when using the htpasswd authentication method. * Various minor fixes. ## 1.1.1 - Second Law of Nature * Fix the owner_write rights rule ## 1.1 - Law of Nature One feature in this release is **not backward compatible**: * Use the first matching section for rights (inspired from daald) Now, the first section matching the path and current user in your custom rights file is used. In the previous versions, the most permissive rights of all the matching sections were applied. This new behaviour gives a simple way to make specific rules at the top of the file independant from the generic ones. Many **improvements in this release are related to security**, you should upgrade Radicale as soon as possible: * Improve the regex used for well-known URIs (by Unrud) * Prevent regex injection in rights management (by Unrud) * Prevent crafted HTTP request from calling arbitrary functions (by Unrud) * Improve URI sanitation and conversion to filesystem path (by Unrud) * Decouple the daemon from its parent environment (by Unrud) Some bugs have been fixed and little enhancements have been added: * Assign new items to corret key (by Unrud) * Avoid race condition in PID file creation (by Unrud) * Improve the docker version (by cdpb) * Encode message and commiter for git commits * Test with Python 3.5 ## 1.0.1 - Sunflower Again * Update the version because of a **stupid** "feature"™ of PyPI ## 1.0 - Sunflower * Enhanced performances (by Mathieu Dupuy) * Add MD5-APR1 and BCRYPT for htpasswd-based authentication (by Jan-Philip Gehrcke) * Use PAM service (by Stephen Paul Weber) * Don't discard PROPPATCH on empty collections (by Markus Unterwaditzer) * Write the path of the collection in the git message (by Matthew Monaco) * Tests launched on Travis ## 0.10 - Lovely Endless Grass * Support well-known URLs (by Mathieu Dupuy) * Fix collection discovery (by Markus Unterwaditzer) * Reload logger config on SIGHUP (by Élie Bouttier) * Remove props files when deleting a collection (by Vincent Untz) * Support salted SHA1 passwords (by Marc Kleine-Budde) * Don't spam the logs about non-SSL IMAP connections to localhost (by Giel van Schijndel) ## 0.9 - Rivers * Custom handlers for auth, storage and rights (by Sergey Fursov) * 1-file-per-event storage (by Jean-Marc Martins) * Git support for filesystem storages (by Jean-Marc Martins) * DB storage working with PostgreSQL, MariaDB and SQLite (by Jean-Marc Martins) * Clean rights manager based on regular expressions (by Sweil) * Support of contacts for Apple's clients * Support colors (by Jochen Sprickerhof) * Decode URLs in XML (by Jean-Marc Martins) * Fix PAM authentication (by Stepan Henek) * Use consistent etags (by 9m66p93w) * Use consistent sorting order (by Daniel Danner) * Return 401 on unauthorized DELETE requests (by Eduard Braun) * Move pid file creation in child process (by Mathieu Dupuy) * Allow requests without base_prefix (by jheidemann) ## 0.8 - Rainbow * New authentication and rights management modules (by Matthias Jordan) * Experimental database storage * Command-line option for custom configuration file (by Mark Adams) * Root URL not at the root of a domain (by Clint Adams, Fabrice Bellet, Vincent Untz) * Improved support for iCal, CalDAVSync, CardDAVSync, CalDavZAP and CardDavMATE * Empty PROPFIND requests handled (by Christoph Polcin) * Colon allowed in passwords * Configurable realm message ## 0.7.1 - Waterfalls * Many address books fixes * New IMAP ACL (by Daniel Aleksandersen) * PAM ACL fixed (by Daniel Aleksandersen) * Courier ACL fixed (by Benjamin Frank) * Always set display name to collections (by Oskari Timperi) * Various DELETE responses fixed ## 0.7 - Eternal Sunshine * Repeating events * Collection deletion * Courier and PAM authentication methods * CardDAV support * Custom LDAP filters supported ## 0.6.4 - Tulips * Fix the installation with Python 3.1 ## 0.6.3 - Red Roses * MOVE requests fixed * Faster REPORT answers * Executable script moved into the package ## 0.6.2 - Seeds * iPhone and iPad support fixed * Backslashes replaced by slashes in PROPFIND answers on Windows * PyPI archive set as default download URL ## 0.6.1 - Growing Up * Example files included in the tarball * htpasswd support fixed * Redirection loop bug fixed * Testing message on GET requests ## 0.6 - Sapling * WSGI support * IPv6 support * Smart, verbose and configurable logs * Apple iCal 4 and iPhone support (by Łukasz Langa) * KDE KOrganizer support * LDAP auth backend (by Corentin Le Bail) * Public and private calendars (by René Neumann) * PID file * MOVE requests management * Journal entries support * Drop Python 2.5 support ## 0.5 - Historical Artifacts * Calendar depth * MacOS and Windows support * HEAD requests management * htpasswd user from calendar path ## 0.4 - Hot Days Back * Personal calendars * Last-Modified HTTP header * `no-ssl` and `foreground` options * Default configuration file ## 0.3 - Dancing Flowers * Evolution support * Version management ## 0.2 - Snowflakes * Sunbird pre-1.0 support * SSL connection * Htpasswd authentication * Daemon mode * User configuration * Twisted dependency removed * Python 3 support * Real URLs for PUT and DELETE * Concurrent modification reported to users * Many bugs fixed (by Roger Wenham) ## 0.1 - Crazy Vegetables * First release * Lightning/Sunbird 0.9 compatibility * Easy installer Radicale-3.1.8/COPYING.md000066400000000000000000001041441426407556000146760ustar00rootroot00000000000000### GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. ### Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. ### TERMS AND CONDITIONS #### 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. #### 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. #### 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. #### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. #### 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. #### 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: - a) The work must carry prominent notices stating that you modified it, and giving a relevant date. - b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". - c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. - d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. #### 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: - a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. - b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. - c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. - d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. - e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. #### 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: - a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or - b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or - c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or - d) Limiting the use for publicity purposes of names of licensors or authors of the material; or - e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or - f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. #### 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. #### 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. #### 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. #### 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. #### 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. #### 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. #### 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. #### 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. #### 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. #### 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS ### How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands \`show w' and \`show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . Radicale-3.1.8/DOCUMENTATION.md000066400000000000000000001316641426407556000156060ustar00rootroot00000000000000# Documentation ## Getting started #### About Radicale Radicale is a small but powerful CalDAV (calendars, to-do lists) and CardDAV (contacts) server, that: * Shares calendars and contact lists through CalDAV, CardDAV and HTTP. * Supports events, todos, journal entries and business cards. * Works out-of-the-box, no complicated setup or configuration required. * Can limit access by authentication. * Can secure connections with TLS. * Works with many [CalDAV and CardDAV clients](#supported-clients). * Stores all data on the file system in a simple folder structure. * Can be extended with plugins. * Is GPLv3-licensed free software. #### Installation 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 ``` When the server is launched, open in your browser! You can login with any username and password. Want more? Check the [tutorials](#tutorials) and the [documentation](#documentation-1). #### What's New? Read the [changelog on GitHub.](https://github.com/Kozea/Radicale/blob/v3/CHANGELOG.md) ## Tutorials ### Simple 5-minute setup You want to try Radicale but only have 5 minutes free in your calendar? Let's go right now and play a bit with Radicale! When everything works, you can get a [client](#supported-clients) and start creating calendars and address books. The server **only** binds to localhost (is **not** reachable over the network) and you can log in with any username and password. If Radicale fits your needs, it may be time for [some basic configuration](#basic-configuration). 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 enough to install the package ``python3-pip``. 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 radicale --storage-filesystem-folder=~/.var/lib/radicale/collections ``` Victory! Open in your browser! You can log in with any username and password. #### Windows The first step is to install Python. Go to [python.org](https://python.org) and download the latest version of Python 3. Then run the installer. On the first window of the installer, check the "Add Python to PATH" box and 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 radicale --storage-filesystem-folder=~/radicale/collections ``` Victory! Open in your browser! You can log in with any username and password. ### Basic Configuration Installation instructions can be found in the [simple 5-minute setup](#simple-5-minute-setup) tutorial. Radicale tries to load configuration files from `/etc/radicale/config` and `~/.config/radicale/config`. Custom paths can be specified with the `--config /path/to/config` command line argument or the `RADICALE_CONFIG` environment variable. Multiple configuration files can be separated by `:` (resp. `;` on Windows). Paths that start with `?` are optional. You should create a new configuration file at the desired location. (If the use of a configuration file is inconvenient, all options can be passed via command line arguments.) All configuration options are described in detail in the [Configuration](#configuration) section. #### Authentication In its default configuration Radicale doesn't check usernames or passwords. If the server is reachable over a network, you should change this. First a `users` file with all usernames and passwords must be created. It can be stored in the same directory as the configuration file. ##### The secure way 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 New password: Re-type new password: # Add another user $ htpasswd /path/to/users user2 New password: Re-type new password: ``` Authentication can be enabled with the following configuration: ```ini [auth] type = htpasswd htpasswd_filename = /path/to/users # encryption method used in the htpasswd file htpasswd_encryption = md5 ``` ##### The simple but insecure way Create the `users` file by hand with lines containing the username and password separated by `:`. Example: ```htpasswd user1:password1 user2:password2 ``` Authentication can be enabled with the following configuration: ```ini [auth] type = htpasswd htpasswd_filename = /path/to/users # encryption method used in the htpasswd file htpasswd_encryption = plain ``` #### Addresses The default configuration binds the server to localhost. It can't be reached from other computers. This can be changed with the following configuration options (IPv4 and IPv6): ```ini [server] hosts = 0.0.0.0:5232, [::]:5232 ``` #### Storage Data is stored in the folder `/var/lib/radicale/collections`. The path can be changed with the following configuration: ```ini [storage] filesystem_folder = /path/to/storage ``` > **Security:** The storage folder should not be readable by unauthorized users. > Otherwise, they can read the calendar data and lock the storage. > You can find OS dependent instructions in the > [Running as a service](#running-as-a-service) section. #### Limits Radicale enforces limits on the maximum number of parallel connections, the maximum file size (important for contacts with big photos) and the rate of incorrect authentication attempts. Connections are terminated after a timeout. The default values should be fine for most scenarios. ```ini [server] max_connections = 20 # 100 Megabyte max_content_length = 100000000 # 30 seconds timeout = 30 [auth] # Average delay after failed login attempts in seconds delay = 1 ``` ### Running as a service The method to run Radicale as a service depends on your host operating system. Follow one of the chapters below depending on your operating system and requirements. #### Linux with systemd system-wide Create the **radicale** user and group for the Radicale service. (Run `useradd --system --user-group --home-dir / --shell /sbin/nologin radicale` as root.) The storage folder must be writable by **radicale**. (Run `mkdir -p /var/lib/radicale/collections && chown -R radicale:radicale /var/lib/radicale/collections` as root.) > **Security:** The storage should not be readable by others. > (Run `chmod -R o= /var/lib/radicale/collections` as root.) Create the file `/etc/systemd/system/radicale.service`: ```ini [Unit] Description=A simple CalDAV (calendar) and CardDAV (contact) server After=network.target Requires=network.target [Service] ExecStart=/usr/bin/env python3 -m radicale Restart=on-failure User=radicale # Deny other users access to the calendar data UMask=0027 # Optional security settings PrivateTmp=true ProtectSystem=strict ProtectHome=true PrivateDevices=true ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true NoNewPrivileges=true ReadWritePaths=/var/lib/radicale/collections [Install] WantedBy=multi-user.target ``` Radicale will load the configuration file from `/etc/radicale/config`. To enable and manage the service run: ```bash # Enable the service $ systemctl enable radicale # Start the service $ systemctl start radicale # Check the status of the service $ systemctl status radicale # View all log messages $ journalctl --unit radicale.service ``` #### Linux with systemd as a user Create the file `~/.config/systemd/user/radicale.service`: ```ini [Unit] Description=A simple CalDAV (calendar) and CardDAV (contact) server [Service] ExecStart=/usr/bin/env python3 -m radicale Restart=on-failure [Install] WantedBy=default.target ``` Radicale will load the configuration file from `~/.config/radicale/config`. You should set the configuration option `filesystem_folder` in the `storage` section to something like `~/.var/lib/radicale/collections`. To enable and manage the service run: ```bash # Enable the service $ systemctl --user enable radicale # Start the service $ systemctl --user start radicale # Check the status of the service $ systemctl --user status radicale # View all log messages $ journalctl --user --unit radicale.service ``` #### Windows with "NSSM - the Non-Sucking Service Manager" First install [NSSM](https://nssm.cc/) and start `nssm install` in a command prompt. Apply the following configuration: * Service name: `Radicale` * Application * Path: `C:\Path\To\Python\python.exe` * Arguments: `-m radicale --config C:\Path\To\Config` * I/O redirection * Error: `C:\Path\To\Radicale.log` > **Security:** Be aware that the service runs in the local system account, > you might want to change this. Managing user accounts is beyond the scope of > this manual. Also, make sure that the storage folder and log file is not > readable by unauthorized users. The log file might grow very big over time, you can configure file rotation in **NSSM** to prevent this. The service is configured to start automatically when the computer starts. To start the service manually open **Services** in **Computer Management** and 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. Example **nginx** configuration: ```nginx location /radicale/ { # The trailing / is important! proxy_pass http://localhost:5232/; # The / is important! 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; } ``` Example **Apache** configuration: ```apache RewriteEngine On RewriteRule ^/radicale$ /radicale/ [R,L] ProxyPass http://localhost:5232/ retry=0 ProxyPassReverse http://localhost:5232/ RequestHeader set X-Script-Name /radicale ``` Example **Apache .htaccess** configuration: ```apache DirectoryIndex disabled RewriteEngine On RewriteRule ^(.*)$ http://localhost:5232/$1 [P,L] # Set to directory of .htaccess file: RequestHeader set X-Script-Name /radicale ``` Be reminded that Radicale's default configuration enforces limits on the maximum number of parallel connections, the maximum file size and the rate of incorrect authentication attempts. Connections are terminated after a timeout. #### Manage user accounts with the reverse proxy Set the configuration option `type` in the `auth` section to `http_x_remote_user`. Radicale uses the username provided in the `X-Remote-User` HTTP header and disables HTTP authentication. Example **nginx** configuration: ```nginx 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 X-Remote-User $remote_user; proxy_set_header Host $http_host; auth_basic "Radicale - Password Required"; auth_basic_user_file /etc/nginx/htpasswd; } ``` Example **Apache** configuration: ```apache RewriteEngine On RewriteRule ^/radicale$ /radicale/ [R,L] AuthType Basic AuthName "Radicale - Password Required" AuthUserFile "/etc/radicale/htpasswd" Require valid-user ProxyPass http://localhost:5232/ retry=0 ProxyPassReverse http://localhost:5232/ RequestHeader set X-Script-Name /radicale RequestHeader set X-Remote-User expr=%{REMOTE_USER} ``` Example **Apache .htaccess** configuration: ```apache DirectoryIndex disabled RewriteEngine On RewriteRule ^(.*)$ http://localhost:5232/$1 [P,L] AuthType Basic AuthName "Radicale - Password Required" AuthUserFile "/etc/radicale/htpasswd" Require valid-user # Set to directory of .htaccess file: RequestHeader set X-Script-Name /radicale RequestHeader set X-Remote-User expr=%{REMOTE_USER} ``` > **Security:** Untrusted clients should not be able to access the Radicale > server directly. Otherwise, they can authenticate as any user. #### Secure connection between Radicale and the reverse proxy SSL certificates can be used to encrypt and authenticate the connection between Radicale and the reverse proxy. First you have to generate a certificate for Radicale and a certificate for the reverse proxy. The following commands generate self-signed certificates. You will be asked to enter additional information about the certificate, the values don't matter and you can keep the defaults. ```bash openssl req -x509 -newkey rsa:4096 -keyout server_key.pem -out server_cert.pem \ -nodes -days 9999 openssl req -x509 -newkey rsa:4096 -keyout client_key.pem -out client_cert.pem \ -nodes -days 9999 ``` Use the following configuration for Radicale: ```ini [server] ssl = True certificate = /path/to/server_cert.pem key = /path/to/server_key.pem certificate_authority = /path/to/client_cert.pem ``` Example **nginx** configuration: ```nginx location /radicale/ { proxy_pass https://localhost:5232/; ... # Place the files somewhere nginx is allowed to access (e.g. /etc/nginx/...). proxy_ssl_certificate /path/to/client_cert.pem; proxy_ssl_certificate_key /path/to/client_key.pem; proxy_ssl_trusted_certificate /path/to/server_cert.pem; } ``` ### WSGI Server Radicale is compatible with the WSGI specification. A configuration file can be set with the `RADICALE_CONFIG` environment variable, otherwise no configuration file is loaded and the default configuration is used. Example **uWSGI** configuration: ```ini [uwsgi] http-socket = 127.0.0.1:5232 processes = 8 plugin = python3 module = radicale env = RADICALE_CONFIG=/etc/radicale/config ``` Example **Gunicorn** configuration: ```bash gunicorn --bind '127.0.0.1:5232' --env 'RADICALE_CONFIG=/etc/radicale/config' \ --workers 8 radicale ``` #### Manage user accounts with the WSGI server Set the configuration option `type` in the `auth` section to `remote_user`. Radicale uses the username provided by the WSGI server and disables authentication over HTTP. ### Versioning with Git This tutorial describes how to keep track of all changes to calendars and address books with **git** (or any other version control system). The repository must be initialized by running `git init` in the file system folder. Internal files of Radicale can be excluded by creating the file `.gitignore` with the following content: ```gitignore .Radicale.cache .Radicale.lock .Radicale.tmp-* ``` 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) ``` The command gets executed after every change to the storage and commits the changes into the **git** repository. ## Documentation ### Configuration Radicale can be configured with a configuration file or with command line arguments. An example configuration file looks like: ```ini [server] # Bind all addresses hosts = 0.0.0.0:5232, [::]:5232 [auth] type = htpasswd htpasswd_filename = ~/.config/radicale/users htpasswd_encryption = md5 [storage] filesystem_folder = ~/.var/lib/radicale/collections ``` Radicale tries to load configuration files from `/etc/radicale/config` and `~/.config/radicale/config`. Custom paths can be specified with the `--config /path/to/config` command line argument or the `RADICALE_CONFIG` environment variable. Multiple configuration files can be separated by `:` (resp. `;` on Windows). Paths that start with `?` are optional. 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 ``` Add the argument `--config ""` to stop Radicale from loading the default configuration files. Run `python3 -m radicale --help` for more information. In the following, all configuration categories and options are described. #### server The configuration options in this category are only relevant in standalone mode. All options are ignored, when Radicale runs via WSGI. ##### hosts A comma separated list of addresses that the server will bind to. Default: `localhost:5232` ##### max_connections The maximum number of parallel connections. Set to `0` to disable the limit. Default: `8` ##### max_content_length The maximum size of the request body. (bytes) Default: `100000000` ##### timeout Socket timeout. (seconds) Default: `30` ##### ssl Enable transport layer encryption. Default: `False` ##### certificate Path of the SSL certifcate. Default: `/etc/ssl/radicale.cert.pem` ##### key Path to the private key for SSL. Only effective if `ssl` is enabled. Default: `/etc/ssl/radicale.key.pem` ##### certificate_authority Path to the CA certificate for validating client certificates. This can be used to secure TCP traffic between Radicale and a reverse proxy. If you want to authenticate users with client-side certificates, you also have to write an authentication plugin that extracts the username from the certificate. Default: #### encoding ##### request Encoding for responding requests. Default: `utf-8` ##### stock Encoding for storing local collections Default: `utf-8` #### auth ##### type The method to verify usernames and passwords. Available backends: `none` : Just allows all usernames and passwords. `htpasswd` : Use an [Apache htpasswd file](https://httpd.apache.org/docs/current/programs/htpasswd.html) to store usernames and passwords. `remote_user` : Takes the username from the `REMOTE_USER` environment variable and disables HTTP authentication. This can be used to provide the username from a WSGI server. `http_x_remote_user` : Takes the username from the `X-Remote-User` HTTP header and disables HTTP authentication. This can be used to provide the username from a reverse proxy. Default: `none` ##### htpasswd_filename Path to the htpasswd file. Default: `/etc/radicale/users` ##### htpasswd_encryption The encryption method that is used in the htpasswd file. Use the [htpasswd](https://httpd.apache.org/docs/current/programs/htpasswd.html) or similar to generate this files. Available methods: `plain` : Passwords are stored in plaintext. This is obviously not secure! The htpasswd file for this can be created by hand and looks like: ```htpasswd user1:password1 user2:password2 ``` `bcrypt` : This uses a modified version of the Blowfish stream cipher. It's very secure. The installation of **radicale[bcrypt]** is required for this. `md5` : This uses an iterated md5 digest of the password with a salt. Default: `md5` ##### delay Average delay after failed login attempts in seconds. Default: `1` ##### realm Message displayed in the client when a password is needed. Default: `Radicale - Password Required` #### rights ##### type The backend that is used to check the access rights of collections. The recommended backend is `owner_only`. If access to calendars and address books outside the home directory of users (that's `/USERNAME/`) is granted, clients won't detect these collections and will not show them to the user. Choosing any other method is only useful if you access calendars and address books directly via URL. Available backends: `authenticated` : Authenticated users can read and write everything. `owner_only` : Authenticated users can read and write their own collections under the path */USERNAME/*. `owner_write` : Authenticated users can read everything and write their own collections under the path */USERNAME/*. `from_file` : Load the rules from a file. Default: `owner_only` ##### file File for the rights backend `from_file`. See the [Rights](#authentication-and-rights) section. #### storage ##### type The backend that is used to store data. Available backends: `multifilesystem` : Stores the data in the filesystem. `multifilesystem_nolock` : The `multifilesystem` backend without file-based locking. Must only be used with a single process. Default: `multifilesystem` ##### filesystem_folder Folder for storing local collections, created if not present. Default: `/var/lib/radicale/collections` ##### max_sync_token_age Delete sync-token that are older than the specified time. (seconds) Default: `2592000` ##### hook Command that is run after changes to storage. Take a look at the [Versioning with Git](#versioning-with-git) tutorial for an example. Default: #### web ##### type The backend that provides the web interface of Radicale. Available backends: `none` : Just shows the message "Radicale works!". `internal` : Allows creation and management of address books and calendars. Default: `internal` #### logging ##### level Set the logging level. Available levels: **debug**, **info**, **warning**, **error**, **critical** Default: `warning` ##### mask_passwords Don't include passwords in logs. Default: `True` #### headers In this section additional HTTP headers that are sent to clients can be specified. An example to relax the same-origin policy: ```ini Access-Control-Allow-Origin = * ``` ### Supported Clients Radicale has been tested with: * [Android](https://android.com/) with [DAVx⁵](https://www.davx5.com/) (formerly DAVdroid) * [GNOME Calendar](https://wiki.gnome.org/Apps/Calendar), [Contacts](https://wiki.gnome.org/Apps/Contacts) and [Evolution](https://wiki.gnome.org/Apps/Evolution) * [Mozilla Thunderbird](https://www.mozilla.org/thunderbird/) with [CardBook](https://addons.mozilla.org/thunderbird/addon/cardbook/) and [Lightning](https://www.mozilla.org/projects/calendar/) * [InfCloud](https://www.inf-it.com/open-source/clients/infcloud/), [CalDavZAP](https://www.inf-it.com/open-source/clients/caldavzap/) and [CardDavMATE](https://www.inf-it.com/open-source/clients/carddavmate/) Many clients do not support the creation of new calendars and address books. You can use Radicale's web interface (e.g. ) to create and manage address books and calendars. In some clients you can just enter the URL of the Radicale server (e.g. `http://localhost:5232`) and your username. In others, you have to enter the URL of the collection directly (e.g. `http://localhost:5232/user/calendar`). #### DAVx⁵ 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** and **Contacts** do not support adding WebDAV calendars and address books directly, but you can add them in **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. #### Thunderbird Add a new calendar on the network. Enter your username and the URL of the Radicale server (e.g. `http://localhost:5232`). After asking for your password, it will list the existing calendars. ##### Adress books with CardBook add-on Add a new address book on the network with CardDAV. Enter the URL of the Radicale server (e.g. `http://localhost:5232`) and your username and password. It will list your existing address books. #### InfCloud, CalDavZAP and CardDavMATE You can integrate InfCloud into Radicale's web interface with [RadicaleInfCloud](https://github.com/Unrud/RadicaleInfCloud). No additional configuration is required. Set the URL of the Radicale server in ``config.js``. If **InfCloud** is not hosted on the same server and port as Radicale, the browser will deny access to the Radicale server, because of the [same-origin policy](https://en.wikipedia.org/wiki/Same-origin_policy). You have to add additional HTTP header in the `headers` section of Radicale's configuration. The documentation of **InfCloud** has more details on this. #### Command line This is not the recommended way of creating and managing your calendars and address books. Use Radicale's web interface or a client with support for it (e.g. **DAVx⁵**). To create a new calendar run something like: ```bash $ curl -u user -X MKCOL 'http://localhost:5232/user/calendar' --data \ ' Calendar Example calendar #ff0000ff ' ``` To create a new address book run something like: ```bash $ curl -u user -X MKCOL 'http://localhost:5232/user/addressbook' --data \ ' Address book Example address book ' ``` The collection `/USERNAME` will be created automatically, when the user authenticates to Radicale for the first time. Clients with automatic discovery of collections will only show calendars and address books that are direct children of the path `/USERNAME/`. Delete the collections by running something like: ```bash curl -u user -X DELETE 'http://localhost:5232/user/calendar' ``` ### Authentication and Rights This section describes the format of the rights file for the `from_file` authentication backend. The configuration option `file` in the `rights` section must point to the rights file. The recommended rights method is `owner_only`. If access to calendars and address books outside the home directory of users (that's `/USERNAME/`) is granted, clients won't detect these collections and will not show them to the user. This is only useful if you access calendars and address books directly via URL. An example rights file: ```ini # Allow reading root collection for authenticated users [root] user: .+ collection: permissions: R # Allow reading and writing principal collection (same as username) [principal] user: .+ collection: {user} permissions: RW # Allow reading and writing calendars and address books that are direct # children of the principal collection [calendars] user: .+ collection: {user}/[^/]+ permissions: rw ``` The titles of the sections are ignored (but must be unique). The keys `user` and `collection` contain regular expressions, that are matched against the username and the path of the collection. Permissions from the first matching section are used. If no section matches, access gets denied. The username is empty for anonymous users. Therefore, the regex `.+` only matches authenticated users and `.*` matches everyone (including anonymous users). The path of the collection is separated by `/` and has no leading or trailing `/`. Therefore, the path of the root collection is empty. In the `collection` regex you can use `{user}` and get groups from the `user` regex with `{0}`, `{1}`, etc. 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. The following `permissions` are recognized: * **R:** read collections (excluding address books and calendars) * **r:** read address book and calendar collections * **i:** subset of **r** that only allows direct access via HTTP method GET (CalDAV/CardDAV is susceptible to expensive search requests) * **W:** write collections (excluding address books and calendars) * **w:** write address book and calendar collections ### Storage This document describes the layout and format of the file system storage (`multifilesystem` backend). It's safe to access and manipulate the data by hand or with scripts. Scripts can be invoked manually, periodically (e.g. with [cron](https://manpages.debian.org/unstable/cron/cron.8.en.html)) or after each change to the storage with the configuration option `hook` in the `storage` section (e.g. [Versioning with Git](#versioning-with-git)). #### Layout The file system contains the following files and folders: * `.Radicale.lock`: The lock file for locking the storage. * `collection-root`: This folder contains all collections and items. A collection is represented by a folder. This folder may contain the file `.Radicale.props` with all WebDAV properties of the collection encoded as [JSON](https://en.wikipedia.org/wiki/JSON). An item is represented by a file containing the iCalendar data. All files and folders, whose names start with a dot but not `.Radicale.` (internal files) are ignored. If you introduce syntax errors in any of the files, all requests that access the faulty data will fail. The logging output should contain the names of the culprits. Caches and sync-tokens are stored in the `.Radicale.cache` folder inside of collections. This folder may be created or modified, while the storage is locked for shared access. In theory, it should be safe to delete the folder. Caches will be recreated automatically and clients will be told that their sync-token isn't valid anymore. You may encounter files or folders that start with `.Radicale.tmp-`. Radicale uses them for atomic creation and deletion of files and folders. They should be deleted after requests are finished but it's possible that they are left behind when Radicale or the computer crashes. It's safe to delete them. #### Locking When the data is accessed by hand or by an externally invoked script, the storage must be locked. The storage can be locked for exclusive or shared access. It prevents Radicale from reading or writing the file system. The storage is locked with exclusive access while the `hook` runs. ##### Linux shell scripts Use the [flock](https://manpages.debian.org/unstable/util-linux/flock.1.en.html) utility. ```bash # Exclusive $ flock --exclusive /path/to/storage/.Radicale.lock COMMAND # Shared $ flock --shared /path/to/storage/.Radicale.lock COMMAND ``` ##### Linux and MacOS Use the [flock](https://manpages.debian.org/unstable/manpages-dev/flock.2.en.html) syscall. Python provides it in the [fcntl](https://docs.python.org/3/library/fcntl.html#fcntl.flock) module. ##### Windows Use [LockFile](https://msdn.microsoft.com/en-us/library/windows/desktop/aa365202%28v=vs.85%29.aspx) for exclusive access or [LockFileEx](https://msdn.microsoft.com/en-us/library/windows/desktop/aa365203%28v=vs.85%29.aspx) which also supports shared access. Setting `nNumberOfBytesToLockLow` to `1` and `nNumberOfBytesToLockHigh` to `0` works. #### Manually creating collections To create a new collection, you have to create the corresponding folder in the file system storage (e.g. `collection-root/user/calendar`). To tell Radicale and clients that the collection is a calendar, you have to create the file ``.Radicale.props`` with the following content in the folder: ```json {"tag": "VCALENDAR"} ``` The calendar is now available at the URL path ``/user/calendar``. For address books the file must contain: ```json {"tag": "VADDRESSBOOK"} ``` Calendar and address book collections must not have any child collections. Clients with automatic discovery of collections will only show calendars and address books that are direct children of the path `/USERNAME/`. Delete collections by deleting the corresponding folders. ### Logging Radicale logs to `stderr`. The verbosity of the log output can be controlled with `--debug` command line argument or the `level` configuration option in the `logging` section. ### Architecture Radicale is a small piece of software, but understanding it is not as easy as it seems. But don't worry, reading this short section is enough to understand what a CalDAV/CardDAV server is, and how Radicale's code is organized. #### Protocol overview Here is a simple overview of the global architecture for reaching a calendar or an address book through network: | Part | Layer | Protocol or Format | |----------|--------------------------|------------------------------------| | Server | Calendar/Contact Storage | iCal/vCard | | '' | Calendar/Contact Server | CalDAV/CardDAV Server | | Transfer | Network | CalDAV/CardDAV (HTTP + TLS) | | Client | Calendar/Contact Client | CalDAV/CardDAV Client | | '' | GUI | Terminal, GTK, Web interface, etc. | Radicale is **only the server part** of this architecture. Please note that: * CalDAV and CardDAV are superset protocols of WebDAV, * WebDAV is a superset protocol of HTTP. Radicale being a CalDAV/CardDAV server, it also can be seen as a special WebDAV and HTTP server. Radicale is **not the client part** of this architecture. It means that Radicale never draws calendars, address books, events and contacts on the screen. It only stores them and give the possibility to share them online with other people. If you want to see or edit your events and your contacts, you have to use another software called a client, that can be a "normal" applications with icons and buttons, a terminal or another web application. #### Code Architecture The ``radicale`` package offers the following modules. `__init__` : Contains the entry point for WSGI. `__main__` : Provides the entry point for the ``radicale`` executable and includes the command line parser. It loads configuration files from the default (or specified) paths and starts the internal server. `app` : This is the core part of Radicale, with the code for the CalDAV/CardDAV server. The code managing the different HTTP requests according to the CalDAV/CardDAV specification can be found here. `auth` : Used for authenticating users based on username and password, mapping usernames to internal users and optionally retrieving credentials from the environment. `config` : Contains the code for managing configuration and loading settings from files. `ìtem` : Internal representation of address book and calendar entries. Based on [VObject](https://eventable.github.io/vobject/). `log` : The logger for Radicale based on the default Python logging module. `rights` : This module is used by Radicale to manage access rights to collections, address books and calendars. `server` : The integrated HTTP server for standalone use. `storage` : This module contains the classes representing collections in Radicale and the code for storing and loading them in the filesystem. `web` : This module contains the web interface. `utils` : Contains general helper functions. `httputils` : Contains helper functions for working with HTTP. `pathutils` : Helper functions for working with paths and the filesystem. `xmlutils` : Helper functions for working with the XML part of CalDAV/CardDAV requests and responses. It's based on the ElementTree XML API. ### Plugins Radicale can be extended by plugins for authentication, rights management and storage. Plugins are **python** modules. #### Getting started To get started we walk through the creation of a simple authentication plugin, that accepts login attempts with a static password. The easiest way to develop and install **python** modules is [Distutils](https://docs.python.org/3/distutils/setupscript.html). For a minimal setup create the file `setup.py` with the following content in an empty folder: ```python #!/usr/bin/env python3 from distutils.core import setup setup(name="radicale_static_password_auth", packages=["radicale_static_password_auth"]) ``` In the same folder create the sub-folder `radicale_static_password_auth`. The folder must have the same name as specified in `packages` above. Create the file `__init__.py` in the `radicale_static_password_auth` folder with the following content: ```python from radicale.auth import BaseAuth from radicale.log import logger PLUGIN_CONFIG_SCHEMA = {"auth": { "password": {"value": "", "type": str}}} class Auth(BaseAuth): def __init__(self, configuration): super().__init__(configuration.copy(PLUGIN_CONFIG_SCHEMA)) def login(self, login, password): # Get password from configuration option static_password = self.configuration.get("auth", "password") # Check authentication logger.info("Login attempt by %r with password %r", login, password) if password == static_password: return login return "" ``` Install the python module by running the following command in the same folder as `setup.py`: ```bash python3 -m pip install . ``` To make use this great creation in Radicale, set the configuration option `type` in the `auth` section to `radicale_static_password_auth`: ```ini [auth] type = radicale_static_password_auth password = secret ``` You can uninstall the module with: ```bash python3 -m pip uninstall radicale_static_password_auth ``` #### Authentication plugins This plugin type is used to check login credentials. The module must contain a class `Auth` that extends `radicale.auth.BaseAuth`. Take a look at the file `radicale/auth/__init__.py` in Radicale's source code for more information. #### Rights management plugins This plugin type is used to check if a user has access to a path. The module must contain a class `Rights` that extends `radicale.rights.BaseRights`. Take a look at the file `radicale/rights/__init__.py` in Radicale's source code for more information. #### Web plugins This plugin type is used to provide the web interface for Radicale. The module must contain a class `Web` that extends `radicale.web.BaseWeb`. Take a look at the file `radicale/web/__init__.py` in Radicale's source code for more information. #### Storage plugins This plugin is used to store collections and items. The module must contain a class `Storage` that extends `radicale.storage.BaseStorage`. Take a look at the file `radicale/storage/__init__.py` in Radicale's source code for more information. ## 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 [Radicale bug-tracker](https://github.com/Kozea/Radicale/issues). #### Hack Interested in hacking? Feel free to clone the [git repository on GitHub](https://github.com/Kozea/Radicale) if you want to 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). ## Download #### PyPI Radicale is [available on PyPI](https://pypi.python.org/pypi/Radicale/). To install, just type as superuser: ```bash python3 -m pip install --upgrade radicale ``` #### Git Repository If you want the development version of Radicale, take a look at the [git repository on GitHub](https://github.com/Kozea/Radicale/), or install it directly with: ```bash python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz ``` You can also download the content of the repository as an [archive](https://github.com/Kozea/Radicale/tarball/master). #### Source Packages You can find the source packages of all releases on [GitHub](https://github.com/Kozea/Radicale/releases). #### Linux Distribution Packages Radicale has been packaged for: * [ArchLinux](https://www.archlinux.org/packages/community/any/radicale/) by David Runge * [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 and Peter Bieringer * [Mageia](http://madb.mageia.org/package/show/application/0/name/radicale) by Jani Välimaa * [OpenBSD](http://openports.se/productivity/radicale) by Sergey Bronnikov, Stuart Henderson and Ian Darwin * [openSUSE](http://software.opensuse.org/package/Radicale?search_term=radicale) by Ákos Szőts and Rueckert * [PyPM](http://code.activestate.com/pypm/radicale/) * [Slackware](http://schoepfer.info/slackware.xhtml#packages-network) by Johannes Schöpfer * [Trisquel](http://packages.trisquel.info/search?searchon=names&keywords=radicale) * [Ubuntu](http://packages.ubuntu.com/radicale) by the MOTU and Jonas Smedegaard Radicale is also [available on Cloudron](https://cloudron.io/button.html?app=org.radicale.cloudronapp2) and has a Dockerfile. If you are interested in creating packages for other Linux distributions, read the ["Contribute" section](#contribute). ## About #### Main Goals Radicale is a complete calendar and contact storing and manipulating solution. It can store multiple calendars and multiple address books. Calendar and contact manipulation is available from both local and distant accesses, possibly limited through authentication policies. It aims to be a lightweight solution, easy to use, easy to install, easy to configure. As a consequence, it requires few software dependencies and is preconfigured to work out-of-the-box. Radicale is written in Python. It runs on most of the UNIX-like platforms (Linux, \*BSD, macOS) and Windows. It is free and open-source software. #### What Radicale Will Never Be Radicale is a server, not a client. No interfaces will be created to work with the server. CalDAV and CardDAV are not perfect protocols. We think that their main problem is their complexity, that is why we decided not to implement the whole standard but just enough to understand some of its client-side implementations. CalDAV and CardDAV are the best open standards available, and they are quite widely used by both clients and servers. We decided to use it, and we will not use another one. #### Technical Choices Important global development choices have been decided before writing code. They are very useful to understand why the Radicale Project is different from other CalDAV and CardDAV servers, and why features are included or not in the code. ##### Oriented to Calendar and Contact User Agents Calendar and contact servers work with calendar and contact clients, using a defined protocol. CalDAV and CardDAV are good protocols, covering lots of features and use cases, but it is quite hard to implement fully. Some calendar servers have been created to follow the CalDAV and CardDAV RFCs as much as possible: [Davical](http://www.davical.org/), [Baïkal](http://sabre.io/baikal/) and [Darwin Calendar Server](http://trac.calendarserver.org/), for example, are much more respectful of CalDAV and CardDAV and can be used with many clients. They are very good choices if you want to develop and test new CalDAV clients, or if you have a possibly heterogeneous list of user agents. Even if it tries it best to follow the RFCs, Radicale does not and **will not** blindly implement the CalDAV and CardDAV standards. It is mainly designed to support the CalDAV and CardDAV implementations of different clients. ##### Simple Radicale is designed to be simple to install, simple to configure, simple to use. The installation is very easy, particularly with Linux: one dependency, no superuser rights needed, no configuration required, no database. Installing and launching the main script out-of-the-box, as a normal user, are often the only steps to have a simple remote calendar and contact access. Contrary to other servers that are often complicated, require high privileges or need a strong configuration, the Radicale Server can (sometimes, if not often) be launched in a couple of minutes, if you follow the [tutorial](#simple-5-minute-setup). ##### Lazy The CalDAV RFC defines what must be done, what can be done and what cannot be done. Many violations of the protocol are totally defined and behaviors are given in such cases. Radicale often assumes that the clients are perfect and that protocol violations do not exist. That is why most of the errors in client requests have undetermined consequences for the lazy server that can reply good answers, bad answers, or even no answer. #### History Radicale has been started as a (free topic) stupid school project replacing another (assigned topic) even more stupid school project. At the beginning, it was just a proof-of-concept. The main goal was to write a small, dirty and simple CalDAV server working with Lightning, using no external libraries. That's how we created a piece of code that's (quite) easy to understand, to use and to hack. The [first lines](https://github.com/Kozea/Radicale/commit/b1591aea) have been added to the SVN (!) repository as I was drinking (many) beers at the very end of 2008 (Python 2.6 and 3.0 were just released). It's now packaged for a growing number of Linux distributions. And that was fun going from here to there thanks to you! Radicale-3.1.8/Dockerfile000066400000000000000000000010431426407556000152300ustar00rootroot00000000000000# This file is intended to be used apart from the containing source code tree. FROM python:3-alpine # Version of Radicale ARG VERSION=v3 # Persistent storage for data VOLUME /var/lib/radicale # TCP port of Radicale EXPOSE 5232 # Run Radicale CMD ["radicale", "--hosts", "0.0.0.0: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 Radicale-3.1.8/MANIFEST.in000066400000000000000000000001471426407556000150000ustar00rootroot00000000000000include CHANGELOG.md COPYING.md DOCUMENTATION.md README.md include config rights include radicale.wsgi Radicale-3.1.8/README.md000066400000000000000000000017211426407556000145200ustar00rootroot00000000000000# 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) Radicale is a small but powerful CalDAV (calendars, to-do lists) and CardDAV (contacts) server, that: * Shares calendars and contact lists through CalDAV, CardDAV and HTTP. * Supports events, todos, journal entries and business cards. * Works out-of-the-box, no complicated setup or configuration required. * Can limit access by authentication. * Can secure connections with TLS. * Works with many CalDAV and CardDAV clients * Stores all data on the file system in a simple folder structure. * Can be extended with plugins. * Is GPLv3-licensed free software. For the complete documentation, please visit [Radicale v3 Documentation](https://radicale.org/v3.html). Radicale-3.1.8/config000066400000000000000000000045761426407556000144440ustar00rootroot00000000000000# -*- mode: conf -*- # vim:ft=cfg # Config file for Radicale - A simple calendar server # # Place it into /etc/radicale/config (global) # or ~/.config/radicale/config (user) # # The current values are the default ones [server] # CalDAV server hostnames separated by a comma # IPv4 syntax: address:port # IPv6 syntax: [address]:port # For example: 0.0.0.0:9999, [::]:9999 #hosts = localhost:5232 # Max parallel connections #max_connections = 8 # Max size of request body (bytes) #max_content_length = 100000000 # Socket timeout (seconds) #timeout = 30 # SSL flag, enable HTTPS protocol #ssl = False # SSL certificate path #certificate = /etc/ssl/radicale.cert.pem # SSL private key #key = /etc/ssl/radicale.key.pem # CA certificate for validating clients. This can be used to secure # TCP traffic between Radicale and a reverse proxy #certificate_authority = [encoding] # Encoding for responding requests #request = utf-8 # Encoding for storing local collections #stock = utf-8 [auth] # Authentication method # Value: none | htpasswd | remote_user | http_x_remote_user #type = none # Htpasswd filename #htpasswd_filename = /etc/radicale/users # Htpasswd encryption method # Value: plain | bcrypt | md5 # bcrypt requires the installation of radicale[bcrypt]. #htpasswd_encryption = md5 # Incorrect authentication delay (seconds) #delay = 1 # Message displayed in the client when a password is needed #realm = Radicale - Password Required [rights] # Rights backend # Value: none | authenticated | owner_only | owner_write | from_file #type = owner_only # File for rights management from_file #file = /etc/radicale/rights [storage] # Storage backend # Value: multifilesystem | multifilesystem_nolock #type = multifilesystem # Folder for storing local collections, created if not present #filesystem_folder = /var/lib/radicale/collections # Delete sync token that are older (seconds) #max_sync_token_age = 2592000 # 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) #hook = [web] # Web interface backend # Value: none | internal #type = internal [logging] # Threshold for the logger # Value: debug | info | warning | error | critical #level = warning # Don't include passwords in logs #mask_passwords = True [headers] # Additional HTTP headers #Access-Control-Allow-Origin = * Radicale-3.1.8/radicale.wsgi000066400000000000000000000001361426407556000156770ustar00rootroot00000000000000""" Radicale WSGI file (mod_wsgi and uWSGI compliant). """ from radicale import application Radicale-3.1.8/radicale/000077500000000000000000000000001426407556000150045ustar00rootroot00000000000000Radicale-3.1.8/radicale/__init__.py000066400000000000000000000055771426407556000171330ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . """ Entry point for external WSGI servers (like uWSGI or Gunicorn). Configuration files can be specified in the environment variable ``RADICALE_CONFIG``. """ import os import threading from typing import Iterable, Optional, cast from radicale import config, log, types, utils from radicale.app import Application from radicale.log import logger VERSION: str = utils.package_version("radicale") _application_instance: Optional[Application] = None _application_config_path: Optional[str] = None _application_lock = threading.Lock() def _get_application_instance(config_path: str, wsgi_errors: types.ErrorStream ) -> Application: global _application_instance, _application_config_path with _application_lock: if _application_instance is None: log.setup() with log.register_stream(wsgi_errors): _application_config_path = config_path configuration = config.load(config.parse_compound_paths( config.DEFAULT_CONFIG_PATH, config_path)) log.set_level(cast(str, configuration.get("logging", "level"))) # Log configuration after logger is configured for source, miss in configuration.sources(): logger.info("%s %s", "Skipped missing" if miss else "Loaded", source) _application_instance = Application(configuration) if _application_config_path != config_path: raise ValueError("RADICALE_CONFIG must not change: %r != %r" % (config_path, _application_config_path)) return _application_instance def application(environ: types.WSGIEnviron, start_response: types.WSGIStartResponse) -> Iterable[bytes]: """Entry point for external WSGI servers.""" config_path = environ.get("RADICALE_CONFIG", os.environ.get("RADICALE_CONFIG")) app = _get_application_instance(config_path, environ["wsgi.errors"]) return app(environ, start_response) Radicale-3.1.8/radicale/__main__.py000066400000000000000000000201341426407556000170760ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2011-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . """ Radicale executable module. This module can be executed from a command line with ``$python -m radicale``. Uses the built-in WSGI server. """ import argparse import contextlib import os import signal import socket import sys from types import FrameType from typing import List, Optional, cast from radicale import VERSION, config, log, server, storage, types from radicale.log import logger def run() -> None: """Run Radicale as a standalone server.""" exit_signal_numbers = [signal.SIGTERM, signal.SIGINT] if sys.platform == "win32": exit_signal_numbers.append(signal.SIGBREAK) else: exit_signal_numbers.append(signal.SIGHUP) exit_signal_numbers.append(signal.SIGQUIT) # Raise SystemExit when signal arrives to run cleanup code # (like destructors, try-finish etc.), otherwise the process exits # without running any of them def exit_signal_handler(signal_number: int, stack_frame: Optional[FrameType]) -> None: sys.exit(1) for signal_number in exit_signal_numbers: signal.signal(signal_number, exit_signal_handler) log.setup() # Get command-line arguments # Configuration options are stored in dest with format "c:SECTION:OPTION" parser = argparse.ArgumentParser( prog="radicale", usage="%(prog)s [OPTIONS]", allow_abbrev=False) parser.add_argument("--version", action="version", version=VERSION) parser.add_argument("--verify-storage", action="store_true", help="check the storage for errors and exit") parser.add_argument("-C", "--config", help="use specific configuration files", nargs="*") parser.add_argument("-D", "--debug", action="store_const", const="debug", dest="c:logging:level", default=argparse.SUPPRESS, help="print debug information") for section, section_data in config.DEFAULT_CONFIG_SCHEMA.items(): if section.startswith("_"): continue assert ":" not in section # check field separator assert "-" not in section and "_" not in section # not implemented group_description = None if section_data.get("_allow_extra"): group_description = "additional options allowed" if section == "headers": group_description += " (e.g. --headers-Pragma=no-cache)" elif "type" in section_data: group_description = "backend specific options omitted" group = parser.add_argument_group(section, group_description) for option, data in section_data.items(): if option.startswith("_"): continue kwargs = data.copy() long_name = "--%s-%s" % (section, option.replace("_", "-")) args: List[str] = list(kwargs.pop("aliases", ())) args.append(long_name) kwargs["dest"] = "c:%s:%s" % (section, option) kwargs["metavar"] = "VALUE" kwargs["default"] = argparse.SUPPRESS del kwargs["value"] with contextlib.suppress(KeyError): del kwargs["internal"] if kwargs["type"] == bool: del kwargs["type"] opposite_args = list(kwargs.pop("opposite_aliases", ())) opposite_args.append("--no%s" % long_name[1:]) group.add_argument(*args, nargs="?", const="True", **kwargs) # Opposite argument kwargs["help"] = "do not %s (opposite of %s)" % ( kwargs["help"], long_name) group.add_argument(*opposite_args, action="store_const", const="False", **kwargs) else: del kwargs["type"] group.add_argument(*args, **kwargs) args_ns, remaining_args = parser.parse_known_args() unrecognized_args = [] while remaining_args: arg = remaining_args.pop(0) for section, data in config.DEFAULT_CONFIG_SCHEMA.items(): if "type" not in data and not data.get("_allow_extra"): continue prefix = "--%s-" % section if arg.startswith(prefix): arg = arg[len(prefix):] break else: unrecognized_args.append(arg) continue value = "" if "=" in arg: arg, value = arg.split("=", maxsplit=1) elif remaining_args and not remaining_args[0].startswith("-"): value = remaining_args.pop(0) option = arg if not data.get("_allow_extra"): # preserve dash in HTTP header names option = option.replace("-", "_") vars(args_ns)["c:%s:%s" % (section, option)] = value if unrecognized_args: parser.error("unrecognized arguments: %s" % " ".join(unrecognized_args)) # Preliminary configure logging with contextlib.suppress(ValueError): log.set_level(config.DEFAULT_CONFIG_SCHEMA["logging"]["level"]["type"]( vars(args_ns).get("c:logging:level", ""))) # Update Radicale configuration according to arguments arguments_config: types.MUTABLE_CONFIG = {} for key, value in vars(args_ns).items(): if key.startswith("c:"): _, section, option = key.split(":", maxsplit=2) arguments_config[section] = arguments_config.get(section, {}) arguments_config[section][option] = value try: configuration = config.load(config.parse_compound_paths( config.DEFAULT_CONFIG_PATH, os.environ.get("RADICALE_CONFIG"), os.pathsep.join(args_ns.config) if args_ns.config is not None else None)) if arguments_config: configuration.update(arguments_config, "command line arguments") except Exception as e: logger.critical("Invalid configuration: %s", e, exc_info=True) sys.exit(1) # Configure logging log.set_level(cast(str, configuration.get("logging", "level"))) # Log configuration after logger is configured for source, miss in configuration.sources(): logger.info("%s %s", "Skipped missing" if miss else "Loaded", source) if args_ns.verify_storage: logger.info("Verifying storage") try: storage_ = storage.load(configuration) with storage_.acquire_lock("r"): if not storage_.verify(): logger.critical("Storage verifcation failed") sys.exit(1) except Exception as e: logger.critical("An exception occurred during storage " "verification: %s", e, exc_info=True) sys.exit(1) return # Create a socket pair to notify the server of program shutdown shutdown_socket, shutdown_socket_out = socket.socketpair() # Shutdown server when signal arrives def shutdown_signal_handler(signal_number: int, stack_frame: Optional[FrameType]) -> None: shutdown_socket.close() for signal_number in exit_signal_numbers: signal.signal(signal_number, shutdown_signal_handler) try: server.serve(configuration, shutdown_socket_out) except Exception as e: logger.critical("An exception occurred during server startup: %s", e, exc_info=True) sys.exit(1) if __name__ == "__main__": run() Radicale-3.1.8/radicale/app/000077500000000000000000000000001426407556000155645ustar00rootroot00000000000000Radicale-3.1.8/radicale/app/__init__.py000066400000000000000000000334441426407556000177050ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . """ Radicale WSGI application. Can be used with an external WSGI server (see ``radicale.application()``) or the built-in server (see ``radicale.server`` module). """ import base64 import datetime import pprint import random import time import zlib from http import client from typing import Iterable, List, Mapping, Tuple, Union from radicale import config, httputils, log, pathutils, types from radicale.app.base import ApplicationBase from radicale.app.delete import ApplicationPartDelete from radicale.app.get import ApplicationPartGet from radicale.app.head import ApplicationPartHead from radicale.app.mkcalendar import ApplicationPartMkcalendar from radicale.app.mkcol import ApplicationPartMkcol from radicale.app.move import ApplicationPartMove from radicale.app.options import ApplicationPartOptions from radicale.app.post import ApplicationPartPost from radicale.app.propfind import ApplicationPartPropfind from radicale.app.proppatch import ApplicationPartProppatch from radicale.app.put import ApplicationPartPut from radicale.app.report import ApplicationPartReport from radicale.log import logger # Combination of types.WSGIStartResponse and WSGI application return value _IntermediateResponse = Tuple[str, List[Tuple[str, str]], Iterable[bytes]] class Application(ApplicationPartDelete, ApplicationPartHead, ApplicationPartGet, ApplicationPartMkcalendar, ApplicationPartMkcol, ApplicationPartMove, ApplicationPartOptions, ApplicationPartPropfind, ApplicationPartProppatch, ApplicationPartPost, ApplicationPartPut, ApplicationPartReport, ApplicationBase): """WSGI application.""" _mask_passwords: bool _auth_delay: float _internal_server: bool _max_content_length: int _auth_realm: str _extra_headers: Mapping[str, str] def __init__(self, configuration: config.Configuration) -> None: """Initialize Application. ``configuration`` see ``radicale.config`` module. The ``configuration`` must not change during the lifetime of this object, it is kept as an internal reference. """ super().__init__(configuration) self._mask_passwords = configuration.get("logging", "mask_passwords") 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._extra_headers = dict() for key in self.configuration.options("headers"): self._extra_headers[key] = configuration.get("headers", key) def _scrub_headers(self, environ: types.WSGIEnviron) -> types.WSGIEnviron: """Mask passwords and cookies.""" headers = dict(environ) if (self._mask_passwords and headers.get("HTTP_AUTHORIZATION", "").startswith("Basic")): headers["HTTP_AUTHORIZATION"] = "Basic **masked**" if headers.get("HTTP_COOKIE"): headers["HTTP_COOKIE"] = "**masked**" return headers def __call__(self, environ: types.WSGIEnviron, start_response: types.WSGIStartResponse) -> Iterable[bytes]: with log.register_stream(environ["wsgi.errors"]): try: status_text, headers, answers = self._handle_request(environ) except Exception as e: logger.error("An exception occurred during %s request on %r: " "%s", environ.get("REQUEST_METHOD", "unknown"), environ.get("PATH_INFO", ""), e, exc_info=True) # Make minimal response status, raw_headers, raw_answer = ( httputils.INTERNAL_SERVER_ERROR) assert isinstance(raw_answer, str) answer = raw_answer.encode("ascii") status_text = "%d %s" % ( status, client.responses.get(status, "Unknown")) headers = [*raw_headers, ("Content-Length", str(len(answer)))] answers = [answer] start_response(status_text, headers) if environ.get("REQUEST_METHOD") == "HEAD": return [] return answers def _handle_request(self, environ: types.WSGIEnviron ) -> _IntermediateResponse: time_begin = datetime.datetime.now() request_method = environ["REQUEST_METHOD"].upper() unsafe_path = environ.get("PATH_INFO", "") """Manage a request.""" def response(status: int, headers: types.WSGIResponseHeaders, answer: Union[None, str, bytes]) -> _IntermediateResponse: """Helper to create response from internal types.WSGIResponse""" headers = dict(headers) # Set content length answers = [] if answer is not None: if isinstance(answer, str): logger.debug("Response content:\n%s", answer) headers["Content-Type"] += "; charset=%s" % self._encoding answer = answer.encode(self._encoding) accept_encoding = [ encoding.strip() for encoding in environ.get("HTTP_ACCEPT_ENCODING", "").split(",") if encoding.strip()] if "gzip" in accept_encoding: zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS) answer = zcomp.compress(answer) + zcomp.flush() headers["Content-Encoding"] = "gzip" headers["Content-Length"] = str(len(answer)) answers.append(answer) # Add extra headers set in configuration headers.update(self._extra_headers) # Start response time_end = datetime.datetime.now() status_text = "%d %s" % ( status, client.responses.get(status, "Unknown")) logger.info("%s response status for %r%s in %.3f seconds: %s", request_method, unsafe_path, depthinfo, (time_end - time_begin).total_seconds(), status_text) # Return response content return status_text, list(headers.items()), answers remote_host = "unknown" if environ.get("REMOTE_HOST"): remote_host = repr(environ["REMOTE_HOST"]) elif environ.get("REMOTE_ADDR"): remote_host = environ["REMOTE_ADDR"] if environ.get("HTTP_X_FORWARDED_FOR"): remote_host = "%s (forwarded for %r)" % ( remote_host, environ["HTTP_X_FORWARDED_FOR"]) remote_useragent = "" if environ.get("HTTP_USER_AGENT"): remote_useragent = " using %r" % environ["HTTP_USER_AGENT"] depthinfo = "" if environ.get("HTTP_DEPTH"): depthinfo = " with depth %r" % environ["HTTP_DEPTH"] 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))) # SCRIPT_NAME is already removed from PATH_INFO, according to the # WSGI specification. # Reverse proxies can overwrite SCRIPT_NAME with X-SCRIPT-NAME header base_prefix_src = ("HTTP_X_SCRIPT_NAME" if "HTTP_X_SCRIPT_NAME" in environ else "SCRIPT_NAME") base_prefix = environ.get(base_prefix_src, "") if base_prefix and base_prefix[0] != "/": logger.error("Base prefix (from %s) must start with '/': %r", base_prefix_src, base_prefix) if base_prefix_src == "HTTP_X_SCRIPT_NAME": return response(*httputils.BAD_REQUEST) return response(*httputils.INTERNAL_SERVER_ERROR) if base_prefix.endswith("/"): logger.warning("Base prefix (from %s) must not end with '/': %r", base_prefix_src, base_prefix) base_prefix = base_prefix.rstrip("/") logger.debug("Base prefix (from %s): %r", base_prefix_src, base_prefix) # Sanitize request URI (a WSGI server indicates with an empty path, # that the URL targets the application root without a trailing slash) path = pathutils.sanitize_path(unsafe_path) logger.debug("Sanitized path: %r", path) # Get function corresponding to method function = getattr(self, "do_%s" % request_method, None) if not function: return response(*httputils.METHOD_NOT_ALLOWED) # Redirect all "…/.well-known/{caldav,carddav}" paths to "/". # This shouldn't be necessary but some clients like TbSync require it. # Status must be MOVED PERMANENTLY using FOUND causes problems if (path.rstrip("/").endswith("/.well-known/caldav") or 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" if path.endswith("/.well-known") or "/.well-known/" in path: return response(*httputils.NOT_FOUND) # Ask authentication backend to check rights login = password = "" external_login = self._auth.get_external_login(environ) authorization = environ.get("HTTP_AUTHORIZATION", "") if external_login: login, password = external_login login, password = login or "", password or "" elif authorization.startswith("Basic"): authorization = authorization[len("Basic"):].strip() login, password = httputils.decode_request( self.configuration, environ, base64.b64decode( authorization.encode("ascii"))).split(":", 1) user = self._auth.login(login, password) or "" if login else "" if user and login == user: logger.info("Successful login: %r", user) elif user: logger.info("Successful login: %r -> %r", login, user) elif login: logger.warning("Failed login attempt from %s: %r", remote_host, login) # Random delay to avoid timing oracles and bruteforce attacks if self._auth_delay > 0: random_delay = self._auth_delay * (0.5 + random.random()) logger.debug("Sleeping %.3f seconds", random_delay) time.sleep(random_delay) if user and not pathutils.is_safe_path_component(user): # Prevent usernames like "user/calendar.ics" logger.info("Refused unsafe username: %r", user) user = "" # Create principal collection if user: principal_path = "/%s/" % user with self._storage.acquire_lock("r", user): principal = next(iter(self._storage.discover( principal_path, depth="1")), None) if not principal: if "W" in self._rights.authorization(user, principal_path): with self._storage.acquire_lock("w", user): try: self._storage.create_collection(principal_path) except ValueError as e: logger.warning("Failed to create principal " "collection %r: %s", user, e) user = "" else: logger.warning("Access to principal path %r denied by " "rights backend", principal_path) if self._internal_server: # Verify content length content_length = int(environ.get("CONTENT_LENGTH") or 0) if content_length: if (self._max_content_length > 0 and content_length > self._max_content_length): logger.info("Request body too large: %d", content_length) return response(*httputils.REQUEST_ENTITY_TOO_LARGE) if not login or user: status, headers, answer = function( environ, base_prefix, path, user) if (status, headers, answer) == httputils.NOT_ALLOWED: logger.info("Access to %r denied for %s", path, repr(user) if user else "anonymous user") else: status, headers, answer = httputils.NOT_ALLOWED if ((status, headers, answer) == httputils.NOT_ALLOWED and not user and not external_login): # Unknown or unauthorized user logger.debug("Asking client for authentication") status = client.UNAUTHORIZED headers = dict(headers) headers.update({ "WWW-Authenticate": "Basic realm=\"%s\"" % self._auth_realm}) return response(status, headers, answer) Radicale-3.1.8/radicale/app/base.py000066400000000000000000000117301426407556000170520ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2020 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 . import io import logging import posixpath import sys import xml.etree.ElementTree as ET from typing import Optional from radicale import (auth, config, httputils, pathutils, rights, storage, types, web, xmlutils) from radicale.log import logger # HACK: https://github.com/tiran/defusedxml/issues/54 import defusedxml.ElementTree as DefusedET # isort:skip sys.modules["xml.etree"].ElementTree = ET # type:ignore[attr-defined] class ApplicationBase: configuration: config.Configuration _auth: auth.BaseAuth _storage: storage.BaseStorage _rights: rights.BaseRights _web: web.BaseWeb _encoding: str def __init__(self, configuration: config.Configuration) -> None: self.configuration = configuration self._auth = auth.load(configuration) self._storage = storage.load(configuration) self._rights = rights.load(configuration) self._web = web.load(configuration) self._encoding = configuration.get("encoding", "request") def _read_xml_request_body(self, environ: types.WSGIEnviron ) -> Optional[ET.Element]: content = httputils.decode_request( self.configuration, environ, httputils.read_raw_request_body(self.configuration, environ)) if not content: return None try: xml_content = DefusedET.fromstring(content) except ET.ParseError as e: 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)) 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)) f = io.BytesIO() ET.ElementTree(xml_content).write(f, encoding=self._encoding, xml_declaration=True) return f.getvalue() def _webdav_error_response(self, status: int, human_tag: str ) -> types.WSGIResponse: """Generate XML error response.""" headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} content = self._xml_response(xmlutils.webdav_error(human_tag)) return status, headers, content class Access: """Helper class to check access rights of an item""" user: str path: str parent_path: str permissions: str _rights: rights.BaseRights _parent_permissions: Optional[str] def __init__(self, rights: rights.BaseRights, user: str, path: str ) -> None: self._rights = rights self.user = user self.path = path self.parent_path = pathutils.unstrip_path( posixpath.dirname(pathutils.strip_path(path)), True) self.permissions = self._rights.authorization(self.user, self.path) self._parent_permissions = None @property def parent_permissions(self) -> str: if self.path == self.parent_path: return self.permissions if self._parent_permissions is None: self._parent_permissions = self._rights.authorization( self.user, self.parent_path) return self._parent_permissions def check(self, permission: str, item: Optional[types.CollectionOrItem] = None) -> bool: if permission not in "rw": raise ValueError("Invalid permission argument: %r" % permission) if not item: permissions = permission + permission.upper() parent_permissions = permission elif isinstance(item, storage.BaseCollection): if item.tag: permissions = permission else: permissions = permission.upper() parent_permissions = "" else: permissions = "" parent_permissions = permission return bool(rights.intersect(self.permissions, permissions) or ( self.path != self.parent_path and rights.intersect(self.parent_permissions, parent_permissions))) Radicale-3.1.8/radicale/app/delete.py000066400000000000000000000060711426407556000174040ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . import xml.etree.ElementTree as ET from http import client from typing import Optional from radicale import httputils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection, item_href: Optional[str] = None) -> ET.Element: """Read and answer DELETE requests. Read rfc4918-9.6 for info. """ collection.delete(item_href) multistatus = ET.Element(xmlutils.make_clark("D:multistatus")) response = ET.Element(xmlutils.make_clark("D:response")) multistatus.append(response) href_element = ET.Element(xmlutils.make_clark("D:href")) href_element.text = xmlutils.make_href(base_prefix, path) response.append(href_element) status = ET.Element(xmlutils.make_clark("D:status")) status.text = xmlutils.make_response(200) response.append(status) return multistatus class ApplicationPartDelete(ApplicationBase): def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """Manage DELETE request.""" access = Access(self._rights, user, path) if not access.check("w"): return httputils.NOT_ALLOWED with self._storage.acquire_lock("w", user): item = next(iter(self._storage.discover(path)), None) if not item: return httputils.NOT_FOUND if not access.check("w", item): return httputils.NOT_ALLOWED if_match = environ.get("HTTP_IF_MATCH", "*") if if_match not in ("*", item.etag): # ETag precondition not verified, do not delete item return httputils.PRECONDITION_FAILED if isinstance(item, storage.BaseCollection): xml_answer = xml_delete(base_prefix, path, item) else: assert item.collection is not None assert item.href is not None xml_answer = xml_delete( base_prefix, path, item.collection, item.href) headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} return client.OK, headers, self._xml_response(xml_answer) Radicale-3.1.8/radicale/app/get.py000066400000000000000000000111041426407556000167120ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . import posixpath from http import client from urllib.parse import quote from radicale import httputils, pathutils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase from radicale.log import logger def propose_filename(collection: storage.BaseCollection) -> str: """Propose a filename for a collection.""" if collection.tag == "VADDRESSBOOK": fallback_title = "Address book" suffix = ".vcf" elif collection.tag == "VCALENDAR": fallback_title = "Calendar" suffix = ".ics" else: fallback_title = posixpath.basename(collection.path) suffix = "" title = collection.get_meta("D:displayname") or fallback_title if title and not title.lower().endswith(suffix.lower()): title += suffix return title class ApplicationPartGet(ApplicationBase): def _content_disposition_attachement(self, filename: str) -> str: value = "attachement" try: encoded_filename = quote(filename, encoding=self._encoding) except UnicodeEncodeError: logger.warning("Failed to encode filename: %r", filename, exc_info=True) encoded_filename = "" if encoded_filename: value += "; filename*=%s''%s" % (self._encoding, encoded_filename) return value def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """Manage GET request.""" # Redirect to /.web if the root path is requested if not pathutils.strip_path(path): return httputils.redirect(base_prefix + "/.web") if path == "/.web" or path.startswith("/.web/"): # Redirect to sanitized path for all subpaths of /.web unsafe_path = environ.get("PATH_INFO", "") if unsafe_path != path: location = base_prefix + path logger.info("Redirecting to sanitized path: %r ==> %r", base_prefix + unsafe_path, location) return httputils.redirect(location, client.MOVED_PERMANENTLY) # Dispatch /.web path to web module return self._web.get(environ, base_prefix, path, user) access = Access(self._rights, user, path) if not access.check("r") and "i" not in access.permissions: return httputils.NOT_ALLOWED with self._storage.acquire_lock("r", user): item = next(iter(self._storage.discover(path)), None) if not item: return httputils.NOT_FOUND if access.check("r", item): limited_access = False elif "i" in access.permissions: limited_access = True else: return httputils.NOT_ALLOWED if isinstance(item, storage.BaseCollection): if not item.tag: return (httputils.NOT_ALLOWED if limited_access else httputils.DIRECTORY_LISTING) content_type = xmlutils.MIMETYPES[item.tag] content_disposition = self._content_disposition_attachement( propose_filename(item)) elif limited_access: return httputils.NOT_ALLOWED else: content_type = xmlutils.OBJECT_MIMETYPES[item.name] content_disposition = "" assert item.last_modified headers = { "Content-Type": content_type, "Last-Modified": item.last_modified, "ETag": item.etag} if content_disposition: headers["Content-Disposition"] = content_disposition answer = item.serialize() return client.OK, headers, answer Radicale-3.1.8/radicale/app/head.py000066400000000000000000000024711426407556000170430ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 radicale import types from radicale.app.base import ApplicationBase from radicale.app.get import ApplicationPartGet class ApplicationPartHead(ApplicationPartGet, ApplicationBase): def do_HEAD(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """Manage HEAD request.""" # Body is dropped in `Application.__call__` for HEAD requests return self.do_GET(environ, base_prefix, path, user) Radicale-3.1.8/radicale/app/mkcalendar.py000066400000000000000000000063711426407556000202460ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . import posixpath import socket from http import client import radicale.item as radicale_item from radicale import httputils, pathutils, storage, types, xmlutils from radicale.app.base import ApplicationBase from radicale.log import logger class ApplicationPartMkcalendar(ApplicationBase): def do_MKCALENDAR(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """Manage MKCALENDAR request.""" if "w" not in self._rights.authorization(user, path): return httputils.NOT_ALLOWED try: xml_content = self._read_xml_request_body(environ) except RuntimeError as e: logger.warning( "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST except socket.timeout: logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT # Prepare before locking props_with_remove = xmlutils.props_from_request(xml_content) props_with_remove["tag"] = "VCALENDAR" try: props = radicale_item.check_and_sanitize_props(props_with_remove) except ValueError as e: logger.warning( "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST # TODO: use this? # timezone = props.get("C:calendar-timezone") with self._storage.acquire_lock("w", user): item = next(iter(self._storage.discover(path)), None) if item: return self._webdav_error_response( client.CONFLICT, "D:resource-must-be-null") parent_path = pathutils.unstrip_path( posixpath.dirname(pathutils.strip_path(path)), True) parent_item = next(iter(self._storage.discover(parent_path)), None) if not parent_item: return httputils.CONFLICT if (not isinstance(parent_item, storage.BaseCollection) or parent_item.tag): return httputils.FORBIDDEN try: self._storage.create_collection(path, props=props) except ValueError as e: logger.warning( "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST return client.CREATED, {}, None Radicale-3.1.8/radicale/app/mkcol.py000066400000000000000000000063701426407556000172510ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . import posixpath import socket from http import client import radicale.item as radicale_item from radicale import httputils, pathutils, rights, storage, types, xmlutils from radicale.app.base import ApplicationBase from radicale.log import logger class ApplicationPartMkcol(ApplicationBase): def do_MKCOL(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """Manage MKCOL request.""" permissions = self._rights.authorization(user, path) if not rights.intersect(permissions, "Ww"): return httputils.NOT_ALLOWED try: xml_content = self._read_xml_request_body(environ) except RuntimeError as e: logger.warning( "Bad MKCOL request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST except socket.timeout: logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT # Prepare before locking props_with_remove = xmlutils.props_from_request(xml_content) try: props = radicale_item.check_and_sanitize_props(props_with_remove) except ValueError as e: 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): return httputils.NOT_ALLOWED with self._storage.acquire_lock("w", user): item = next(iter(self._storage.discover(path)), None) if item: return httputils.METHOD_NOT_ALLOWED parent_path = pathutils.unstrip_path( posixpath.dirname(pathutils.strip_path(path)), True) parent_item = next(iter(self._storage.discover(parent_path)), None) if not parent_item: return httputils.CONFLICT if (not isinstance(parent_item, storage.BaseCollection) or parent_item.tag): return httputils.FORBIDDEN try: self._storage.create_collection(path, props=props) except ValueError as e: logger.warning( "Bad MKCOL request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST return client.CREATED, {}, None Radicale-3.1.8/radicale/app/move.py000066400000000000000000000104631426407556000171100ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . import posixpath from http import client from urllib.parse import urlparse from radicale import httputils, pathutils, storage, types from radicale.app.base import Access, ApplicationBase from radicale.log import logger class ApplicationPartMove(ApplicationBase): def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """Manage MOVE request.""" raw_dest = environ.get("HTTP_DESTINATION", "") to_url = urlparse(raw_dest) if to_url.netloc != environ["HTTP_HOST"]: logger.info("Unsupported destination address: %r", raw_dest) # Remote destination server, not supported return httputils.REMOTE_DESTINATION access = Access(self._rights, user, path) if not access.check("w"): return httputils.NOT_ALLOWED to_path = pathutils.sanitize_path(to_url.path) if not (to_path + "/").startswith(base_prefix + "/"): logger.warning("Destination %r from MOVE request on %r doesn't " "start with base prefix", to_path, path) return httputils.NOT_ALLOWED to_path = to_path[len(base_prefix):] to_access = Access(self._rights, user, to_path) if not to_access.check("w"): return httputils.NOT_ALLOWED with self._storage.acquire_lock("w", user): item = next(iter(self._storage.discover(path)), None) if not item: return httputils.NOT_FOUND if (not access.check("w", item) or not to_access.check("w", item)): return httputils.NOT_ALLOWED if isinstance(item, storage.BaseCollection): # TODO: support moving collections return httputils.METHOD_NOT_ALLOWED to_item = next(iter(self._storage.discover(to_path)), None) if isinstance(to_item, storage.BaseCollection): return httputils.FORBIDDEN to_parent_path = pathutils.unstrip_path( posixpath.dirname(pathutils.strip_path(to_path)), True) to_collection = next(iter( self._storage.discover(to_parent_path)), None) if not to_collection: return httputils.CONFLICT assert isinstance(to_collection, storage.BaseCollection) assert item.collection is not None collection_tag = item.collection.tag if not collection_tag or collection_tag != to_collection.tag: return httputils.FORBIDDEN if to_item and environ.get("HTTP_OVERWRITE", "F") != "T": return httputils.PRECONDITION_FAILED if (to_item and item.uid != to_item.uid or not to_item and to_collection.path != item.collection.path and to_collection.has_uid(item.uid)): return self._webdav_error_response( client.CONFLICT, "%s:no-uid-conflict" % ( "C" if collection_tag == "VCALENDAR" else "CR")) to_href = posixpath.basename(pathutils.strip_path(to_path)) try: self._storage.move(item, to_collection, to_href) except ValueError as e: logger.warning( "Bad MOVE request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST return client.NO_CONTENT if to_item else client.CREATED, {}, None Radicale-3.1.8/radicale/app/options.py000066400000000000000000000025631426407556000176370ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 http import client from radicale import httputils, types from radicale.app.base import ApplicationBase class ApplicationPartOptions(ApplicationBase): def do_OPTIONS(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """Manage OPTIONS request.""" headers = { "Allow": ", ".join( name[3:] for name in dir(self) if name.startswith("do_")), "DAV": httputils.DAV_HEADERS} return client.OK, headers, None Radicale-3.1.8/radicale/app/post.py000066400000000000000000000025261426407556000171300ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2018 Unrud # Copyright © 2020 Tom Hacohen # # 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 radicale import httputils, types from radicale.app.base import ApplicationBase class ApplicationPartPost(ApplicationBase): def do_POST(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """Manage POST request.""" if path == "/.web" or path.startswith("/.web/"): return self._web.post(environ, base_prefix, path, user) return httputils.METHOD_NOT_ALLOWED Radicale-3.1.8/radicale/app/propfind.py000066400000000000000000000424001426407556000177570ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . import collections import itertools import posixpath import socket import xml.etree.ElementTree as ET from http import client from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple from radicale import httputils, pathutils, rights, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase from radicale.log import logger def xml_propfind(base_prefix: str, path: str, xml_request: Optional[ET.Element], allowed_items: Iterable[Tuple[types.CollectionOrItem, str]], user: str, encoding: str) -> Optional[ET.Element]: """Read and answer PROPFIND requests. Read rfc4918-9.1 for info. The collections parameter is a list of collections that are to be included in the output. """ # A client may choose not to submit a request body. An empty PROPFIND # request body MUST be treated as if it were an 'allprop' request. top_element = (xml_request[0] if xml_request is not None else ET.Element(xmlutils.make_clark("D:allprop"))) props: List[str] = [] allprop = False propname = False if top_element.tag == xmlutils.make_clark("D:allprop"): allprop = True elif top_element.tag == xmlutils.make_clark("D:propname"): propname = True elif top_element.tag == xmlutils.make_clark("D:prop"): props.extend(prop.tag for prop in top_element) if xmlutils.make_clark("D:current-user-principal") in props and not user: # Ask for authentication # Returning the DAV:unauthenticated pseudo-principal as specified in # RFC 5397 doesn't seem to work with DAVx5. return None # Writing answer multistatus = ET.Element(xmlutils.make_clark("D:multistatus")) for item, permission in allowed_items: write = permission == "w" multistatus.append(xml_propfind_response( base_prefix, path, item, props, user, encoding, write=write, allprop=allprop, propname=propname)) return multistatus def xml_propfind_response( base_prefix: str, path: str, item: types.CollectionOrItem, props: Sequence[str], user: str, encoding: str, write: bool = False, propname: bool = False, allprop: bool = False) -> ET.Element: """Build and return a PROPFIND response.""" if propname and allprop or (props and (propname or allprop)): raise ValueError("Only use one of props, propname and allprops") if isinstance(item, storage.BaseCollection): is_collection = True is_leaf = item.tag in ("VADDRESSBOOK", "VCALENDAR") collection = item # Some clients expect collections to end with `/` uri = pathutils.unstrip_path(item.path, True) else: is_collection = is_leaf = False assert item.collection is not None assert item.href collection = item.collection uri = pathutils.unstrip_path(posixpath.join( collection.path, item.href)) response = ET.Element(xmlutils.make_clark("D:response")) href = ET.Element(xmlutils.make_clark("D:href")) href.text = xmlutils.make_href(base_prefix, uri) response.append(href) if propname or allprop: props = [] # Should list all properties that can be retrieved by the code below props.append(xmlutils.make_clark("D:principal-collection-set")) props.append(xmlutils.make_clark("D:current-user-principal")) props.append(xmlutils.make_clark("D:current-user-privilege-set")) props.append(xmlutils.make_clark("D:supported-report-set")) props.append(xmlutils.make_clark("D:resourcetype")) props.append(xmlutils.make_clark("D:owner")) if is_collection and collection.is_principal: props.append(xmlutils.make_clark("C:calendar-user-address-set")) props.append(xmlutils.make_clark("D:principal-URL")) props.append(xmlutils.make_clark("CR:addressbook-home-set")) props.append(xmlutils.make_clark("C:calendar-home-set")) if not is_collection or is_leaf: props.append(xmlutils.make_clark("D:getetag")) props.append(xmlutils.make_clark("D:getlastmodified")) props.append(xmlutils.make_clark("D:getcontenttype")) props.append(xmlutils.make_clark("D:getcontentlength")) if is_collection: if is_leaf: props.append(xmlutils.make_clark("D:displayname")) props.append(xmlutils.make_clark("D:sync-token")) if collection.tag == "VCALENDAR": props.append(xmlutils.make_clark("CS:getctag")) props.append( xmlutils.make_clark("C:supported-calendar-component-set")) meta = collection.get_meta() for tag in meta: if tag == "tag": continue clark_tag = xmlutils.make_clark(tag) if clark_tag not in props: props.append(clark_tag) responses: Dict[int, List[ET.Element]] = collections.defaultdict(list) if propname: for tag in props: responses[200].append(ET.Element(tag)) props = [] for tag in props: element = ET.Element(tag) is404 = False if tag == xmlutils.make_clark("D:getetag"): if not is_collection or is_leaf: element.text = item.etag else: is404 = True elif tag == xmlutils.make_clark("D:getlastmodified"): if not is_collection or is_leaf: element.text = item.last_modified else: is404 = True elif tag == xmlutils.make_clark("D:principal-collection-set"): child_element = ET.Element(xmlutils.make_clark("D:href")) child_element.text = xmlutils.make_href(base_prefix, "/") element.append(child_element) elif (tag in (xmlutils.make_clark("C:calendar-user-address-set"), xmlutils.make_clark("D:principal-URL"), xmlutils.make_clark("CR:addressbook-home-set"), xmlutils.make_clark("C:calendar-home-set")) and is_collection and collection.is_principal): child_element = ET.Element(xmlutils.make_clark("D:href")) child_element.text = xmlutils.make_href(base_prefix, path) element.append(child_element) elif tag == xmlutils.make_clark("C:supported-calendar-component-set"): human_tag = xmlutils.make_human_tag(tag) if is_collection and is_leaf: components_text = collection.get_meta(human_tag) if components_text: components = components_text.split(",") else: components = ["VTODO", "VEVENT", "VJOURNAL"] for component in components: comp = ET.Element(xmlutils.make_clark("C:comp")) comp.set("name", component) element.append(comp) else: is404 = True elif tag == xmlutils.make_clark("D:current-user-principal"): if user: child_element = ET.Element(xmlutils.make_clark("D:href")) child_element.text = xmlutils.make_href( base_prefix, "/%s/" % user) element.append(child_element) else: element.append(ET.Element( xmlutils.make_clark("D:unauthenticated"))) elif tag == xmlutils.make_clark("D:current-user-privilege-set"): privileges = ["D:read"] if write: privileges.append("D:all") privileges.append("D:write") privileges.append("D:write-properties") privileges.append("D:write-content") for human_tag in privileges: privilege = ET.Element(xmlutils.make_clark("D:privilege")) privilege.append(ET.Element( xmlutils.make_clark(human_tag))) element.append(privilege) elif tag == xmlutils.make_clark("D:supported-report-set"): # These 3 reports are not implemented reports = ["D:expand-property", "D:principal-search-property-set", "D:principal-property-search"] if is_collection and is_leaf: reports.append("D:sync-collection") if collection.tag == "VADDRESSBOOK": reports.append("CR:addressbook-multiget") reports.append("CR:addressbook-query") elif collection.tag == "VCALENDAR": reports.append("C:calendar-multiget") reports.append("C:calendar-query") for human_tag in reports: supported_report = ET.Element( xmlutils.make_clark("D:supported-report")) report_element = ET.Element(xmlutils.make_clark("D:report")) report_element.append( ET.Element(xmlutils.make_clark(human_tag))) supported_report.append(report_element) element.append(supported_report) elif tag == xmlutils.make_clark("D:getcontentlength"): if not is_collection or is_leaf: element.text = str(len(item.serialize().encode(encoding))) else: is404 = True elif tag == xmlutils.make_clark("D:owner"): # return empty elment, if no owner available (rfc3744-5.1) if collection.owner: child_element = ET.Element(xmlutils.make_clark("D:href")) child_element.text = xmlutils.make_href( base_prefix, "/%s/" % collection.owner) element.append(child_element) elif is_collection: if tag == xmlutils.make_clark("D:getcontenttype"): if is_leaf: element.text = xmlutils.MIMETYPES[ collection.tag] else: is404 = True elif tag == xmlutils.make_clark("D:resourcetype"): if collection.is_principal: child_element = ET.Element( xmlutils.make_clark("D:principal")) element.append(child_element) if is_leaf: if collection.tag == "VADDRESSBOOK": child_element = ET.Element( xmlutils.make_clark("CR:addressbook")) element.append(child_element) elif collection.tag == "VCALENDAR": child_element = ET.Element( xmlutils.make_clark("C:calendar")) element.append(child_element) child_element = ET.Element(xmlutils.make_clark("D:collection")) element.append(child_element) elif tag == xmlutils.make_clark("RADICALE:displayname"): # Only for internal use by the web interface displayname = collection.get_meta("D:displayname") if displayname is not None: element.text = displayname else: is404 = True elif tag == xmlutils.make_clark("D:displayname"): displayname = collection.get_meta("D:displayname") if not displayname and is_leaf: displayname = collection.path if displayname is not None: element.text = displayname else: is404 = True elif tag == xmlutils.make_clark("CS:getctag"): if is_leaf: element.text = collection.etag else: is404 = True elif tag == xmlutils.make_clark("D:sync-token"): if is_leaf: element.text, _ = collection.sync() else: is404 = True else: human_tag = xmlutils.make_human_tag(tag) tag_text = collection.get_meta(human_tag) if tag_text is not None: element.text = tag_text else: is404 = True # Not for collections elif tag == xmlutils.make_clark("D:getcontenttype"): assert not isinstance(item, storage.BaseCollection) element.text = xmlutils.get_content_type(item, encoding) elif tag == xmlutils.make_clark("D:resourcetype"): # resourcetype must be returned empty for non-collection elements pass else: is404 = True responses[404 if is404 else 200].append(element) for status_code, childs in responses.items(): if not childs: continue propstat = ET.Element(xmlutils.make_clark("D:propstat")) response.append(propstat) prop = ET.Element(xmlutils.make_clark("D:prop")) prop.extend(childs) propstat.append(prop) status = ET.Element(xmlutils.make_clark("D:status")) status.text = xmlutils.make_response(status_code) propstat.append(status) return response class ApplicationPartPropfind(ApplicationBase): def _collect_allowed_items( self, items: Iterable[types.CollectionOrItem], user: str ) -> Iterator[Tuple[types.CollectionOrItem, str]]: """Get items from request that user is allowed to access.""" for item in items: if isinstance(item, storage.BaseCollection): path = pathutils.unstrip_path(item.path, True) if item.tag: permissions = rights.intersect( self._rights.authorization(user, path), "rw") target = "collection with tag %r" % item.path else: permissions = rights.intersect( self._rights.authorization(user, path), "RW") target = "collection %r" % item.path else: assert item.collection is not None path = pathutils.unstrip_path(item.collection.path, True) permissions = rights.intersect( self._rights.authorization(user, path), "rw") target = "item %r from %r" % (item.href, item.collection.path) if rights.intersect(permissions, "Ww"): permission = "w" status = "write" elif rights.intersect(permissions, "Rr"): permission = "r" status = "read" else: permission = "" status = "NO" logger.debug( "%s has %s access to %s", repr(user) if user else "anonymous user", status, target) if permission: yield item, permission def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """Manage PROPFIND request.""" access = Access(self._rights, user, path) if not access.check("r"): return httputils.NOT_ALLOWED try: xml_content = self._read_xml_request_body(environ) except RuntimeError as e: logger.warning( "Bad PROPFIND request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST except socket.timeout: logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT with self._storage.acquire_lock("r", user): items_iter = iter(self._storage.discover( path, environ.get("HTTP_DEPTH", "0"))) # take root item for rights checking item = next(items_iter, None) if not item: return httputils.NOT_FOUND if not access.check("r", item): return httputils.NOT_ALLOWED # put item back items_iter = itertools.chain([item], items_iter) allowed_items = self._collect_allowed_items(items_iter, user) headers = {"DAV": httputils.DAV_HEADERS, "Content-Type": "text/xml; charset=%s" % self._encoding} xml_answer = xml_propfind(base_prefix, path, xml_content, allowed_items, user, self._encoding) if xml_answer is None: return httputils.NOT_ALLOWED return client.MULTI_STATUS, headers, self._xml_response(xml_answer) Radicale-3.1.8/radicale/app/proppatch.py000066400000000000000000000102011426407556000201300ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . import socket import xml.etree.ElementTree as ET from http import client from typing import Dict, Optional, cast import radicale.item as radicale_item from radicale import httputils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase from radicale.log import logger def xml_proppatch(base_prefix: str, path: str, xml_request: Optional[ET.Element], collection: storage.BaseCollection) -> ET.Element: """Read and answer PROPPATCH requests. Read rfc4918-9.2 for info. """ multistatus = ET.Element(xmlutils.make_clark("D:multistatus")) response = ET.Element(xmlutils.make_clark("D:response")) multistatus.append(response) href = ET.Element(xmlutils.make_clark("D:href")) href.text = xmlutils.make_href(base_prefix, path) response.append(href) # Create D:propstat element for props with status 200 OK propstat = ET.Element(xmlutils.make_clark("D:propstat")) status = ET.Element(xmlutils.make_clark("D:status")) status.text = xmlutils.make_response(200) props_ok = ET.Element(xmlutils.make_clark("D:prop")) propstat.append(props_ok) propstat.append(status) response.append(propstat) props_with_remove = xmlutils.props_from_request(xml_request) all_props_with_remove = cast(Dict[str, Optional[str]], dict(collection.get_meta())) all_props_with_remove.update(props_with_remove) all_props = radicale_item.check_and_sanitize_props(all_props_with_remove) collection.set_meta(all_props) for short_name in props_with_remove: props_ok.append(ET.Element(xmlutils.make_clark(short_name))) return multistatus class ApplicationPartProppatch(ApplicationBase): def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """Manage PROPPATCH request.""" access = Access(self._rights, user, path) if not access.check("w"): return httputils.NOT_ALLOWED try: xml_content = self._read_xml_request_body(environ) except RuntimeError as e: logger.warning( "Bad PROPPATCH request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST except socket.timeout: logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT with self._storage.acquire_lock("w", user): item = next(iter(self._storage.discover(path)), None) if not item: return httputils.NOT_FOUND if not access.check("w", item): return httputils.NOT_ALLOWED if not isinstance(item, storage.BaseCollection): return httputils.FORBIDDEN headers = {"DAV": httputils.DAV_HEADERS, "Content-Type": "text/xml; charset=%s" % self._encoding} try: xml_answer = xml_proppatch(base_prefix, path, xml_content, item) except ValueError as e: logger.warning( "Bad PROPPATCH request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST return client.MULTI_STATUS, headers, self._xml_response(xml_answer) Radicale-3.1.8/radicale/app/put.py000066400000000000000000000247031426407556000167540ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . import itertools import posixpath import socket import sys from http import client from types import TracebackType from typing import Iterator, List, Mapping, MutableMapping, Optional, Tuple import vobject import radicale.item as radicale_item from radicale import httputils, pathutils, rights, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase from radicale.log import logger MIMETYPE_TAGS: Mapping[str, str] = {value: key for key, value in xmlutils.MIMETYPES.items()} def prepare(vobject_items: List[vobject.base.Component], path: str, content_type: str, permission: bool, parent_permission: bool, tag: Optional[str] = None, write_whole_collection: Optional[bool] = None) -> Tuple[ Iterator[radicale_item.Item], # items Optional[str], # tag Optional[bool], # write_whole_collection Optional[MutableMapping[str, str]], # props Optional[Tuple[type, BaseException, Optional[TracebackType]]]]: if (write_whole_collection or permission and not parent_permission): write_whole_collection = True tag = radicale_item.predict_tag_of_whole_collection( vobject_items, MIMETYPE_TAGS.get(content_type)) if not tag: raise ValueError("Can't determine collection tag") collection_path = pathutils.strip_path(path) elif (write_whole_collection is not None and not write_whole_collection or not permission and parent_permission): write_whole_collection = False if tag is None: tag = radicale_item.predict_tag_of_parent_collection(vobject_items) collection_path = posixpath.dirname(pathutils.strip_path(path)) props: Optional[MutableMapping[str, str]] = None stored_exc_info = None items = [] try: if tag and write_whole_collection is not None: radicale_item.check_and_sanitize_items( vobject_items, is_collection=write_whole_collection, tag=tag) if write_whole_collection and tag == "VCALENDAR": vobject_components: List[vobject.base.Component] = [] vobject_item, = vobject_items for content in ("vevent", "vtodo", "vjournal"): vobject_components.extend( getattr(vobject_item, "%s_list" % content, [])) vobject_components_by_uid = itertools.groupby( sorted(vobject_components, key=radicale_item.get_uid), radicale_item.get_uid) for _, components in vobject_components_by_uid: vobject_collection = vobject.iCalendar() for component in components: vobject_collection.add(component) item = radicale_item.Item(collection_path=collection_path, vobject_item=vobject_collection) item.prepare() items.append(item) elif write_whole_collection and tag == "VADDRESSBOOK": for vobject_item in vobject_items: item = radicale_item.Item(collection_path=collection_path, vobject_item=vobject_item) item.prepare() items.append(item) elif not write_whole_collection: vobject_item, = vobject_items item = radicale_item.Item(collection_path=collection_path, vobject_item=vobject_item) item.prepare() items.append(item) if write_whole_collection: props = {} if tag: props["tag"] = tag if tag == "VCALENDAR" and vobject_items: if hasattr(vobject_items[0], "x_wr_calname"): calname = vobject_items[0].x_wr_calname.value if calname: props["D:displayname"] = calname if hasattr(vobject_items[0], "x_wr_caldesc"): caldesc = vobject_items[0].x_wr_caldesc.value if caldesc: props["C:calendar-description"] = caldesc props = radicale_item.check_and_sanitize_props(props) except Exception: exc_info_or_none_tuple = sys.exc_info() assert exc_info_or_none_tuple[0] is not None stored_exc_info = exc_info_or_none_tuple # Use iterator for items and delete references to free memory early def items_iter() -> Iterator[radicale_item.Item]: while items: yield items.pop(0) return items_iter(), tag, write_whole_collection, props, stored_exc_info class ApplicationPartPut(ApplicationBase): def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """Manage PUT request.""" access = Access(self._rights, user, path) if not access.check("w"): return httputils.NOT_ALLOWED 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) return httputils.BAD_REQUEST except socket.timeout: logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT # Prepare before locking content_type = environ.get("CONTENT_TYPE", "").split(";", maxsplit=1)[0] try: 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) return httputils.BAD_REQUEST (prepared_items, prepared_tag, prepared_write_whole_collection, prepared_props, prepared_exc_info) = prepare( vobject_items, path, content_type, bool(rights.intersect(access.permissions, "Ww")), bool(rights.intersect(access.parent_permissions, "w"))) with self._storage.acquire_lock("w", user): item = next(iter(self._storage.discover(path)), None) parent_item = next(iter( self._storage.discover(access.parent_path)), None) if not isinstance(parent_item, storage.BaseCollection): return httputils.CONFLICT write_whole_collection = ( isinstance(item, storage.BaseCollection) or not parent_item.tag) if write_whole_collection: tag = prepared_tag else: tag = parent_item.tag if write_whole_collection: if ("w" if tag else "W") not in access.permissions: return httputils.NOT_ALLOWED elif "w" not in access.parent_permissions: return httputils.NOT_ALLOWED etag = environ.get("HTTP_IF_MATCH", "") if not item and etag: # Etag asked but no item found: item has been removed return httputils.PRECONDITION_FAILED if item and etag and item.etag != etag: # Etag asked but item not matching: item has changed return httputils.PRECONDITION_FAILED match = environ.get("HTTP_IF_NONE_MATCH", "") == "*" if item and match: # Creation asked but item found: item can't be replaced return httputils.PRECONDITION_FAILED if (tag != prepared_tag or prepared_write_whole_collection != write_whole_collection): (prepared_items, prepared_tag, prepared_write_whole_collection, prepared_props, prepared_exc_info) = prepare( vobject_items, path, content_type, bool(rights.intersect(access.permissions, "Ww")), bool(rights.intersect(access.parent_permissions, "w")), tag, write_whole_collection) props = prepared_props if prepared_exc_info: logger.warning( "Bad PUT request on %r: %s", path, prepared_exc_info[1], exc_info=prepared_exc_info) return httputils.BAD_REQUEST if write_whole_collection: try: etag = self._storage.create_collection( path, prepared_items, props).etag except ValueError as e: logger.warning( "Bad PUT request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST else: assert not isinstance(item, storage.BaseCollection) prepared_item, = prepared_items if (item and item.uid != prepared_item.uid or not item and parent_item.has_uid(prepared_item.uid)): return self._webdav_error_response( client.CONFLICT, "%s:no-uid-conflict" % ( "C" if tag == "VCALENDAR" else "CR")) href = posixpath.basename(pathutils.strip_path(path)) try: etag = parent_item.upload(href, prepared_item).etag except ValueError as e: logger.warning( "Bad PUT request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST headers = {"ETag": etag} return client.CREATED, headers, None Radicale-3.1.8/radicale/app/report.py000066400000000000000000000330601426407556000174530ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . import contextlib import posixpath import socket import xml.etree.ElementTree as ET from http import client from typing import Callable, Iterable, Iterator, Optional, Sequence, Tuple from urllib.parse import unquote, urlparse import radicale.item as radicale_item from radicale import httputils, pathutils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase from radicale.item import filter as radicale_filter from radicale.log import logger 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 rfc3253-3.6 for info. """ multistatus = ET.Element(xmlutils.make_clark("D:multistatus")) if xml_request is None: return client.MULTI_STATUS, multistatus root = xml_request if root.tag in (xmlutils.make_clark("D:principal-search-property-set"), xmlutils.make_clark("D:principal-property-search"), xmlutils.make_clark("D:expand-property")): # We don't support searching for principals or indirect retrieving of # properties, just return an empty result. # InfCloud asks for expand-property reports (even if we don't announce # support for them) and stops working if an error code is returned. logger.warning("Unsupported REPORT method %r on %r requested", xmlutils.make_human_tag(root.tag), path) return client.MULTI_STATUS, multistatus if (root.tag == xmlutils.make_clark("C:calendar-multiget") and collection.tag != "VCALENDAR" or root.tag == xmlutils.make_clark("CR:addressbook-multiget") and collection.tag != "VADDRESSBOOK" or root.tag == xmlutils.make_clark("D:sync-collection") and collection.tag not in ("VADDRESSBOOK", "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") prop_element = root.find(xmlutils.make_clark("D:prop")) props = ([prop.tag for prop in prop_element] if prop_element is not None else []) hreferences: Iterable[str] if root.tag in ( xmlutils.make_clark("C:calendar-multiget"), xmlutils.make_clark("CR:addressbook-multiget")): # Read rfc4791-7.9 for info hreferences = set() for href_element in root.findall(xmlutils.make_clark("D:href")): temp_url_path = urlparse(href_element.text).path assert isinstance(temp_url_path, str) href_path = pathutils.sanitize_path(unquote(temp_url_path)) if (href_path + "/").startswith(base_prefix + "/"): hreferences.add(href_path[len(base_prefix):]) else: logger.warning("Skipping invalid path %r in REPORT request on " "%r", href_path, path) elif root.tag == xmlutils.make_clark("D:sync-collection"): old_sync_token_element = root.find( xmlutils.make_clark("D:sync-token")) old_sync_token = "" if old_sync_token_element is not None and old_sync_token_element.text: old_sync_token = old_sync_token_element.text.strip() logger.debug("Client provided sync token: %r", old_sync_token) try: sync_token, names = collection.sync(old_sync_token) except ValueError as e: # Invalid sync token logger.warning("Client provided invalid sync token %r: %s", old_sync_token, e, exc_info=True) # client.CONFLICT doesn't work with some clients (e.g. InfCloud) return (client.FORBIDDEN, xmlutils.webdav_error("D:valid-sync-token")) hreferences = (pathutils.unstrip_path( posixpath.join(collection.path, n)) for n in names) # Append current sync token to response sync_token_element = ET.Element(xmlutils.make_clark("D:sync-token")) sync_token_element.text = sync_token multistatus.append(sync_token_element) else: hreferences = (path,) filters = ( root.findall(xmlutils.make_clark("C:filter")) + root.findall(xmlutils.make_clark("CR:filter"))) # Retrieve everything required for finishing the request. retrieved_items = list(retrieve_items( base_prefix, path, collection, hreferences, filters, multistatus)) collection_tag = collection.tag # !!! Don't access storage after this !!! unlock_storage_fn() while retrieved_items: # ``item.vobject_item`` might be accessed during filtering. # Don't keep reference to ``item``, because VObject requires a lot of # memory. item, filters_matched = retrieved_items.pop(0) if filters and not filters_matched: try: if not all(test_filter(collection_tag, item, filter_) for filter_ in filters): continue except ValueError as e: raise ValueError("Failed to filter item %r from %r: %s" % (item.href, collection.path, e)) from e except Exception as e: raise RuntimeError("Failed to filter item %r from %r: %s" % (item.href, collection.path, e)) from e found_props = [] not_found_props = [] for tag in props: element = ET.Element(tag) if tag == xmlutils.make_clark("D:getetag"): element.text = item.etag found_props.append(element) elif tag == xmlutils.make_clark("D:getcontenttype"): element.text = xmlutils.get_content_type(item, encoding) found_props.append(element) elif tag in ( xmlutils.make_clark("C:calendar-data"), xmlutils.make_clark("CR:address-data")): element.text = item.serialize() found_props.append(element) else: not_found_props.append(element) assert item.href uri = pathutils.unstrip_path( posixpath.join(collection.path, item.href)) multistatus.append(xml_item_response( base_prefix, uri, found_props=found_props, not_found_props=not_found_props, found_item=True)) return client.MULTI_STATUS, multistatus def xml_item_response(base_prefix: str, href: str, found_props: Sequence[ET.Element] = (), not_found_props: Sequence[ET.Element] = (), found_item: bool = True) -> ET.Element: response = ET.Element(xmlutils.make_clark("D:response")) href_element = ET.Element(xmlutils.make_clark("D:href")) href_element.text = xmlutils.make_href(base_prefix, href) response.append(href_element) if found_item: for code, props in ((200, found_props), (404, not_found_props)): if props: propstat = ET.Element(xmlutils.make_clark("D:propstat")) status = ET.Element(xmlutils.make_clark("D:status")) status.text = xmlutils.make_response(code) prop_element = ET.Element(xmlutils.make_clark("D:prop")) for prop in props: prop_element.append(prop) propstat.append(prop_element) propstat.append(status) response.append(propstat) else: status = ET.Element(xmlutils.make_clark("D:status")) status.text = xmlutils.make_response(404) response.append(status) return response def retrieve_items( base_prefix: str, path: str, collection: storage.BaseCollection, hreferences: Iterable[str], filters: Sequence[ET.Element], multistatus: ET.Element) -> Iterator[Tuple[radicale_item.Item, bool]]: """Retrieves all items that are referenced in ``hreferences`` from ``collection`` and adds 404 responses for missing and invalid items to ``multistatus``.""" collection_requested = False def get_names() -> Iterator[str]: """Extracts all names from references in ``hreferences`` and adds 404 responses for invalid references to ``multistatus``. If the whole collections is referenced ``collection_requested`` gets set to ``True``.""" nonlocal collection_requested for hreference in hreferences: try: name = pathutils.name_from_path(hreference, collection) except ValueError as e: logger.warning("Skipping invalid path %r in REPORT request on " "%r: %s", hreference, path, e) response = xml_item_response(base_prefix, hreference, found_item=False) multistatus.append(response) continue if name: # Reference is an item yield name else: # Reference is a collection collection_requested = True for name, item in collection.get_multi(get_names()): if not item: uri = pathutils.unstrip_path(posixpath.join(collection.path, name)) response = xml_item_response(base_prefix, uri, found_item=False) multistatus.append(response) else: yield item, False if collection_requested: yield from collection.get_filtered(filters) def test_filter(collection_tag: str, item: radicale_item.Item, filter_: ET.Element) -> bool: """Match an item against a filter.""" if (collection_tag == "VCALENDAR" and filter_.tag != xmlutils.make_clark("C:%s" % filter_)): if len(filter_) == 0: return True if len(filter_) > 1: raise ValueError("Filter with %d children" % len(filter_)) if filter_[0].tag != xmlutils.make_clark("C:comp-filter"): raise ValueError("Unexpected %r in filter" % filter_[0].tag) return radicale_filter.comp_match(item, filter_[0]) if (collection_tag == "VADDRESSBOOK" and filter_.tag != xmlutils.make_clark("CR:%s" % filter_)): for child in filter_: if child.tag != xmlutils.make_clark("CR:prop-filter"): raise ValueError("Unexpected %r in filter" % child.tag) test = filter_.get("test", "anyof") if test == "anyof": return any(radicale_filter.prop_match(item.vobject_item, f, "CR") for f in filter_) if test == "allof": return all(radicale_filter.prop_match(item.vobject_item, f, "CR") for f in filter_) raise ValueError("Unsupported filter test: %r" % test) raise ValueError("Unsupported filter %r for %r" % (filter_.tag, collection_tag)) class ApplicationPartReport(ApplicationBase): def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """Manage REPORT request.""" access = Access(self._rights, user, path) if not access.check("r"): return httputils.NOT_ALLOWED try: xml_content = self._read_xml_request_body(environ) except RuntimeError as e: logger.warning("Bad REPORT request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST except socket.timeout: logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT with contextlib.ExitStack() as lock_stack: lock_stack.enter_context(self._storage.acquire_lock("r", user)) item = next(iter(self._storage.discover(path)), None) if not item: return httputils.NOT_FOUND if not access.check("r", item): return httputils.NOT_ALLOWED if isinstance(item, storage.BaseCollection): collection = item 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) Radicale-3.1.8/radicale/auth/000077500000000000000000000000001426407556000157455ustar00rootroot00000000000000Radicale-3.1.8/radicale/auth/__init__.py000066400000000000000000000052431426407556000200620ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . """ Authentication module. Authentication is based on usernames and passwords. If something more advanced is needed an external WSGI server or reverse proxy can be used (see ``remote_user`` or ``http_x_remote_user`` backend). Take a look at the class ``BaseAuth`` if you want to implement your own. """ from typing import Sequence, Tuple, Union from radicale import config, types, utils INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user", "htpasswd") def load(configuration: "config.Configuration") -> "BaseAuth": """Load the authentication module chosen in configuration.""" return utils.load_plugin(INTERNAL_TYPES, "auth", "Auth", BaseAuth, configuration) class BaseAuth: def __init__(self, configuration: "config.Configuration") -> None: """Initialize BaseAuth. ``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 get_external_login(self, environ: types.WSGIEnviron) -> Union[ Tuple[()], Tuple[str, str]]: """Optionally provide the login and password externally. ``environ`` a dict with the WSGI environment If ``()`` is returned, Radicale handles HTTP authentication. Otherwise, returns a tuple ``(login, password)``. For anonymous users ``login`` must be ``""``. """ return () def login(self, login: str, password: str) -> str: """Check credentials and map login to internal user ``login`` the login name ``password`` the password Returns the username or ``""`` for invalid credentials. """ raise NotImplementedError Radicale-3.1.8/radicale/auth/htpasswd.py000066400000000000000000000127741426407556000201670ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . """ Authentication backend that checks credentials with a htpasswd file. 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 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. The `is_authenticated(user, password)` function provided by this module verifies the user-given credentials by parsing the htpasswd credential file pointed to by the ``htpasswd_filename`` configuration value while assuming the password encryption method specified via the ``htpasswd_encryption`` configuration value. The following htpasswd password encrpytion methods are supported by Radicale out-of-the-box: - 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 """ import functools import hmac from typing import Any from passlib.hash import apr_md5_crypt from radicale import auth, config class Auth(auth.BaseAuth): _filename: str _encoding: str def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) self._filename = configuration.get("auth", "htpasswd_filename") self._encoding = configuration.get("encoding", "stock") encryption: str = configuration.get("auth", "htpasswd_encryption") if encryption == "plain": self._verify = self._plain elif encryption == "md5": self._verify = self._md5apr1 elif encryption == "bcrypt": try: from passlib.hash 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) else: raise RuntimeError("The htpasswd encryption method %r is not " "supported." % encryption) def _plain(self, hash_value: str, password: str) -> bool: """Check if ``hash_value`` and ``password`` match, plain method.""" 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()) 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: """Validate credentials. Iterate through htpasswd credential file until login matches, extract hash (encrypted password) and check hash against password, using the method specified in the Radicale config. The content of the file is not cached because reading is generally a very cheap operation, and it's useful to get live updates of the htpasswd file. """ try: with open(self._filename, encoding=self._encoding) as f: for line in f: line = line.rstrip("\n") if line.lstrip() and not line.lstrip().startswith("#"): try: hash_login, hash_value = line.split( ":", maxsplit=1) # Always compare both login and password to avoid # timing attacks, see #591. login_ok = hmac.compare_digest( hash_login.encode(), login.encode()) password_ok = self._verify(hash_value, password) if login_ok and password_ok: return login except ValueError as e: raise RuntimeError("Invalid htpasswd file %r: %s" % (self._filename, e)) from e except OSError as e: raise RuntimeError("Failed to load htpasswd file %r: %s" % (self._filename, e)) from e return "" Radicale-3.1.8/radicale/auth/http_x_remote_user.py000066400000000000000000000025251426407556000222420ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . """ Authentication backend that takes the username from the ``HTTP_X_REMOTE_USER`` header. It's intended for use with a reverse proxy. Be aware as this will be insecure if the reverse proxy is not configured properly. """ from typing import Tuple, Union from radicale import types from radicale.auth import none class Auth(none.Auth): def get_external_login(self, environ: types.WSGIEnviron) -> Union[ Tuple[()], Tuple[str, str]]: return environ.get("HTTP_X_REMOTE_USER", ""), "" Radicale-3.1.8/radicale/auth/none.py000066400000000000000000000020341426407556000172550ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . """ A dummy backend that accepts any username and password. """ from radicale import auth class Auth(auth.BaseAuth): def login(self, login: str, password: str) -> str: return login Radicale-3.1.8/radicale/auth/remote_user.py000066400000000000000000000024361426407556000206550ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . """ Authentication backend that takes the username from the ``REMOTE_USER`` WSGI environment variable. It's intended for use with an external WSGI server. """ from typing import Tuple, Union from radicale import types from radicale.auth import none class Auth(none.Auth): def get_external_login(self, environ: types.WSGIEnviron ) -> Union[Tuple[()], Tuple[str, str]]: return environ.get("REMOTE_USER", ""), "" Radicale-3.1.8/radicale/config.py000066400000000000000000000403711426407556000166300ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2017-2019 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 . """ Configuration module Use ``load()`` to obtain an instance of ``Configuration`` for use with ``radicale.app.Application``. """ import contextlib import math import os import string import sys from collections import OrderedDict from configparser import RawConfigParser from typing import (Any, Callable, ClassVar, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union) from radicale import auth, rights, storage, types, web DEFAULT_CONFIG_PATH: str = os.pathsep.join([ "?/etc/radicale/config", "?~/.config/radicale/config"]) def positive_int(value: Any) -> int: value = int(value) if value < 0: raise ValueError("value is negative: %d" % value) return value def positive_float(value: Any) -> float: value = float(value) if not math.isfinite(value): raise ValueError("value is infinite") if math.isnan(value): raise ValueError("value is not a number") if value < 0: raise ValueError("value is negative: %f" % value) return value def logging_level(value: Any) -> str: if value not in ("debug", "info", "warning", "error", "critical"): raise ValueError("unsupported level: %r" % value) return value def filepath(value: Any) -> str: if not value: return "" value = os.path.expanduser(value) if sys.platform == "win32": value = os.path.expandvars(value) return os.path.abspath(value) def list_of_ip_address(value: Any) -> List[Tuple[str, int]]: def ip_address(value): try: address, port = value.rsplit(":", 1) return address.strip(string.whitespace + "[]"), int(port) except ValueError: raise ValueError("malformed IP address: %r" % value) return [ip_address(s) for s in value.split(",")] def str_or_callable(value: Any) -> Union[str, Callable]: if callable(value): return value return str(value) def unspecified_type(value: Any) -> Any: return value def _convert_to_bool(value: Any) -> bool: if value.lower() not in RawConfigParser.BOOLEAN_STATES: raise ValueError("not a boolean: %r" % value) return RawConfigParser.BOOLEAN_STATES[value.lower()] INTERNAL_OPTIONS: Sequence[str] = ("_allow_extra",) # Default configuration DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ ("server", OrderedDict([ ("hosts", { "value": "localhost:5232", "help": "set server hostnames including ports", "aliases": ("-H", "--hosts",), "type": list_of_ip_address}), ("max_connections", { "value": "8", "help": "maximum number of parallel connections", "type": positive_int}), ("max_content_length", { "value": "100000000", "help": "maximum size of request body in bytes", "type": positive_int}), ("timeout", { "value": "30", "help": "socket timeout", "type": positive_float}), ("ssl", { "value": "False", "help": "use SSL connection", "aliases": ("-s", "--ssl",), "opposite_aliases": ("-S", "--no-ssl",), "type": bool}), ("certificate", { "value": "/etc/ssl/radicale.cert.pem", "help": "set certificate file", "aliases": ("-c", "--certificate",), "type": filepath}), ("key", { "value": "/etc/ssl/radicale.key.pem", "help": "set private key file", "aliases": ("-k", "--key",), "type": filepath}), ("certificate_authority", { "value": "", "help": "set CA certificate for validating clients", "aliases": ("--certificate-authority",), "type": filepath}), ("_internal_server", { "value": "False", "help": "the internal server is used", "type": bool})])), ("encoding", OrderedDict([ ("request", { "value": "utf-8", "help": "encoding for responding requests", "type": str}), ("stock", { "value": "utf-8", "help": "encoding for storing local collections", "type": str})])), ("auth", OrderedDict([ ("type", { "value": "none", "help": "authentication method", "type": str_or_callable, "internal": auth.INTERNAL_TYPES}), ("htpasswd_filename", { "value": "/etc/radicale/users", "help": "htpasswd filename", "type": filepath}), ("htpasswd_encryption", { "value": "md5", "help": "htpasswd encryption method", "type": str}), ("realm", { "value": "Radicale - Password Required", "help": "message displayed when a password is needed", "type": str}), ("delay", { "value": "1", "help": "incorrect authentication delay", "type": positive_float})])), ("rights", OrderedDict([ ("type", { "value": "owner_only", "help": "rights backend", "type": str_or_callable, "internal": rights.INTERNAL_TYPES}), ("file", { "value": "/etc/radicale/rights", "help": "file for rights management from_file", "type": filepath})])), ("storage", OrderedDict([ ("type", { "value": "multifilesystem", "help": "storage backend", "type": str_or_callable, "internal": storage.INTERNAL_TYPES}), ("filesystem_folder", { "value": "/var/lib/radicale/collections", "help": "path where collections are stored", "type": filepath}), ("max_sync_token_age", { "value": "2592000", # 30 days "help": "delete sync token that are older", "type": positive_int}), ("hook", { "value": "", "help": "command that is run after changes to storage", "type": str}), ("_filesystem_fsync", { "value": "True", "help": "sync all changes to filesystem during requests", "type": bool})])), ("web", OrderedDict([ ("type", { "value": "internal", "help": "web interface backend", "type": str_or_callable, "internal": web.INTERNAL_TYPES})])), ("logging", OrderedDict([ ("level", { "value": "warning", "help": "threshold for the logger", "type": logging_level}), ("mask_passwords", { "value": "True", "help": "mask passwords in logs", "type": bool})])), ("headers", OrderedDict([ ("_allow_extra", str)]))]) def parse_compound_paths(*compound_paths: Optional[str] ) -> List[Tuple[str, bool]]: """Parse a compound path and return the individual paths. Paths in a compound path are joined by ``os.pathsep``. If a path starts with ``?`` the return value ``IGNORE_IF_MISSING`` is set. When multiple ``compound_paths`` are passed, the last argument that is not ``None`` is used. Returns a dict of the format ``[(PATH, IGNORE_IF_MISSING), ...]`` """ compound_path = "" for p in compound_paths: if p is not None: compound_path = p paths = [] for path in compound_path.split(os.pathsep): ignore_if_missing = path.startswith("?") if ignore_if_missing: path = path[1:] path = filepath(path) if path: paths.append((path, ignore_if_missing)) return paths def load(paths: Optional[Iterable[Tuple[str, bool]]] = None ) -> "Configuration": """ Create instance of ``Configuration`` for use with ``radicale.app.Application``. ``paths`` a list of configuration files with the format ``[(PATH, IGNORE_IF_MISSING), ...]``. If a configuration file is missing and IGNORE_IF_MISSING is set, the config is set to ``Configuration.SOURCE_MISSING``. The configuration can later be changed with ``Configuration.update()``. """ if paths is None: paths = [] configuration = Configuration(DEFAULT_CONFIG_SCHEMA) for path, ignore_if_missing in paths: parser = RawConfigParser() config_source = "config file %r" % path config: types.CONFIG try: with open(path, "r") as f: parser.read_file(f) config = {s: {o: parser[s][o] for o in parser.options(s)} for s in parser.sections()} except Exception as e: if not (ignore_if_missing and isinstance(e, ( FileNotFoundError, NotADirectoryError, PermissionError))): raise RuntimeError("Failed to load %s: %s" % (config_source, e) ) from e config = Configuration.SOURCE_MISSING configuration.update(config, config_source) return configuration _Self = TypeVar("_Self", bound="Configuration") class Configuration: SOURCE_MISSING: ClassVar[types.CONFIG] = {} _schema: types.CONFIG_SCHEMA _values: types.MUTABLE_CONFIG _configs: List[Tuple[types.CONFIG, str, bool]] def __init__(self, schema: types.CONFIG_SCHEMA) -> None: """Initialize configuration. ``schema`` a dict that describes the configuration format. See ``DEFAULT_CONFIG_SCHEMA``. The content of ``schema`` must not change afterwards, it is kept as an internal reference. Use ``load()`` to create an instance for use with ``radicale.app.Application``. """ self._schema = schema self._values = {} self._configs = [] default = {section: {option: self._schema[section][option]["value"] for option in self._schema[section] if option not in INTERNAL_OPTIONS} for section in self._schema} self.update(default, "default config", privileged=True) def update(self, config: types.CONFIG, source: Optional[str] = None, privileged: bool = False) -> None: """Update the configuration. ``config`` a dict of the format {SECTION: {OPTION: VALUE, ...}, ...}. The configuration is checked for errors according to the config schema. The content of ``config`` must not change afterwards, it is kept as an internal reference. ``source`` a description of the configuration source (used in error messages). ``privileged`` allows updating sections and options starting with "_". """ if source is None: source = "unspecified config" new_values: types.MUTABLE_CONFIG = {} for section in config: if (section not in self._schema or section.startswith("_") and not privileged): raise ValueError( "Invalid section %r in %s" % (section, source)) new_values[section] = {} extra_type = None extra_type = self._schema[section].get("_allow_extra") if "type" in self._schema[section]: if "type" in config[section]: plugin = config[section]["type"] else: plugin = self.get(section, "type") if plugin not in self._schema[section]["type"]["internal"]: extra_type = unspecified_type for option in config[section]: type_ = extra_type if option in self._schema[section]: type_ = self._schema[section][option]["type"] if (not type_ or option in INTERNAL_OPTIONS or option.startswith("_") and not privileged): raise RuntimeError("Invalid option %r in section %r in " "%s" % (option, section, source)) raw_value = config[section][option] try: if type_ == bool and not isinstance(raw_value, bool): raw_value = _convert_to_bool(raw_value) new_values[section][option] = type_(raw_value) except Exception as e: raise RuntimeError( "Invalid %s value for option %r in section %r in %s: " "%r" % (type_.__name__, option, section, source, raw_value)) from e self._configs.append((config, source, bool(privileged))) for section in new_values: self._values[section] = self._values.get(section, {}) self._values[section].update(new_values[section]) def get(self, section: str, option: str) -> Any: """Get the value of ``option`` in ``section``.""" with contextlib.suppress(KeyError): return self._values[section][option] raise KeyError(section, option) def get_raw(self, section: str, option: str) -> Any: """Get the raw value of ``option`` in ``section``.""" for config, _, _ in reversed(self._configs): if option in config.get(section, {}): return config[section][option] raise KeyError(section, option) def get_source(self, section: str, option: str) -> str: """Get the source that provides ``option`` in ``section``.""" for config, source, _ in reversed(self._configs): if option in config.get(section, {}): return source raise KeyError(section, option) def sections(self) -> List[str]: """List all sections.""" return list(self._values.keys()) def options(self, section: str) -> List[str]: """List all options in ``section``""" return list(self._values[section].keys()) def sources(self) -> List[Tuple[str, bool]]: """List all config sources.""" return [(source, config is self.SOURCE_MISSING) for config, source, _ in self._configs] def copy(self: _Self, plugin_schema: Optional[types.CONFIG_SCHEMA] = None ) -> _Self: """Create a copy of the configuration ``plugin_schema`` is a optional dict that contains additional options for usage with a plugin. See ``DEFAULT_CONFIG_SCHEMA``. """ if plugin_schema is None: schema = self._schema else: new_schema = dict(self._schema) for section, options in plugin_schema.items(): if (section not in new_schema or "type" not in new_schema[section] or "internal" not in new_schema[section]["type"]): raise ValueError("not a plugin section: %r" % section) new_section = dict(new_schema[section]) new_type = dict(new_section["type"]) new_type["internal"] = (self.get(section, "type"),) new_section["type"] = new_type for option, value in options.items(): if option in new_section: raise ValueError("option already exists in %r: %r" % (section, option)) new_section[option] = value new_schema[section] = new_section schema = new_schema copy = type(self)(schema) for config, source, privileged in self._configs: copy.update(config, source, privileged) return copy Radicale-3.1.8/radicale/httputils.py000066400000000000000000000205271426407556000174240ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . """ Helper functions for HTTP. """ import contextlib import os import pathlib import sys import time from http import client from typing import List, Mapping, Union, cast from radicale import config, pathutils, types from radicale.log import logger if sys.version_info < (3, 9): import pkg_resources _TRAVERSABLE_LIKE_TYPE = pathlib.Path else: import importlib.abc from importlib import resources _TRAVERSABLE_LIKE_TYPE = Union[importlib.abc.Traversable, pathlib.Path] NOT_ALLOWED: types.WSGIResponse = ( client.FORBIDDEN, (("Content-Type", "text/plain"),), "Access to the requested resource forbidden.") FORBIDDEN: types.WSGIResponse = ( client.FORBIDDEN, (("Content-Type", "text/plain"),), "Action on the requested resource refused.") BAD_REQUEST: types.WSGIResponse = ( client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request") NOT_FOUND: types.WSGIResponse = ( client.NOT_FOUND, (("Content-Type", "text/plain"),), "The requested resource could not be found.") CONFLICT: types.WSGIResponse = ( client.CONFLICT, (("Content-Type", "text/plain"),), "Conflict in the request.") METHOD_NOT_ALLOWED: types.WSGIResponse = ( client.METHOD_NOT_ALLOWED, (("Content-Type", "text/plain"),), "The method is not allowed on the requested resource.") PRECONDITION_FAILED: types.WSGIResponse = ( client.PRECONDITION_FAILED, (("Content-Type", "text/plain"),), "Precondition failed.") REQUEST_TIMEOUT: types.WSGIResponse = ( client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),), "Connection timed out.") REQUEST_ENTITY_TOO_LARGE: types.WSGIResponse = ( client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),), "Request body too large.") REMOTE_DESTINATION: types.WSGIResponse = ( client.BAD_GATEWAY, (("Content-Type", "text/plain"),), "Remote destination not supported.") DIRECTORY_LISTING: types.WSGIResponse = ( client.FORBIDDEN, (("Content-Type", "text/plain"),), "Directory listings are not supported.") INTERNAL_SERVER_ERROR: types.WSGIResponse = ( client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),), "A server error occurred. Please contact the administrator.") DAV_HEADERS: str = "1, 2, 3, calendar-access, addressbook, extended-mkcol" MIMETYPES: Mapping[str, str] = { ".css": "text/css", ".eot": "application/vnd.ms-fontobject", ".gif": "image/gif", ".html": "text/html", ".js": "application/javascript", ".manifest": "text/cache-manifest", ".png": "image/png", ".svg": "image/svg+xml", ".ttf": "application/font-sfnt", ".txt": "text/plain", ".woff": "application/font-woff", ".woff2": "font/woff2", ".xml": "text/xml"} FALLBACK_MIMETYPE: str = "application/octet-stream" def decode_request(configuration: "config.Configuration", environ: types.WSGIEnviron, text: bytes) -> str: """Try to magically decode ``text`` according to given ``environ``.""" # List of charsets to try charsets: List[str] = [] # First append content charset given in the request content_type = environ.get("CONTENT_TYPE") if content_type and "charset=" in content_type: charsets.append( content_type.split("charset=")[1].split(";")[0].strip()) # Then append default Radicale charset charsets.append(cast(str, configuration.get("encoding", "request"))) # Then append various fallbacks charsets.append("utf-8") charsets.append("iso8859-1") # Remove duplicates for i, s in reversed(list(enumerate(charsets))): if s in charsets[:i]: del charsets[i] # Try to decode for charset in charsets: with contextlib.suppress(UnicodeDecodeError): return text.decode(charset) raise UnicodeDecodeError("decode_request", text, 0, len(text), "all codecs failed [%s]" % ", ".join(charsets)) def read_raw_request_body(configuration: "config.Configuration", environ: types.WSGIEnviron) -> bytes: content_length = int(environ.get("CONTENT_LENGTH") or 0) if not content_length: return b"" content = environ["wsgi.input"].read(content_length) if len(content) < content_length: raise RuntimeError("Request body too short: %d" % len(content)) return content 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) return content def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse: return (status, {"Location": location, "Content-Type": "text/plain"}, "Redirected to %s" % location) def _serve_traversable( traversable: _TRAVERSABLE_LIKE_TYPE, base_prefix: str, path: str, path_prefix: str, index_file: str, mimetypes: Mapping[str, str], fallback_mimetype: str) -> types.WSGIResponse: if path != path_prefix and not path.startswith(path_prefix): raise ValueError("path must start with path_prefix: %r --> %r" % (path_prefix, path)) assert pathutils.sanitize_path(path) == path parts_path = path[len(path_prefix):].strip('/') parts = parts_path.split("/") if parts_path else [] for part in parts: if not pathutils.is_safe_filesystem_path_component(part): logger.debug("Web content with unsafe path %r requested", path) return NOT_FOUND if (not traversable.is_dir() or all(part != entry.name for entry in traversable.iterdir())): return NOT_FOUND traversable = traversable.joinpath(part) if traversable.is_dir(): if not path.endswith("/"): return redirect(base_prefix + path + "/") if not index_file: return NOT_FOUND traversable = traversable.joinpath(index_file) if not traversable.is_file(): return NOT_FOUND content_type = MIMETYPES.get( os.path.splitext(traversable.name)[1].lower(), FALLBACK_MIMETYPE) headers = {"Content-Type": content_type} if isinstance(traversable, pathlib.Path): headers["Last-Modified"] = time.strftime( "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(traversable.stat().st_mtime)) answer = traversable.read_bytes() return client.OK, headers, answer def serve_resource( package: str, resource: str, base_prefix: str, path: str, path_prefix: str = "/.web", index_file: str = "index.html", mimetypes: Mapping[str, str] = MIMETYPES, fallback_mimetype: str = FALLBACK_MIMETYPE) -> types.WSGIResponse: if sys.version_info < (3, 9): traversable = pathlib.Path( pkg_resources.resource_filename(package, resource)) else: traversable = resources.files(package).joinpath(resource) return _serve_traversable(traversable, base_prefix, path, path_prefix, index_file, mimetypes, fallback_mimetype) def serve_folder( folder: str, base_prefix: str, path: str, path_prefix: str = "/.web", index_file: str = "index.html", mimetypes: Mapping[str, str] = MIMETYPES, fallback_mimetype: str = FALLBACK_MIMETYPE) -> types.WSGIResponse: # deprecated: use `serve_resource` instead traversable = pathlib.Path(folder) return _serve_traversable(traversable, base_prefix, path, path_prefix, index_file, mimetypes, fallback_mimetype) Radicale-3.1.8/radicale/item/000077500000000000000000000000001426407556000157425ustar00rootroot00000000000000Radicale-3.1.8/radicale/item/__init__.py000066400000000000000000000434701426407556000200630ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2014 Jean-Marc Martins # Copyright © 2008-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 . """ Module for address books and calendar entries (see ``Item``). """ import binascii import contextlib import math import os import re from datetime import datetime, timedelta from hashlib import sha256 from itertools import chain from typing import (Any, Callable, List, MutableMapping, Optional, Sequence, Tuple) import vobject from radicale import storage # noqa:F401 from radicale import pathutils from radicale.item import filter as radicale_filter from radicale.log import logger def read_components(s: str) -> List[vobject.base.Component]: """Wrapper for vobject.readComponents""" # Workaround for bug in InfCloud # PHOTO is a data URI 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)) def predict_tag_of_parent_collection( vobject_items: Sequence[vobject.base.Component]) -> Optional[str]: """Returns the predicted tag or `None`""" if len(vobject_items) != 1: return None if vobject_items[0].name == "VCALENDAR": return "VCALENDAR" if vobject_items[0].name in ("VCARD", "VLIST"): return "VADDRESSBOOK" return None def predict_tag_of_whole_collection( vobject_items: Sequence[vobject.base.Component], fallback_tag: Optional[str] = None) -> Optional[str]: """Returns the predicted tag or `fallback_tag`""" if vobject_items and vobject_items[0].name == "VCALENDAR": return "VCALENDAR" if vobject_items and vobject_items[0].name in ("VCARD", "VLIST"): return "VADDRESSBOOK" if not fallback_tag and not vobject_items: # Maybe an empty address book return "VADDRESSBOOK" return fallback_tag def check_and_sanitize_items( vobject_items: List[vobject.base.Component], is_collection: bool = False, tag: str = "") -> None: """Check vobject items for common errors and add missing UIDs. Modifies the list `vobject_items`. ``is_collection`` indicates that vobject_item contains unrelated components. The ``tag`` of the collection. """ if tag and tag not in ("VCALENDAR", "VADDRESSBOOK"): 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)) if tag == "VCALENDAR": if len(vobject_items) > 1: raise RuntimeError("VCALENDAR collection contains %d " "components" % len(vobject_items)) vobject_item = vobject_items[0] if vobject_item.name != "VCALENDAR": raise ValueError("Item type %r not supported in %r " "collection" % (vobject_item.name, tag)) component_uids = set() for component in vobject_item.components(): if component.name in ("VTODO", "VEVENT", "VJOURNAL"): component_uid = get_uid(component) if component_uid: component_uids.add(component_uid) component_name = None object_uid = None object_uid_set = False for component in vobject_item.components(): # https://tools.ietf.org/html/rfc4791#section-4.1 if component.name == "VTIMEZONE": continue if component_name is None or is_collection: component_name = component.name elif component_name != component.name: raise ValueError("Multiple component types in object: %r, %r" % (component_name, component.name)) if component_name not in ("VTODO", "VEVENT", "VJOURNAL"): continue component_uid = get_uid(component) if not object_uid_set or is_collection: object_uid_set = True object_uid = component_uid if not component_uid: if not is_collection: raise ValueError("%s component without UID in object" % component_name) component_uid = find_available_uid( component_uids.__contains__) component_uids.add(component_uid) if hasattr(component, "uid"): component.uid.value = component_uid else: component.add("UID").value = component_uid elif not object_uid or not component_uid: raise ValueError("Multiple %s components without UID in " "object" % component_name) elif object_uid != component_uid: raise ValueError( "Multiple %s components with different UIDs in object: " "%r, %r" % (component_name, object_uid, component_uid)) # Workaround for bug in Lightning (Thunderbird) # Rescheduling a single occurrence from a repeating event creates # an event with DTEND and DURATION:PT0S if (hasattr(component, "dtend") and hasattr(component, "duration") and component.duration.value == timedelta(0)): logger.debug("Quirks: Removing zero duration from %s in " "object %r", component_name, component_uid) del component.duration # Workaround for Evolution # EXDATE has value DATE even if DTSTART/DTEND is DATE-TIME. # The RFC is vaguely formulated on the issue. # To resolve the issue convert EXDATE and RDATE to # the same type as DTDSTART if hasattr(component, "dtstart"): ref_date = component.dtstart.value 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): continue for i, date in enumerate(dates.value): dates.value[i] = ref_date.replace( date.year, date.month, date.day) with contextlib.suppress(KeyError): del dates.params["VALUE"] if ref_value_param is not None: dates.params["VALUE"] = ref_value_param # vobject interprets recurrence rules on demand try: component.rruleset except Exception as e: raise ValueError("Invalid recurrence rules in %s in object %r" % (component.name, component_uid)) from e elif tag == "VADDRESSBOOK": # https://tools.ietf.org/html/rfc6352#section-5.1 object_uids = set() for vobject_item in vobject_items: if vobject_item.name == "VCARD": object_uid = get_uid(vobject_item) if object_uid: object_uids.add(object_uid) for vobject_item in vobject_items: if vobject_item.name == "VLIST": # Custom format used by SOGo Connector to store lists of # contacts continue if vobject_item.name != "VCARD": raise ValueError("Item type %r not supported in %r " "collection" % (vobject_item.name, tag)) object_uid = get_uid(vobject_item) if not object_uid: if not is_collection: raise ValueError("%s object without UID" % vobject_item.name) object_uid = find_available_uid(object_uids.__contains__) object_uids.add(object_uid) if hasattr(vobject_item, "uid"): vobject_item.uid.value = object_uid else: vobject_item.add("UID").value = object_uid else: for item in vobject_items: raise ValueError("Item type %r not supported in %s collection" % (item.name, repr(tag) if tag else "generic")) def check_and_sanitize_props(props: MutableMapping[Any, Any] ) -> MutableMapping[str, str]: """Check collection properties for common errors. Modifies the dict `props`. """ for k, v in list(props.items()): # Make copy to be able to delete items if not isinstance(k, str): raise ValueError("Key must be %r not %r: %r" % ( str.__name__, type(k).__name__, k)) if not isinstance(v, str): if v is None: del props[k] continue 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"): raise ValueError("Unsupported collection tag: %r" % v) return props def find_available_uid(exists_fn: Callable[[str], bool], suffix: str = "" ) -> str: """Generate a pseudo-random UID""" # Prevent infinite loop for _ in range(1000): r = binascii.hexlify(os.urandom(16)).decode("ascii") name = "%s-%s-%s-%s-%s%s" % ( r[:8], r[8:12], r[12:16], r[16:20], r[20:], suffix) if not exists_fn(name): return name # Something is wrong with the PRNG or `exists_fn` raise RuntimeError("No available random UID found") def get_etag(text: str) -> str: """Etag from collection or item. Encoded as quoted-string (see RFC 2616). """ etag = sha256() etag.update(text.encode()) return '"%s"' % etag.hexdigest() def get_uid(vobject_component: vobject.base.Component) -> str: """UID value of an item if defined.""" return (vobject_component.uid.value or "" if hasattr(vobject_component, "uid") else "") def get_uid_from_object(vobject_item: vobject.base.Component) -> str: """UID value of an calendar/addressbook object.""" if vobject_item.name == "VCALENDAR": if hasattr(vobject_item, "vevent"): return get_uid(vobject_item.vevent) if hasattr(vobject_item, "vjournal"): return get_uid(vobject_item.vjournal) if hasattr(vobject_item, "vtodo"): return get_uid(vobject_item.vtodo) elif vobject_item.name == "VCARD": return get_uid(vobject_item) return "" def find_tag(vobject_item: vobject.base.Component) -> str: """Find component name from ``vobject_item``.""" if vobject_item.name == "VCALENDAR": for component in vobject_item.components(): if component.name != "VTIMEZONE": return component.name or "" return "" def find_time_range(vobject_item: vobject.base.Component, tag: str ) -> Tuple[int, int]: """Find enclosing time range from ``vobject item``. ``tag`` must be set to the return value of ``find_tag``. Returns a tuple (``start``, ``end``) where ``start`` and ``end`` are POSIX timestamps. This is intened to be used for matching against simplified prefilters. """ if not tag: return radicale_filter.TIMESTAMP_MIN, radicale_filter.TIMESTAMP_MAX start = end = None def range_fn(range_start: datetime, range_end: datetime, is_recurrence: bool) -> bool: nonlocal start, end if start is None or range_start < start: start = range_start if end is None or end < range_end: end = range_end return False def infinity_fn(range_start: datetime) -> bool: nonlocal start, end if start is None or range_start < start: start = range_start end = radicale_filter.DATETIME_MAX return True radicale_filter.visit_time_ranges(vobject_item, tag, range_fn, infinity_fn) if start is None: start = radicale_filter.DATETIME_MIN if end is None: end = radicale_filter.DATETIME_MAX return math.floor(start.timestamp()), math.ceil(end.timestamp()) class Item: """Class for address book and calendar entries.""" collection: Optional["storage.BaseCollection"] href: Optional[str] last_modified: Optional[str] _collection_path: str _text: Optional[str] _vobject_item: Optional[vobject.base.Component] _etag: Optional[str] _uid: Optional[str] _name: Optional[str] _component_name: Optional[str] _time_range: Optional[Tuple[int, int]] def __init__(self, collection_path: Optional[str] = None, collection: Optional["storage.BaseCollection"] = None, vobject_item: Optional[vobject.base.Component] = None, href: Optional[str] = None, last_modified: Optional[str] = None, text: Optional[str] = None, etag: Optional[str] = None, uid: Optional[str] = None, name: Optional[str] = None, component_name: Optional[str] = None, time_range: Optional[Tuple[int, int]] = None): """Initialize an item. ``collection_path`` the path of the parent collection (optional if ``collection`` is set). ``collection`` the parent collection (optional). ``href`` the href of the item. ``last_modified`` the HTTP-datetime of when the item was modified. ``text`` the text representation of the item (optional if ``vobject_item`` is set). ``vobject_item`` the vobject item (optional if ``text`` is set). ``etag`` the etag of the item (optional). See ``get_etag``. ``uid`` the UID of the object (optional). See ``get_uid_from_object``. ``name`` the name of the item (optional). See ``vobject_item.name``. ``component_name`` the name of the primary component (optional). See ``find_tag``. ``time_range`` the enclosing time range. See ``find_time_range``. """ if text is None and vobject_item is None: raise ValueError( "At least one of 'text' or 'vobject_item' must be set") if collection_path is None: if collection is None: raise ValueError("At least one of 'collection_path' or " "'collection' must be set") collection_path = collection.path assert collection_path == pathutils.strip_path( pathutils.sanitize_path(collection_path)) self._collection_path = collection_path self.collection = collection self.href = href self.last_modified = last_modified self._text = text self._vobject_item = vobject_item self._etag = etag self._uid = uid self._name = name self._component_name = component_name self._time_range = time_range def serialize(self) -> str: if self._text is None: try: self._text = self.vobject_item.serialize() except Exception as e: raise RuntimeError("Failed to serialize item %r from %r: %s" % (self.href, self._collection_path, e)) from e return self._text @property def vobject_item(self): if self._vobject_item is None: try: self._vobject_item = vobject.readOne(self._text) except Exception as e: raise RuntimeError("Failed to parse item %r from %r: %s" % (self.href, self._collection_path, e)) from e return self._vobject_item @property def etag(self) -> str: """Encoded as quoted-string (see RFC 2616).""" if self._etag is None: self._etag = get_etag(self.serialize()) return self._etag @property def uid(self) -> str: if self._uid is None: self._uid = get_uid_from_object(self.vobject_item) return self._uid @property def name(self) -> str: if self._name is None: self._name = self.vobject_item.name or "" return self._name @property def component_name(self) -> str: if self._component_name is None: self._component_name = find_tag(self.vobject_item) return self._component_name @property def time_range(self) -> Tuple[int, int]: if self._time_range is None: self._time_range = find_time_range( self.vobject_item, self.component_name) return self._time_range def prepare(self) -> None: """Fill cache with values.""" orig_vobject_item = self._vobject_item self.serialize() self.etag self.uid self.name self.time_range self.component_name self._vobject_item = orig_vobject_item Radicale-3.1.8/radicale/item/filter.py000066400000000000000000000533151426407556000176100ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2015 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 . import math import xml.etree.ElementTree as ET from datetime import date, datetime, timedelta, timezone from itertools import chain from typing import (Callable, Iterable, Iterator, List, Optional, Sequence, Tuple) import vobject from radicale import item, xmlutils from radicale.log import logger DAY: timedelta = timedelta(days=1) SECOND: timedelta = timedelta(seconds=1) DATETIME_MIN: datetime = datetime.min.replace(tzinfo=timezone.utc) DATETIME_MAX: datetime = datetime.max.replace(tzinfo=timezone.utc) TIMESTAMP_MIN: int = math.floor(DATETIME_MIN.timestamp()) TIMESTAMP_MAX: int = math.ceil(DATETIME_MAX.timestamp()) def date_to_datetime(d: date) -> datetime: """Transform any date to a UTC datetime. If ``d`` is a datetime without timezone, return as UTC datetime. If ``d`` is already a datetime with timezone, return as is. """ if not isinstance(d, datetime): d = datetime.combine(d, datetime.min.time()) if not d.tzinfo: d = d.replace(tzinfo=timezone.utc) return d def comp_match(item: "item.Item", filter_: ET.Element, level: int = 0) -> bool: """Check whether the ``item`` matches the comp ``filter_``. If ``level`` is ``0``, the filter is applied on the item's collection. Otherwise, it's applied on the item. See rfc4791-9.7.1. """ # TODO: Filtering VALARM and VFREEBUSY is not implemented # HACK: the filters are tested separately against all components if level == 0: tag = item.name elif level == 1: tag = item.component_name else: logger.warning( "Filters with three levels of comp-filter are not supported") return True if not tag: return False name = filter_.get("name", "").upper() if len(filter_) == 0: # Point #1 of rfc4791-9.7.1 return name == tag if len(filter_) == 1: if filter_[0].tag == xmlutils.make_clark("C:is-not-defined"): # Point #2 of rfc4791-9.7.1 return name != tag if name != tag: return False if (level == 0 and name != "VCALENDAR" or level == 1 and name not in ("VTODO", "VEVENT", "VJOURNAL")): logger.warning("Filtering %s is not supported", name) return True # Point #3 and #4 of rfc4791-9.7.1 components = ([item.vobject_item] if level == 0 else list(getattr(item.vobject_item, "%s_list" % tag.lower()))) for child in filter_: if child.tag == xmlutils.make_clark("C:prop-filter"): if not any(prop_match(comp, child, "C") for comp in components): return False elif child.tag == xmlutils.make_clark("C:time-range"): if not time_range_match(item.vobject_item, filter_[0], tag): return False elif child.tag == xmlutils.make_clark("C:comp-filter"): if not comp_match(item, child, level=level + 1): return False else: raise ValueError("Unexpected %r in comp-filter" % child.tag) return True def prop_match(vobject_item: vobject.base.Component, filter_: ET.Element, ns: str) -> bool: """Check whether the ``item`` matches the prop ``filter_``. See rfc4791-9.7.2 and rfc6352-10.5.1. """ name = filter_.get("name", "").lower() if len(filter_) == 0: # Point #1 of rfc4791-9.7.2 return name in vobject_item.contents if len(filter_) == 1: if filter_[0].tag == xmlutils.make_clark("%s:is-not-defined" % ns): # Point #2 of rfc4791-9.7.2 return name not in vobject_item.contents if name not in vobject_item.contents: return False # Point #3 and #4 of rfc4791-9.7.2 for child in filter_: if ns == "C" and child.tag == xmlutils.make_clark("C:time-range"): if not time_range_match(vobject_item, child, name): return False elif child.tag == xmlutils.make_clark("%s:text-match" % ns): if not text_match(vobject_item, child, name, ns): return False elif child.tag == xmlutils.make_clark("%s:param-filter" % ns): if not param_filter_match(vobject_item, child, name, ns): return False else: raise ValueError("Unexpected %r in prop-filter" % child.tag) return True def time_range_match(vobject_item: vobject.base.Component, filter_: ET.Element, child_name: str) -> bool: """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: 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) matched = False def range_fn(range_start: datetime, range_end: datetime, is_recurrence: bool) -> bool: nonlocal matched if start < range_end and range_start < end: matched = True return True if end < range_start and not is_recurrence: return True return False def infinity_fn(start: datetime) -> bool: return False visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn) return matched 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: """Visit all time ranges in the component/property ``child_name`` of `vobject_item`` with visitors ``range_fn`` and ``infinity_fn``. ``range_fn`` gets called for every time_range with ``start`` and ``end`` datetimes and ``is_recurrence`` as arguments. If the function returns True, the operation is cancelled. ``infinity_fn`` gets called when an infinite recurrence rule is detected with ``start`` datetime as argument. If the function returns True, the operation is cancelled. See rfc4791-9.9. """ # HACK: According to rfc5545-3.8.4.4 an recurrance that is resheduled # with Recurrence ID affects the recurrence itself and all following # recurrences too. This is not respected and client don't seem to bother # either. def getrruleset(child: vobject.base.Component, ignore: Sequence[date] ) -> Tuple[Iterable[date], bool]: infinite = False for rrule in child.contents.get("rrule", []): if (";UNTIL=" not in rrule.value.upper() and ";COUNT=" not in rrule.value.upper()): infinite = True break if infinite: for dtstart in child.getrruleset(addRDate=True): if dtstart in ignore: continue if infinity_fn(date_to_datetime(dtstart)): return (), True break return filter(lambda dtstart: dtstart not in ignore, child.getrruleset(addRDate=True)), False def get_children(components: Iterable[vobject.base.Component]) -> Iterator[ Tuple[vobject.base.Component, bool, List[date]]]: main = None recurrences = [] for comp in components: if hasattr(comp, "recurrence_id") and comp.recurrence_id.value: recurrences.append(comp.recurrence_id.value) if comp.rruleset: # Prevent possible infinite loop raise ValueError("Overwritten recurrence with RRULESET") yield comp, True, [] else: if main is not None: raise ValueError("Multiple main components") main = comp if main is None: raise ValueError("Main component missing") yield main, False, recurrences # Comments give the lines in the tables of the specification if child_name == "VEVENT": for child, is_recurrence, recurrences in get_children( vobject_item.vevent_list): # TODO: check if there's a timezone dtstart = child.dtstart.value if child.rruleset: dtstarts, infinity = getrruleset(child, recurrences) if infinity: return else: dtstarts = (dtstart,) dtend = getattr(child, "dtend", None) if dtend is not None: dtend = dtend.value original_duration = (dtend - dtstart).total_seconds() dtend = date_to_datetime(dtend) duration = getattr(child, "duration", None) if duration is not None: original_duration = duration = duration.value for dtstart in dtstarts: dtstart_is_datetime = isinstance(dtstart, datetime) dtstart = date_to_datetime(dtstart) if dtend is not None: # Line 1 dtend = dtstart + timedelta(seconds=original_duration) if range_fn(dtstart, dtend, is_recurrence): return elif duration is not None: if original_duration is None: original_duration = duration.seconds if duration.seconds > 0: # Line 2 if range_fn(dtstart, dtstart + duration, is_recurrence): return else: # Line 3 if range_fn(dtstart, dtstart + SECOND, is_recurrence): return elif dtstart_is_datetime: # Line 4 if range_fn(dtstart, dtstart + SECOND, is_recurrence): return else: # Line 5 if range_fn(dtstart, dtstart + DAY, is_recurrence): return elif child_name == "VTODO": for child, is_recurrence, recurrences in get_children( vobject_item.vtodo_list): dtstart = getattr(child, "dtstart", None) duration = getattr(child, "duration", None) due = getattr(child, "due", None) completed = getattr(child, "completed", None) created = getattr(child, "created", None) if dtstart is not None: dtstart = date_to_datetime(dtstart.value) if duration is not None: duration = duration.value if due is not None: due = date_to_datetime(due.value) if dtstart is not None: original_duration = (due - dtstart).total_seconds() if completed is not None: completed = date_to_datetime(completed.value) if created is not None: created = date_to_datetime(created.value) original_duration = (completed - created).total_seconds() elif created is not None: created = date_to_datetime(created.value) if child.rruleset: reference_dates, infinity = getrruleset(child, recurrences) if infinity: return else: if dtstart is not None: reference_dates = (dtstart,) elif due is not None: reference_dates = (due,) elif completed is not None: reference_dates = (completed,) elif created is not None: reference_dates = (created,) else: # Line 8 if range_fn(DATETIME_MIN, DATETIME_MAX, is_recurrence): return reference_dates = () for reference_date in reference_dates: reference_date = date_to_datetime(reference_date) if dtstart is not None and duration is not None: # Line 1 if range_fn(reference_date, reference_date + duration + SECOND, is_recurrence): return if range_fn(reference_date + duration - SECOND, reference_date + duration + SECOND, is_recurrence): return elif dtstart is not None and due is not None: # Line 2 due = reference_date + timedelta(seconds=original_duration) if (range_fn(reference_date, due, is_recurrence) or range_fn(reference_date, reference_date + SECOND, is_recurrence) or range_fn(due - SECOND, due, is_recurrence) or range_fn(due - SECOND, reference_date + SECOND, is_recurrence)): return elif dtstart is not None: if range_fn(reference_date, reference_date + SECOND, is_recurrence): return elif due is not None: # Line 4 if range_fn(reference_date - SECOND, reference_date, is_recurrence): return elif completed is not None and created is not None: # Line 5 completed = reference_date + timedelta( seconds=original_duration) if (range_fn(reference_date - SECOND, reference_date + SECOND, is_recurrence) or range_fn(completed - SECOND, completed + SECOND, is_recurrence) or range_fn(reference_date - SECOND, reference_date + SECOND, is_recurrence) or range_fn(completed - SECOND, completed + SECOND, is_recurrence)): return elif completed is not None: # Line 6 if range_fn(reference_date - SECOND, reference_date + SECOND, is_recurrence): return elif created is not None: # Line 7 if range_fn(reference_date, DATETIME_MAX, is_recurrence): return elif child_name == "VJOURNAL": for child, is_recurrence, recurrences in get_children( vobject_item.vjournal_list): dtstart = getattr(child, "dtstart", None) if dtstart is not None: dtstart = dtstart.value if child.rruleset: dtstarts, infinity = getrruleset(child, recurrences) if infinity: return else: dtstarts = (dtstart,) for dtstart in dtstarts: dtstart_is_datetime = isinstance(dtstart, datetime) dtstart = date_to_datetime(dtstart) if dtstart_is_datetime: # Line 1 if range_fn(dtstart, dtstart + SECOND, is_recurrence): return else: # Line 2 if range_fn(dtstart, dtstart + DAY, is_recurrence): return else: # Match a property child = getattr(vobject_item, child_name.lower()) if isinstance(child, date): child_is_datetime = isinstance(child, datetime) child = date_to_datetime(child) if child_is_datetime: range_fn(child, child + SECOND, False) else: range_fn(child, child + DAY, False) def text_match(vobject_item: vobject.base.Component, filter_: ET.Element, child_name: str, ns: str, attrib_name: Optional[str] = None) -> bool: """Check whether the ``item`` matches the text-match ``filter_``. See rfc4791-9.7.5. """ # TODO: collations are not supported, but the default ones needed # for DAV servers are actually pretty useless. Texts are lowered to # be case-insensitive, almost as the "i;ascii-casemap" value. text = next(filter_.itertext()).lower() match_type = "contains" if ns == "CR": match_type = filter_.get("match-type", match_type) def match(value: str) -> bool: value = value.lower() if match_type == "equals": return value == text if match_type == "contains": return text in value if match_type == "starts-with": return value.startswith(text) if match_type == "ends-with": return value.endswith(text) raise ValueError("Unexpected text-match match-type: %r" % match_type) children = getattr(vobject_item, "%s_list" % child_name, []) if attrib_name is not None: condition = any( match(attrib) for child in children for attrib in child.params.get(attrib_name, [])) else: condition = any(match(child.value) for child in children) if filter_.get("negate-condition") == "yes": return not condition return condition def param_filter_match(vobject_item: vobject.base.Component, filter_: ET.Element, parent_name: str, ns: str) -> bool: """Check whether the ``item`` matches the param-filter ``filter_``. See rfc4791-9.7.3. """ name = filter_.get("name", "").upper() children = getattr(vobject_item, "%s_list" % parent_name, []) condition = any(name in child.params for child in children) if len(filter_) > 0: if filter_[0].tag == xmlutils.make_clark("%s:text-match" % ns): return condition and text_match( vobject_item, filter_[0], parent_name, ns, name) if filter_[0].tag == xmlutils.make_clark("%s:is-not-defined" % ns): return not condition return condition def simplify_prefilters(filters: Iterable[ET.Element], collection_tag: str ) -> Tuple[Optional[str], int, int, bool]: """Creates a simplified condition from ``filters``. Returns a tuple (``tag``, ``start``, ``end``, ``simple``) where ``tag`` is a string or None (match all) and ``start`` and ``end`` are POSIX timestamps (as int). ``simple`` is a bool that indicates that ``filters`` and the simplified condition are identical. """ flat_filters = list(chain.from_iterable(filters)) simple = len(flat_filters) <= 1 for col_filter in flat_filters: if collection_tag != "VCALENDAR": simple = False break if (col_filter.tag != xmlutils.make_clark("C:comp-filter") or col_filter.get("name", "").upper() != "VCALENDAR"): simple = False continue simple &= len(col_filter) <= 1 for comp_filter in col_filter: if comp_filter.tag != xmlutils.make_clark("C:comp-filter"): simple = False continue tag = comp_filter.get("name", "").upper() if comp_filter.find( xmlutils.make_clark("C:is-not-defined")) is not None: simple = False continue simple &= len(comp_filter) <= 1 for time_filter in comp_filter: if tag not in ("VTODO", "VEVENT", "VJOURNAL"): simple = False break 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 return tag, start, end, simple return tag, TIMESTAMP_MIN, TIMESTAMP_MAX, simple return None, TIMESTAMP_MIN, TIMESTAMP_MAX, simple Radicale-3.1.8/radicale/log.py000066400000000000000000000103621426407556000161410ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2011-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . """ Functions to set up Python's logging facility for Radicale's WSGI application. Log messages are sent to the first available target of: - Error stream specified by the WSGI server in "wsgi.errors" - ``sys.stderr`` """ import logging import os import sys import threading from typing import Any, Callable, ClassVar, Dict, Iterator, Union from radicale import types LOGGER_NAME: str = "radicale" LOGGER_FORMAT: str = "[%(asctime)s] [%(ident)s] [%(levelname)s] %(message)s" DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S %z" logger: logging.Logger = logging.getLogger(LOGGER_NAME) class RemoveTracebackFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: record.exc_info = None return True REMOVE_TRACEBACK_FILTER: logging.Filter = RemoveTracebackFilter() class IdentLogRecordFactory: """LogRecordFactory that adds ``ident`` attribute.""" def __init__(self, upstream_factory: Callable[..., logging.LogRecord] ) -> None: self._upstream_factory = upstream_factory 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 record.ident = ident # type:ignore[attr-defined] return record class ThreadedStreamHandler(logging.Handler): """Sends logging output to the stream registered for the current thread or ``sys.stderr`` when no stream was registered.""" terminator: ClassVar[str] = "\n" _streams: Dict[int, types.ErrorStream] def __init__(self) -> None: super().__init__() self._streams = {} 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() except Exception: self.handleError(record) @types.contextmanager def register_stream(self, stream: types.ErrorStream) -> Iterator[None]: """Register stream for logging output of the current thread.""" key = threading.get_ident() self._streams[key] = stream try: yield finally: del self._streams[key] @types.contextmanager def register_stream(stream: types.ErrorStream) -> Iterator[None]: """Register stream for logging output of the current thread.""" yield def setup() -> None: """Set global logging up.""" global register_stream handler = ThreadedStreamHandler() logging.basicConfig(format=LOGGER_FORMAT, datefmt=DATE_FORMAT, handlers=[handler]) register_stream = handler.register_stream log_record_factory = IdentLogRecordFactory(logging.getLogRecordFactory()) logging.setLogRecordFactory(log_record_factory) set_level(logging.WARNING) def set_level(level: Union[int, str]) -> 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.addFilter(REMOVE_TRACEBACK_FILTER) Radicale-3.1.8/radicale/pathutils.py000066400000000000000000000244531426407556000174030ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-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 . """ Helper functions for working with the file system. """ import errno import os import posixpath import sys import threading from tempfile import TemporaryDirectory from typing import Iterator, Type, Union from radicale import storage, types if sys.platform == "win32": import ctypes import ctypes.wintypes import msvcrt LOCKFILE_EXCLUSIVE_LOCK: int = 2 ULONG_PTR: Union[Type[ctypes.c_uint32], Type[ctypes.c_uint64]] if ctypes.sizeof(ctypes.c_void_p) == 4: ULONG_PTR = ctypes.c_uint32 else: ULONG_PTR = ctypes.c_uint64 class Overlapped(ctypes.Structure): _fields_ = [ ("internal", ULONG_PTR), ("internal_high", ULONG_PTR), ("offset", ctypes.wintypes.DWORD), ("offset_high", ctypes.wintypes.DWORD), ("h_event", ctypes.wintypes.HANDLE)] kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) lock_file_ex = kernel32.LockFileEx lock_file_ex.argtypes = [ ctypes.wintypes.HANDLE, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.POINTER(Overlapped)] lock_file_ex.restype = ctypes.wintypes.BOOL unlock_file_ex = kernel32.UnlockFileEx unlock_file_ex.argtypes = [ ctypes.wintypes.HANDLE, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.POINTER(Overlapped)] unlock_file_ex.restype = ctypes.wintypes.BOOL else: import fcntl if sys.platform == "linux": import ctypes RENAME_EXCHANGE: int = 2 renameat2 = None try: renameat2 = ctypes.CDLL(None, use_errno=True).renameat2 except AttributeError: pass else: renameat2.argtypes = [ ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] renameat2.restype = ctypes.c_int if sys.platform == "darwin": # Definition missing in PyPy F_FULLFSYNC: int = getattr(fcntl, "F_FULLFSYNC", 51) class RwLock: """A readers-Writer lock that locks a file.""" _path: str _readers: int _writer: bool _lock: threading.Lock def __init__(self, path: str) -> None: self._path = path self._readers = 0 self._writer = False self._lock = threading.Lock() @property def locked(self) -> str: with self._lock: if self._readers > 0: return "r" if self._writer: return "w" return "" @types.contextmanager def acquire(self, mode: str) -> Iterator[None]: if mode not in "rw": raise ValueError("Invalid mode: %r" % mode) with open(self._path, "w+") as lock_file: if sys.platform == "win32": handle = msvcrt.get_osfhandle(lock_file.fileno()) flags = LOCKFILE_EXCLUSIVE_LOCK if mode == "w" else 0 overlapped = Overlapped() try: if not lock_file_ex(handle, flags, 0, 1, 0, overlapped): raise ctypes.WinError() except OSError as e: raise RuntimeError("Locking the storage failed: %s" % e ) from e else: _cmd = fcntl.LOCK_EX if mode == "w" else fcntl.LOCK_SH try: fcntl.flock(lock_file.fileno(), _cmd) except OSError as e: raise RuntimeError("Locking the storage failed: %s" % e ) from e with self._lock: if self._writer or mode == "w" and self._readers != 0: raise RuntimeError("Locking the storage failed: " "Guarantees failed") if mode == "r": self._readers += 1 else: self._writer = True try: yield finally: with self._lock: if mode == "r": self._readers -= 1 self._writer = False def rename_exchange(src: str, dst: str) -> None: """Exchange the files or directories `src` and `dst`. Both `src` and `dst` must exist but may be of different types. On Linux with renameat2 the operation is atomic. On other platforms it's not atomic. """ src_dir, src_base = os.path.split(src) dst_dir, dst_base = os.path.split(dst) src_dir = src_dir or os.curdir dst_dir = dst_dir or os.curdir if not src_base or not dst_base: raise ValueError("Invalid arguments: %r -> %r" % (src, dst)) if sys.platform == "linux" and renameat2: src_base_bytes = os.fsencode(src_base) dst_base_bytes = os.fsencode(dst_base) src_dir_fd = os.open(src_dir, 0) try: dst_dir_fd = os.open(dst_dir, 0) try: if renameat2(src_dir_fd, src_base_bytes, dst_dir_fd, dst_base_bytes, RENAME_EXCHANGE) == 0: return errno_ = ctypes.get_errno() # Fallback if RENAME_EXCHANGE not supported by filesystem if errno_ != errno.EINVAL: raise OSError(errno_, os.strerror(errno_)) finally: os.close(dst_dir_fd) finally: os.close(src_dir_fd) with TemporaryDirectory(prefix=".Radicale.tmp-", dir=src_dir ) as tmp_dir: os.rename(dst, os.path.join(tmp_dir, "interim")) os.rename(src, dst) os.rename(os.path.join(tmp_dir, "interim"), src) def fsync(fd: int) -> None: if sys.platform == "darwin": try: fcntl.fcntl(fd, F_FULLFSYNC) return except OSError as e: # Fallback if F_FULLFSYNC not supported by filesystem if e.errno != errno.EINVAL: raise os.fsync(fd) def strip_path(path: str) -> str: assert sanitize_path(path) == path return path.strip("/") def unstrip_path(stripped_path: str, trailing_slash: bool = False) -> str: assert strip_path(sanitize_path(stripped_path)) == stripped_path assert stripped_path or trailing_slash path = "/%s" % stripped_path if trailing_slash and not path.endswith("/"): path += "/" return path def sanitize_path(path: str) -> str: """Make path absolute with leading slash to prevent access to other data. Preserve potential trailing slash. """ trailing_slash = "/" if path.endswith("/") else "" path = posixpath.normpath(path) new_path = "/" for part in path.split("/"): if not is_safe_path_component(part): continue new_path = posixpath.join(new_path, part) trailing_slash = "" if new_path.endswith("/") else trailing_slash return new_path + trailing_slash def is_safe_path_component(path: str) -> bool: """Check if path is a single component of a path. Check that the path is safe to join too. """ return bool(path) and "/" not in path and path not in (".", "..") def is_safe_filesystem_path_component(path: str) -> bool: """Check if path is a single component of a local and posix filesystem path. Check that the path is safe to join too. """ return ( bool(path) and not os.path.splitdrive(path)[0] and (sys.platform != "win32" or ":" not in path) and # Block NTFS-ADS not os.path.split(path)[0] and path not in (os.curdir, os.pardir) and not path.startswith(".") and not path.endswith("~") and is_safe_path_component(path)) def path_to_filesystem(root: str, sane_path: str) -> str: """Convert `sane_path` to a local filesystem path relative to `root`. `root` must be a secure filesystem path, it will be prepend to the path. `sane_path` must be a sanitized path without leading or trailing ``/``. Conversion of `sane_path` is done in a secure manner, or raises ``ValueError``. """ assert sane_path == strip_path(sanitize_path(sane_path)) safe_path = root parts = sane_path.split("/") if sane_path else [] for part in parts: if not is_safe_filesystem_path_component(part): raise UnsafePathError(part) safe_path_parent = safe_path safe_path = os.path.join(safe_path, part) # Check for conflicting files (e.g. case-insensitive file systems # or short names on Windows file systems) if (os.path.lexists(safe_path) and part not in (e.name for e in os.scandir(safe_path_parent))): raise CollidingPathError(part) return safe_path class UnsafePathError(ValueError): def __init__(self, path: str) -> None: super().__init__("Can't translate name safely to filesystem: %r" % path) class CollidingPathError(ValueError): def __init__(self, path: str) -> None: super().__init__("File name collision: %r" % path) def name_from_path(path: str, collection: "storage.BaseCollection") -> str: """Return Radicale item name from ``path``.""" assert sanitize_path(path) == path start = unstrip_path(collection.path, True) if not (path + "/").startswith(start): raise ValueError("%r doesn't start with %r" % (path, start)) name = path[len(start):] if name and not is_safe_path_component(name): raise ValueError("%r is not a component in collection %r" % (name, collection.path)) return name Radicale-3.1.8/radicale/py.typed000066400000000000000000000000001426407556000164710ustar00rootroot00000000000000Radicale-3.1.8/radicale/rights/000077500000000000000000000000001426407556000163045ustar00rootroot00000000000000Radicale-3.1.8/radicale/rights/__init__.py000066400000000000000000000051511426407556000204170ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2012-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 . """ The rights module used to determine if a user can read and/or write collections and entries. Permissions: - R: read collections (excluding address books and calendars) - r: read address book and calendar collections - i: subset of **r** that only allows direct access via HTTP method GET (CalDAV/CardDAV is susceptible to expensive search requests) - W: write collections (excluding address books and calendars) - w: write address book and calendar collections Take a look at the class ``BaseRights`` if you want to implement your own. """ from typing import Sequence from radicale import config, utils INTERNAL_TYPES: Sequence[str] = ("authenticated", "owner_write", "owner_only", "from_file") def load(configuration: "config.Configuration") -> "BaseRights": """Load the rights module chosen in configuration.""" return utils.load_plugin(INTERNAL_TYPES, "rights", "Rights", BaseRights, configuration) def intersect(a: str, b: str) -> str: """Intersect two lists of rights. Returns all rights that are both in ``a`` and ``b``. """ return "".join(set(a).intersection(set(b))) class BaseRights: def __init__(self, configuration: "config.Configuration") -> None: """Initialize BaseRights. ``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 authorization(self, user: str, path: str) -> str: """Get granted rights of ``user`` for the collection ``path``. If ``user`` is empty, check for anonymous rights. ``path`` is sanitized. Returns granted rights (e.g. ``"RW"``). """ raise NotImplementedError Radicale-3.1.8/radicale/rights/authenticated.py000066400000000000000000000026761426407556000215130ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2012-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 . """ Rights backend that allows authenticated users to read and write all calendars and address books. """ from radicale import config, pathutils, rights class Rights(rights.BaseRights): def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) self._verify_user = self.configuration.get("auth", "type") != "none" def authorization(self, user: str, path: str) -> str: if self._verify_user and not user: return "" sane_path = pathutils.strip_path(path) if "/" not in sane_path: return "RW" if sane_path.count("/") == 1: return "rw" return "" Radicale-3.1.8/radicale/rights/from_file.py000066400000000000000000000072431426407556000206260ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . """ Rights backend based on a regex-based file whose name is specified in the config (section "rights", key "file"). 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 `}}` if you want to use regular curly braces in the "user" and "collection" regexes. For example, for the "user" key, ".+" means "authenticated user" and ".*" means "anybody" (including anonymous users). Section names are only used for naming the rule. Leading or ending slashes are trimmed from collection's path. """ import configparser import re from radicale import config, pathutils, rights from radicale.log import logger class Rights(rights.BaseRights): _filename: str def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) self._filename = configuration.get("rights", "file") 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(): try: user_pattern = rights_config.get(section, "user") collection_pattern = rights_config.get(section, "collection") # Use empty format() for harmonized handling of curly braces user_match = re.fullmatch(user_pattern.format(), 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) 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", user, sane_path, user_pattern, collection_pattern, section) return rights_config.get(section, "permissions") 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 "" Radicale-3.1.8/radicale/rights/owner_only.py000066400000000000000000000027071426407556000210570ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2012-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 . """ Rights backend that allows authenticated users to read and write their own calendars and address books. """ import radicale.rights.authenticated as authenticated from radicale import pathutils class Rights(authenticated.Rights): def authorization(self, user: str, path: str) -> str: if self._verify_user and not user: return "" sane_path = pathutils.strip_path(path) if not sane_path: return "R" if self._verify_user and user != sane_path.split("/", maxsplit=1)[0]: return "" if "/" not in sane_path: return "RW" if sane_path.count("/") == 1: return "rw" return "" Radicale-3.1.8/radicale/rights/owner_write.py000066400000000000000000000030461426407556000212250ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2012-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 . """ Rights backend that allows authenticated users to read all calendars and address books but only grants write access to their own. """ import radicale.rights.authenticated as authenticated from radicale import pathutils class Rights(authenticated.Rights): def authorization(self, user: str, path: str) -> str: if self._verify_user and not user: return "" sane_path = pathutils.strip_path(path) if not sane_path: return "R" if self._verify_user: owned = user == sane_path.split("/", maxsplit=1)[0] else: owned = True if "/" not in sane_path: return "RW" if owned else "R" if sane_path.count("/") == 1: return "rw" if owned else "r" return "" Radicale-3.1.8/radicale/server.py000066400000000000000000000356751426407556000167040ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . """ Built-in WSGI server. """ import errno import http import select import socket import socketserver import ssl import sys import wsgiref.simple_server from typing import (Any, Callable, Dict, List, MutableMapping, Optional, Set, Tuple, Union) from urllib.parse import unquote from radicale import Application, config from radicale.log import logger COMPAT_EAI_ADDRFAMILY: int if hasattr(socket, "EAI_ADDRFAMILY"): COMPAT_EAI_ADDRFAMILY = socket.EAI_ADDRFAMILY # type:ignore[attr-defined] elif hasattr(socket, "EAI_NONAME"): # Windows and BSD don't have a special error code for this COMPAT_EAI_ADDRFAMILY = socket.EAI_NONAME COMPAT_EAI_NODATA: int if hasattr(socket, "EAI_NODATA"): COMPAT_EAI_NODATA = socket.EAI_NODATA elif hasattr(socket, "EAI_NONAME"): # Windows and BSD don't have a special error code for this COMPAT_EAI_NODATA = socket.EAI_NONAME COMPAT_IPPROTO_IPV6: int if hasattr(socket, "IPPROTO_IPV6"): COMPAT_IPPROTO_IPV6 = socket.IPPROTO_IPV6 elif sys.platform == "win32": # HACK: https://bugs.python.org/issue29515 COMPAT_IPPROTO_IPV6 = 41 # IPv4 (host, port) and IPv6 (host, port, flowinfo, scopeid) ADDRESS_TYPE = Union[Tuple[str, int], Tuple[str, int, int, int]] def format_address(address: ADDRESS_TYPE) -> str: return "[%s]:%d" % address[:2] class ParallelHTTPServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGIServer): configuration: config.Configuration worker_sockets: Set[socket.socket] _timeout: float # We wait for child threads ourself (ThreadingMixIn) block_on_close: bool = False daemon_threads: bool = True def __init__(self, configuration: config.Configuration, family: int, address: Tuple[str, int], RequestHandlerClass: Callable[..., http.server.BaseHTTPRequestHandler]) -> None: self.configuration = configuration self.address_family = family super().__init__(address, RequestHandlerClass) self.worker_sockets = set() self._timeout = configuration.get("server", "timeout") def server_bind(self) -> None: if self.address_family == socket.AF_INET6: # Only allow IPv6 connections to the IPv6 socket self.socket.setsockopt(COMPAT_IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) super().server_bind() def get_request( # type:ignore[override] self) -> Tuple[socket.socket, Tuple[ADDRESS_TYPE, socket.socket]]: # Set timeout for client request: socket.socket client_address: ADDRESS_TYPE request, client_address = super().get_request() # type:ignore[misc] if self._timeout > 0: request.settimeout(self._timeout) worker_socket, worker_socket_out = socket.socketpair() self.worker_sockets.add(worker_socket_out) # HACK: Forward `worker_socket` via `client_address` return value # to worker thread. # The super class calls `verify_request`, `process_request` and # `handle_error` with modified `client_address` value. return request, (client_address, worker_socket) def verify_request( # type:ignore[override] self, request: socket.socket, client_address_and_socket: Tuple[ADDRESS_TYPE, socket.socket]) -> bool: return True def process_request( # type:ignore[override] self, request: socket.socket, client_address_and_socket: Tuple[ADDRESS_TYPE, socket.socket]) -> None: # HACK: Super class calls `finish_request` in new thread with # `client_address_and_socket` return super().process_request( request, client_address_and_socket) # type:ignore[arg-type] def finish_request( # type:ignore[override] self, request: socket.socket, client_address_and_socket: Tuple[ADDRESS_TYPE, socket.socket]) -> None: # HACK: Unpack `client_address_and_socket` and call super class # `finish_request` with original `client_address` client_address, worker_socket = client_address_and_socket try: return self.finish_request_locked(request, client_address) finally: worker_socket.close() def finish_request_locked(self, request: socket.socket, client_address: ADDRESS_TYPE) -> None: return super().finish_request( request, client_address) # type:ignore[arg-type] def handle_error( # type:ignore[override] self, request: socket.socket, client_address_or_client_address_and_socket: Union[ADDRESS_TYPE, Tuple[ADDRESS_TYPE, socket.socket]]) -> None: # HACK: This method can be called with the modified # `client_address_and_socket` or the original `client_address` value e = sys.exc_info()[1] assert e is not None if isinstance(e, socket.timeout): logger.info("Client timed out", exc_info=True) else: logger.error("An exception occurred during request: %s", sys.exc_info()[1], exc_info=True) class ParallelHTTPSServer(ParallelHTTPServer): def server_bind(self) -> None: super().server_bind() # Wrap the TCP socket in an SSL socket certfile: str = self.configuration.get("server", "certificate") keyfile: str = self.configuration.get("server", "key") cafile: str = self.configuration.get("server", "certificate_authority") # Test if the files can be read for name, filename in [("certificate", certfile), ("key", keyfile), ("certificate_authority", cafile)]: type_name = config.DEFAULT_CONFIG_SCHEMA["server"][name][ "type"].__name__ source = self.configuration.get_source("server", name) if name == "certificate_authority" and not filename: continue try: open(filename, "r").close() except OSError as e: raise RuntimeError( "Invalid %s value for option %r in section %r in %s: %r " "(%s)" % (type_name, name, "server", source, filename, e)) from e context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) context.load_cert_chain(certfile=certfile, keyfile=keyfile) if cafile: context.load_verify_locations(cafile=cafile) context.verify_mode = ssl.CERT_REQUIRED self.socket = context.wrap_socket( self.socket, server_side=True, do_handshake_on_connect=False) def finish_request_locked( # type:ignore[override] self, request: ssl.SSLSocket, client_address: ADDRESS_TYPE ) -> None: try: try: request.do_handshake() except socket.timeout: raise except Exception as e: raise RuntimeError("SSL handshake failed: %s" % e) from e except Exception: try: self.handle_error(request, client_address) finally: self.shutdown_request(request) # type:ignore[attr-defined] return return super().finish_request_locked(request, client_address) class ServerHandler(wsgiref.simple_server.ServerHandler): # Don't pollute WSGI environ with OS environment os_environ: MutableMapping[str, str] = {} def log_exception(self, exc_info) -> None: logger.error("An exception occurred during request: %s", exc_info[1], exc_info=exc_info) # type:ignore[arg-type] class RequestHandler(wsgiref.simple_server.WSGIRequestHandler): """HTTP requests handler.""" # HACK: Assigned in `socketserver.StreamRequestHandler` connection: socket.socket def log_request(self, code: Union[int, str] = "-", size: Union[int, str] = "-") -> None: pass # Disable request logging. def log_error(self, format_: str, *args: Any) -> None: logger.error("An error occurred during request: %s", format_ % args) def get_environ(self) -> Dict[str, Any]: env = super().get_environ() if isinstance(self.connection, ssl.SSLSocket): # The certificate can be evaluated by the auth module env["REMOTE_CERTIFICATE"] = self.connection.getpeercert() # Parent class only tries latin1 encoding env["PATH_INFO"] = unquote(self.path.split("?", 1)[0]) return env def handle(self) -> None: """Copy of WSGIRequestHandler.handle with different ServerHandler""" self.raw_requestline = self.rfile.readline(65537) if len(self.raw_requestline) > 65536: self.requestline = "" self.request_version = "" self.command = "" self.send_error(414) return if not self.parse_request(): return handler = ServerHandler( self.rfile, self.wfile, self.get_stderr(), self.get_environ() ) handler.request_handler = self # type:ignore[attr-defined] app = self.server.get_app() # type:ignore[attr-defined] handler.run(app) def serve(configuration: config.Configuration, shutdown_socket: Optional[socket.socket] = None) -> None: """Serve radicale from configuration. `shutdown_socket` can be used to gracefully shutdown the server. The socket can be created with `socket.socketpair()`, when the other socket gets closed the server stops accepting new requests by clients and the function returns after all active requests are finished. """ logger.info("Starting Radicale") # Copy configuration before modifying configuration = configuration.copy() configuration.update({"server": {"_internal_server": "True"}}, "server", privileged=True) use_ssl: bool = configuration.get("server", "ssl") server_class = ParallelHTTPSServer if use_ssl else ParallelHTTPServer application = Application(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 try: server = server_class(configuration, family, address, 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 servers[server.socket] = server bind_ok = True server.set_app(application) logger.info("Listening on %r%s", format_address(server.server_address), " with SSL" if use_ssl else "") if not servers: raise RuntimeError("No servers started") # Mainloop select_timeout = None if sys.platform == "win32": # Fallback to busy waiting. (select(...) blocks SIGINT on Windows.) select_timeout = 1.0 max_connections: int = configuration.get("server", "max_connections") logger.info("Radicale server ready") while True: rlist: List[socket.socket] = [] # Wait for finished clients for server in servers.values(): rlist.extend(server.worker_sockets) # Accept new connections if max_connections is not reached if max_connections <= 0 or len(rlist) < max_connections: rlist.extend(servers) # Use socket to get notified of program shutdown if shutdown_socket is not None: rlist.append(shutdown_socket) rlist, _, _ = select.select(rlist, [], [], select_timeout) rset = set(rlist) if shutdown_socket in rset: logger.info("Stopping Radicale") break for server in servers.values(): finished_sockets = server.worker_sockets.intersection(rset) for s in finished_sockets: s.close() server.worker_sockets.remove(s) rset.remove(s) if finished_sockets: server.service_actions() if rset: active_server = servers.get(rset.pop()) if active_server: active_server.handle_request() finally: # Wait for clients to finish and close servers for server in servers.values(): for s in server.worker_sockets: s.recv(1) s.close() server.server_close() Radicale-3.1.8/radicale/storage/000077500000000000000000000000001426407556000164505ustar00rootroot00000000000000Radicale-3.1.8/radicale/storage/__init__.py000066400000000000000000000303371426407556000205670ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-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 . """ The storage module that stores calendars and address books. Take a look at the class ``BaseCollection`` if you want to implement your own. """ import json import xml.etree.ElementTree as ET from hashlib import sha256 from typing import (Iterable, Iterator, Mapping, Optional, Sequence, Set, Tuple, Union, overload) import vobject from radicale import config from radicale import item as radicale_item from radicale import types, utils from radicale.item import filter as radicale_filter INTERNAL_TYPES: Sequence[str] = ("multifilesystem", "multifilesystem_nolock",) CACHE_DEPS: Sequence[str] = ("radicale", "vobject", "python-dateutil",) CACHE_VERSION: bytes = "".join( "%s=%s;" % (pkg, utils.package_version(pkg)) for pkg in CACHE_DEPS).encode() def load(configuration: "config.Configuration") -> "BaseStorage": """Load the storage module chosen in configuration.""" return utils.load_plugin(INTERNAL_TYPES, "storage", "Storage", BaseStorage, configuration) class ComponentExistsError(ValueError): def __init__(self, path: str) -> None: message = "Component already exists: %r" % path super().__init__(message) class ComponentNotFoundError(ValueError): def __init__(self, path: str) -> None: message = "Component doesn't exist: %r" % path super().__init__(message) class BaseCollection: @property def path(self) -> str: """The sanitized path of the collection without leading or trailing ``/``.""" raise NotImplementedError @property def owner(self) -> str: """The owner of the collection.""" return self.path.split("/", maxsplit=1)[0] @property def is_principal(self) -> bool: """Collection is a principal.""" return bool(self.path) and "/" not in self.path @property def etag(self) -> str: """Encoded as quoted-string (see RFC 2616).""" etag = sha256() for item in self.get_all(): assert item.href etag.update((item.href + "/" + item.etag).encode()) etag.update(json.dumps(self.get_meta(), sort_keys=True).encode()) return '"%s"' % etag.hexdigest() @property def tag(self) -> str: """The tag of the collection.""" return self.get_meta("tag") or "" def sync(self, old_token: str = "") -> Tuple[str, Iterable[str]]: """Get the current sync token and changed items for synchronization. ``old_token`` an old sync token which is used as the base of the delta update. If sync token is empty, all items are returned. ValueError is raised for invalid or old tokens. WARNING: This simple default implementation treats all sync-token as invalid. """ def hrefs_iter() -> Iterator[str]: for item in self.get_all(): assert item.href yield item.href token = "http://radicale.org/ns/sync/%s" % self.etag.strip("\"") if old_token: raise ValueError("Sync token are not supported") return token, hrefs_iter() def get_multi(self, hrefs: Iterable[str] ) -> Iterable[Tuple[str, Optional["radicale_item.Item"]]]: """Fetch multiple items. It's not required to return the requested items in the correct order. Duplicated hrefs can be ignored. Returns tuples with the href and the item or None if the item doesn't exist. """ raise NotImplementedError def get_all(self) -> Iterable["radicale_item.Item"]: """Fetch all items.""" raise NotImplementedError def get_filtered(self, filters: Iterable[ET.Element] ) -> Iterable[Tuple["radicale_item.Item", bool]]: """Fetch all items with optional filtering. This can largely improve performance of reports depending on the filters and this implementation. Returns tuples in the form ``(item, filters_matched)``. ``filters_matched`` is a bool that indicates if ``filters`` are fully matched. """ if not self.tag: return tag, start, end, simple = radicale_filter.simplify_prefilters( filters, self.tag) for item in self.get_all(): if tag is not None and tag != item.component_name: continue istart, iend = item.time_range if istart >= end or iend <= start: continue yield item, simple and (start <= istart or iend <= end) def has_uid(self, uid: str) -> bool: """Check if a UID exists in the collection.""" for item in self.get_all(): if item.uid == uid: return True return False def upload(self, href: str, item: "radicale_item.Item") -> ( "radicale_item.Item"): """Upload a new or replace an existing item.""" raise NotImplementedError def delete(self, href: Optional[str] = None) -> None: """Delete an item. When ``href`` is ``None``, delete the collection. """ raise NotImplementedError @overload def get_meta(self, key: None = None) -> Mapping[str, str]: ... @overload def get_meta(self, key: str) -> Optional[str]: ... def get_meta(self, key: Optional[str] = None ) -> Union[Mapping[str, str], Optional[str]]: """Get metadata value for collection. Return the value of the property ``key``. If ``key`` is ``None`` return a dict with all properties """ raise NotImplementedError def set_meta(self, props: Mapping[str, str]) -> None: """Set metadata values for collection. ``props`` a dict with values for properties. """ raise NotImplementedError @property def last_modified(self) -> str: """Get the HTTP-datetime of when the collection was modified.""" raise NotImplementedError def serialize(self) -> str: """Get the unicode string representing the whole collection.""" if self.tag == "VCALENDAR": in_vcalendar = False vtimezones = "" included_tzids: Set[str] = set() vtimezone = [] tzid = None components = "" # Concatenate all child elements of VCALENDAR from all items # together, while preventing duplicated VTIMEZONE entries. # VTIMEZONEs are only distinguished by their TZID, if different # timezones share the same TZID this produces erroneous output. # VObject fails at this too. for item in self.get_all(): depth = 0 for line in item.serialize().split("\r\n"): if line.startswith("BEGIN:"): depth += 1 if depth == 1 and line == "BEGIN:VCALENDAR": in_vcalendar = True elif in_vcalendar: if depth == 1 and line.startswith("END:"): in_vcalendar = False if depth == 2 and line == "BEGIN:VTIMEZONE": vtimezone.append(line + "\r\n") elif vtimezone: vtimezone.append(line + "\r\n") if depth == 2 and line.startswith("TZID:"): tzid = line[len("TZID:"):] elif depth == 2 and line.startswith("END:"): if tzid is None or tzid not in included_tzids: vtimezones += "".join(vtimezone) if tzid is not None: included_tzids.add(tzid) vtimezone.clear() tzid = None elif depth >= 2: components += line + "\r\n" if line.startswith("END:"): depth -= 1 template = vobject.iCalendar() displayname = self.get_meta("D:displayname") if displayname: template.add("X-WR-CALNAME") template.x_wr_calname.value_param = "TEXT" template.x_wr_calname.value = displayname description = self.get_meta("C:calendar-description") if description: template.add("X-WR-CALDESC") template.x_wr_caldesc.value_param = "TEXT" template.x_wr_caldesc.value = description template = template.serialize() template_insert_pos = template.find("\r\nEND:VCALENDAR\r\n") + 2 assert template_insert_pos != -1 return (template[:template_insert_pos] + vtimezones + components + template[template_insert_pos:]) if self.tag == "VADDRESSBOOK": return "".join((item.serialize() for item in self.get_all())) return "" class BaseStorage: def __init__(self, configuration: "config.Configuration") -> None: """Initialize BaseStorage. ``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 discover(self, path: str, depth: str = "0") -> Iterable[ "types.CollectionOrItem"]: """Discover a list of collections under the given ``path``. ``path`` is sanitized. If ``depth`` is "0", only the actual object under ``path`` is returned. If ``depth`` is anything but "0", it is considered as "1" and direct children are included in the result. The root collection "/" must always exist. """ raise NotImplementedError def move(self, item: "radicale_item.Item", to_collection: BaseCollection, to_href: str) -> None: """Move an object. ``item`` is the item to move. ``to_collection`` is the target collection. ``to_href`` is the target name in ``to_collection``. An item with the same name might already exist. """ raise NotImplementedError def create_collection( self, href: str, items: Optional[Iterable["radicale_item.Item"]] = None, props: Optional[Mapping[str, str]] = None) -> BaseCollection: """Create a collection. ``href`` is the sanitized path. If the collection already exists and neither ``collection`` nor ``props`` are set, this method shouldn't do anything. Otherwise the existing collection must be replaced. ``collection`` is a list of vobject components. ``props`` are metadata values for the collection. ``props["tag"]`` is the type of collection (VCALENDAR or VADDRESSBOOK). If the key ``tag`` is missing, ``items`` is ignored. """ raise NotImplementedError @types.contextmanager def acquire_lock(self, mode: str, user: str = "") -> Iterator[None]: """Set a context manager to lock the whole storage. ``mode`` must either be "r" for shared access or "w" for exclusive access. ``user`` is the name of the logged in user or empty. """ raise NotImplementedError def verify(self) -> bool: """Check the storage for errors.""" raise NotImplementedError Radicale-3.1.8/radicale/storage/multifilesystem/000077500000000000000000000000001426407556000217075ustar00rootroot00000000000000Radicale-3.1.8/radicale/storage/multifilesystem/__init__.py000066400000000000000000000072111426407556000240210ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . """ Storage backend that stores data in the file system. Uses one folder per collection and one file per collection entry. """ import os import time from typing import ClassVar, Iterator, Optional, Type from radicale import config from radicale.storage.multifilesystem.base import CollectionBase, StorageBase from radicale.storage.multifilesystem.cache import CollectionPartCache from radicale.storage.multifilesystem.create_collection import \ StoragePartCreateCollection from radicale.storage.multifilesystem.delete import CollectionPartDelete from radicale.storage.multifilesystem.discover import StoragePartDiscover from radicale.storage.multifilesystem.get import CollectionPartGet from radicale.storage.multifilesystem.history import CollectionPartHistory from radicale.storage.multifilesystem.lock import (CollectionPartLock, StoragePartLock) from radicale.storage.multifilesystem.meta import CollectionPartMeta from radicale.storage.multifilesystem.move import StoragePartMove from radicale.storage.multifilesystem.sync import CollectionPartSync from radicale.storage.multifilesystem.upload import CollectionPartUpload from radicale.storage.multifilesystem.verify import StoragePartVerify class Collection( CollectionPartDelete, CollectionPartMeta, CollectionPartSync, CollectionPartUpload, CollectionPartGet, CollectionPartCache, CollectionPartLock, CollectionPartHistory, CollectionBase): _etag_cache: Optional[str] def __init__(self, storage_: "Storage", path: str, filesystem_path: Optional[str] = None) -> None: super().__init__(storage_, path, filesystem_path) self._etag_cache = None @property def path(self) -> str: return self._path @property def last_modified(self) -> str: def relevant_files_iter() -> Iterator[str]: yield self._filesystem_path if os.path.exists(self._props_path): yield self._props_path for href in self._list(): yield os.path.join(self._filesystem_path, href) last = max(map(os.path.getmtime, relevant_files_iter())) return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last)) @property def etag(self) -> str: # reuse cached value if the storage is read-only if self._storage._lock.locked == "w" or self._etag_cache is None: self._etag_cache = super().etag return self._etag_cache class Storage( StoragePartCreateCollection, StoragePartLock, StoragePartMove, StoragePartVerify, StoragePartDiscover, StorageBase): _collection_class: ClassVar[Type[Collection]] = Collection def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) self._makedirs_synced(self._filesystem_folder) Radicale-3.1.8/radicale/storage/multifilesystem/base.py000066400000000000000000000112311426407556000231710ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . import os import sys from tempfile import TemporaryDirectory from typing import IO, AnyStr, ClassVar, Iterator, Optional, Type from radicale import config, pathutils, storage, types from radicale.storage import multifilesystem # noqa:F401 class CollectionBase(storage.BaseCollection): _storage: "multifilesystem.Storage" _path: str _encoding: str _filesystem_path: str def __init__(self, storage_: "multifilesystem.Storage", path: str, filesystem_path: Optional[str] = None) -> None: super().__init__() self._storage = storage_ folder = storage_._get_collection_root_folder() # Path should already be sanitized self._path = pathutils.strip_path(path) self._encoding = storage_.configuration.get("encoding", "stock") if filesystem_path is None: filesystem_path = pathutils.path_to_filesystem(folder, self.path) self._filesystem_path = filesystem_path @types.contextmanager 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 parent_dir, name = os.path.split(path) # Do not use mkstemp because it creates with permissions 0o600 with TemporaryDirectory( prefix=".Radicale.tmp-", dir=parent_dir) as tmp_dir: with open(os.path.join(tmp_dir, name), mode, newline=newline, encoding=None if "b" in mode else self._encoding) as tmp: yield tmp tmp.flush() self._storage._fsync(tmp) os.replace(os.path.join(tmp_dir, name), path) self._storage._sync_directory(parent_dir) class StorageBase(storage.BaseStorage): _collection_class: ClassVar[Type["multifilesystem.Collection"]] _filesystem_folder: str _filesystem_fsync: bool def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) self._filesystem_folder = configuration.get( "storage", "filesystem_folder") self._filesystem_fsync = configuration.get( "storage", "_filesystem_fsync") def _get_collection_root_folder(self) -> str: return os.path.join(self._filesystem_folder, "collection-root") def _fsync(self, f: IO[AnyStr]) -> None: if self._filesystem_fsync: try: pathutils.fsync(f.fileno()) except OSError as e: raise RuntimeError("Fsync'ing file %r failed: %s" % (f.name, e)) from e def _sync_directory(self, path: str) -> None: """Sync directory to disk. This only works on POSIX and does nothing on other systems. """ if not self._filesystem_fsync: return if sys.platform != "win32": try: fd = os.open(path, 0) try: pathutils.fsync(fd) finally: os.close(fd) except OSError as e: raise RuntimeError("Fsync'ing directory %r failed: %s" % (path, e)) from e def _makedirs_synced(self, filesystem_path: str) -> None: """Recursively create a directory and its parents in a sync'ed way. This method acts silently when the folder already exists. """ if os.path.isdir(filesystem_path): return parent_filesystem_path = os.path.dirname(filesystem_path) # Prevent infinite loop if filesystem_path != parent_filesystem_path: # Create parent dirs recursively self._makedirs_synced(parent_filesystem_path) # Possible race! os.makedirs(filesystem_path, exist_ok=True) self._sync_directory(parent_filesystem_path) Radicale-3.1.8/radicale/storage/multifilesystem/cache.py000066400000000000000000000114331426407556000233260ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-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 . import contextlib import os import pickle import time from hashlib import sha256 from typing import BinaryIO, Iterable, NamedTuple, Optional, cast import radicale.item as radicale_item from radicale import pathutils, storage from radicale.log import logger from radicale.storage.multifilesystem.base import CollectionBase CacheContent = NamedTuple("CacheContent", [ ("uid", str), ("etag", str), ("text", str), ("name", str), ("tag", str), ("start", int), ("end", int)]) class CollectionPartCache(CollectionBase): def _clean_cache(self, folder: str, names: Iterable[str], max_age: int = 0) -> None: """Delete all ``names`` in ``folder`` that are older than ``max_age``. """ age_limit: Optional[float] = None if max_age is not None and max_age > 0: age_limit = time.time() - max_age modified = False for name in names: if not pathutils.is_safe_filesystem_path_component(name): continue if age_limit is not None: try: # Race: Another process might have deleted the file. mtime = os.path.getmtime(os.path.join(folder, name)) except FileNotFoundError: continue if mtime > age_limit: continue logger.debug("Found expired item in cache: %r", name) # Race: Another process might have deleted or locked the # file. try: os.remove(os.path.join(folder, name)) except (FileNotFoundError, PermissionError): continue modified = True if modified: self._storage._sync_directory(folder) @staticmethod def _item_cache_hash(raw_text: bytes) -> str: _hash = sha256() _hash.update(storage.CACHE_VERSION) _hash.update(raw_text) return _hash.hexdigest() def _item_cache_content(self, item: radicale_item.Item) -> CacheContent: return CacheContent(item.uid, item.etag, item.serialize(), item.name, item.component_name, *item.time_range) def _store_item_cache(self, href: str, item: radicale_item.Item, cache_hash: str = "") -> CacheContent: if not cache_hash: cache_hash = self._item_cache_hash( item.serialize().encode(self._encoding)) cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache", "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( os.path.join(cache_folder, href), "wb") as fo: fb = cast(BinaryIO, fo) pickle.dump((cache_hash, *content), fb) return content def _load_item_cache(self, href: str, cache_hash: str ) -> Optional[CacheContent]: cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache", "item") try: with open(os.path.join(cache_folder, href), "rb") as f: hash_, *remainder = pickle.load(f) if hash_ and hash_ == cache_hash: return CacheContent(*remainder) except FileNotFoundError: pass except (pickle.UnpicklingError, ValueError) as e: logger.warning("Failed to load item cache entry %r in %r: %s", href, self.path, e, exc_info=True) return None def _clean_item_cache(self) -> None: cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache", "item") self._clean_cache(cache_folder, ( e.name for e in os.scandir(cache_folder) if not os.path.isfile(os.path.join(self._filesystem_path, e.name)))) Radicale-3.1.8/radicale/storage/multifilesystem/create_collection.py000066400000000000000000000061161426407556000257430ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-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 . import os from tempfile import TemporaryDirectory from typing import Iterable, Optional, cast import radicale.item as radicale_item from radicale import pathutils from radicale.storage import multifilesystem from radicale.storage.multifilesystem.base import StorageBase class StoragePartCreateCollection(StorageBase): def create_collection(self, href: str, items: Optional[Iterable[radicale_item.Item]] = None, props=None) -> "multifilesystem.Collection": folder = self._get_collection_root_folder() # Path should already be sanitized sane_path = pathutils.strip_path(href) filesystem_path = pathutils.path_to_filesystem(folder, sane_path) if not props: self._makedirs_synced(filesystem_path) return self._collection_class( cast(multifilesystem.Storage, self), pathutils.unstrip_path(sane_path, True)) parent_dir = os.path.dirname(filesystem_path) self._makedirs_synced(parent_dir) # Create a temporary directory with an unsafe name with TemporaryDirectory(prefix=".Radicale.tmp-", dir=parent_dir ) as tmp_dir: # The temporary directory itself can't be renamed tmp_filesystem_path = os.path.join(tmp_dir, "collection") os.makedirs(tmp_filesystem_path) col = self._collection_class( cast(multifilesystem.Storage, self), pathutils.unstrip_path(sane_path, True), filesystem_path=tmp_filesystem_path) col.set_meta(props) if items is not None: if props.get("tag") == "VCALENDAR": col._upload_all_nonatomic(items, suffix=".ics") elif props.get("tag") == "VADDRESSBOOK": col._upload_all_nonatomic(items, suffix=".vcf") if os.path.lexists(filesystem_path): pathutils.rename_exchange(tmp_filesystem_path, filesystem_path) else: os.rename(tmp_filesystem_path, filesystem_path) self._sync_directory(parent_dir) return self._collection_class( cast(multifilesystem.Storage, self), pathutils.unstrip_path(sane_path, True)) Radicale-3.1.8/radicale/storage/multifilesystem/delete.py000066400000000000000000000045221426407556000235260ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-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 . import os from tempfile import TemporaryDirectory from typing import Optional from radicale import pathutils, storage from radicale.storage.multifilesystem.base import CollectionBase from radicale.storage.multifilesystem.history import CollectionPartHistory class CollectionPartDelete(CollectionPartHistory, CollectionBase): def delete(self, href: Optional[str] = None) -> None: if href is None: # Delete the collection parent_dir = os.path.dirname(self._filesystem_path) try: os.rmdir(self._filesystem_path) except OSError: with TemporaryDirectory(prefix=".Radicale.tmp-", dir=parent_dir ) as tmp: os.rename(self._filesystem_path, os.path.join( tmp, os.path.basename(self._filesystem_path))) self._storage._sync_directory(parent_dir) else: self._storage._sync_directory(parent_dir) else: # Delete an item if not pathutils.is_safe_filesystem_path_component(href): raise pathutils.UnsafePathError(href) path = pathutils.path_to_filesystem(self._filesystem_path, href) if not os.path.isfile(path): raise storage.ComponentNotFoundError(href) os.remove(path) self._storage._sync_directory(os.path.dirname(path)) # Track the change self._update_history_etag(href, None) self._clean_history() Radicale-3.1.8/radicale/storage/multifilesystem/discover.py000066400000000000000000000075151426407556000241070ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-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 . import os import posixpath from typing import Callable, ContextManager, Iterator, Optional, cast from radicale import pathutils, types from radicale.log import logger from radicale.storage import multifilesystem from radicale.storage.multifilesystem.base import StorageBase @types.contextmanager def _null_child_context_manager(path: str, href: Optional[str]) -> Iterator[None]: yield class StoragePartDiscover(StorageBase): def discover( self, path: str, depth: str = "0", child_context_manager: Optional[ Callable[[str, Optional[str]], ContextManager[None]]] = None ) -> Iterator[types.CollectionOrItem]: # assert isinstance(self, multifilesystem.Storage) if child_context_manager is None: child_context_manager = _null_child_context_manager # Path should already be sanitized sane_path = pathutils.strip_path(path) attributes = sane_path.split("/") if sane_path else [] folder = self._get_collection_root_folder() # Create the root collection self._makedirs_synced(folder) try: filesystem_path = pathutils.path_to_filesystem(folder, sane_path) except ValueError as e: # Path is unsafe logger.debug("Unsafe path %r requested from storage: %s", sane_path, e, exc_info=True) return # Check if the path exists and if it leads to a collection or an item href: Optional[str] if not os.path.isdir(filesystem_path): if attributes and os.path.isfile(filesystem_path): href = attributes.pop() else: return else: href = None sane_path = "/".join(attributes) collection = self._collection_class( cast(multifilesystem.Storage, self), pathutils.unstrip_path(sane_path, True)) if href: item = collection._get(href) if item is not None: yield item return yield collection if depth == "0": return for href in collection._list(): with child_context_manager(sane_path, href): item = collection._get(href) if item is not None: yield item for entry in os.scandir(filesystem_path): if not entry.is_dir(): continue href = entry.name if not pathutils.is_safe_filesystem_path_component(href): if not href.startswith(".Radicale"): logger.debug("Skipping collection %r in %r", href, sane_path) continue sane_child_path = posixpath.join(sane_path, href) child_path = pathutils.unstrip_path(sane_child_path, True) with child_context_manager(sane_child_path, None): yield self._collection_class( cast(multifilesystem.Storage, self), child_path) Radicale-3.1.8/radicale/storage/multifilesystem/get.py000066400000000000000000000153551426407556000230510ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-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 . import os import sys import time from typing import Iterable, Iterator, Optional, Tuple import radicale.item as radicale_item from radicale import pathutils from radicale.log import logger from radicale.storage import multifilesystem from radicale.storage.multifilesystem.base import CollectionBase from radicale.storage.multifilesystem.cache import CollectionPartCache from radicale.storage.multifilesystem.lock import CollectionPartLock class CollectionPartGet(CollectionPartCache, CollectionPartLock, CollectionBase): _item_cache_cleaned: bool def __init__(self, storage_: "multifilesystem.Storage", path: str, filesystem_path: Optional[str] = None) -> None: super().__init__(storage_, path, filesystem_path) self._item_cache_cleaned = False def _list(self) -> Iterator[str]: for entry in os.scandir(self._filesystem_path): if not entry.is_file(): continue href = entry.name if not pathutils.is_safe_filesystem_path_component(href): if not href.startswith(".Radicale"): logger.debug("Skipping item %r in %r", href, self.path) continue yield href def _get(self, href: str, verify_href: bool = True ) -> Optional[radicale_item.Item]: if verify_href: try: if not pathutils.is_safe_filesystem_path_component(href): raise pathutils.UnsafePathError(href) path = pathutils.path_to_filesystem(self._filesystem_path, href) except ValueError as e: logger.debug( "Can't translate name %r safely to filesystem in %r: %s", href, self.path, e, exc_info=True) return None else: path = os.path.join(self._filesystem_path, href) try: with open(path, "rb") as f: raw_text = f.read() except (FileNotFoundError, IsADirectoryError): return None except PermissionError: # Windows raises ``PermissionError`` when ``path`` is a directory if (sys.platform == "win32" and os.path.isdir(path) and os.access(path, os.R_OK)): return None raise # The hash of the component in the file system. This is used to check, # if the entry in the cache is still valid. cache_hash = self._item_cache_hash(raw_text) 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 # generating the same data in parallel. # This improves the performance for multiple requests. if self._storage._lock.locked == "r": # Check if another process created the file in the meantime cache_content = self._load_item_cache(href, cache_hash) if cache_content is None: try: vobject_items = radicale_item.read_components( raw_text.decode(self._encoding)) radicale_item.check_and_sanitize_items( vobject_items, tag=self.tag) vobject_item, = vobject_items temp_item = radicale_item.Item( collection=self, vobject_item=vobject_item) 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 # Clean cache entries once after the data in the file # system was edited externally. if not self._item_cache_cleaned: self._item_cache_cleaned = True self._clean_item_cache() last_modified = time.strftime( "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(os.path.getmtime(path))) # Don't keep reference to ``vobject_item``, because it requires a lot # of memory. return radicale_item.Item( collection=self, href=href, last_modified=last_modified, etag=cache_content.etag, text=cache_content.text, uid=cache_content.uid, name=cache_content.name, component_name=cache_content.tag, time_range=(cache_content.start, cache_content.end)) 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 # we only need to call os.listdir once. files = None for href in hrefs: if files is None: # List dir after hrefs returned one item, the iterator may be # empty and the for-loop is never executed. files = os.listdir(self._filesystem_path) path = os.path.join(self._filesystem_path, href) if (not pathutils.is_safe_filesystem_path_component(href) or href not in files and os.path.lexists(path)): logger.debug("Can't translate name safely to filesystem: %r", href) yield (href, None) else: yield (href, self._get(href, verify_href=False)) 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 # are from os.listdir. item = self._get(href, verify_href=False) if item is not None: yield item Radicale-3.1.8/radicale/storage/multifilesystem/history.py000066400000000000000000000103311426407556000237600ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . import binascii import contextlib import os import pickle from typing import BinaryIO, Optional, cast import radicale.item as radicale_item from radicale import pathutils from radicale.log import logger from radicale.storage import multifilesystem from radicale.storage.multifilesystem.base import CollectionBase class CollectionPartHistory(CollectionBase): _max_sync_token_age: int def __init__(self, storage_: "multifilesystem.Storage", path: str, filesystem_path: Optional[str] = None) -> None: super().__init__(storage_, path, filesystem_path) self._max_sync_token_age = storage_.configuration.get( "storage", "max_sync_token_age") def _update_history_etag(self, href, item): """Updates and retrieves the history etag from the history cache. The history cache contains a file for each current and deleted item of the collection. These files contain the etag of the item (empty string for deleted items) and a history etag, which is a hash over the previous history etag and the etag separated by "/". """ history_folder = os.path.join(self._filesystem_path, ".Radicale.cache", "history") try: with open(os.path.join(history_folder, href), "rb") as f: cache_etag, history_etag = pickle.load(f) except (FileNotFoundError, pickle.UnpicklingError, ValueError) as e: if isinstance(e, (pickle.UnpicklingError, ValueError)): logger.warning( "Failed to load history cache entry %r in %r: %s", href, self.path, e, exc_info=True) cache_etag = "" # Initialize with random data to prevent collisions with cleaned # expired items. history_etag = binascii.hexlify(os.urandom(16)).decode("ascii") etag = item.etag if item else "" if etag != cache_etag: self._storage._makedirs_synced(history_folder) history_etag = radicale_item.get_etag( history_etag + "/" + etag).strip("\"") # Race: Other processes might have created and locked the file. with contextlib.suppress(PermissionError), self._atomic_write( os.path.join(history_folder, href), "wb") as fo: fb = cast(BinaryIO, fo) pickle.dump([etag, history_etag], fb) return history_etag def _get_deleted_history_hrefs(self): """Returns the hrefs of all deleted items that are still in the history cache.""" history_folder = os.path.join(self._filesystem_path, ".Radicale.cache", "history") with contextlib.suppress(FileNotFoundError): for entry in os.scandir(history_folder): href = entry.name if not pathutils.is_safe_filesystem_path_component(href): continue if os.path.isfile(os.path.join(self._filesystem_path, href)): continue yield href def _clean_history(self): # Delete all expired history entries of deleted items. history_folder = os.path.join(self._filesystem_path, ".Radicale.cache", "history") self._clean_cache(history_folder, self._get_deleted_history_hrefs(), max_age=self._max_sync_token_age) Radicale-3.1.8/radicale/storage/multifilesystem/lock.py000066400000000000000000000101501426407556000232060ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . import contextlib import logging import os import shlex import signal import subprocess import sys from typing import Iterator from radicale import config, pathutils, types from radicale.log import logger from radicale.storage.multifilesystem.base import CollectionBase, StorageBase class CollectionPartLock(CollectionBase): @types.contextmanager def _acquire_cache_lock(self, ns: str = "") -> Iterator[None]: if self._storage._lock.locked == "w": yield return cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache") self._storage._makedirs_synced(cache_folder) lock_path = os.path.join(cache_folder, ".Radicale.lock" + (".%s" % ns if ns else "")) lock = pathutils.RwLock(lock_path) with lock.acquire("w"): yield class StoragePartLock(StorageBase): _lock: pathutils.RwLock _hook: str def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) lock_path = os.path.join(self._filesystem_folder, ".Radicale.lock") self._lock = pathutils.RwLock(lock_path) self._hook = configuration.get("storage", "hook") @types.contextmanager def acquire_lock(self, mode: str, user: str = "") -> Iterator[None]: with self._lock.acquire(mode): yield # execute hook if mode == "w" and self._hook: debug = logger.isEnabledFor(logging.DEBUG) # Use new process group for child to prevent terminals # from sending SIGINT etc. preexec_fn = None creationflags = 0 if sys.platform == "win32": creationflags |= subprocess.CREATE_NEW_PROCESS_GROUP else: # Process group is also used to identify child processes preexec_fn = os.setpgrp command = self._hook % { "user": shlex.quote(user or "Anonymous")} logger.debug("Running storage hook") p = subprocess.Popen( command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE if debug else subprocess.DEVNULL, stderr=subprocess.PIPE if debug else subprocess.DEVNULL, shell=True, universal_newlines=True, preexec_fn=preexec_fn, cwd=self._filesystem_folder, creationflags=creationflags) try: stdout_data, stderr_data = p.communicate() except BaseException: # e.g. KeyboardInterrupt or SystemExit p.kill() p.wait() raise finally: if sys.platform != "win32": # Kill remaining children identified by process group with contextlib.suppress(OSError): os.killpg(p.pid, signal.SIGKILL) if stdout_data: logger.debug("Captured stdout from hook:\n%s", stdout_data) if stderr_data: logger.debug("Captured stderr from hook:\n%s", stderr_data) if p.returncode != 0: raise subprocess.CalledProcessError(p.returncode, p.args) Radicale-3.1.8/radicale/storage/multifilesystem/meta.py000066400000000000000000000052701426407556000232130ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-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 . import json import os from typing import Mapping, Optional, TextIO, Union, cast, overload import radicale.item as radicale_item from radicale.storage import multifilesystem from radicale.storage.multifilesystem.base import CollectionBase class CollectionPartMeta(CollectionBase): _meta_cache: Optional[Mapping[str, str]] _props_path: str def __init__(self, storage_: "multifilesystem.Storage", path: str, filesystem_path: Optional[str] = None) -> None: super().__init__(storage_, path, filesystem_path) self._meta_cache = None self._props_path = os.path.join( self._filesystem_path, ".Radicale.props") @overload def get_meta(self, key: None = None) -> Mapping[str, str]: ... @overload def get_meta(self, key: str) -> Optional[str]: ... def get_meta(self, key: Optional[str] = None) -> Union[Mapping[str, str], Optional[str]]: # reuse cached value if the storage is read-only if self._storage._lock.locked == "w" or self._meta_cache is None: try: try: with open(self._props_path, encoding=self._encoding) as f: temp_meta = json.load(f) except FileNotFoundError: temp_meta = {} self._meta_cache = radicale_item.check_and_sanitize_props( temp_meta) except ValueError as e: raise RuntimeError("Failed to load properties of collection " "%r: %s" % (self.path, e)) from e 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: f = cast(TextIO, fo) json.dump(props, f, sort_keys=True) Radicale-3.1.8/radicale/storage/multifilesystem/move.py000066400000000000000000000055561426407556000232420ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-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 . import os from radicale import item as radicale_item from radicale import pathutils, storage from radicale.storage import multifilesystem from radicale.storage.multifilesystem.base import StorageBase class StoragePartMove(StorageBase): def move(self, item: radicale_item.Item, to_collection: storage.BaseCollection, to_href: str) -> None: if not pathutils.is_safe_filesystem_path_component(to_href): raise pathutils.UnsafePathError(to_href) assert isinstance(to_collection, multifilesystem.Collection) assert isinstance(item.collection, multifilesystem.Collection) assert item.href os.replace(pathutils.path_to_filesystem( item.collection._filesystem_path, item.href), pathutils.path_to_filesystem( to_collection._filesystem_path, to_href)) self._sync_directory(to_collection._filesystem_path) if item.collection._filesystem_path != to_collection._filesystem_path: self._sync_directory(item.collection._filesystem_path) # Move the item cache entry cache_folder = os.path.join(item.collection._filesystem_path, ".Radicale.cache", "item") to_cache_folder = os.path.join(to_collection._filesystem_path, ".Radicale.cache", "item") self._makedirs_synced(to_cache_folder) try: os.replace(os.path.join(cache_folder, item.href), os.path.join(to_cache_folder, to_href)) except FileNotFoundError: pass else: self._makedirs_synced(to_cache_folder) if cache_folder != to_cache_folder: self._makedirs_synced(cache_folder) # Track the change to_collection._update_history_etag(to_href, item) item.collection._update_history_etag(item.href, None) to_collection._clean_history() if item.collection._filesystem_path != to_collection._filesystem_path: item.collection._clean_history() Radicale-3.1.8/radicale/storage/multifilesystem/sync.py000066400000000000000000000131001426407556000232300ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . import contextlib import itertools import os import pickle from hashlib import sha256 from typing import BinaryIO, Iterable, Tuple, cast from radicale.log import logger from radicale.storage.multifilesystem.base import CollectionBase from radicale.storage.multifilesystem.cache import CollectionPartCache from radicale.storage.multifilesystem.history import CollectionPartHistory class CollectionPartSync(CollectionPartCache, CollectionPartHistory, CollectionBase): def sync(self, old_token: str = "") -> Tuple[str, Iterable[str]]: # The sync token has the form http://radicale.org/ns/sync/TOKEN_NAME # where TOKEN_NAME is the sha256 hash of all history etags of present # and past items of the collection. def check_token_name(token_name: str) -> bool: if len(token_name) != 64: return False for c in token_name: if c not in "0123456789abcdef": return False return True old_token_name = "" if old_token: # Extract the token name from the sync token if not old_token.startswith("http://radicale.org/ns/sync/"): raise ValueError("Malformed token: %r" % old_token) old_token_name = old_token[len("http://radicale.org/ns/sync/"):] if not check_token_name(old_token_name): raise ValueError("Malformed token: %r" % old_token) # Get the current state and sync-token of the collection. state = {} token_name_hash = sha256() # Find the history of all existing and deleted items for href, item in itertools.chain( ((item.href, item) for item in self.get_all()), ((href, None) for href in self._get_deleted_history_hrefs())): history_etag = self._update_history_etag(href, item) state[href] = history_etag token_name_hash.update((href + "/" + history_etag).encode()) token_name = token_name_hash.hexdigest() token = "http://radicale.org/ns/sync/%s" % token_name if token_name == old_token_name: # Nothing changed return token, () token_folder = os.path.join(self._filesystem_path, ".Radicale.cache", "sync-token") token_path = os.path.join(token_folder, token_name) old_state = {} if old_token_name: # load the old token state old_token_path = os.path.join(token_folder, old_token_name) try: # Race: Another process might have deleted the file. with open(old_token_path, "rb") as f: old_state = pickle.load(f) except (FileNotFoundError, pickle.UnpicklingError, ValueError) as e: if isinstance(e, (pickle.UnpicklingError, ValueError)): logger.warning( "Failed to load stored sync token %r in %r: %s", old_token_name, self.path, e, exc_info=True) # Delete the damaged file with contextlib.suppress(FileNotFoundError, PermissionError): os.remove(old_token_path) raise ValueError("Token not found: %r" % old_token) # write the new token state or update the modification time of # existing token state if not os.path.exists(token_path): 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: fb = cast(BinaryIO, fo) pickle.dump(state, fb) except PermissionError: pass else: # clean up old sync tokens and item cache self._clean_cache(token_folder, os.listdir(token_folder), max_age=self._max_sync_token_age) self._clean_history() else: # Try to update the modification time with contextlib.suppress(FileNotFoundError): # Race: Another process might have deleted the file. os.utime(token_path) changes = [] # Find all new, changed and deleted (that are still in the item cache) # items for href, history_etag in state.items(): if history_etag != old_state.get(href): changes.append(href) # Find all deleted items that are no longer in the item cache for href, history_etag in old_state.items(): if href not in state: changes.append(href) return token, changes Radicale-3.1.8/radicale/storage/multifilesystem/upload.py000066400000000000000000000115451426407556000235530ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-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 . import errno import os import pickle import sys from typing import Iterable, Iterator, TextIO, cast import radicale.item as radicale_item from radicale import pathutils from radicale.storage.multifilesystem.base import CollectionBase from radicale.storage.multifilesystem.cache import CollectionPartCache from radicale.storage.multifilesystem.get import CollectionPartGet from radicale.storage.multifilesystem.history import CollectionPartHistory class CollectionPartUpload(CollectionPartGet, CollectionPartCache, CollectionPartHistory, CollectionBase): def upload(self, href: str, item: radicale_item.Item ) -> radicale_item.Item: if not pathutils.is_safe_filesystem_path_component(href): raise pathutils.UnsafePathError(href) try: self._store_item_cache(href, item) except Exception as e: 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: f = cast(TextIO, fo) f.write(item.serialize()) # Clean the cache after the actual item is stored, or the cache entry # will be removed again. self._clean_item_cache() # Track the change self._update_history_etag(href, item) self._clean_history() uploaded_item = self._get(href, verify_href=False) if uploaded_item is None: raise RuntimeError("Storage modified externally") return uploaded_item def _upload_all_nonatomic(self, items: Iterable[radicale_item.Item], suffix: str = "") -> None: """Upload a new set of items non-atomic""" def is_safe_free_href(href: str) -> bool: return (pathutils.is_safe_filesystem_path_component(href) and not os.path.lexists( os.path.join(self._filesystem_path, href))) def get_safe_free_hrefs(uid: str) -> Iterator[str]: for href in [uid if uid.lower().endswith(suffix.lower()) else uid + suffix, radicale_item.get_etag(uid).strip('"') + suffix]: if is_safe_free_href(href): yield href yield radicale_item.find_available_uid( lambda href: not is_safe_free_href(href), suffix) cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache", "item") self._storage._makedirs_synced(cache_folder) for item in items: uid = item.uid try: cache_content = self._item_cache_content(item) except Exception as e: raise ValueError( "Failed to store item %r in temporary collection %r: %s" % (uid, self.path, e)) from e for href in get_safe_free_hrefs(uid): try: f = open(os.path.join(self._filesystem_path, href), "w", newline="", encoding=self._encoding) except OSError as e: if (sys.platform != "win32" and e.errno == errno.EINVAL or sys.platform == "win32" and e.errno == 123): # not a valid filename continue raise break else: raise RuntimeError("No href found for item %r in temporary " "collection %r" % (uid, self.path)) with f: f.write(item.serialize()) f.flush() self._storage._fsync(f) with open(os.path.join(cache_folder, href), "wb") as fb: pickle.dump(cache_content, fb) fb.flush() self._storage._fsync(fb) self._storage._sync_directory(cache_folder) self._storage._sync_directory(self._filesystem_path) Radicale-3.1.8/radicale/storage/multifilesystem/verify.py000066400000000000000000000070241426407556000235700ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-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 typing import Iterator, Optional, Set from radicale import pathutils, storage, types from radicale.log import logger from radicale.storage.multifilesystem.base import StorageBase from radicale.storage.multifilesystem.discover import StoragePartDiscover class StoragePartVerify(StoragePartDiscover, StorageBase): def verify(self) -> bool: item_errors = collection_errors = 0 @types.contextmanager def exception_cm(sane_path: str, href: Optional[str] ) -> Iterator[None]: nonlocal item_errors, collection_errors try: yield except Exception as e: if href is not None: item_errors += 1 name = "item %r in %r" % (href, sane_path) else: collection_errors += 1 name = "collection %r" % sane_path logger.error("Invalid %s: %s", name, e, exc_info=True) remaining_sane_paths = [""] 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) with exception_cm(sane_path, None): saved_item_errors = item_errors collection: Optional[storage.BaseCollection] = None uids: Set[str] = set() has_child_collections = False for item in self.discover(path, "1", exception_cm): if not collection: assert isinstance(item, storage.BaseCollection) collection = item collection.get_meta() continue if isinstance(item, storage.BaseCollection): has_child_collections = True remaining_sane_paths.append(item.path) elif item.uid in uids: logger.error("Invalid item %r in %r: UID conflict %r", item.href, sane_path, item.uid) else: uids.add(item.uid) logger.debug("Verified item %r in %r", item.href, sane_path) assert collection if item_errors == saved_item_errors: collection.sync() if has_child_collections and collection.tag: logger.error("Invalid collection %r: %r must not have " "child collections", sane_path, collection.tag) return item_errors == 0 and collection_errors == 0 Radicale-3.1.8/radicale/storage/multifilesystem_nolock.py000066400000000000000000000066261426407556000236400ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2021 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 . """ The multifilesystem backend without file-based locking. """ import threading from collections import deque from typing import ClassVar, Deque, Dict, Hashable, Iterator, Type from radicale import config, pathutils, types from radicale.storage import multifilesystem class RwLock(pathutils.RwLock): _cond: threading.Condition def __init__(self) -> None: super().__init__("") self._cond = threading.Condition(self._lock) @types.contextmanager def acquire(self, mode: str, user: str = "") -> Iterator[None]: if mode not in "rw": raise ValueError("Invalid mode: %r" % mode) with self._cond: self._cond.wait_for(lambda: not self._writer and ( mode == "r" or self._readers == 0)) if mode == "r": self._readers += 1 else: self._writer = True try: yield finally: with self._cond: if mode == "r": self._readers -= 1 self._writer = False if self._readers == 0: self._cond.notify_all() class LockDict: _lock: threading.Lock _dict: Dict[Hashable, Deque[threading.Lock]] def __init__(self) -> None: self._lock = threading.Lock() self._dict = {} @types.contextmanager def acquire(self, key: Hashable) -> Iterator[None]: with self._lock: waiters = self._dict.get(key) if waiters is None: self._dict[key] = waiters = deque() wait = bool(waiters) waiter = threading.Lock() waiter.acquire() waiters.append(waiter) if wait: waiter.acquire() try: yield finally: with self._lock: assert waiters[0] is waiter and self._dict[key] is waiters del waiters[0] if waiters: waiters[0].release() else: del self._dict[key] class Collection(multifilesystem.Collection): _storage: "Storage" @types.contextmanager def _acquire_cache_lock(self, ns: str = "") -> Iterator[None]: if self._storage._lock.locked == "w": yield return with self._storage._cache_lock.acquire((self.path, ns)): yield class Storage(multifilesystem.Storage): _collection_class: ClassVar[Type[Collection]] = Collection _cache_lock: LockDict def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) self._lock = RwLock() self._cache_lock = LockDict() Radicale-3.1.8/radicale/tests/000077500000000000000000000000001426407556000161465ustar00rootroot00000000000000Radicale-3.1.8/radicale/tests/__init__.py000066400000000000000000000220411426407556000202560ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2012-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 . """ Tests for Radicale. """ import base64 import logging import shutil import sys import tempfile 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 radicale from radicale import app, config, types, xmlutils RESPONSES = Dict[str, Union[int, Dict[str, Tuple[int, ET.Element]]]] # Enable debug output radicale.log.logger.setLevel(logging.DEBUG) class BaseTest: """Base class for tests.""" colpath: str configuration: config.Configuration application: app.Application def setup(self) -> None: self.configuration = config.load() self.colpath = tempfile.mkdtemp() self.configure({ "storage": {"filesystem_folder": self.colpath, # Disable syncing to disk for better performance "_filesystem_fsync": "False"}, # Set incorrect authentication delay to a short duration "auth": {"delay": "0.001"}}) def configure(self, config_: types.CONFIG) -> None: self.configuration.update(config_, "test", privileged=True) self.application = app.Application(self.configuration) def teardown(self) -> None: shutil.rmtree(self.colpath) def request(self, method: str, path: str, data: Optional[str] = None, check: Optional[int] = None, **kwargs ) -> Tuple[int, Dict[str, str], str]: """Send a request.""" login = kwargs.pop("login", None) if login is not None and not isinstance(login, str): raise TypeError("login argument must be %r, not %r" % (str, type(login))) environ: Dict[str, Any] = {k.upper(): v for k, v in kwargs.items()} for k, v in environ.items(): if not isinstance(v, str): raise TypeError("type of %r is %r, expected %r" % (k, type(v), str)) encoding: str = self.configuration.get("encoding", "request") if login: environ["HTTP_AUTHORIZATION"] = "Basic " + base64.b64encode( login.encode(encoding)).decode() environ["REQUEST_METHOD"] = method.upper() environ["PATH_INFO"] = path if data: data_bytes = data.encode(encoding) environ["wsgi.input"] = BytesIO(data_bytes) environ["CONTENT_LENGTH"] = str(len(data_bytes)) environ["wsgi.errors"] = sys.stderr status = headers = None def start_response(status_: str, headers_: List[Tuple[str, str]] ) -> None: nonlocal status, headers status = int(status_.split()[0]) headers = dict(headers_) answers = list(self.application(environ, start_response)) assert status is not None and headers is not None assert check is None or status == check, "%d != %d" % (status, check) return status, headers, answers[0].decode() if answers else "" @staticmethod 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]]]] = {} 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]] = {} for propstat in response.findall( xmlutils.make_clark("D:propstat")): status = propstat.find(xmlutils.make_clark("D:status")) assert status.text.startswith("HTTP/1.1 ") status_code = int(status.text.split(" ")[1]) 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) status = response.find(xmlutils.make_clark("D:status")) if status is not None: assert not prop_respones 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 return path_responses def get(self, path: str, check: Optional[int] = 200, **kwargs ) -> Tuple[int, str]: assert "data" not in 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]: status, _, answer = self.request("POST", path, data, check=check, **kwargs) return status, answer def put(self, path: str, data: str, check: Optional[int] = 201, **kwargs) -> Tuple[int, str]: status, _, answer = self.request("PUT", path, data, check=check, **kwargs) return status, answer def propfind(self, path: str, data: Optional[str] = None, check: Optional[int] = 207, **kwargs ) -> Tuple[int, RESPONSES]: status, _, answer = self.request("PROPFIND", path, data, check=check, **kwargs) if status < 200 or 300 <= status: return status, {} assert answer is not None responses = self.parse_responses(answer) if kwargs.get("HTTP_DEPTH", "0") == "0": assert len(responses) == 1 and path in responses return status, responses def proppatch(self, path: str, data: Optional[str] = None, check: Optional[int] = 207, **kwargs ) -> Tuple[int, RESPONSES]: status, _, answer = self.request("PROPPATCH", path, data, check=check, **kwargs) if status < 200 or 300 <= status: return status, {} assert answer is not None responses = self.parse_responses(answer) assert len(responses) == 1 and path in responses return status, responses def report(self, path: str, data: str, check: Optional[int] = 207, **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) def delete(self, path: str, check: Optional[int] = 200, **kwargs ) -> Tuple[int, RESPONSES]: assert "data" not in kwargs status, _, answer = self.request("DELETE", path, check=check, **kwargs) if status < 200 or 300 <= status: return status, {} assert answer is not None responses = self.parse_responses(answer) assert len(responses) == 1 and path in responses return status, responses def mkcalendar(self, path: str, data: Optional[str] = None, check: Optional[int] = 201, **kwargs ) -> Tuple[int, str]: status, _, answer = self.request("MKCALENDAR", path, data, check=check, **kwargs) return status, answer def mkcol(self, path: str, data: Optional[str] = None, check: Optional[int] = 201, **kwargs) -> int: status, _, _ = self.request("MKCOL", path, data, check=check, **kwargs) return status def create_addressbook(self, path: str, check: Optional[int] = 201, **kwargs) -> int: assert "data" not in kwargs return self.mkcol(path, """\ """, check=check, **kwargs) Radicale-3.1.8/radicale/tests/custom/000077500000000000000000000000001426407556000174605ustar00rootroot00000000000000Radicale-3.1.8/radicale/tests/custom/__init__.py000066400000000000000000000000001426407556000215570ustar00rootroot00000000000000Radicale-3.1.8/radicale/tests/custom/auth.py000066400000000000000000000021151426407556000207720ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-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 . """ Custom authentication. Just check username for testing """ from radicale import auth class Auth(auth.BaseAuth): def login(self, login: str, password: str) -> str: if login == "tmp": return login return "" Radicale-3.1.8/radicale/tests/custom/rights.py000066400000000000000000000020321426407556000213270ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # 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 . """ Custom rights management. """ from radicale import pathutils, rights class Rights(rights.BaseRights): def authorization(self, user: str, path: str) -> str: sane_path = pathutils.strip_path(path) if sane_path not in ("tmp", "other"): return "" return "RrWw" Radicale-3.1.8/radicale/tests/custom/storage_simple_sync.py000066400000000000000000000021621426407556000241040ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2012-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 . """ Custom storage backend. Copy of multifilesystem storage backend that uses the default ``sync`` implementation for testing. """ from radicale.storage import BaseCollection, multifilesystem class Collection(multifilesystem.Collection): sync = BaseCollection.sync class Storage(multifilesystem.Storage): _collection_class = Collection Radicale-3.1.8/radicale/tests/custom/web.py000066400000000000000000000024631426407556000206140ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # 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 . """ Custom web plugin. """ from http import client from radicale import httputils, types, web class Web(web.BaseWeb): def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: return client.OK, {"Content-Type": "text/plain"}, "custom" def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: content = httputils.read_request_body(self.configuration, environ) return client.OK, {"Content-Type": "text/plain"}, "echo:" + content Radicale-3.1.8/radicale/tests/helpers.py000066400000000000000000000032571426407556000201710ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . """ Radicale Helpers module. This module offers helpers to use in tests. """ import os from radicale import config, types EXAMPLES_FOLDER: str = os.path.join(os.path.dirname(__file__), "static") def get_file_path(file_name: str) -> str: return os.path.join(EXAMPLES_FOLDER, file_name) def get_file_content(file_name: str) -> str: with open(get_file_path(file_name), encoding="utf-8") as f: return f.read() def configuration_to_dict(configuration: config.Configuration) -> types.CONFIG: """Convert configuration to a dict with raw values.""" return {section: {option: configuration.get_raw(section, option) for option in configuration.options(section) if not option.startswith("_")} for section in configuration.sections() if not section.startswith("_")} Radicale-3.1.8/radicale/tests/static/000077500000000000000000000000001426407556000174355ustar00rootroot00000000000000Radicale-3.1.8/radicale/tests/static/allprop.xml000066400000000000000000000001411426407556000216240ustar00rootroot00000000000000 Radicale-3.1.8/radicale/tests/static/broken-vcard.vcf000066400000000000000000000003741426407556000225160ustar00rootroot00000000000000BEGIN:VCARD VERSION:3.0 PRODID:-//Inverse inc.//SOGo Connector 1.0//EN UID:C68582D2-2E60-0001-C2C0-000000000000.vcf X-MOZILLA-HTML:FALSE EMAIL;TYPE=work:test-misses-N-or-FN@example.com X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf END:VCARD Radicale-3.1.8/radicale/tests/static/broken-vevent.ics000066400000000000000000000007471426407556000227320ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//Radicale//NONSGML Radicale Server//EN VERSION:2.0 BEGIN:VEVENT CREATED:20160725T060147Z LAST-MODIFIED:20160727T193435Z DTSTAMP:20160727T193435Z UID:040000008200E00074C5B7101A82E00800000000 SUMMARY:Broken ICS END of VEVENT missing by accident STATUS:CONFIRMED X-MOZ-LASTACK:20160727T193435Z DTSTART;TZID=Europe/Budapest:20160727T170000 DTEND;TZID=Europe/Budapest:20160727T223000 CLASS:PUBLIC X-LIC-ERROR:No value for LOCATION property. Removing entire property: Radicale-3.1.8/radicale/tests/static/cert.pem000066400000000000000000000023101426407556000210710ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIIDXDCCAkSgAwIBAgIJAKBsA+sXwPtuMA0GCSqGSIb3DQEBCwUAMEIxCzAJBgNV BAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0RlZmF1bHQg Q29tcGFueSBMdGQwIBcNMTgwOTAzMjAyNDE2WhgPMjExODA4MTAyMDI0MTZaMEIx CzAJBgNVBAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0Rl ZmF1bHQgQ29tcGFueSBMdGQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB AQDMEBfr6oEk/t1Op9fSRRRrReQOZqx+gC1jHONSDXudDyfZBFSQx1QY9EtFqMUr lvY3uI+rohujMTfXih6AEXTHHJmRIk80hDR/ovDMDiC5+z6EuKwbKPtjDMKqn7Hb YoA4pyRWwzPydrZRVeG9+z4YY5uMRCmpzLqWcm04kgCEeJqKpb9ZQMKL/8fq8a9p v5rfOXqtneje4yJAOF/L2EXk/MjdqvYR/cu2kTP8IDocTYZj6xjA9GVb37Xga+YG u/SbGSU9vU8rmXJqqAFR/im97bz960Q/Q2VN2y9nTLEPCjGeyxcatxDw6vc1s2GE 5ttuu6aPmRc392T3kFV9ZnYdAgMBAAGjUzBRMB0GA1UdDgQWBBRKPvGgdpsYK/ma 3l+FMUIngO9xGTAfBgNVHSMEGDAWgBRKPvGgdpsYK/ma3l+FMUIngO9xGTAPBgNV HRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCID4FTrX6DJKQzvDTg6ejP ziSeoea7+nqtVogEBfmzm8YY4pu6qbNM8EHwbP9cnbZ6V48PmZUV4hQibGy33C6E EIvqNBHcO/WqjbL2IWKcuZH7pMQVedR3GAV8sJMMwBOTtdopcTbnYFRZYwXV2dKe reo5ukDZo8KyQHS9lloi5IPhsTufPBK3n9EtMa/Ch7bqmXEiSkKFU04o2kuj0Urk hG8lnX1Ff2xWjG5N9Hp7xaEWk3LO/nDxlF/AmF3pDuWkZXpzNpUk70KlNx8xSKYR cHmp2Z1hrA7PvUrG46I2dwC+y09hRXFSqYBT2po9Uzwj8aSNXGr1vKBzebqi9Sxc -----END CERTIFICATE----- Radicale-3.1.8/radicale/tests/static/contact1.vcf000066400000000000000000000001261426407556000216500ustar00rootroot00000000000000BEGIN:VCARD VERSION:3.0 UID:contact1 N:Contact;;;; FN:Contact NICKNAME:test END:VCARD Radicale-3.1.8/radicale/tests/static/contact_multiple.vcf000066400000000000000000000002241426407556000235010ustar00rootroot00000000000000BEGIN:VCARD VERSION:3.0 UID:contact1 N:Contact1;;;; FN:Contact1 END:VCARD BEGIN:VCARD VERSION:3.0 UID:contact2 N:Contact2;;;; FN:Contact2 END:VCARD Radicale-3.1.8/radicale/tests/static/contact_photo_with_data_uri.vcf000066400000000000000000000003461426407556000257070ustar00rootroot00000000000000BEGIN:VCARD VERSION:3.0 UID:contact N:Contact;;;; FN:Contact NICKNAME:test PHOTO;ENCODING=b;TYPE=png:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAD0lEQVQIHQEEAPv/AP///wX+Av4DfRnGAAAAAElFTkSuQmCC END:VCARD Radicale-3.1.8/radicale/tests/static/event1.ics000066400000000000000000000016221426407556000213400ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN VERSION:2.0 BEGIN:VTIMEZONE TZID:Europe/Paris X-LIC-LOCATION:Europe/Paris BEGIN:DAYLIGHT TZOFFSETFROM:+0100 TZOFFSETTO:+0200 TZNAME:CEST DTSTART:19700329T020000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0200 TZOFFSETTO:+0100 TZNAME:CET DTSTART:19701025T030000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 END:STANDARD END:VTIMEZONE BEGIN:VEVENT CREATED:20130902T150157Z LAST-MODIFIED:20130902T150158Z DTSTAMP:20130902T150158Z UID:event1 SUMMARY:Event ORGANIZER:mailto:unclesam@example.com ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com DTSTART;TZID=Europe/Paris:20130901T180000 DTEND;TZID=Europe/Paris:20130901T190000 END:VEVENT END:VCALENDAR Radicale-3.1.8/radicale/tests/static/event1_modified.ics000066400000000000000000000016221426407556000232000ustar00rootroot00000000000000BEGIN: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:20130902T150159Z UID:event1 SUMMARY:Event ORGANIZER:mailto:unclesam@example.com ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com DTSTART;TZID=Europe/Paris:20130901T180000 DTEND;TZID=Europe/Paris:20130901T190000 END:VEVENT END:VCALENDAR Radicale-3.1.8/radicale/tests/static/event2.ics000066400000000000000000000016161426407556000213440ustar00rootroot00000000000000BEGIN: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:event2 SUMMARY:Event2 DTSTART;TZID=Europe/Paris:20130902T180000 DTEND;TZID=Europe/Paris:20130902T190000 RRULE:FREQ=WEEKLY SEQUENCE:1 END:VEVENT BEGIN:VEVENT DTSTART;TZID=Europe/Paris:20130910T170000 DTEND;TZID=Europe/Paris:20130910T180000 DTSTAMP:20140902T150158Z SUMMARY:Event2 UID:event2 RECURRENCE-ID;TZID=Europe/Paris:20130909T180000 SEQUENCE:2 END:VEVENT END:VCALENDAR Radicale-3.1.8/radicale/tests/static/event3.ics000066400000000000000000000011701426407556000213400ustar00rootroot00000000000000BEGIN: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:event3 SUMMARY:Event3 DTSTART;TZID=Europe/Paris:20130903 DURATION:PT1H END:VEVENT END:VCALENDAR Radicale-3.1.8/radicale/tests/static/event4.ics000066400000000000000000000011611426407556000213410ustar00rootroot00000000000000BEGIN: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:event4 SUMMARY:Event4 DTSTART;TZID=Europe/Paris:20130904T180000 END:VEVENT END:VCALENDAR Radicale-3.1.8/radicale/tests/static/event5.ics000066400000000000000000000011521426407556000213420ustar00rootroot00000000000000BEGIN: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:event5 SUMMARY:Event5 DTSTART;TZID=Europe/Paris:20130905 END:VEVENT END:VCALENDAR Radicale-3.1.8/radicale/tests/static/event6.ics000066400000000000000000000017671426407556000213570ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN BEGIN:VTIMEZONE TZID:Europe/Paris BEGIN:STANDARD DTSTART:19701025T030000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 END:STANDARD BEGIN:DAYLIGHT DTSTART:19700329T020000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT UID:event6 DTSTART;TZID=Europe/Paris:20170601T080000 DTEND;TZID=Europe/Paris:20170601T090000 CREATED:20170601T060000Z DTSTAMP:20170601T060000Z LAST-MODIFIED:20170601T060000Z RRULE:FREQ=DAILY;UNTIL=20170602T060000Z SUMMARY:event6 TRANSP:OPAQUE X-MOZ-GENERATION:1 END:VEVENT BEGIN:VEVENT UID:event6 RECURRENCE-ID;TZID=Europe/Paris:20170602T080000 DTSTART;TZID=Europe/Paris:20170701T080000 DTEND;TZID=Europe/Paris:20170701T090000 CREATED:20170601T060000Z DTSTAMP:20170601T060000Z LAST-MODIFIED:20170601T060000Z SEQUENCE:1 SUMMARY:event6 TRANSP:OPAQUE X-MOZ-GENERATION:1 END:VEVENT END:VCALENDAR Radicale-3.1.8/radicale/tests/static/event7.ics000066400000000000000000000024211426407556000213440ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN BEGIN:VTIMEZONE TZID:Europe/Paris BEGIN:STANDARD DTSTART:19701025T030000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 END:STANDARD BEGIN:DAYLIGHT DTSTART:19700329T020000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT UID:event7 DTSTART;TZID=Europe/Paris:20170701T080000 DTEND;TZID=Europe/Paris:20170701T090000 CREATED:20170601T060000Z DTSTAMP:20170601T060000Z LAST-MODIFIED:20170601T060000Z RRULE:FREQ=DAILY SUMMARY:event7 TRANSP:OPAQUE X-MOZ-GENERATION:1 END:VEVENT BEGIN:VEVENT UID:event7 RECURRENCE-ID;TZID=Europe/Paris:20170702T080000 DTSTART;TZID=Europe/Paris:20170702T080000 DTEND;TZID=Europe/Paris:20170702T090000 CREATED:20170601T060000Z DTSTAMP:20170601T060000Z LAST-MODIFIED:20170601T060000Z SEQUENCE:1 SUMMARY:event7 TRANSP:OPAQUE X-MOZ-GENERATION:1 END:VEVENT BEGIN:VEVENT UID:event7 RECURRENCE-ID;TZID=Europe/Paris:20170703T080000 DTSTART;TZID=Europe/Paris:20170601T080000 DTEND;TZID=Europe/Paris:20170601T090000 CREATED:20170601T060000Z DTSTAMP:20170601T060000Z LAST-MODIFIED:20170601T060000Z SEQUENCE:1 SUMMARY:event7 TRANSP:OPAQUE X-MOZ-GENERATION:1 END:VEVENT END:VCALENDAR Radicale-3.1.8/radicale/tests/static/event8.ics000066400000000000000000000013061426407556000213460ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN BEGIN:VTIMEZONE TZID:Europe/Paris BEGIN:STANDARD DTSTART:19701025T030000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 END:STANDARD BEGIN:DAYLIGHT DTSTART:19700329T020000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT UID:event8 DTSTART;TZID=Europe/Paris:20170601T080000 DTEND;TZID=Europe/Paris:20170601T090000 CREATED:20170601T060000Z DTSTAMP:20170601T060000Z LAST-MODIFIED:20170601T060000Z RDATE;TZID=Europe/Paris:20170701T080000 SUMMARY:event8 TRANSP:OPAQUE X-MOZ-GENERATION:1 END:VEVENT END:VCALENDAR Radicale-3.1.8/radicale/tests/static/event9.ics000066400000000000000000000012341426407556000213470ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN BEGIN:VTIMEZONE TZID:Europe/Paris BEGIN:STANDARD DTSTART;VALUE=DATE-TIME:19701025T030000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 END:STANDARD BEGIN:DAYLIGHT DTSTART;VALUE=DATE-TIME:19700329T020000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT DTSTAMP:20170510T072956Z UID:event9 SUMMARY:event9 DTSTART;VALUE=DATE-TIME;TZID=Europe/Paris:20170601T080000 DTEND;VALUE=DATE-TIME:20170601T080000Z RRULE:FREQ=DAILY;UNTIL=20170602T060000Z END:VEVENT END:VCALENDAR Radicale-3.1.8/radicale/tests/static/event_mixed_datetime_and_date.ics000066400000000000000000000013431426407556000261400ustar00rootroot00000000000000BEGIN: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:event_mixed_datetime_and_date SUMMARY:Event DTSTART;TZID=Europe/Paris:20130901T180000 DTEND;TZID=Europe/Paris:20130901T190000 RRULE:FREQ=DAILY;COUNT=3 EXDATE;VALUE=DATE:20130902 END:VEVENT END:VCALENDAR Radicale-3.1.8/radicale/tests/static/event_multiple.ics000066400000000000000000000012521426407556000231710ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN VERSION:2.0 BEGIN:VTIMEZONE TZID:Europe/Paris X-LIC-LOCATION:Europe/Paris BEGIN:DAYLIGHT TZOFFSETFROM:+0100 TZOFFSETTO:+0200 TZNAME:CEST DTSTART:19700329T020000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0200 TZOFFSETTO:+0100 TZNAME:CET DTSTART:19701025T030000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 END:STANDARD END:VTIMEZONE BEGIN:VEVENT UID:event SUMMARY:Event DTSTART;TZID=Europe/Paris:20130901T190000 DTEND;TZID=Europe/Paris:20130901T200000 END:VEVENT BEGIN:VTODO UID:todo DTSTART;TZID=Europe/Paris:20130901T220000 DURATION:PT1H SUMMARY:Todo END:VTODO END:VCALENDAR Radicale-3.1.8/radicale/tests/static/event_multiple_case_sensitive_uids.ics000066400000000000000000000004421426407556000273010ustar00rootroot00000000000000BEGIN:VCALENDAR PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN VERSION:2.0 BEGIN:VEVENT UID:event SUMMARY:Event 1 DTSTART:20130901T190000 DTEND:20130901T200000 END:VEVENT BEGIN:VEVENT UID:EVENT SUMMARY:Event 2 DTSTART:20130901T200000 DTEND:20130901T210000 END:VEVENT END:VCALENDAR Radicale-3.1.8/radicale/tests/static/event_timezone_seconds.ics000066400000000000000000000013111426407556000247020ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Apple Inc.//Mac OS X 10.13.4//EN CALSCALE:GREGORIAN BEGIN:VTIMEZONE TZID:Europe/Moscow BEGIN:STANDARD TZOFFSETFROM:+023017 DTSTART:20010101T000000 TZNAME:GMT+3 TZOFFSETTO:+023017 END:STANDARD END:VTIMEZONE BEGIN:VEVENT CREATED:20180420T193555Z UID:E96B9F38-8F70-4F1D-AAAC-2CD0BAC40551 DTEND;TZID=Europe/Moscow:20180420T130000 TRANSP:OPAQUE X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC SUMMARY:ня — 2 DTSTART;TZID=Europe/Moscow:20180420T120000 DTSTAMP:20180420T200353Z SEQUENCE:0 BEGIN:VALARM X-WR-ALARMUID:06071073-A112-40CA-83AA-C05F54736B36 UID:06071073-A112-40CA-83AA-C05F54736B36 TRIGGER;VALUE=DATE-TIME:19760401T005545Z ACTION:NONE END:VALARM END:VEVENT END:VCALENDAR Radicale-3.1.8/radicale/tests/static/journal1.ics000066400000000000000000000011231426407556000216650ustar00rootroot00000000000000BEGIN: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:19700101T000000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0200 TZOFFSETTO:+0100 TZNAME:CET DTSTART:19700101T000000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 END:STANDARD END:VTIMEZONE BEGIN:VJOURNAL UID:journal1 DTSTAMP;TZID=Europe/Paris:19940817T000000 SUMMARY:happy new year DESCRIPTION: Happy new year 2000 ! END:VJOURNAL END:VCALENDAR Radicale-3.1.8/radicale/tests/static/journal2.ics000066400000000000000000000011701426407556000216700ustar00rootroot00000000000000BEGIN: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:VJOURNAL UID:journal2 DTSTAMP:19950817T000000 DTSTART;TZID=Europe/Paris:20000101T100000 SUMMARY:happy new year DESCRIPTION: Happy new year ! RRULE:FREQ=YEARLY END:VJOURNAL END:VCALENDAR Radicale-3.1.8/radicale/tests/static/journal3.ics000066400000000000000000000011351426407556000216720ustar00rootroot00000000000000BEGIN: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:VJOURNAL UID:journal3 DTSTAMP:19950817T000000 DTSTART;VALUE=DATE:20000101 SUMMARY:happy new year DESCRIPTION: Happy new year 2001 ! END:VJOURNAL END:VCALENDAR Radicale-3.1.8/radicale/tests/static/journal4.ics000066400000000000000000000007051426407556000216750ustar00rootroot00000000000000BEGIN: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 END:VCALENDAR Radicale-3.1.8/radicale/tests/static/journal5.ics000066400000000000000000000007051426407556000216760ustar00rootroot00000000000000BEGIN: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 END:VCALENDAR Radicale-3.1.8/radicale/tests/static/key.pem000066400000000000000000000032501426407556000207300ustar00rootroot00000000000000-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDMEBfr6oEk/t1O p9fSRRRrReQOZqx+gC1jHONSDXudDyfZBFSQx1QY9EtFqMUrlvY3uI+rohujMTfX ih6AEXTHHJmRIk80hDR/ovDMDiC5+z6EuKwbKPtjDMKqn7HbYoA4pyRWwzPydrZR VeG9+z4YY5uMRCmpzLqWcm04kgCEeJqKpb9ZQMKL/8fq8a9pv5rfOXqtneje4yJA OF/L2EXk/MjdqvYR/cu2kTP8IDocTYZj6xjA9GVb37Xga+YGu/SbGSU9vU8rmXJq qAFR/im97bz960Q/Q2VN2y9nTLEPCjGeyxcatxDw6vc1s2GE5ttuu6aPmRc392T3 kFV9ZnYdAgMBAAECggEAeQ7HEjbBPJBR+9qIp35Buc3xmDWC+VzTECxQExpajfcy vYTbIjSOCGvMx9tydQSOtsmvubNmz+5f4WdX5sP0Ujb+R2JiOJaBioLAdV2gPpT1 JsljmI08bSthxNUOL0cFKBbH8QzGoX2ZdTEMxabp1JAq9BBv4wLIYn4pm1jKI8tU bzqgx6OjS9bd/su0EPjksLs3pQUN/+f2O7ta6jgXnk68akDtICUq8ELiv2q2+zM1 pZ3npjR/Nc6CLcp9jCYnlQ5hwqJK1ZFXzMUGxpbMXc0rcppVCjR9Tu5ThC4qIPEE tvDeXhy+j1XX1LV1dL2Nt4vTpLpd4xPthvfjxyJUgQKBgQD2x1kZvR3FJZMjXwpt G4MUtVp2VUcGm6Q1790HruHrHFqD2zZpsfcLhyCcGlVt2lVrhVjUeZ1jwKuxAAfE dO1KdTQF0cdMsHAoAkGairfwi4VGIL7PqIHBZXNUiSWY9p61ybZ8tABRv5edxwvK qRdbId9x4ooeTK76H3+gWB19IQKBgQDTsCGkrgLMaiTBAc7Wf8xnpz9x6P2IGCgo 0jg7MKnHEE+Mx/MPn8TwEmB5a4Ldp5LlJ2mSkxm8BohtHvCVYyNZnilmIgXeZhbx mEwKPe/carqGk36DozlZqhrx1n87jWmwO3kCNNyTv1aODwubdA0rO+hzpZXA7zi+ ADBLlr+9fQKBgEVH/BTEyjnR7bgNc6DkC23h6C62jEUnpvdZiuUgTN6zzBmejm0o AGJlIluQ7RD1LewMuL6WEgCyU8FSb9vQs9mmg99qYJiAJEynLYHUlgVbNiRVBxzH gv4nnDRMeJi0DCSfJ7Nk2X4Z2tf5zK6twBfer5uKbRpKjwk7lJoQgt7hAoGBALDm fIbw/9exT/uWtjHcZIWuZz+a89v6S/0pB+K23PpEcCX2pfFFk78HrGVradYvhntH P1tE4HmXgASomWZNjaoDmRcHkZ3z9HJ60fixH7Qz4KI7ubrp+TAsDg5RMMwkddDX Ml2crUQu3ncirZGAHs0laDDUjFvJzcJByBoy5RLFAoGBAKFmic8xdYzHeQLurU/Z 8LPBHTLw1z/o4y5GK+kBGZArpENJTd89/y4FlCboLp5bPYtL2k85KYYGtXKgLN48 GZSFVGVGEir3q6lxUHFq49oj1uywQBSxrhe0ZByngP/0pwvcjqzg0hd8Oz+TmVrK C3zzE6uYw/gVocCTX9xXIzoN -----END PRIVATE KEY----- Radicale-3.1.8/radicale/tests/static/mkcol_make_calendar.xml000066400000000000000000000004751426407556000241200ustar00rootroot00000000000000 #BADA55 Radicale-3.1.8/radicale/tests/static/propfind_calendar_color.xml000066400000000000000000000002441426407556000250270ustar00rootroot00000000000000 Radicale-3.1.8/radicale/tests/static/propfind_multiple.xml000066400000000000000000000003531426407556000237140ustar00rootroot00000000000000 Radicale-3.1.8/radicale/tests/static/propname.xml000066400000000000000000000001421426407556000217750ustar00rootroot00000000000000 Radicale-3.1.8/radicale/tests/static/proppatch_remove_calendar_color.xml000066400000000000000000000003211426407556000265570ustar00rootroot00000000000000 Radicale-3.1.8/radicale/tests/static/proppatch_remove_multiple1.xml000066400000000000000000000004321426407556000255270ustar00rootroot00000000000000 Radicale-3.1.8/radicale/tests/static/proppatch_remove_multiple2.xml000066400000000000000000000005201426407556000255260ustar00rootroot00000000000000 Radicale-3.1.8/radicale/tests/static/proppatch_set_and_remove.xml000066400000000000000000000005461426407556000252360ustar00rootroot00000000000000 test2 Radicale-3.1.8/radicale/tests/static/proppatch_set_calendar_color.xml000066400000000000000000000003431426407556000260610ustar00rootroot00000000000000 #BADA55 Radicale-3.1.8/radicale/tests/static/proppatch_set_multiple1.xml000066400000000000000000000005071426407556000250300ustar00rootroot00000000000000 #BADA55 test Radicale-3.1.8/radicale/tests/static/proppatch_set_multiple2.xml000066400000000000000000000005671426407556000250370ustar00rootroot00000000000000 #BADA55 test Radicale-3.1.8/radicale/tests/static/todo1.ics000066400000000000000000000010501426407556000211570ustar00rootroot00000000000000BEGIN: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:VTODO DTSTART;TZID=Europe/Paris:20130901T220000 DURATION:PT1H SUMMARY:Todo UID:todo END:VTODO END:VCALENDAR Radicale-3.1.8/radicale/tests/static/todo2.ics000066400000000000000000000011071426407556000211630ustar00rootroot00000000000000BEGIN: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:VTODO DTSTART;TZID=Europe/Paris:20130901T180000 DUE;TZID=Europe/Paris:20130903T180000 RRULE:FREQ=MONTHLY UID:todo2 END:VTODO END:VCALENDAR Radicale-3.1.8/radicale/tests/static/todo3.ics000066400000000000000000000010161426407556000211630ustar00rootroot00000000000000BEGIN: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:VTODO DTSTART;TZID=Europe/Paris:20130901T180000 UID:todo3 END:VTODO END:VCALENDAR Radicale-3.1.8/radicale/tests/static/todo4.ics000066400000000000000000000010121426407556000211600ustar00rootroot00000000000000BEGIN: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:VTODO DUE;TZID=Europe/Paris:20130901T180000 UID:todo4 END:VTODO END:VCALENDAR Radicale-3.1.8/radicale/tests/static/todo5.ics000066400000000000000000000010721426407556000211670ustar00rootroot00000000000000BEGIN: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:VTODO CREATED;TZID=Europe/Paris:20130903T180000 COMPLETED;TZID=Europe/Paris:20130920T180000 UID:todo5 END:VTODO END:VCALENDAR Radicale-3.1.8/radicale/tests/static/todo6.ics000066400000000000000000000010201426407556000211610ustar00rootroot00000000000000BEGIN: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:VTODO COMPLETED;TZID=Europe/Paris:20130920T180000 UID:todo6 END:VTODO END:VCALENDAR Radicale-3.1.8/radicale/tests/static/todo7.ics000066400000000000000000000010161426407556000211670ustar00rootroot00000000000000BEGIN: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:VTODO CREATED;TZID=Europe/Paris:20130803T180000 UID:todo7 END:VTODO END:VCALENDAR Radicale-3.1.8/radicale/tests/static/todo8.ics000066400000000000000000000007441426407556000211770ustar00rootroot00000000000000BEGIN: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:VTODO UID:todo8 END:VTODO END:VCALENDAR Radicale-3.1.8/radicale/tests/static/todo9.ics000066400000000000000000000004411426407556000211720ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:+//IDN bitfire.at//DAVdroid/1.9.9-gplay ical4j/2.x BEGIN:VTODO DTSTAMP:20180102T122043Z UID:todo9 CREATED:20180102T122042Z LAST-MODIFIED:20180102T122042Z SUMMARY:todo9 STATUS:NEEDS-ACTION RRULE:FREQ=WEEKLY DUE;VALUE=DATE:20130901 END:VTODO END:VCALENDAR Radicale-3.1.8/radicale/tests/test_auth.py000066400000000000000000000140721426407556000205240ustar00rootroot00000000000000# 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 # # 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 . """ Radicale tests with simple requests and authentication. """ import os import sys from typing import Iterable, Tuple, Union import pytest from radicale import xmlutils from radicale.tests import BaseTest class TestBaseAuthRequests(BaseTest): """Tests basic requests with auth. We should setup auth for each type before creating the Application object. """ def _test_htpasswd(self, htpasswd_encryption: str, htpasswd_content: str, test_matrix: Union[str, Iterable[Tuple[str, str, bool]]] = "ascii") -> None: """Test htpasswd authentication with user "tmp" and password "bepo" for ``test_matrix`` "ascii" or user "😀" and password "🔑" for ``test_matrix`` "unicode".""" htpasswd_file_path = os.path.join(self.colpath, ".htpasswd") encoding: str = self.configuration.get("encoding", "stock") with open(htpasswd_file_path, "w", encoding=encoding) as f: f.write(htpasswd_content) self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": htpasswd_file_path, "htpasswd_encryption": htpasswd_encryption}}) if test_matrix == "ascii": test_matrix = (("tmp", "bepo", True), ("tmp", "tmp", False), ("tmp", "", False), ("unk", "unk", False), ("unk", "", False), ("", "", False)) elif test_matrix == "unicode": test_matrix = (("😀", "🔑", True), ("😀", "🌹", False), ("😁", "🔑", False), ("😀", "", False), ("", "🔑", False), ("", "", False)) elif isinstance(test_matrix, str): raise ValueError("Unknown test matrix %r" % test_matrix) for user, password, valid in test_matrix: self.propfind("/", check=207 if valid else 401, login="%s:%s" % (user, password)) def test_htpasswd_plain(self) -> None: self._test_htpasswd("plain", "tmp:bepo") def test_htpasswd_plain_password_split(self) -> None: self._test_htpasswd("plain", "tmp:be:po", ( ("tmp", "be:po", True), ("tmp", "bepo", False))) def test_htpasswd_plain_unicode(self) -> None: self._test_htpasswd("plain", "😀:🔑", "unicode") def test_htpasswd_md5(self) -> None: self._test_htpasswd("md5", "tmp:$apr1$BI7VKCZh$GKW4vq2hqDINMr8uv7lDY/") def test_htpasswd_md5_unicode(self): self._test_htpasswd( "md5", "😀:$apr1$w4ev89r1$29xO8EvJmS2HEAadQ5qy11", "unicode") def test_htpasswd_bcrypt(self) -> None: self._test_htpasswd("bcrypt", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3V" "NTRI3w5KDnj8NTUKJNWfVpvRq") def test_htpasswd_bcrypt_unicode(self) -> None: self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK" "6U9Sqlzr.W1mMVCS8wJUftnW", "unicode") def test_htpasswd_multi(self) -> None: self._test_htpasswd("plain", "ign:ign\ntmp:bepo") @pytest.mark.skipif(sys.platform == "win32", reason="leading and trailing " "whitespaces not allowed in file names") def test_htpasswd_whitespace_user(self) -> None: for user in (" tmp", "tmp ", " tmp "): self._test_htpasswd("plain", "%s:bepo" % user, ( (user, "bepo", True), ("tmp", "bepo", False))) def test_htpasswd_whitespace_password(self) -> None: for password in (" bepo", "bepo ", " bepo "): self._test_htpasswd("plain", "tmp:%s" % password, ( ("tmp", password, True), ("tmp", "bepo", False))) def test_htpasswd_comment(self) -> None: self._test_htpasswd("plain", "#comment\n #comment\n \ntmp:bepo\n\n") def test_remote_user(self) -> None: self.configure({"auth": {"type": "remote_user"}}) _, responses = self.propfind("/", """\ """, REMOTE_USER="test") assert responses is not None response = responses["/"] assert not isinstance(response, int) status, prop = response["D:current-user-principal"] assert status == 200 href_element = prop.find(xmlutils.make_clark("D:href")) assert href_element is not None and href_element.text == "/test/" def test_http_x_remote_user(self) -> None: self.configure({"auth": {"type": "http_x_remote_user"}}) _, responses = self.propfind("/", """\ """, HTTP_X_REMOTE_USER="test") assert responses is not None response = responses["/"] assert not isinstance(response, int) status, prop = response["D:current-user-principal"] assert status == 200 href_element = prop.find(xmlutils.make_clark("D:href")) assert href_element is not None and href_element.text == "/test/" def test_custom(self) -> None: """Custom authentication.""" self.configure({"auth": {"type": "radicale.tests.custom.auth"}}) self.propfind("/tmp/", login="tmp:") Radicale-3.1.8/radicale/tests/test_base.py000066400000000000000000002175251426407556000205050ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . """ Radicale tests with simple requests. """ import os import posixpath from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple import defusedxml.ElementTree as DefusedET from radicale import storage, xmlutils from radicale.tests import RESPONSES, BaseTest from radicale.tests.helpers import get_file_content class TestBaseRequests(BaseTest): """Tests with simple requests.""" # 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) rights_file_path = os.path.join(self.colpath, "rights") with open(rights_file_path, "w") as f: f.write("""\ [allow all] user: .* collection: .* permissions: RrWw""") self.configure({"rights": {"file": rights_file_path, "type": "from_file"}}) def test_root(self) -> None: """GET request at "/".""" for path in ["", "/", "//"]: _, headers, answer = self.request("GET", path, check=302) assert headers.get("Location") == "/.web" assert answer == "Redirected to /.web" def test_root_script_name(self) -> None: """GET request at "/" with SCRIPT_NAME.""" for path in ["", "/", "//"]: _, headers, _ = self.request("GET", path, check=302, SCRIPT_NAME="/radicale") assert headers.get("Location") == "/radicale/.web" def test_root_broken_script_name(self) -> None: """GET request at "/" with SCRIPT_NAME ending with "/".""" for script_name, prefix in [ ("/", ""), ("//", ""), ("/radicale/", "/radicale"), ("radicale", None), ("radicale//", None)]: _, headers, _ = self.request( "GET", "/", check=500 if prefix is None else 302, SCRIPT_NAME=script_name) assert (prefix is None or headers.get("Location") == prefix + "/.web") def test_root_http_x_script_name(self) -> None: """GET request at "/" with HTTP_X_SCRIPT_NAME.""" for path in ["", "/", "//"]: _, headers, _ = self.request("GET", path, check=302, HTTP_X_SCRIPT_NAME="/radicale") assert headers.get("Location") == "/radicale/.web" def test_root_broken_http_x_script_name(self) -> None: """GET request at "/" with HTTP_X_SCRIPT_NAME ending with "/".""" for script_name, prefix in [ ("/", ""), ("//", ""), ("/radicale/", "/radicale"), ("radicale", None), ("radicale//", None)]: _, headers, _ = self.request( "GET", "/", check=400 if prefix is None else 302, HTTP_X_SCRIPT_NAME=script_name) assert (prefix is None or headers.get("Location") == prefix + "/.web") def test_sanitized_path(self) -> None: """GET request with unsanitized paths.""" for path, sane_path in [ ("//.web", "/.web"), ("//.web/", "/.web/"), ("/.web//", "/.web/"), ("/.web/a//b", "/.web/a/b")]: _, headers, _ = self.request("GET", path, check=301) assert headers.get("Location") == sane_path _, headers, _ = self.request("GET", path, check=301, SCRIPT_NAME="/radicale") assert headers.get("Location") == "/radicale%s" % sane_path _, headers, _ = self.request("GET", path, check=301, HTTP_X_SCRIPT_NAME="/radicale") assert headers.get("Location") == "/radicale%s" % sane_path def test_add_event(self) -> None: """Add an event.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") path = "/calendar.ics/event1.ics" self.put(path, event) _, headers, answer = self.request("GET", path, check=200) assert "ETag" in headers assert headers["Content-Type"] == "text/calendar; charset=utf-8" assert "VEVENT" in answer assert "Event" in answer assert "UID:event" in answer def test_add_event_without_uid(self) -> None: """Add an event without UID.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics").replace("UID:event1\n", "") assert "\nUID:" not in event path = "/calendar.ics/event.ics" self.put(path, event, check=400) def test_add_event_duplicate_uid(self) -> None: """Add an event with an existing UID.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") self.put("/calendar.ics/event1.ics", event) status, answer = self.put( "/calendar.ics/event1-duplicate.ics", event, check=None) 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_add_event_with_mixed_datetime_and_date(self) -> None: """Test event with DTSTART as DATE-TIME and EXDATE as DATE.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event_mixed_datetime_and_date.ics") self.put("/calendar.ics/event.ics", event) def test_add_todo(self) -> None: """Add a todo.""" self.mkcalendar("/calendar.ics/") todo = get_file_content("todo1.ics") path = "/calendar.ics/todo1.ics" self.put(path, todo) _, headers, answer = self.request("GET", path, check=200) assert "ETag" in headers assert headers["Content-Type"] == "text/calendar; charset=utf-8" assert "VTODO" in answer assert "Todo" in answer assert "UID:todo" in answer def test_add_contact(self) -> None: """Add a contact.""" self.create_addressbook("/contacts.vcf/") contact = get_file_content("contact1.vcf") path = "/contacts.vcf/contact.vcf" self.put(path, contact) _, headers, answer = self.request("GET", path, check=200) assert "ETag" in headers assert headers["Content-Type"] == "text/vcard; charset=utf-8" assert "VCARD" in answer assert "UID:contact1" in answer _, answer = self.get(path) assert "UID:contact1" in answer def test_add_contact_photo_with_data_uri(self) -> None: """Test workaround for broken PHOTO data from InfCloud""" self.create_addressbook("/contacts.vcf/") contact = get_file_content("contact_photo_with_data_uri.vcf") self.put("/contacts.vcf/contact.vcf", contact) def test_add_contact_without_uid(self) -> None: """Add a contact without UID.""" self.create_addressbook("/contacts.vcf/") contact = get_file_content("contact1.vcf").replace("UID:contact1\n", "") assert "\nUID" not in contact path = "/contacts.vcf/contact.vcf" self.put(path, contact, check=400) def test_update_event(self) -> None: """Update an event.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") event_modified = get_file_content("event1_modified.ics") path = "/calendar.ics/event1.ics" self.put(path, event) self.put(path, event_modified) _, answer = self.get("/calendar.ics/") assert answer.count("BEGIN:VEVENT") == 1 _, answer = self.get(path) assert "DTSTAMP:20130902T150159Z" in answer def test_update_event_uid_event(self) -> None: """Update an event with a different UID.""" self.mkcalendar("/calendar.ics/") event1 = get_file_content("event1.ics") event2 = get_file_content("event2.ics") path = "/calendar.ics/event1.ics" self.put(path, event1) status, answer = self.put(path, event2, check=None) 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_put_whole_calendar(self) -> None: """Create and overwrite a whole calendar.""" self.put("/calendar.ics/", "BEGIN:VCALENDAR\r\nEND:VCALENDAR") event1 = get_file_content("event1.ics") self.put("/calendar.ics/test_event.ics", event1) # Overwrite events = get_file_content("event_multiple.ics") self.put("/calendar.ics/", events) self.get("/calendar.ics/test_event.ics", check=404) _, answer = self.get("/calendar.ics/") assert "\r\nUID:event\r\n" in answer and "\r\nUID:todo\r\n" in answer assert "\r\nUID:event1\r\n" not in answer def test_put_whole_calendar_without_uids(self) -> None: """Create a whole calendar without UID.""" event = get_file_content("event_multiple.ics") event = event.replace("UID:event\n", "").replace("UID:todo\n", "") assert "\nUID:" not in event self.put("/calendar.ics/", event) _, answer = self.get("/calendar.ics") uids = [] for line in answer.split("\r\n"): if line.startswith("UID:"): uids.append(line[len("UID:"):]) assert len(uids) == 2 for i, uid1 in enumerate(uids): assert uid1 for uid2 in uids[i + 1:]: assert uid1 != uid2 def test_put_whole_calendar_case_sensitive_uids(self) -> None: """Create a whole calendar with case-sensitive UIDs.""" events = get_file_content("event_multiple_case_sensitive_uids.ics") self.put("/calendar.ics/", events) _, answer = self.get("/calendar.ics/") assert "\r\nUID:event\r\n" in answer and "\r\nUID:EVENT\r\n" in answer def test_put_whole_addressbook(self) -> None: """Create and overwrite a whole addressbook.""" contacts = get_file_content("contact_multiple.vcf") self.put("/contacts.vcf/", contacts) _, answer = self.get("/contacts.vcf/") assert answer is not None assert "\r\nUID:contact1\r\n" in answer assert "\r\nUID:contact2\r\n" in answer def test_put_whole_addressbook_without_uids(self) -> None: """Create a whole addressbook without UID.""" contacts = get_file_content("contact_multiple.vcf") contacts = contacts.replace("UID:contact1\n", "").replace( "UID:contact2\n", "") assert "\nUID:" not in contacts self.put("/contacts.vcf/", contacts) _, answer = self.get("/contacts.vcf") uids = [] for line in answer.split("\r\n"): if line.startswith("UID:"): uids.append(line[len("UID:"):]) assert len(uids) == 2 for i, uid1 in enumerate(uids): assert uid1 for uid2 in uids[i + 1:]: assert uid1 != uid2 def test_verify(self) -> None: """Verify the storage.""" contacts = get_file_content("contact_multiple.vcf") self.put("/contacts.vcf/", contacts) events = get_file_content("event_multiple.ics") self.put("/calendar.ics/", events) s = storage.load(self.configuration) assert s.verify() def test_delete(self) -> None: """Delete an event.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") path = "/calendar.ics/event1.ics" self.put(path, event) _, responses = self.delete(path) assert responses[path] == 200 _, answer = self.get("/calendar.ics/") assert "VEVENT" not in answer def test_mkcalendar(self) -> None: """Make a calendar.""" self.mkcalendar("/calendar.ics/") _, answer = self.get("/calendar.ics/") assert "BEGIN:VCALENDAR" in answer assert "END:VCALENDAR" in answer def test_mkcalendar_overwrite(self) -> None: """Try to overwrite an existing calendar.""" self.mkcalendar("/calendar.ics/") status, answer = self.mkcalendar("/calendar.ics/", check=None) assert status in (403, 409) xml = DefusedET.fromstring(answer) assert xml.tag == xmlutils.make_clark("D:error") assert xml.find(xmlutils.make_clark( "D:resource-must-be-null")) is not None def test_mkcalendar_intermediate(self) -> None: """Try make a calendar in a unmapped collection.""" self.mkcalendar("/unmapped/calendar.ics/", check=409) def test_mkcol(self) -> None: """Make a collection.""" self.mkcol("/user/") def test_mkcol_overwrite(self) -> None: """Try to overwrite an existing collection.""" self.mkcol("/user/") self.mkcol("/user/", check=405) def test_mkcol_intermediate(self) -> None: """Try make a collection in a unmapped collection.""" self.mkcol("/unmapped/user/", check=409) def test_mkcol_make_calendar(self) -> None: """Make a calendar with additional props.""" mkcol_make_calendar = get_file_content("mkcol_make_calendar.xml") self.mkcol("/calendar.ics/", mkcol_make_calendar) _, answer = self.get("/calendar.ics/") assert answer is not None assert "BEGIN:VCALENDAR" in answer assert "END:VCALENDAR" in answer # Read additional properties propfind = get_file_content("propfind_calendar_color.xml") _, responses = self.propfind("/calendar.ics/", propfind) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 1 status, prop = response["ICAL:calendar-color"] assert status == 200 and prop.text == "#BADA55" def test_move(self) -> None: """Move a item.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") path1 = "/calendar.ics/event1.ics" path2 = "/calendar.ics/event2.ics" self.put(path1, event) self.request("MOVE", path1, check=201, HTTP_DESTINATION=path2, HTTP_HOST="") self.get(path1, check=404) self.get(path2) def test_move_between_colections(self) -> None: """Move a item.""" self.mkcalendar("/calendar1.ics/") self.mkcalendar("/calendar2.ics/") event = get_file_content("event1.ics") path1 = "/calendar1.ics/event1.ics" path2 = "/calendar2.ics/event2.ics" self.put(path1, event) self.request("MOVE", path1, check=201, HTTP_DESTINATION=path2, HTTP_HOST="") self.get(path1, check=404) self.get(path2) def test_move_between_colections_duplicate_uid(self) -> None: """Move a item to a collection which already contains the UID.""" self.mkcalendar("/calendar1.ics/") self.mkcalendar("/calendar2.ics/") event = get_file_content("event1.ics") path1 = "/calendar1.ics/event1.ics" path2 = "/calendar2.ics/event2.ics" self.put(path1, event) self.put("/calendar2.ics/event1.ics", event) status, _, answer = self.request( "MOVE", path1, HTTP_DESTINATION=path2, HTTP_HOST="") 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: """Move a item to a collection which already contains the item.""" self.mkcalendar("/calendar1.ics/") self.mkcalendar("/calendar2.ics/") event = get_file_content("event1.ics") path1 = "/calendar1.ics/event1.ics" path2 = "/calendar2.ics/event1.ics" 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") def test_move_between_colections_overwrite_uid_conflict(self) -> None: """Move a item to a collection which already contains the item with a different UID.""" self.mkcalendar("/calendar1.ics/") self.mkcalendar("/calendar2.ics/") event1 = get_file_content("event1.ics") event2 = get_file_content("event2.ics") path1 = "/calendar1.ics/event1.ics" 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") 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_head(self) -> None: _, headers, answer = self.request("HEAD", "/", check=302) assert int(headers.get("Content-Length", "0")) > 0 and not answer def test_options(self) -> None: _, headers, _ = self.request("OPTIONS", "/", check=200) assert "DAV" in headers def test_delete_collection(self) -> None: """Delete a collection.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") self.put("/calendar.ics/event1.ics", event) _, responses = self.delete("/calendar.ics/") assert responses["/calendar.ics/"] == 200 self.get("/calendar.ics/", check=404) def test_delete_root_collection(self) -> None: """Delete the root collection.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") self.put("/event1.ics", event) self.put("/calendar.ics/event1.ics", event) _, responses = self.delete("/") assert len(responses) == 1 and responses["/"] == 200 self.get("/calendar.ics/", check=404) self.get("/event1.ics", 404) def test_propfind(self) -> None: calendar_path = "/calendar.ics/" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") event_path = posixpath.join(calendar_path, "event.ics") self.put(event_path, event) _, responses = self.propfind("/", HTTP_DEPTH="1") assert len(responses) == 2 assert "/" in responses and calendar_path in responses _, responses = self.propfind(calendar_path, HTTP_DEPTH="1") assert len(responses) == 2 assert calendar_path in responses and event_path in responses def test_propfind_propname(self) -> None: self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") self.put("/calendar.ics/event.ics", event) propfind = get_file_content("propname.xml") _, responses = self.propfind("/calendar.ics/", propfind) response = responses["/calendar.ics/"] assert not isinstance(response, int) status, prop = response["D:sync-token"] assert status == 200 and not prop.text _, responses = self.propfind("/calendar.ics/event.ics", propfind) response = responses["/calendar.ics/event.ics"] assert not isinstance(response, int) status, prop = response["D:getetag"] assert status == 200 and not prop.text def test_propfind_allprop(self) -> None: self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") self.put("/calendar.ics/event.ics", event) propfind = get_file_content("allprop.xml") _, responses = self.propfind("/calendar.ics/", propfind) response = responses["/calendar.ics/"] assert not isinstance(response, int) status, prop = response["D:sync-token"] assert status == 200 and prop.text _, responses = self.propfind("/calendar.ics/event.ics", propfind) response = responses["/calendar.ics/event.ics"] assert not isinstance(response, int) status, prop = response["D:getetag"] assert status == 200 and prop.text def test_propfind_nonexistent(self) -> None: """Read a property that does not exist.""" self.mkcalendar("/calendar.ics/") propfind = get_file_content("propfind_calendar_color.xml") _, responses = self.propfind("/calendar.ics/", propfind) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 1 status, prop = response["ICAL:calendar-color"] assert status == 404 and not prop.text def test_proppatch(self) -> None: """Set/Remove a property and read it back.""" self.mkcalendar("/calendar.ics/") proppatch = get_file_content("proppatch_set_calendar_color.xml") _, responses = self.proppatch("/calendar.ics/", proppatch) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 1 status, prop = response["ICAL:calendar-color"] assert status == 200 and not prop.text # Read property back propfind = get_file_content("propfind_calendar_color.xml") _, responses = self.propfind("/calendar.ics/", propfind) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 1 status, prop = response["ICAL:calendar-color"] assert status == 200 and prop.text == "#BADA55" propfind = get_file_content("allprop.xml") _, responses = self.propfind("/calendar.ics/", propfind) response = responses["/calendar.ics/"] assert not isinstance(response, int) status, prop = response["ICAL:calendar-color"] assert status == 200 and prop.text == "#BADA55" # Remove property proppatch = get_file_content("proppatch_remove_calendar_color.xml") _, responses = self.proppatch("/calendar.ics/", proppatch) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 1 status, prop = response["ICAL:calendar-color"] assert status == 200 and not prop.text # Read property back propfind = get_file_content("propfind_calendar_color.xml") _, responses = self.propfind("/calendar.ics/", propfind) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 1 status, prop = response["ICAL:calendar-color"] assert status == 404 def test_proppatch_multiple1(self) -> None: """Set/Remove a multiple properties and read them back.""" self.mkcalendar("/calendar.ics/") propfind = get_file_content("propfind_multiple.xml") proppatch = get_file_content("proppatch_set_multiple1.xml") _, responses = self.proppatch("/calendar.ics/", proppatch) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 2 status, prop = response["ICAL:calendar-color"] assert status == 200 and not prop.text status, prop = response["C:calendar-description"] assert status == 200 and not prop.text # Read properties back _, responses = self.propfind("/calendar.ics/", propfind) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 2 status, prop = response["ICAL:calendar-color"] assert status == 200 and prop.text == "#BADA55" status, prop = response["C:calendar-description"] assert status == 200 and prop.text == "test" # Remove properties proppatch = get_file_content("proppatch_remove_multiple1.xml") _, responses = self.proppatch("/calendar.ics/", proppatch) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 2 status, prop = response["ICAL:calendar-color"] assert status == 200 and not prop.text status, prop = response["C:calendar-description"] assert status == 200 and not prop.text # Read properties back _, responses = self.propfind("/calendar.ics/", propfind) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 2 status, prop = response["ICAL:calendar-color"] assert status == 404 status, prop = response["C:calendar-description"] assert status == 404 def test_proppatch_multiple2(self) -> None: """Set/Remove a multiple properties and read them back.""" self.mkcalendar("/calendar.ics/") propfind = get_file_content("propfind_multiple.xml") proppatch = get_file_content("proppatch_set_multiple2.xml") _, responses = self.proppatch("/calendar.ics/", proppatch) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 2 status, prop = response["ICAL:calendar-color"] assert status == 200 and not prop.text status, prop = response["C:calendar-description"] assert status == 200 and not prop.text # Read properties back _, responses = self.propfind("/calendar.ics/", propfind) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 2 assert len(response) == 2 status, prop = response["ICAL:calendar-color"] assert status == 200 and prop.text == "#BADA55" status, prop = response["C:calendar-description"] assert status == 200 and prop.text == "test" # Remove properties proppatch = get_file_content("proppatch_remove_multiple2.xml") _, responses = self.proppatch("/calendar.ics/", proppatch) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 2 status, prop = response["ICAL:calendar-color"] assert status == 200 and not prop.text status, prop = response["C:calendar-description"] assert status == 200 and not prop.text # Read properties back _, responses = self.propfind("/calendar.ics/", propfind) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 2 status, prop = response["ICAL:calendar-color"] assert status == 404 status, prop = response["C:calendar-description"] assert status == 404 def test_proppatch_set_and_remove(self) -> None: """Set and remove multiple properties in single request.""" self.mkcalendar("/calendar.ics/") propfind = get_file_content("propfind_multiple.xml") # Prepare proppatch = get_file_content("proppatch_set_multiple1.xml") self.proppatch("/calendar.ics/", proppatch) # Remove and set properties in single request proppatch = get_file_content("proppatch_set_and_remove.xml") _, responses = self.proppatch("/calendar.ics/", proppatch) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 2 status, prop = response["ICAL:calendar-color"] assert status == 200 and not prop.text status, prop = response["C:calendar-description"] assert status == 200 and not prop.text # Read properties back _, responses = self.propfind("/calendar.ics/", propfind) response = responses["/calendar.ics/"] assert not isinstance(response, int) and len(response) == 2 status, prop = response["ICAL:calendar-color"] assert status == 404 status, prop = response["C:calendar-description"] assert status == 200 and prop.text == "test2" def test_put_whole_calendar_multiple_events_with_same_uid(self) -> None: """Add two events with the same UID.""" self.put("/calendar.ics/", get_file_content("event2.ics")) _, responses = self.report("/calendar.ics/", """\ """) assert len(responses) == 1 response = responses["/calendar.ics/event2.ics"] assert not isinstance(response, int) status, prop = response["D:getetag"] assert status == 200 and prop.text _, answer = self.get("/calendar.ics/") assert answer.count("BEGIN:VEVENT") == 2 def _test_filter(self, filters: Iterable[str], kind: str = "event", test: Optional[str] = None, items: Iterable[int] = (1,) ) -> List[str]: filter_template = "%s" create_collection_fn: Callable[[str], Any] if kind in ("event", "journal", "todo"): create_collection_fn = self.mkcalendar path = "/calendar.ics/" filename_template = "%s%d.ics" namespace = "urn:ietf:params:xml:ns:caldav" report = "calendar-query" elif kind == "contact": create_collection_fn = self.create_addressbook if test: filter_template = '%%s' % test path = "/contacts.vcf/" filename_template = "%s%d.vcf" namespace = "urn:ietf:params:xml:ns:carddav" report = "addressbook-query" else: raise ValueError("Unsupported kind: %r" % kind) status, _, = self.delete(path, check=None) assert status in (200, 404) create_collection_fn(path) for i in items: filename = filename_template % (kind, i) event = get_file_content(filename) self.put(posixpath.join(path, filename), event) filters_text = "".join(filter_template % f for f in filters) _, responses = self.report(path, """\ {2} """.format(namespace, report, filters_text)) assert responses is not None paths = [] for path, props in responses.items(): assert not isinstance(props, int) and len(props) == 1 status, prop = props["D:getetag"] assert status == 200 and prop.text paths.append(path) return paths def test_addressbook_empty_filter(self) -> None: self._test_filter([""], kind="contact") def test_addressbook_prop_filter(self) -> None: assert "/contacts.vcf/contact1.vcf" in self._test_filter(["""\ es """], "contact") assert "/contacts.vcf/contact1.vcf" in self._test_filter(["""\ es """], "contact") assert "/contacts.vcf/contact1.vcf" not in self._test_filter(["""\ a """], "contact") assert "/contacts.vcf/contact1.vcf" in self._test_filter(["""\ test """], "contact") assert "/contacts.vcf/contact1.vcf" not in self._test_filter(["""\ tes """], "contact") assert "/contacts.vcf/contact1.vcf" not in self._test_filter(["""\ est """], "contact") assert "/contacts.vcf/contact1.vcf" in self._test_filter(["""\ tes """], "contact") assert "/contacts.vcf/contact1.vcf" not in self._test_filter(["""\ est """], "contact") assert "/contacts.vcf/contact1.vcf" in self._test_filter(["""\ est """], "contact") assert "/contacts.vcf/contact1.vcf" not in self._test_filter(["""\ tes """], "contact") def test_addressbook_prop_filter_any(self) -> None: assert "/contacts.vcf/contact1.vcf" in self._test_filter(["""\ test test """], "contact", test="anyof") assert "/contacts.vcf/contact1.vcf" not in self._test_filter(["""\ a test """], "contact", test="anyof") assert "/contacts.vcf/contact1.vcf" in self._test_filter(["""\ test test """], "contact") def test_addressbook_prop_filter_all(self) -> None: assert "/contacts.vcf/contact1.vcf" in self._test_filter(["""\ tes est """], "contact", test="allof") assert "/contacts.vcf/contact1.vcf" not in self._test_filter(["""\ test test """], "contact", test="allof") def test_calendar_empty_filter(self) -> None: self._test_filter([""]) def test_calendar_tag_filter(self) -> None: """Report request with tag-based filter on calendar.""" assert "/calendar.ics/event1.ics" in self._test_filter(["""\ """]) def test_item_tag_filter(self) -> None: """Report request with tag-based filter on an item.""" assert "/calendar.ics/event1.ics" in self._test_filter(["""\ """]) assert "/calendar.ics/event1.ics" not in self._test_filter(["""\ """]) def test_item_not_tag_filter(self) -> None: """Report request with tag-based is-not filter on an item.""" assert "/calendar.ics/event1.ics" not in self._test_filter(["""\ """]) assert "/calendar.ics/event1.ics" in self._test_filter(["""\ """]) def test_item_prop_filter(self) -> None: """Report request with prop-based filter on an item.""" assert "/calendar.ics/event1.ics" in self._test_filter(["""\ """]) assert "/calendar.ics/event1.ics" not in self._test_filter(["""\ """]) def test_item_not_prop_filter(self) -> None: """Report request with prop-based is-not filter on an item.""" assert "/calendar.ics/event1.ics" not in self._test_filter(["""\ """]) assert "/calendar.ics/event1.ics" in self._test_filter(["""\ """]) def test_mutiple_filters(self) -> None: """Report request with multiple filters on an item.""" assert "/calendar.ics/event1.ics" not in self._test_filter(["""\ """, """ """]) assert "/calendar.ics/event1.ics" in self._test_filter(["""\ """, """ """]) assert "/calendar.ics/event1.ics" in self._test_filter(["""\ """]) def test_text_match_filter(self) -> None: """Report request with text-match filter on calendar.""" assert "/calendar.ics/event1.ics" in self._test_filter(["""\ event """]) assert "/calendar.ics/event1.ics" not in self._test_filter(["""\ event """]) assert "/calendar.ics/event1.ics" not in self._test_filter(["""\ unknown """]) assert "/calendar.ics/event1.ics" not in self._test_filter(["""\ event """]) def test_param_filter(self) -> None: """Report request with param-filter on calendar.""" assert "/calendar.ics/event1.ics" in self._test_filter(["""\ ACCEPTED """]) assert "/calendar.ics/event1.ics" not in self._test_filter(["""\ UNKNOWN """]) assert "/calendar.ics/event1.ics" not in self._test_filter(["""\ """]) assert "/calendar.ics/event1.ics" in self._test_filter(["""\ """]) def test_time_range_filter_events(self) -> None: """Report request with time-range filter on events.""" answer = self._test_filter(["""\ """], "event", items=range(1, 6)) assert "/calendar.ics/event1.ics" in answer assert "/calendar.ics/event2.ics" in answer assert "/calendar.ics/event3.ics" in answer assert "/calendar.ics/event4.ics" in answer assert "/calendar.ics/event5.ics" in answer answer = self._test_filter(["""\ """], "event", items=range(1, 6)) assert "/calendar.ics/event1.ics" not in answer answer = self._test_filter(["""\ """], items=range(1, 6)) assert "/calendar.ics/event1.ics" not in answer assert "/calendar.ics/event2.ics" not in answer assert "/calendar.ics/event3.ics" not in answer assert "/calendar.ics/event4.ics" not in answer assert "/calendar.ics/event5.ics" not in answer answer = self._test_filter(["""\ """], items=range(1, 6)) assert "/calendar.ics/event1.ics" not in answer assert "/calendar.ics/event2.ics" in answer assert "/calendar.ics/event3.ics" in answer assert "/calendar.ics/event4.ics" in answer assert "/calendar.ics/event5.ics" in answer answer = self._test_filter(["""\ """], items=range(1, 6)) assert "/calendar.ics/event1.ics" not in answer assert "/calendar.ics/event2.ics" not in answer assert "/calendar.ics/event3.ics" in answer assert "/calendar.ics/event4.ics" in answer assert "/calendar.ics/event5.ics" in answer answer = self._test_filter(["""\ """], items=range(1, 6)) assert "/calendar.ics/event1.ics" not in answer assert "/calendar.ics/event2.ics" not in answer assert "/calendar.ics/event3.ics" in answer assert "/calendar.ics/event4.ics" not in answer assert "/calendar.ics/event5.ics" not in answer answer = self._test_filter(["""\ """], items=range(1, 6)) assert "/calendar.ics/event1.ics" not in answer assert "/calendar.ics/event2.ics" not in answer assert "/calendar.ics/event3.ics" not in answer assert "/calendar.ics/event4.ics" not in answer assert "/calendar.ics/event5.ics" not in answer # HACK: VObject doesn't match RECURRENCE-ID to recurrences, the # overwritten recurrence is still used for filtering. answer = self._test_filter(["""\ """], items=(6, 7, 8, 9)) assert "/calendar.ics/event6.ics" in answer assert "/calendar.ics/event7.ics" in answer assert "/calendar.ics/event8.ics" in answer assert "/calendar.ics/event9.ics" in answer answer = self._test_filter(["""\ """], items=(6, 7, 8, 9)) assert "/calendar.ics/event6.ics" in answer assert "/calendar.ics/event7.ics" in answer assert "/calendar.ics/event8.ics" in answer assert "/calendar.ics/event9.ics" not in answer answer = self._test_filter(["""\ """], items=(6, 7, 8, 9)) assert "/calendar.ics/event6.ics" not in answer assert "/calendar.ics/event7.ics" not in answer assert "/calendar.ics/event8.ics" not in answer assert "/calendar.ics/event9.ics" not in answer answer = self._test_filter(["""\ """], items=(9,)) assert "/calendar.ics/event9.ics" in answer answer = self._test_filter(["""\ """], items=(9,)) assert "/calendar.ics/event9.ics" not in answer def test_time_range_filter_events_rrule(self) -> None: """Report request with time-range filter on events with rrules.""" answer = self._test_filter(["""\ """], "event", items=(1, 2)) assert "/calendar.ics/event1.ics" in answer assert "/calendar.ics/event2.ics" in answer answer = self._test_filter(["""\ """], "event", items=(1, 2)) assert "/calendar.ics/event1.ics" not in answer assert "/calendar.ics/event2.ics" in answer answer = self._test_filter(["""\ """], "event", items=(1, 2)) assert "/calendar.ics/event1.ics" not in answer assert "/calendar.ics/event2.ics" not in answer answer = self._test_filter(["""\ """], "event", items=(1, 2)) assert "/calendar.ics/event1.ics" not in answer assert "/calendar.ics/event2.ics" not in answer def test_time_range_filter_todos(self) -> None: """Report request with time-range filter on todos.""" answer = self._test_filter(["""\ """], "todo", items=range(1, 9)) assert "/calendar.ics/todo1.ics" in answer assert "/calendar.ics/todo2.ics" in answer assert "/calendar.ics/todo3.ics" in answer assert "/calendar.ics/todo4.ics" in answer assert "/calendar.ics/todo5.ics" in answer assert "/calendar.ics/todo6.ics" in answer assert "/calendar.ics/todo7.ics" in answer assert "/calendar.ics/todo8.ics" in answer answer = self._test_filter(["""\ """], "todo", items=range(1, 9)) assert "/calendar.ics/todo1.ics" not in answer assert "/calendar.ics/todo2.ics" in answer assert "/calendar.ics/todo3.ics" in answer assert "/calendar.ics/todo4.ics" not in answer assert "/calendar.ics/todo5.ics" not in answer assert "/calendar.ics/todo6.ics" not in answer assert "/calendar.ics/todo7.ics" in answer assert "/calendar.ics/todo8.ics" in answer answer = self._test_filter(["""\ """], "todo", items=range(1, 9)) assert "/calendar.ics/todo2.ics" not in answer answer = self._test_filter(["""\ """], "todo", items=range(1, 9)) assert "/calendar.ics/todo2.ics" not in answer answer = self._test_filter(["""\ """], "todo", items=range(1, 9)) assert "/calendar.ics/todo3.ics" not in answer answer = self._test_filter(["""\ """], "todo", items=range(1, 9)) assert "/calendar.ics/todo7.ics" in answer def test_time_range_filter_todos_rrule(self) -> None: """Report request with time-range filter on todos with rrules.""" answer = self._test_filter(["""\ """], "todo", items=(1, 2, 9)) assert "/calendar.ics/todo1.ics" in answer assert "/calendar.ics/todo2.ics" in answer assert "/calendar.ics/todo9.ics" in answer answer = self._test_filter(["""\ """], "todo", items=(1, 2, 9)) assert "/calendar.ics/todo1.ics" not in answer assert "/calendar.ics/todo2.ics" in answer assert "/calendar.ics/todo9.ics" in answer answer = self._test_filter(["""\ """], "todo", items=(1, 2)) assert "/calendar.ics/todo1.ics" not in answer assert "/calendar.ics/todo2.ics" in answer answer = self._test_filter(["""\ """], "todo", items=(1, 2)) assert "/calendar.ics/todo1.ics" not in answer assert "/calendar.ics/todo2.ics" not in answer answer = self._test_filter(["""\ """], "todo", items=(9,)) assert "/calendar.ics/todo9.ics" not in answer def test_time_range_filter_journals(self) -> None: """Report request with time-range filter on journals.""" answer = self._test_filter(["""\ """], "journal", items=(1, 2, 3)) assert "/calendar.ics/journal1.ics" not in answer assert "/calendar.ics/journal2.ics" in answer assert "/calendar.ics/journal3.ics" in answer answer = self._test_filter(["""\ """], "journal", items=(1, 2, 3)) assert "/calendar.ics/journal1.ics" not in answer assert "/calendar.ics/journal2.ics" in answer assert "/calendar.ics/journal3.ics" in answer answer = self._test_filter(["""\ """], "journal", items=(1, 2, 3)) assert "/calendar.ics/journal1.ics" not in answer assert "/calendar.ics/journal2.ics" not in answer assert "/calendar.ics/journal3.ics" not in answer answer = self._test_filter(["""\ """], "journal", items=(1, 2, 3)) assert "/calendar.ics/journal1.ics" not in answer assert "/calendar.ics/journal2.ics" in answer assert "/calendar.ics/journal3.ics" not in answer answer = self._test_filter(["""\ """], "journal", items=(1, 2, 3)) assert "/calendar.ics/journal1.ics" not in answer assert "/calendar.ics/journal2.ics" in answer assert "/calendar.ics/journal3.ics" in answer def test_time_range_filter_journals_rrule(self) -> None: """Report request with time-range filter on journals with rrules.""" answer = self._test_filter(["""\ """], "journal", items=(1, 2)) assert "/calendar.ics/journal1.ics" not in answer assert "/calendar.ics/journal2.ics" in answer answer = self._test_filter(["""\ """], "journal", items=(1, 2)) assert "/calendar.ics/journal1.ics" not in answer assert "/calendar.ics/journal2.ics" in answer answer = self._test_filter(["""\ """], "journal", items=(1, 2)) assert "/calendar.ics/journal1.ics" not in answer assert "/calendar.ics/journal2.ics" not in answer def test_report_item(self) -> None: """Test report request on an item""" calendar_path = "/calendar.ics/" self.mkcalendar(calendar_path) event = get_file_content("event1.ics") event_path = posixpath.join(calendar_path, "event.ics") self.put(event_path, event) _, responses = self.report(event_path, """\ """) assert len(responses) == 1 response = responses[event_path] assert not isinstance(response, int) status, prop = response["D:getetag"] assert status == 200 and prop.text def _report_sync_token( self, calendar_path: str, sync_token: Optional[str] = None ) -> Tuple[str, RESPONSES]: sync_token_xml = ( "" % sync_token if sync_token else "") status, _, answer = self.request("REPORT", calendar_path, """\ %s """ % sync_token_xml) xml = DefusedET.fromstring(answer) if status in (403, 409): assert xml.tag == xmlutils.make_clark("D:error") assert sync_token and xml.find( xmlutils.make_clark("D:valid-sync-token")) is not None return "", {} assert status == 207 assert xml.tag == xmlutils.make_clark("D:multistatus") sync_token = xml.find(xmlutils.make_clark("D:sync-token")).text.strip() assert sync_token responses = self.parse_responses(answer) for href, response in responses.items(): if not isinstance(response, int): status, prop = response["D:getetag"] assert status == 200 and prop.text and len(response) == 1 responses[href] = response = 200 assert response in (200, 404) return sync_token, responses def test_report_sync_collection_no_change(self) -> None: """Test sync-collection report without modifying the collection""" calendar_path = "/calendar.ics/" self.mkcalendar(calendar_path) event = get_file_content("event1.ics") event_path = posixpath.join(calendar_path, "event.ics") self.put(event_path, event) sync_token, responses = self._report_sync_token(calendar_path) assert len(responses) == 1 and responses[event_path] == 200 new_sync_token, responses = self._report_sync_token( calendar_path, sync_token) if not self.full_sync_token_support and not new_sync_token: return assert sync_token == new_sync_token and len(responses) == 0 def test_report_sync_collection_add(self) -> None: """Test sync-collection report with an added item""" calendar_path = "/calendar.ics/" self.mkcalendar(calendar_path) sync_token, responses = self._report_sync_token(calendar_path) assert len(responses) == 0 event = get_file_content("event1.ics") event_path = posixpath.join(calendar_path, "event.ics") self.put(event_path, event) sync_token, responses = self._report_sync_token( calendar_path, sync_token) if not self.full_sync_token_support and not sync_token: return assert len(responses) == 1 and responses[event_path] == 200 def test_report_sync_collection_delete(self) -> None: """Test sync-collection report with a deleted item""" calendar_path = "/calendar.ics/" self.mkcalendar(calendar_path) event = get_file_content("event1.ics") event_path = posixpath.join(calendar_path, "event.ics") self.put(event_path, event) sync_token, responses = self._report_sync_token(calendar_path) assert len(responses) == 1 and responses[event_path] == 200 self.delete(event_path) sync_token, responses = self._report_sync_token( calendar_path, sync_token) if not self.full_sync_token_support and not sync_token: return assert len(responses) == 1 and responses[event_path] == 404 def test_report_sync_collection_create_delete(self) -> None: """Test sync-collection report with a created and deleted item""" calendar_path = "/calendar.ics/" self.mkcalendar(calendar_path) sync_token, responses = self._report_sync_token(calendar_path) assert len(responses) == 0 event = get_file_content("event1.ics") event_path = posixpath.join(calendar_path, "event.ics") self.put(event_path, event) self.delete(event_path) sync_token, responses = self._report_sync_token( calendar_path, sync_token) if not self.full_sync_token_support and not sync_token: return assert len(responses) == 1 and responses[event_path] == 404 def test_report_sync_collection_modify_undo(self) -> None: """Test sync-collection report with a modified and changed back item""" calendar_path = "/calendar.ics/" self.mkcalendar(calendar_path) event1 = get_file_content("event1.ics") event2 = get_file_content("event1_modified.ics") event_path = posixpath.join(calendar_path, "event.ics") self.put(event_path, event1) sync_token, responses = self._report_sync_token(calendar_path) assert len(responses) == 1 and responses[event_path] == 200 self.put(event_path, event2) self.put(event_path, event1) sync_token, responses = self._report_sync_token( calendar_path, sync_token) if not self.full_sync_token_support and not sync_token: return assert len(responses) == 1 and responses[event_path] == 200 def test_report_sync_collection_move(self) -> None: """Test sync-collection report a moved item""" calendar_path = "/calendar.ics/" self.mkcalendar(calendar_path) event = get_file_content("event1.ics") event1_path = posixpath.join(calendar_path, "event1.ics") event2_path = posixpath.join(calendar_path, "event2.ics") self.put(event1_path, event) 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="") sync_token, responses = self._report_sync_token( calendar_path, sync_token) if not self.full_sync_token_support and not sync_token: return assert len(responses) == 2 and (responses[event1_path] == 404 and responses[event2_path] == 200) def test_report_sync_collection_move_undo(self) -> None: """Test sync-collection report with a moved and moved back item""" calendar_path = "/calendar.ics/" self.mkcalendar(calendar_path) event = get_file_content("event1.ics") event1_path = posixpath.join(calendar_path, "event1.ics") event2_path = posixpath.join(calendar_path, "event2.ics") self.put(event1_path, event) 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="") self.request("MOVE", event2_path, check=201, HTTP_DESTINATION=event1_path, HTTP_HOST="") sync_token, responses = self._report_sync_token( calendar_path, sync_token) if not self.full_sync_token_support and not sync_token: return assert len(responses) == 2 and (responses[event1_path] == 200 and responses[event2_path] == 404) def test_report_sync_collection_invalid_sync_token(self) -> None: """Test sync-collection report with an invalid sync token""" calendar_path = "/calendar.ics/" self.mkcalendar(calendar_path) sync_token, _ = self._report_sync_token( calendar_path, "http://radicale.org/ns/sync/INVALID") assert not sync_token def test_propfind_sync_token(self) -> None: """Retrieve the sync-token with a propfind request""" calendar_path = "/calendar.ics/" self.mkcalendar(calendar_path) propfind = get_file_content("allprop.xml") _, responses = self.propfind(calendar_path, propfind) response = responses[calendar_path] assert not isinstance(response, int) status, sync_token = response["D:sync-token"] assert status == 200 and sync_token.text event = get_file_content("event1.ics") event_path = posixpath.join(calendar_path, "event.ics") self.put(event_path, event) _, responses = self.propfind(calendar_path, propfind) response = responses[calendar_path] assert not isinstance(response, int) status, new_sync_token = response["D:sync-token"] assert status == 200 and new_sync_token.text assert sync_token.text != new_sync_token.text def test_propfind_same_as_sync_collection_sync_token(self) -> None: """Compare sync-token property with sync-collection sync-token""" calendar_path = "/calendar.ics/" self.mkcalendar(calendar_path) propfind = get_file_content("allprop.xml") _, responses = self.propfind(calendar_path, propfind) response = responses[calendar_path] assert not isinstance(response, int) status, sync_token = response["D:sync-token"] assert status == 200 and sync_token.text report_sync_token, _ = self._report_sync_token(calendar_path) assert sync_token.text == report_sync_token def test_calendar_getcontenttype(self) -> None: """Test report request on an item""" self.mkcalendar("/test/") for component in ("event", "todo", "journal"): event = get_file_content("%s1.ics" % component) status, _ = self.delete("/test/test.ics", check=None) assert status in (200, 404) self.put("/test/test.ics", event) _, responses = self.report("/test/", """\ """) assert len(responses) == 1 response = responses["/test/test.ics"] assert not isinstance(response, int) and len(response) == 1 status, prop = response["D:getcontenttype"] assert status == 200 and prop.text == ( "text/calendar;charset=utf-8;component=V%s" % component.upper()) def test_addressbook_getcontenttype(self) -> None: """Test report request on an item""" self.create_addressbook("/test/") contact = get_file_content("contact1.vcf") self.put("/test/test.vcf", contact) _, responses = self.report("/test/", """\ """) assert len(responses) == 1 response = responses["/test/test.vcf"] assert not isinstance(response, int) and len(response) == 1 status, prop = response["D:getcontenttype"] assert status == 200 and prop.text == "text/vcard;charset=utf-8" def test_authorization(self) -> None: _, responses = self.propfind("/", """\ """, login="user:") response = responses["/"] assert not isinstance(response, int) and len(response) == 1 status, prop = response["D:current-user-principal"] assert status == 200 and len(prop) == 1 element = prop.find(xmlutils.make_clark("D:href")) assert element is not None and element.text == "/user/" def test_authentication(self) -> None: """Test if server sends authentication request.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": os.devnull, "htpasswd_encryption": "plain"}, "rights": {"type": "owner_only"}}) status, headers, _ = self.request("MKCOL", "/user/") assert status in (401, 403) assert headers.get("WWW-Authenticate") def test_principal_collection_creation(self) -> None: """Verify existence of the principal collection.""" self.propfind("/user/", login="user:") def test_authentication_current_user_principal_hack(self) -> None: """Test if server sends authentication request when accessing current-user-principal prop (workaround for DAVx5).""" status, headers, _ = self.request("PROPFIND", "/", """\ """) assert status in (401, 403) assert headers.get("WWW-Authenticate") def test_existence_of_root_collections(self) -> None: """Verify that the root collection always exists.""" # Use PROPFIND because GET returns message self.propfind("/") # it should still exist after deletion self.delete("/") self.propfind("/") def test_well_known(self) -> None: for path in ["/.well-known/caldav", "/.well-known/carddav"]: for path in [path, "/foo" + path]: _, headers, _ = self.request("GET", path, check=301) assert headers.get("Location") == "/" def test_well_known_script_name(self) -> None: for path in ["/.well-known/caldav", "/.well-known/carddav"]: for path in [path, "/foo" + path]: _, headers, _ = self.request( "GET", path, check=301, SCRIPT_NAME="/radicale") assert headers.get("Location") == "/radicale/" def test_well_known_not_found(self) -> None: for path in ["/.well-known", "/.well-known/", "/.well-known/foo"]: for path in [path, "/foo" + path]: self.get(path, check=404) def test_custom_headers(self) -> None: self.configure({"headers": {"test": "123"}}) # Test if header is set on success _, headers, _ = self.request("OPTIONS", "/", check=200) assert headers.get("test") == "123" # Test if header is set on failure _, headers, _ = self.request("GET", "/.well-known/foo", check=404) assert headers.get("test") == "123" def test_timezone_seconds(self) -> None: """Verify that timezones with minutes and seconds work.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event_timezone_seconds.ics") self.put("/calendar.ics/event.ics", event) Radicale-3.1.8/radicale/tests/test_config.py000066400000000000000000000172641426407556000210360ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2019 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 . import os import shutil import tempfile from configparser import RawConfigParser from typing import List, Tuple import pytest from radicale import config, types from radicale.tests.helpers import configuration_to_dict class TestConfig: """Test the configuration.""" colpath: str def setup(self) -> None: self.colpath = tempfile.mkdtemp() def teardown(self) -> None: shutil.rmtree(self.colpath) def _write_config(self, config_dict: types.CONFIG, name: str) -> str: parser = RawConfigParser() parser.read_dict(config_dict) config_path = os.path.join(self.colpath, name) with open(config_path, "w") as f: parser.write(f) return config_path def test_parse_compound_paths(self) -> None: assert len(config.parse_compound_paths()) == 0 assert len(config.parse_compound_paths("")) == 0 assert len(config.parse_compound_paths(None, "")) == 0 assert len(config.parse_compound_paths("config", "")) == 0 assert len(config.parse_compound_paths("config", None)) == 1 assert len(config.parse_compound_paths(os.pathsep.join(["", ""]))) == 0 assert len(config.parse_compound_paths(os.pathsep.join([ "", "config", ""]))) == 1 paths = config.parse_compound_paths(os.pathsep.join([ "config1", "?config2", "config3"])) assert len(paths) == 3 for i, (name, ignore_if_missing) in enumerate([ ("config1", False), ("config2", True), ("config3", False)]): assert os.path.isabs(paths[i][0]) assert os.path.basename(paths[i][0]) == name assert paths[i][1] is ignore_if_missing def test_load_empty(self) -> None: config_path = self._write_config({}, "config") config.load([(config_path, False)]) def test_load_full(self) -> None: config_path = self._write_config( configuration_to_dict(config.load()), "config") config.load([(config_path, False)]) def test_load_missing(self) -> None: config_path = os.path.join(self.colpath, "does_not_exist") config.load([(config_path, True)]) with pytest.raises(Exception) as exc_info: config.load([(config_path, False)]) e = exc_info.value assert "Failed to load config file %r" % config_path in str(e) def test_load_multiple(self) -> None: config_path1 = self._write_config({ "server": {"hosts": "192.0.2.1:1111"}}, "config1") config_path2 = self._write_config({ "server": {"max_connections": 1111}}, "config2") configuration = config.load([(config_path1, False), (config_path2, False)]) server_hosts: List[Tuple[str, int]] = configuration.get( "server", "hosts") assert len(server_hosts) == 1 assert server_hosts[0] == ("192.0.2.1", 1111) assert configuration.get("server", "max_connections") == 1111 def test_copy(self) -> None: configuration1 = config.load() configuration1.update({"server": {"max_connections": "1111"}}, "test") configuration2 = configuration1.copy() configuration2.update({"server": {"max_connections": "1112"}}, "test") assert configuration1.get("server", "max_connections") == 1111 assert configuration2.get("server", "max_connections") == 1112 def test_invalid_section(self) -> None: configuration = config.load() with pytest.raises(Exception) as exc_info: configuration.update({"does_not_exist": {"x": "x"}}, "test") e = exc_info.value assert "Invalid section 'does_not_exist'" in str(e) def test_invalid_option(self) -> None: configuration = config.load() with pytest.raises(Exception) as exc_info: configuration.update({"server": {"x": "x"}}, "test") e = exc_info.value assert "Invalid option 'x'" in str(e) assert "section 'server'" in str(e) def test_invalid_option_plugin(self) -> None: configuration = config.load() with pytest.raises(Exception) as exc_info: configuration.update({"auth": {"x": "x"}}, "test") e = exc_info.value assert "Invalid option 'x'" in str(e) assert "section 'auth'" in str(e) def test_invalid_value(self) -> None: configuration = config.load() with pytest.raises(Exception) as exc_info: configuration.update({"server": {"max_connections": "x"}}, "test") e = exc_info.value assert "Invalid positive_int" in str(e) assert "option 'max_connections" in str(e) assert "section 'server" in str(e) assert "'x'" in str(e) def test_privileged(self) -> None: configuration = config.load() configuration.update({"server": {"_internal_server": "True"}}, "test", privileged=True) with pytest.raises(Exception) as exc_info: configuration.update( {"server": {"_internal_server": "True"}}, "test") e = exc_info.value assert "Invalid option '_internal_server'" in str(e) def test_plugin_schema(self) -> None: plugin_schema: types.CONFIG_SCHEMA = { "auth": {"new_option": {"value": "False", "type": bool}}} configuration = config.load() configuration.update({"auth": {"type": "new_plugin"}}, "test") plugin_configuration = configuration.copy(plugin_schema) assert plugin_configuration.get("auth", "new_option") is False configuration.update({"auth": {"new_option": "True"}}, "test") plugin_configuration = configuration.copy(plugin_schema) assert plugin_configuration.get("auth", "new_option") is True def test_plugin_schema_duplicate_option(self) -> None: plugin_schema: types.CONFIG_SCHEMA = { "auth": {"type": {"value": "False", "type": bool}}} configuration = config.load() with pytest.raises(Exception) as exc_info: configuration.copy(plugin_schema) e = exc_info.value assert "option already exists in 'auth': 'type'" in str(e) def test_plugin_schema_invalid(self) -> None: plugin_schema: types.CONFIG_SCHEMA = { "server": {"new_option": {"value": "False", "type": bool}}} configuration = config.load() with pytest.raises(Exception) as exc_info: configuration.copy(plugin_schema) e = exc_info.value assert "not a plugin section: 'server" in str(e) def test_plugin_schema_option_invalid(self) -> None: plugin_schema: types.CONFIG_SCHEMA = {"auth": {}} configuration = config.load() configuration.update({"auth": {"type": "new_plugin", "new_option": False}}, "test") with pytest.raises(Exception) as exc_info: configuration.copy(plugin_schema) e = exc_info.value assert "Invalid option 'new_option'" in str(e) assert "section 'auth'" in str(e) Radicale-3.1.8/radicale/tests/test_rights.py000066400000000000000000000171441426407556000210660ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2017-2019 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 . """ Radicale tests with simple requests and rights. """ import os from radicale.tests import BaseTest from radicale.tests.helpers import get_file_content class TestBaseRightsRequests(BaseTest): """Tests basic requests with rights.""" def _test_rights(self, rights_type: str, user: str, path: str, mode: str, expected_status: int, with_auth: bool = True) -> None: assert mode in ("r", "w") assert user in ("", "tmp") htpasswd_file_path = os.path.join(self.colpath, ".htpasswd") with open(htpasswd_file_path, "w") as f: f.write("tmp:bepo\nother:bepo") self.configure({ "rights": {"type": rights_type}, "auth": {"type": "htpasswd" if with_auth else "none", "htpasswd_filename": htpasswd_file_path, "htpasswd_encryption": "plain"}}) for u in ("tmp", "other"): # Indirect creation of principal collection self.propfind("/%s/" % u, login="%s:bepo" % u) (self.propfind if mode == "r" else self.proppatch)( path, check=expected_status, login="tmp:bepo" if user else None) def test_owner_only(self) -> None: self._test_rights("owner_only", "", "/", "r", 401) self._test_rights("owner_only", "", "/", "w", 401) self._test_rights("owner_only", "", "/tmp/", "r", 401) self._test_rights("owner_only", "", "/tmp/", "w", 401) self._test_rights("owner_only", "tmp", "/", "r", 207) self._test_rights("owner_only", "tmp", "/", "w", 403) self._test_rights("owner_only", "tmp", "/tmp/", "r", 207) self._test_rights("owner_only", "tmp", "/tmp/", "w", 207) self._test_rights("owner_only", "tmp", "/other/", "r", 403) self._test_rights("owner_only", "tmp", "/other/", "w", 403) def test_owner_only_without_auth(self) -> None: self._test_rights("owner_only", "", "/", "r", 207, False) self._test_rights("owner_only", "", "/", "w", 401, False) self._test_rights("owner_only", "", "/tmp/", "r", 207, False) self._test_rights("owner_only", "", "/tmp/", "w", 207, False) def test_owner_write(self) -> None: self._test_rights("owner_write", "", "/", "r", 401) self._test_rights("owner_write", "", "/", "w", 401) self._test_rights("owner_write", "", "/tmp/", "r", 401) self._test_rights("owner_write", "", "/tmp/", "w", 401) self._test_rights("owner_write", "tmp", "/", "r", 207) self._test_rights("owner_write", "tmp", "/", "w", 403) self._test_rights("owner_write", "tmp", "/tmp/", "r", 207) self._test_rights("owner_write", "tmp", "/tmp/", "w", 207) self._test_rights("owner_write", "tmp", "/other/", "r", 207) self._test_rights("owner_write", "tmp", "/other/", "w", 403) def test_owner_write_without_auth(self) -> None: self._test_rights("owner_write", "", "/", "r", 207, False) self._test_rights("owner_write", "", "/", "w", 401, False) self._test_rights("owner_write", "", "/tmp/", "r", 207, False) self._test_rights("owner_write", "", "/tmp/", "w", 207, False) def test_authenticated(self) -> None: self._test_rights("authenticated", "", "/", "r", 401) self._test_rights("authenticated", "", "/", "w", 401) self._test_rights("authenticated", "", "/tmp/", "r", 401) self._test_rights("authenticated", "", "/tmp/", "w", 401) self._test_rights("authenticated", "tmp", "/", "r", 207) self._test_rights("authenticated", "tmp", "/", "w", 207) self._test_rights("authenticated", "tmp", "/tmp/", "r", 207) self._test_rights("authenticated", "tmp", "/tmp/", "w", 207) self._test_rights("authenticated", "tmp", "/other/", "r", 207) self._test_rights("authenticated", "tmp", "/other/", "w", 207) def test_authenticated_without_auth(self) -> None: self._test_rights("authenticated", "", "/", "r", 207, False) self._test_rights("authenticated", "", "/", "w", 207, False) self._test_rights("authenticated", "", "/tmp/", "r", 207, False) self._test_rights("authenticated", "", "/tmp/", "w", 207, False) def test_from_file(self) -> None: rights_file_path = os.path.join(self.colpath, "rights") with open(rights_file_path, "w") as f: f.write("""\ [owner] user: .+ collection: {user}(/.*)? permissions: RrWw [custom] user: .* collection: custom(/.*)? permissions: Rr""") self.configure({"rights": {"file": rights_file_path}}) self._test_rights("from_file", "", "/other/", "r", 401) self._test_rights("from_file", "tmp", "/other/", "r", 403) self._test_rights("from_file", "", "/custom/sub", "r", 404) self._test_rights("from_file", "tmp", "/custom/sub", "r", 404) self._test_rights("from_file", "", "/custom/sub", "w", 401) self._test_rights("from_file", "tmp", "/custom/sub", "w", 403) def test_from_file_limited_get(self): rights_file_path = os.path.join(self.colpath, "rights") with open(rights_file_path, "w") as f: f.write("""\ [write-all] user: tmp collection: .* permissions: RrWw [limited-public] user: .* collection: public/[^/]* permissions: i""") self.configure({"rights": {"type": "from_file", "file": rights_file_path}}) self.mkcalendar("/tmp/calendar", login="tmp:bepo") self.mkcol("/public", login="tmp:bepo") self.mkcalendar("/public/calendar", login="tmp:bepo") self.get("/tmp/calendar", check=401) self.get("/public/", check=401) self.get("/public/calendar") self.get("/public/calendar/1.ics", check=401) def test_custom(self) -> None: """Custom rights management.""" self._test_rights("radicale.tests.custom.rights", "", "/", "r", 401) self._test_rights( "radicale.tests.custom.rights", "", "/tmp/", "r", 207) def test_collections_and_items(self) -> None: """Test rights for creation of collections, calendars and items. Collections are allowed at "/" and "/.../". Calendars/Address books are allowed at "/.../.../". Items are allowed at "/.../.../...". """ self.mkcalendar("/", check=401) self.mkcalendar("/user/", check=401) self.mkcol("/user/") self.mkcol("/user/calendar/", check=401) self.mkcalendar("/user/calendar/") self.mkcol("/user/calendar/item", check=401) self.mkcalendar("/user/calendar/item", check=401) def test_put_collections_and_items(self) -> None: """Test rights for creation of calendars and items with PUT.""" self.put("/user/", "BEGIN:VCALENDAR\r\nEND:VCALENDAR", check=401) self.mkcol("/user/") self.put("/user/calendar/", "BEGIN:VCALENDAR\r\nEND:VCALENDAR") event1 = get_file_content("event1.ics") self.put("/user/calendar/event1.ics", event1) Radicale-3.1.8/radicale/tests/test_server.py000066400000000000000000000227201426407556000210700ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2018-2019 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 . """ Test the internal server. """ import errno import os import socket import ssl import subprocess import sys import threading import time from configparser import RawConfigParser from http.client import HTTPMessage from typing import IO, Callable, Dict, Optional, Tuple, cast from urllib import request from urllib.error import HTTPError, URLError import pytest from radicale import config, server from radicale.tests import BaseTest from radicale.tests.helpers import configuration_to_dict, get_file_path class DisabledRedirectHandler(request.HTTPRedirectHandler): def redirect_request( self, req: request.Request, fp: IO[bytes], code: int, msg: str, headers: HTTPMessage, newurl: str) -> None: return None class TestBaseServerRequests(BaseTest): """Test the internal server.""" shutdown_socket: socket.socket thread: threading.Thread opener: request.OpenerDirector def setup(self) -> None: super().setup() 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.sockname = sock.getsockname() self.configure({"server": {"hosts": "[%s]:%d" % self.sockname}, # Enable debugging for new processes "logging": {"level": "debug"}}) self.thread = threading.Thread(target=server.serve, args=( self.configuration, shutdown_socket_out)) ssl_context = ssl.create_default_context() ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE self.opener = request.build_opener( request.HTTPSHandler(context=ssl_context), DisabledRedirectHandler) def teardown(self) -> None: self.shutdown_socket.close() try: self.thread.join() except RuntimeError: # Thread never started pass super().teardown() def request(self, method: str, path: str, data: Optional[str] = None, check: Optional[int] = None, **kwargs ) -> Tuple[int, Dict[str, str], str]: """Send a request.""" login = kwargs.pop("login", None) if login is not None and not isinstance(login, str): raise TypeError("login argument must be %r, not %r" % (str, type(login))) if login: raise NotImplementedError is_alive_fn: Optional[Callable[[], bool]] = kwargs.pop( "is_alive_fn", None) headers: Dict[str, str] = kwargs for k, v in headers.items(): if not isinstance(v, str): raise TypeError("type of %r is %r, expected %r" % (k, type(v), str)) if is_alive_fn is None: is_alive_fn = self.thread.is_alive encoding: str = self.configuration.get("encoding", "request") scheme = "https" if self.configuration.get("server", "ssl") else "http" data_bytes = None if data: data_bytes = data.encode(encoding) req = request.Request( "%s://[%s]:%d%s" % (scheme, *self.sockname, path), data=data_bytes, headers=headers, method=method) while True: assert is_alive_fn() try: with self.opener.open(req) as f: return f.getcode(), dict(f.info()), f.read().decode() except HTTPError as e: assert check is None or e.code == check, "%d != %d" % (e.code, check) return e.code, dict(e.headers), e.read().decode() except URLError as e: if not isinstance(e.reason, ConnectionRefusedError): raise time.sleep(0.1) def test_root(self) -> None: self.thread.start() self.get("/", check=302) def test_ssl(self) -> None: self.configure({"server": {"ssl": "True", "certificate": get_file_path("cert.pem"), "key": get_file_path("key.pem")}}) self.thread.start() self.get("/", check=302) def test_bind_fail(self) -> None: for address_family, address in [(socket.AF_INET, "::1"), (socket.AF_INET6, "127.0.0.1")]: with socket.socket(address_family, socket.SOCK_STREAM) as sock: if address_family == socket.AF_INET6: # Only allow IPv6 connections to the IPv6 socket sock.setsockopt(server.COMPAT_IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) with pytest.raises(OSError) as exc_info: sock.bind((address, 0)) # See ``radicale.server.serve`` assert (isinstance(exc_info.value, socket.gaierror) and exc_info.value.errno in ( socket.EAI_NONAME, server.COMPAT_EAI_ADDRFAMILY, server.COMPAT_EAI_NODATA) or str(exc_info.value) == "address family mismatched" or exc_info.value.errno in ( errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT, errno.EPROTONOSUPPORT)) def test_ipv6(self) -> None: try: with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as sock: # Only allow IPv6 connections to the IPv6 socket sock.setsockopt( server.COMPAT_IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) # Find available port sock.bind(("::1", 0)) self.sockname = sock.getsockname()[:2] except OSError as e: if e.errno in (errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT, errno.EPROTONOSUPPORT): pytest.skip("IPv6 not supported") raise self.configure({"server": {"hosts": "[%s]:%d" % self.sockname}}) self.thread.start() self.get("/", check=302) def test_command_line_interface(self, with_bool_options=False) -> None: self.configure({"headers": {"Test-Server": "test"}}) config_args = [] for section in self.configuration.sections(): if section.startswith("_"): continue for option in self.configuration.options(section): if option.startswith("_"): continue long_name = "--%s-%s" % (section, option.replace("_", "-")) if with_bool_options and config.DEFAULT_CONFIG_SCHEMA.get( section, {}).get(option, {}).get("type") == bool: if not cast(bool, self.configuration.get(section, option)): long_name = "--no%s" % long_name[1:] config_args.append(long_name) else: config_args.append(long_name) raw_value = self.configuration.get_raw(section, option) assert isinstance(raw_value, str) config_args.append(raw_value) config_args.append("--headers-Test-Header=test") p = subprocess.Popen( [sys.executable, "-m", "radicale"] + config_args, env={**os.environ, "PYTHONPATH": os.pathsep.join(sys.path)}) try: status, headers, _ = self.request( "GET", "/", check=302, is_alive_fn=lambda: p.poll() is None) for key in self.configuration.options("headers"): assert headers.get(key) == self.configuration.get( "headers", key) finally: p.terminate() p.wait() if sys.platform != "win32": assert p.returncode == 0 def test_command_line_interface_with_bool_options(self) -> None: self.test_command_line_interface(with_bool_options=True) def test_wsgi_server(self) -> None: config_path = os.path.join(self.colpath, "config") parser = RawConfigParser() parser.read_dict(configuration_to_dict(self.configuration)) with open(config_path, "w") as f: parser.write(f) env = os.environ.copy() env["PYTHONPATH"] = os.pathsep.join(sys.path) env["RADICALE_CONFIG"] = config_path raw_server_hosts = self.configuration.get_raw("server", "hosts") assert isinstance(raw_server_hosts, str) p = subprocess.Popen([ sys.executable, "-m", "waitress", "--listen", raw_server_hosts, "radicale:application"], env=env) try: self.get("/", is_alive_fn=lambda: p.poll() is None, check=302) finally: p.terminate() p.wait() Radicale-3.1.8/radicale/tests/test_storage.py000066400000000000000000000165121426407556000212300ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2019 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 . """ Tests for storage backends. """ import os import shutil from typing import ClassVar, cast import pytest import radicale.tests.custom.storage_simple_sync from radicale.tests import BaseTest from radicale.tests.helpers import get_file_content from radicale.tests.test_base import TestBaseRequests as _TestBaseRequests class TestMultiFileSystem(BaseTest): """Tests for multifilesystem.""" def setup(self) -> None: _TestBaseRequests.setup(cast(_TestBaseRequests, self)) self.configure({"storage": {"type": "multifilesystem"}}) def test_folder_creation(self) -> None: """Verify that the folder is created.""" folder = os.path.join(self.colpath, "subfolder") self.configure({"storage": {"filesystem_folder": folder}}) assert os.path.isdir(folder) def test_fsync(self) -> None: """Create a directory and file with syncing enabled.""" self.configure({"storage": {"_filesystem_fsync": "True"}}) self.mkcalendar("/calendar.ics/") def test_hook(self) -> None: """Run hook.""" self.configure({"storage": {"hook": "mkdir %s" % os.path.join( "collection-root", "created_by_hook")}}) self.mkcalendar("/calendar.ics/") self.propfind("/created_by_hook/") def test_hook_read_access(self) -> None: """Verify that hook is not run for read accesses.""" self.configure({"storage": {"hook": "mkdir %s" % os.path.join( "collection-root", "created_by_hook")}}) self.propfind("/") self.propfind("/created_by_hook/", check=404) @pytest.mark.skipif(not shutil.which("flock"), reason="flock command not found") def test_hook_storage_locked(self) -> None: """Verify that the storage is locked when the hook runs.""" self.configure({"storage": {"hook": ( "flock -n .Radicale.lock || exit 0; exit 1")}}) self.mkcalendar("/calendar.ics/") def test_hook_principal_collection_creation(self) -> None: """Verify that the hooks runs when a new user is created.""" self.configure({"storage": {"hook": "mkdir %s" % os.path.join( "collection-root", "created_by_hook")}}) self.propfind("/", login="user:") self.propfind("/created_by_hook/") def test_hook_fail(self) -> None: """Verify that a request fails if the hook fails.""" self.configure({"storage": {"hook": "exit 1"}}) self.mkcalendar("/calendar.ics/", check=500) def test_item_cache_rebuild(self) -> None: """Delete the item cache and verify that it is rebuild.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") path = "/calendar.ics/event1.ics" self.put(path, event) _, answer1 = self.get(path) cache_folder = os.path.join(self.colpath, "collection-root", "calendar.ics", ".Radicale.cache", "item") assert os.path.exists(os.path.join(cache_folder, "event1.ics")) shutil.rmtree(cache_folder) _, answer2 = self.get(path) assert answer1 == answer2 assert os.path.exists(os.path.join(cache_folder, "event1.ics")) def test_put_whole_calendar_uids_used_as_file_names(self) -> None: """Test if UIDs are used as file names.""" _TestBaseRequests.test_put_whole_calendar( cast(_TestBaseRequests, self)) for uid in ("todo", "event"): _, answer = self.get("/calendar.ics/%s.ics" % uid) assert "\r\nUID:%s\r\n" % uid in answer def test_put_whole_calendar_random_uids_used_as_file_names(self) -> None: """Test if UIDs are used as file names.""" _TestBaseRequests.test_put_whole_calendar_without_uids( cast(_TestBaseRequests, self)) _, answer = self.get("/calendar.ics") assert answer is not None uids = [] for line in answer.split("\r\n"): if line.startswith("UID:"): uids.append(line[len("UID:"):]) for uid in uids: _, answer = self.get("/calendar.ics/%s.ics" % uid) assert answer is not None assert "\r\nUID:%s\r\n" % uid in answer def test_put_whole_addressbook_uids_used_as_file_names(self) -> None: """Test if UIDs are used as file names.""" _TestBaseRequests.test_put_whole_addressbook( cast(_TestBaseRequests, self)) for uid in ("contact1", "contact2"): _, answer = self.get("/contacts.vcf/%s.vcf" % uid) assert "\r\nUID:%s\r\n" % uid in answer def test_put_whole_addressbook_random_uids_used_as_file_names( self) -> None: """Test if UIDs are used as file names.""" _TestBaseRequests.test_put_whole_addressbook_without_uids( cast(_TestBaseRequests, self)) _, answer = self.get("/contacts.vcf") assert answer is not None uids = [] for line in answer.split("\r\n"): if line.startswith("UID:"): uids.append(line[len("UID:"):]) for uid in uids: _, answer = self.get("/contacts.vcf/%s.vcf" % uid) assert answer is not None assert "\r\nUID:%s\r\n" % uid in answer class TestMultiFileSystemNoLock(BaseTest): """Tests for multifilesystem_nolock.""" def setup(self) -> None: _TestBaseRequests.setup(cast(_TestBaseRequests, self)) self.configure({"storage": {"type": "multifilesystem_nolock"}}) test_add_event = _TestBaseRequests.test_add_event test_item_cache_rebuild = TestMultiFileSystem.test_item_cache_rebuild class TestCustomStorageSystem(BaseTest): """Test custom backend loading.""" def setup(self) -> None: _TestBaseRequests.setup(cast(_TestBaseRequests, self)) self.configure({"storage": { "type": "radicale.tests.custom.storage_simple_sync"}}) full_sync_token_support: ClassVar[bool] = False test_add_event = _TestBaseRequests.test_add_event _report_sync_token = _TestBaseRequests._report_sync_token # include tests related to sync token s: str = "" for s in dir(_TestBaseRequests): if s.startswith("test_") and "sync" in s.split("_"): locals()[s] = getattr(_TestBaseRequests, s) del s class TestCustomStorageSystemCallable(BaseTest): """Test custom backend loading with ``callable``.""" def setup(self) -> None: _TestBaseRequests.setup(cast(_TestBaseRequests, self)) self.configure({"storage": { "type": radicale.tests.custom.storage_simple_sync.Storage}}) test_add_event = _TestBaseRequests.test_add_event Radicale-3.1.8/radicale/tests/test_web.py000066400000000000000000000033061426407556000203360ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2018-2019 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 . """ Test web plugin. """ from radicale.tests import BaseTest class TestBaseWebRequests(BaseTest): """Test web plugin.""" def test_internal(self) -> None: _, headers, _ = self.request("GET", "/.web", check=302) assert headers.get("Location") == "/.web/" _, answer = self.get("/.web/") assert answer self.post("/.web", check=405) def test_none(self) -> None: self.configure({"web": {"type": "none"}}) _, answer = self.get("/.web") assert answer _, headers, _ = self.request("GET", "/.web/", check=302) assert headers.get("Location") == "/.web" self.post("/.web", check=405) def test_custom(self) -> None: """Custom web plugin.""" self.configure({"web": {"type": "radicale.tests.custom.web"}}) _, answer = self.get("/.web") assert answer == "custom" _, answer = self.post("/.web", "body content") assert answer == "echo:body content" Radicale-3.1.8/radicale/types.py000066400000000000000000000042421426407556000165240ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2020 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 . import contextlib import sys from typing import (Any, Callable, ContextManager, Iterator, List, Mapping, MutableMapping, Sequence, Tuple, TypeVar, Union) WSGIResponseHeaders = Union[Mapping[str, str], Sequence[Tuple[str, str]]] WSGIResponse = Tuple[int, WSGIResponseHeaders, Union[None, str, bytes]] WSGIEnviron = Mapping[str, Any] WSGIStartResponse = Callable[[str, List[Tuple[str, str]]], Any] CONFIG = Mapping[str, Mapping[str, Any]] MUTABLE_CONFIG = MutableMapping[str, MutableMapping[str, Any]] CONFIG_SCHEMA = Mapping[str, Mapping[str, Any]] _T = TypeVar("_T") def contextmanager(func: Callable[..., Iterator[_T]] ) -> Callable[..., ContextManager[_T]]: """Compatibility wrapper for `contextlib.contextmanager` with `typeguard`""" result = contextlib.contextmanager(func) result.__annotations__ = {**func.__annotations__, "return": ContextManager[_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 ErrorStream(Protocol): def flush(self) -> None: ... def write(self, s: str) -> None: ... else: ErrorStream = Any InputStream = Any from radicale import item, storage # noqa:E402 isort:skip CollectionOrItem = Union[item.Item, storage.BaseCollection] Radicale-3.1.8/radicale/utils.py000066400000000000000000000040571426407556000165240ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-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 . import sys from importlib import import_module 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) def load_plugin(internal_types: Sequence[str], module_name: str, class_name: str, base_class: Type[_T_co], configuration: "config.Configuration") -> _T_co: type_: Union[str, Callable] = configuration.get(module_name, "type") if callable(type_): logger.info("%s type is %r", module_name, type_) return type_(configuration) if type_ in internal_types: module = "radicale.%s.%s" % (module_name, type_) else: module = type_ try: class_ = getattr(import_module(module), class_name) except Exception as e: raise RuntimeError("Failed to load %s module %r: %s" % (module_name, module, e)) from e logger.info("%s type is %r", module_name, module) return class_(configuration) def package_version(name): if sys.version_info < (3, 8): return pkg_resources.get_distribution(name).version return metadata.version(name) Radicale-3.1.8/radicale/web/000077500000000000000000000000001426407556000155615ustar00rootroot00000000000000Radicale-3.1.8/radicale/web/__init__.py000066400000000000000000000047041426407556000176770ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # 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 . """ The web module for the website at ``/.web``. Take a look at the class ``BaseWeb`` if you want to implement your own. """ from typing import Sequence from radicale import config, httputils, types, utils INTERNAL_TYPES: Sequence[str] = ("none", "internal") def load(configuration: "config.Configuration") -> "BaseWeb": """Load the web module chosen in configuration.""" return utils.load_plugin(INTERNAL_TYPES, "web", "Web", BaseWeb, configuration) class BaseWeb: configuration: "config.Configuration" def __init__(self, configuration: "config.Configuration") -> None: """Initialize BaseWeb. ``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 get(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """GET request. ``base_prefix`` is sanitized and never ends with "/". ``path`` is sanitized and always starts with "/.web" ``user`` is empty for anonymous users. """ return httputils.METHOD_NOT_ALLOWED def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """POST request. ``base_prefix`` is sanitized and never ends with "/". ``path`` is sanitized and always starts with "/.web" ``user`` is empty for anonymous users. Use ``httputils.read*_request_body(self.configuration, environ)`` to read the body. """ return httputils.METHOD_NOT_ALLOWED Radicale-3.1.8/radicale/web/internal.py000066400000000000000000000025561426407556000177570ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # 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 . """ The default web backend. Features: - Create and delete address books and calendars. - Edit basic metadata of existing address books and calendars. - Upload address books and calendars from files. """ from radicale import httputils, types, web MIMETYPES = httputils.MIMETYPES # deprecated FALLBACK_MIMETYPE = httputils.FALLBACK_MIMETYPE # deprecated class Web(web.BaseWeb): def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: return httputils.serve_resource("radicale.web", "internal_data", base_prefix, path) Radicale-3.1.8/radicale/web/internal_data/000077500000000000000000000000001426407556000203665ustar00rootroot00000000000000Radicale-3.1.8/radicale/web/internal_data/css/000077500000000000000000000000001426407556000211565ustar00rootroot00000000000000Radicale-3.1.8/radicale/web/internal_data/css/icon.png000066400000000000000000000020551426407556000226160ustar00rootroot00000000000000PNG  IHDR szzsRGBbKGD pHYs  tIME !;]IDATXýh[U?u,f3 l (Ee0iNp?RCH&2 QĦ2*D͕ Ӣt9DҺ-}ml}#s>{Ϲ7(V‘l ګ*/eN2ziaQҦ /#%u0Ԇ_'Ϩ\Rz^FnrRU뺪ǑS2/%')1x?ރHюML,:QH~%0һ` $s ܫz}fvRĀ|3 3/> J7bL`IENDB`Radicale-3.1.8/radicale/web/internal_data/css/main.css000066400000000000000000000043611426407556000226200ustar00rootroot00000000000000body{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}} Radicale-3.1.8/radicale/web/internal_data/fn.js000066400000000000000000001171371426407556000213410ustar00rootroot00000000000000/** * This file is part of Radicale Server - Calendar Server * Copyright © 2017-2018 Unrud * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ /** * Server address * @const * @type {string} */ const SERVER = location.origin; /** * Path of the root collection on the server (must end with /) * @const * @type {string} */ const ROOT_PATH = (new URL("..", location.href)).pathname; /** * Regex to match and normalize color * @const */ const COLOR_RE = new RegExp("^(#[0-9A-Fa-f]{6})(?:[0-9A-Fa-f]{2})?$"); /** * Escape string for usage in XML * @param {string} s * @return {string} */ function escape_xml(s) { return (s .replace(/&/g, "&") .replace(/"/g, """) .replace(/'/g, "'") .replace(//g, ">")); } /** * @enum {string} */ const CollectionType = { PRINCIPAL: "PRINCIPAL", ADDRESSBOOK: "ADDRESSBOOK", CALENDAR_JOURNAL_TASKS: "CALENDAR_JOURNAL_TASKS", CALENDAR_JOURNAL: "CALENDAR_JOURNAL", CALENDAR_TASKS: "CALENDAR_TASKS", JOURNAL_TASKS: "JOURNAL_TASKS", CALENDAR: "CALENDAR", JOURNAL: "JOURNAL", TASKS: "TASKS", is_subset: function(a, b) { let components = a.split("_"); for (let i = 0; i < components.length; i++) { if (b.search(components[i]) === -1) { return false; } } return true; }, union: function(a, b) { if (a.search(this.ADDRESSBOOK) !== -1 || b.search(this.ADDRESSBOOK) !== -1) { if (a && a !== this.ADDRESSBOOK || b && b !== this.ADDRESSBOOK) { throw "Invalid union: " + a + " " + b; } return this.ADDRESSBOOK; } let union = []; if (a.search(this.CALENDAR) !== -1 || b.search(this.CALENDAR) !== -1) { union.push(this.CALENDAR); } if (a.search(this.JOURNAL) !== -1 || b.search(this.JOURNAL) !== -1) { union.push(this.JOURNAL); } if (a.search(this.TASKS) !== -1 || b.search(this.TASKS) !== -1) { union.push(this.TASKS); } return union.join("_"); } }; /** * @constructor * @struct * @param {string} href Must always start and end with /. * @param {CollectionType} type * @param {string} displayname * @param {string} description * @param {string} color */ function Collection(href, type, displayname, description, color) { this.href = href; this.type = type; this.displayname = displayname; this.color = color; this.description = description; } /** * Find the principal collection. * @param {string} user * @param {string} password * @param {function(?Collection, ?string)} callback Returns result or error * @return {XMLHttpRequest} */ function get_principal(user, password, callback) { let request = new XMLHttpRequest(); request.open("PROPFIND", SERVER + ROOT_PATH, true, user, password); request.onreadystatechange = function() { if (request.readyState !== 4) { return; } if (request.status === 207) { let xml = request.responseXML; let principal_element = xml.querySelector("*|multistatus:root > *|response:first-of-type > *|propstat > *|prop > *|current-user-principal > *|href"); let displayname_element = xml.querySelector("*|multistatus:root > *|response:first-of-type > *|propstat > *|prop > *|displayname"); if (principal_element) { callback(new Collection( principal_element.textContent, CollectionType.PRINCIPAL, displayname_element ? displayname_element.textContent : "", "", ""), null); } else { callback(null, "Internal error"); } } else { callback(null, request.status + " " + request.statusText); } }; request.send('' + '' + '' + '' + '' + '' + ''); return request; } /** * Find all calendars and addressbooks in collection. * @param {string} user * @param {string} password * @param {Collection} collection * @param {function(?Array, ?string)} callback Returns result or error * @return {XMLHttpRequest} */ function get_collections(user, password, collection, callback) { let request = new XMLHttpRequest(); request.open("PROPFIND", SERVER + collection.href, true, user, password); request.setRequestHeader("depth", "1"); request.onreadystatechange = function() { if (request.readyState !== 4) { return; } if (request.status === 207) { let xml = request.responseXML; let collections = []; let response_query = "*|multistatus:root > *|response"; let responses = xml.querySelectorAll(response_query); for (let i = 0; i < responses.length; i++) { let response = responses[i]; let href_element = response.querySelector(response_query + " > *|href"); let resourcetype_query = response_query + " > *|propstat > *|prop > *|resourcetype"; let resourcetype_element = response.querySelector(resourcetype_query); let displayname_element = response.querySelector(response_query + " > *|propstat > *|prop > *|displayname"); let calendarcolor_element = response.querySelector(response_query + " > *|propstat > *|prop > *|calendar-color"); let addressbookcolor_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-color"); let calendardesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|calendar-description"); let addressbookdesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-description"); let components_query = response_query + " > *|propstat > *|prop > *|supported-calendar-component-set"; let components_element = response.querySelector(components_query); let href = href_element ? href_element.textContent : ""; let displayname = displayname_element ? displayname_element.textContent : ""; let type = ""; let color = ""; let description = ""; 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 : ""; } else if (resourcetype_element.querySelector(resourcetype_query + " > *|calendar")) { if (components_element) { if (components_element.querySelector(components_query + " > *|comp[name=VEVENT]")) { type = CollectionType.union(type, CollectionType.CALENDAR); } if (components_element.querySelector(components_query + " > *|comp[name=VJOURNAL]")) { type = CollectionType.union(type, CollectionType.JOURNAL); } if (components_element.querySelector(components_query + " > *|comp[name=VTODO]")) { type = CollectionType.union(type, CollectionType.TASKS); } } color = calendarcolor_element ? calendarcolor_element.textContent : ""; description = calendardesc_element ? calendardesc_element.textContent : ""; } } let sane_color = color.trim(); if (sane_color) { let color_match = COLOR_RE.exec(sane_color); if (color_match) { sane_color = color_match[1]; } else { sane_color = ""; } } if (href.substr(-1) === "/" && href !== collection.href && type) { collections.push(new Collection(href, type, displayname, description, sane_color)); } } collections.sort(function(a, b) { /** @type {string} */ let ca = a.displayname || a.href; /** @type {string} */ let cb = b.displayname || b.href; return ca.localeCompare(cb); }); callback(collections, null); } else { callback(null, request.status + " " + request.statusText); } }; request.send('' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''); return request; } /** * @param {string} user * @param {string} password * @param {string} collection_href Must always start and end with /. * @param {File} file * @param {function(?string)} callback Returns error or null * @return {XMLHttpRequest} */ function upload_collection(user, password, collection_href, file, callback) { let request = new XMLHttpRequest(); request.open("PUT", SERVER + collection_href, true, user, password); request.onreadystatechange = function() { if (request.readyState !== 4) { return; } if (200 <= request.status && request.status < 300) { callback(null); } else { callback(request.status + " " + request.statusText); } }; request.setRequestHeader("If-None-Match", "*"); request.send(file); return request; } /** * @param {string} user * @param {string} password * @param {Collection} collection * @param {function(?string)} callback Returns error or null * @return {XMLHttpRequest} */ function delete_collection(user, password, collection, callback) { let request = new XMLHttpRequest(); request.open("DELETE", SERVER + collection.href, true, user, password); request.onreadystatechange = function() { if (request.readyState !== 4) { return; } if (200 <= request.status && request.status < 300) { callback(null); } else { callback(request.status + " " + request.statusText); } }; request.send(); return request; } /** * @param {string} user * @param {string} password * @param {Collection} collection * @param {boolean} create * @param {function(?string)} callback Returns error or null * @return {XMLHttpRequest} */ 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.onreadystatechange = function() { if (request.readyState !== 4) { return; } if (200 <= request.status && request.status < 300) { callback(null); } else { callback(request.status + " " + request.statusText); } }; let displayname = escape_xml(collection.displayname); let calendar_color = ""; let addressbook_color = ""; let calendar_description = ""; let addressbook_description = ""; 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 { calendar_color = escape_xml(collection.color + (collection.color ? "ff" : "")); calendar_description = escape_xml(collection.description); resourcetype = ''; if (CollectionType.is_subset(CollectionType.CALENDAR, collection.type)) { components += ''; } if (CollectionType.is_subset(CollectionType.JOURNAL, collection.type)) { components += ''; } if (CollectionType.is_subset(CollectionType.TASKS, collection.type)) { components += ''; } } 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/">' + '' + '' + (create ? '' + resourcetype + '' : '') + (components ? '' + components + '' : '') + (displayname ? '' + displayname + '' : '') + (calendar_color ? '' + calendar_color + '' : '') + (addressbook_color ? '' + addressbook_color + '' : '') + (addressbook_description ? '' + addressbook_description + '' : '') + (calendar_description ? '' + calendar_description + '' : '') + '' + '' + (!create ? ('' + '' + (!components ? '' : '') + (!displayname ? '' : '') + (!calendar_color ? '' : '') + (!addressbook_color ? '' : '') + (!addressbook_description ? '' : '') + (!calendar_description ? '' : '') + '' + ''): '') + ''); return request; } /** * @param {string} user * @param {string} password * @param {Collection} collection * @param {function(?string)} callback Returns error or null * @return {XMLHttpRequest} */ function create_collection(user, password, collection, callback) { return create_edit_collection(user, password, collection, true, callback); } /** * @param {string} user * @param {string} password * @param {Collection} collection * @param {function(?string)} callback Returns error or null * @return {XMLHttpRequest} */ function edit_collection(user, password, collection, callback) { return create_edit_collection(user, password, collection, false, callback); } /** * @return {string} */ function random_uuid() { return random_hex(8) + "-" + random_hex(4) + "-" + random_hex(4) + "-" + random_hex(4) + "-" + random_hex(12); } /** * @interface */ function Scene() {} /** * Scene is on top of stack and visible. */ Scene.prototype.show = function() {}; /** * Scene is no longer visible. */ Scene.prototype.hide = function() {}; /** * Scene is removed from scene stack. */ Scene.prototype.release = function() {}; /** * @type {Array} */ let scene_stack = []; /** * Push scene onto stack. * @param {Scene} scene * @param {boolean} replace Replace the scene on top of the stack. */ function push_scene(scene, replace) { if (scene_stack.length >= 1) { scene_stack[scene_stack.length - 1].hide(); if (replace) { scene_stack.pop().release(); } } scene_stack.push(scene); scene.show(); } /** * Remove scenes from stack. * @param {number} index New top of stack */ function pop_scene(index) { if (scene_stack.length - 1 <= index) { return; } scene_stack[scene_stack.length - 1].hide(); while (scene_stack.length - 1 > index) { let old_length = scene_stack.length; scene_stack.pop().release(); if (old_length - 1 === index + 1) { break; } } if (scene_stack.length >= 1) { let scene = scene_stack[scene_stack.length - 1]; scene.show(); } else { throw "Scene stack is empty"; } } /** * @constructor * @implements {Scene} */ function LoginScene() { let html_scene = document.getElementById("loginscene"); let form = html_scene.querySelector("[data-name=form]"); let user_form = html_scene.querySelector("[data-name=user]"); let password_form = html_scene.querySelector("[data-name=password]"); 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]"); /** @type {?number} */ let scene_index = null; let user = ""; let error = ""; /** @type {?XMLHttpRequest} */ let principal_req = null; function read_form() { user = user_form.value; } function fill_form() { user_form.value = user; password_form.value = ""; error_form.textContent = error ? "Error: " + error : ""; } function onlogin() { try { read_form(); let password = password_form.value; if (user) { error = ""; // setup logout logout_view.classList.remove("hidden"); logout_btn.onclick = onlogout; logout_user_form.textContent = user; // Fetch principal let loading_scene = new LoadingScene(); push_scene(loading_scene, false); principal_req = get_principal(user, password, function(collection, error1) { if (scene_index === null) { return; } principal_req = null; if (error1) { error = error1; pop_scene(scene_index); } else { // show collections let saved_user = user; user = ""; let collections_scene = new CollectionsScene( saved_user, password, collection, function(error1) { error = error1; user = saved_user; }); push_scene(collections_scene, true); } }); } else { error = "Username is empty"; fill_form(); } } catch(err) { console.error(err); } return false; } function onlogout() { try { if (scene_index === null) { return false; } user = ""; pop_scene(scene_index); } catch (err) { console.error(err); } return false; } function remove_logout() { logout_view.classList.add("hidden"); logout_btn.onclick = null; logout_user_form.textContent = ""; } this.show = function() { remove_logout(); fill_form(); form.onsubmit = onlogin; html_scene.classList.remove("hidden"); scene_index = scene_stack.length - 1; user_form.focus(); }; this.hide = function() { read_form(); html_scene.classList.add("hidden"); form.onsubmit = null; }; this.release = function() { scene_index = null; // cancel pending requests if (principal_req !== null) { principal_req.abort(); principal_req = null; } remove_logout(); }; } /** * @constructor * @implements {Scene} */ function LoadingScene() { let html_scene = document.getElementById("loadingscene"); this.show = function() { html_scene.classList.remove("hidden"); }; this.hide = function() { html_scene.classList.add("hidden"); }; this.release = function() {}; } /** * @constructor * @implements {Scene} * @param {string} user * @param {string} password * @param {Collection} collection The principal collection. * @param {function(string)} onerror Called when an error occurs, before the * scene is popped. */ function CollectionsScene(user, password, collection, onerror) { let html_scene = document.getElementById("collectionsscene"); let template = html_scene.querySelector("[data-name=collectiontemplate]"); let new_btn = html_scene.querySelector("[data-name=new]"); let upload_btn = html_scene.querySelector("[data-name=upload]"); /** @type {?number} */ let scene_index = null; /** @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 { let create_collection_scene = new CreateEditCollectionScene(user, password, collection); push_scene(create_collection_scene, false); } catch(err) { console.error(err); } return false; } 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); } } catch(err) { console.error(err); } return false; } function onedit(collection) { try { let edit_collection_scene = new CreateEditCollectionScene(user, password, collection); push_scene(edit_collection_scene, false); } catch(err) { console.error(err); } return false; } function ondelete(collection) { try { let delete_collection_scene = new DeleteCollectionScene(user, password, collection); push_scene(delete_collection_scene, false); } catch(err) { console.error(err); } return false; } function show_collections(collections) { 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 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]"); if (collection.color) { color_form.style.color = collection.color; } else { color_form.classList.add("hidden"); } let possible_types = [CollectionType.ADDRESSBOOK]; [CollectionType.CALENDAR, ""].forEach(function(e) { [CollectionType.union(e, CollectionType.JOURNAL), e].forEach(function(e) { [CollectionType.union(e, CollectionType.TASKS), e].forEach(function(e) { if (e) { possible_types.push(e); } }); }); }); possible_types.forEach(function(e) { if (e !== collection.type) { node.querySelector("[data-name=" + e + "]").classList.add("hidden"); } }); title_form.textContent = collection.displayname || collection.href; description_form.textContent = collection.description; let href = SERVER + collection.href; url_form.href = href; url_form.textContent = href; delete_btn.onclick = function() {return ondelete(collection);}; edit_btn.onclick = function() {return onedit(collection);}; node.classList.remove("hidden"); nodes.push(node); template.parentNode.insertBefore(node, template); }); } function update() { let loading_scene = new LoadingScene(); push_scene(loading_scene, false); collections_req = get_collections(user, password, collection, function(collections1, error) { if (scene_index === null) { return; } collections_req = null; if (error) { onerror(error); pop_scene(scene_index - 1); } else { collections = collections1; pop_scene(scene_index); } }); } this.show = function() { html_scene.classList.remove("hidden"); new_btn.onclick = onnew; upload_btn.onclick = onupload; filesInputForm.reset(); filesInput.onchange = onfileschange; if (collections === null) { update(); } else { // from update loading scene show_collections(collections); } }; this.hide = function() { html_scene.classList.add("hidden"); 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) { node.parentNode.removeChild(node); }); nodes = []; }; this.release = function() { scene_index = null; if (collections_req !== null) { collections_req.abort(); collections_req = null; } collections = null; filesInputForm.reset(); }; } /** * @constructor * @implements {Scene} * @param {string} user * @param {string} password * @param {Collection} collection parent collection * @param {Array} files */ function UploadCollectionScene(user, password, collection, files) { let html_scene = document.getElementById("uploadcollectionscene"); let template = html_scene.querySelector("[data-name=filetemplate]"); let close_btn = html_scene.querySelector("[data-name=close]"); /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let upload_req = null; /** @type {Array} */ let errors = []; /** @type {?Array} */ let nodes = null; function upload_next() { try { if (files.length === errors.length) { if (errors.every(error => error === null)) { pop_scene(scene_index - 1); } else { close_btn.classList.remove("hidden"); } } 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; } upload_req = null; errors.push(error); updateFileStatus(errors.length - 1); upload_next(); }); } } catch(err) { console.error(err); } return false; } function onclose() { try { pop_scene(scene_index - 1); } catch(err) { console.error(err); } return false; } function updateFileStatus(i) { 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]) { success_form.classList.add("hidden"); error_form.textContent = "Error: " + errors[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"); } 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); } if (scene_index === null) { scene_index = scene_stack.length - 1; upload_next(); } }; this.hide = function() { html_scene.classList.add("hidden"); close_btn.classList.remove("hidden"); close_btn.onclick = null; nodes.forEach(function(node) { node.parentNode.removeChild(node); }); nodes = null; }; this.release = function() { scene_index = null; if (upload_req !== null) { upload_req.abort(); upload_req = null; } }; } /** * @constructor * @implements {Scene} * @param {string} user * @param {string} password * @param {Collection} collection */ 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 delete_btn = html_scene.querySelector("[data-name=delete]"); let cancel_btn = html_scene.querySelector("[data-name=cancel]"); /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let delete_req = null; let error = ""; function ondelete() { try { let loading_scene = new LoadingScene(); push_scene(loading_scene); delete_req = delete_collection(user, password, collection, function(error1) { if (scene_index === null) { return; } delete_req = null; if (error1) { error = error1; pop_scene(scene_index); } else { pop_scene(scene_index - 1); } }); } catch(err) { console.error(err); } return false; } function oncancel() { try { pop_scene(scene_index - 1); } catch(err) { console.error(err); } return false; } 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; }; this.hide = function() { html_scene.classList.add("hidden"); cancel_btn.onclick = null; delete_btn.onclick = null; }; this.release = function() { scene_index = null; if (delete_req !== null) { delete_req.abort(); delete_req = null; } }; } /** * Generate random hex number. * @param {number} length * @return {string} */ function random_hex(length) { let bytes = new Uint8Array(Math.ceil(length / 2)); window.crypto.getRandomValues(bytes); return bytes.reduce((s, b) => s + b.toString(16).padStart(2, "0"), "").substring(0, length); } /** * @constructor * @implements {Scene} * @param {string} user * @param {string} password * @param {Collection} collection if it's a principal collection, a new * collection will be created inside of it. * Otherwise the collection will be edited. */ function CreateEditCollectionScene(user, password, collection) { let edit = collection.type !== CollectionType.PRINCIPAL; 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 displayname_form = html_scene.querySelector("[data-name=displayname]"); let description_form = html_scene.querySelector("[data-name=description]"); let type_form = html_scene.querySelector("[data-name=type]"); let color_form = html_scene.querySelector("[data-name=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 = ""; /** @type {?Element} */ let saved_type_form = null; let href = edit ? collection.href : collection.href + random_uuid() + "/"; let displayname = edit ? collection.displayname : ""; let description = edit ? collection.description : ""; let type = edit ? collection.type : CollectionType.CALENDAR_JOURNAL_TASKS; let color = edit && collection.color ? collection.color : "#" + random_hex(6); function remove_invalid_types() { if (!edit) { return; } /** @type {HTMLOptionsCollection} */ let options = type_form.options; // remove all options that are not supersets for (let i = options.length - 1; i >= 0; i--) { if (!CollectionType.is_subset(type, options[i].value)) { options.remove(i); } } } function read_form() { displayname = displayname_form.value; description = description_form.value; type = type_form.value; color = color_form.value; } function fill_form() { displayname_form.value = displayname; description_form.value = description; type_form.value = type; color_form.value = color; error_form.textContent = error ? "Error: " + error : ""; } function onsubmit() { try { read_form(); let sane_color = color.trim(); if (sane_color) { let color_match = COLOR_RE.exec(sane_color); if (!color_match) { error = "Invalid color"; fill_form(); return false; } sane_color = color_match[1]; } let loading_scene = new LoadingScene(); push_scene(loading_scene); let collection = new Collection(href, type, displayname, description, sane_color); let callback = function(error1) { if (scene_index === null) { return; } create_edit_req = null; if (error1) { error = error1; pop_scene(scene_index); } else { pop_scene(scene_index - 1); } }; if (edit) { create_edit_req = edit_collection(user, password, collection, callback); } else { create_edit_req = create_collection(user, password, collection, callback); } } catch(err) { console.error(err); } return false; } function oncancel() { try { pop_scene(scene_index - 1); } catch(err) { console.error(err); } return false; } this.show = function() { this.release(); scene_index = scene_stack.length - 1; // Clone type_form because it's impossible to hide options without removing them saved_type_form = type_form; type_form = type_form.cloneNode(true); saved_type_form.parentNode.replaceChild(type_form, saved_type_form); remove_invalid_types(); html_scene.classList.remove("hidden"); if (edit) { title_form.textContent = collection.displayname || collection.href; } fill_form(); submit_btn.onclick = onsubmit; cancel_btn.onclick = oncancel; }; this.hide = function() { read_form(); html_scene.classList.add("hidden"); // restore type_form type_form.parentNode.replaceChild(saved_type_form, type_form); type_form = saved_type_form; saved_type_form = null; submit_btn.onclick = null; cancel_btn.onclick = null; }; this.release = function() { scene_index = null; if (create_edit_req !== null) { create_edit_req.abort(); create_edit_req = null; } }; } function main() { // Hide startup loading message document.getElementById("loadingscene").classList.add("hidden"); push_scene(new LoginScene(), false); } window.addEventListener("load", main); Radicale-3.1.8/radicale/web/internal_data/index.html000066400000000000000000000131101426407556000223570ustar00rootroot00000000000000 Radicale Web Interface

Loading

Please wait...

Radicale-3.1.8/radicale/web/none.py000066400000000000000000000024341426407556000170750ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # 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 . """ A dummy web backend that shows a simple message. """ from http import client from radicale import httputils, pathutils, types, web class Web(web.BaseWeb): def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: assert path == "/.web" or path.startswith("/.web/") assert pathutils.sanitize_path(path) == path if path != "/.web": return httputils.redirect(base_prefix + "/.web") return client.OK, {"Content-Type": "text/plain"}, "Radicale works!" Radicale-3.1.8/radicale/xmlutils.py000066400000000000000000000153641426407556000172500ustar00rootroot00000000000000# This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2015 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 . """ Helper functions for XML. """ import copy import xml.etree.ElementTree as ET from collections import OrderedDict from http import client from typing import Dict, Mapping, Optional from urllib.parse import quote from radicale import item, pathutils MIMETYPES: Mapping[str, str] = { "VADDRESSBOOK": "text/vcard", "VCALENDAR": "text/calendar"} OBJECT_MIMETYPES: Mapping[str, str] = { "VCARD": "text/vcard", "VLIST": "text/x-vlist", "VCALENDAR": "text/calendar"} NAMESPACES: Mapping[str, str] = { "C": "urn:ietf:params:xml:ns:caldav", "CR": "urn:ietf:params:xml:ns:carddav", "D": "DAV:", "CS": "http://calendarserver.org/ns/", "ICAL": "http://apple.com/ns/ical/", "ME": "http://me.com/_namespace/", "RADICALE": "http://radicale.org/ns/"} NAMESPACES_REV: Mapping[str, str] = {v: k for k, v in NAMESPACES.items()} for short, url in NAMESPACES.items(): ET.register_namespace("" if short == "D" else short, url) def pretty_xml(element: ET.Element) -> str: """Indent an ElementTree ``element`` and its children.""" def pretty_xml_recursive(element: ET.Element, level: int) -> None: indent = "\n" + level * " " if len(element) > 0: if not (element.text or "").strip(): element.text = indent + " " if not (element.tail or "").strip(): element.tail = indent for sub_element in element: pretty_xml_recursive(sub_element, level + 1) if not (sub_element.tail or "").strip(): sub_element.tail = indent elif level > 0 and not (element.tail or "").strip(): element.tail = indent element = copy.deepcopy(element) pretty_xml_recursive(element, 0) return '\n%s' % ET.tostring(element, "unicode") def make_clark(human_tag: str) -> str: """Get XML Clark notation from human tag ``human_tag``. If ``human_tag`` is already in XML Clark notation it is returned as-is. """ if human_tag.startswith("{"): ns, tag = human_tag[len("{"):].split("}", maxsplit=1) if not ns or not tag: raise ValueError("Invalid XML tag: %r" % human_tag) return human_tag ns_prefix, tag = human_tag.split(":", maxsplit=1) if not ns_prefix or not tag: raise ValueError("Invalid XML tag: %r" % human_tag) ns = NAMESPACES.get(ns_prefix, "") if not ns: raise ValueError("Unknown XML namespace prefix: %r" % human_tag) return "{%s}%s" % (ns, tag) def make_human_tag(clark_tag: str) -> str: """Replace known namespaces in XML Clark notation ``clark_tag`` with prefix. If the namespace is not in ``NAMESPACES`` the tag is returned as-is. """ if not clark_tag.startswith("{"): ns_prefix, tag = clark_tag.split(":", maxsplit=1) if not ns_prefix or not tag: raise ValueError("Invalid XML tag: %r" % clark_tag) if ns_prefix not in NAMESPACES: raise ValueError("Unknown XML namespace prefix: %r" % clark_tag) return clark_tag ns, tag = clark_tag[len("{"):].split("}", maxsplit=1) if not ns or not tag: raise ValueError("Invalid XML tag: %r" % clark_tag) ns_prefix = NAMESPACES_REV.get(ns, "") if ns_prefix: return "%s:%s" % (ns_prefix, tag) return clark_tag def make_response(code: int) -> str: """Return full W3C names from HTTP status codes.""" return "HTTP/1.1 %i %s" % (code, client.responses[code]) def make_href(base_prefix: str, href: str) -> str: """Return prefixed href.""" assert href == pathutils.sanitize_path(href) return quote("%s%s" % (base_prefix, href)) def webdav_error(human_tag: str) -> ET.Element: """Generate XML error message.""" root = ET.Element(make_clark("D:error")) root.append(ET.Element(make_clark(human_tag))) return root def get_content_type(item: "item.Item", encoding: str) -> str: """Get the content-type of an item with charset and component parameters. """ mimetype = OBJECT_MIMETYPES[item.name] tag = item.component_name content_type = "%s;charset=%s" % (mimetype, encoding) if tag: content_type += ";component=%s" % tag return content_type def props_from_request(xml_request: Optional[ET.Element] ) -> Dict[str, Optional[str]]: """Return a list of properties as a dictionary. Properties that should be removed are set to `None`. """ result: OrderedDict = OrderedDict() if xml_request is None: return result # Requests can contain multipe and elements. # Each of these elements must contain exactly one element which # can contain multpile properties. # The order of the elements in the document must be respected. props = [] for element in xml_request: if element.tag in (make_clark("D:set"), make_clark("D:remove")): for prop in element.findall("./%s/*" % make_clark("D:prop")): props.append((element.tag == make_clark("D:set"), prop)) for is_set, prop in props: key = make_human_tag(prop.tag) value = None if prop.tag == make_clark("D:resourcetype"): key = "tag" if is_set: for resource_type in prop: if resource_type.tag == make_clark("C:calendar"): value = "VCALENDAR" break if resource_type.tag == make_clark("CR:addressbook"): value = "VADDRESSBOOK" break elif prop.tag == make_clark("C:supported-calendar-component-set"): if is_set: value = ",".join( supported_comp.attrib["name"] for supported_comp in prop if supported_comp.tag == make_clark("C:comp")) elif is_set: value = prop.text or "" result[key] = value result.move_to_end(key) return result Radicale-3.1.8/rights000066400000000000000000000051471426407556000144720ustar00rootroot00000000000000# -*- mode: conf -*- # vim:ft=cfg # Rights management file for Radicale - A simple calendar server # # The default path for this file is /etc/radicale/rights # The path can be specified in the rights section of the configuration file # # Section names are used for naming rules and must be unique. # The first rule matching both user and collection patterns will be used. # Example: owner_only plugin # Allow reading root collection for authenticated users #[root] #user: .+ #collection: #permissions: R # Allow reading and writing principal collection (same as username) #[principal] #user: .+ #collection: {user} #permissions: RW # Allow reading and writing calendars and address books that are direct # children of the principal collection #[calendars] #user: .+ #collection: {user}/[^/]+ #permissions: rw # Example: owner_write plugin # Only listed additional rules for the owner_only plugin example. # Allow reading principal collections of all users #[read-all-principals] #user: .+ #collection: [^/]+ #permissions: R # Allow reading all calendars and address books that are direct children of any # principal collection #[read-all-calendars] #user: .+ #collection: [^/]+/[^/]+ #permissions: r # Example: authenticated plugin # Allow reading and writing root and principal collections of all users #[root-and-principals] #user: .+ #collection: [^/]* #permissions: RW # Allow reading and writing all calendars and address books that are direct # children of any principal collection #[calendars] #user: .+ #collection: [^/]+/[^/]+ #permissions: rw # Example: Allow user "admin" to read everything #[admin-read-all] #user: admin #collection: .* #permissions: Rr # Example: Allow everybody (including unauthenticated users) to read # the collection "public" # Allow reading collection "public" for authenticated users #[public-principal] #user: .+ #collection: public #permissions: R # Allow reading all calendars and address books that are direct children of # the collection "public" for authenticated users #[public-calendars] #user: .+ #collection: public/[^/]+ #permissions: r # Allow access to public calendars and address books via HTTP GET for everyone #[public-calendars-restricted] #user: .* #collection: public/[^/]+ #permissions: i # Example: Grant users of the form user@domain.tld read access to the # collection "domain.tld" # Allow reading the domain collection #[read-domain-principal] #user: .+@([^@]+) #collection: {0} #permissions: R # Allow reading all calendars and address books that are direct children of # the domain collection #[read-domain-calendars] #user: .+@([^@]+) #collection: {0}/[^/]+ #permissions: r Radicale-3.1.8/setup.cfg000066400000000000000000000061121426407556000150610ustar00rootroot00000000000000[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 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__.: Radicale-3.1.8/setup.py000066400000000000000000000060771426407556000147640ustar00rootroot00000000000000# 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.1.8" 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/main.css", "web/internal_data/fn.js", "web/internal_data/index.html"] install_requires = ["defusedxml", "passlib", "vobject>=0.9.6", "python-dateutil>=2.7.3", "setuptools; python_version<'3.9'"] bcrypt_requires = ["passlib[bcrypt]", "bcrypt"] # typeguard requires pytest<7 test_requires = ["pytest<7", "typeguard", "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.6.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.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Office/Business :: Groupware"])