pax_global_header00006660000000000000000000000064137277426270014533gustar00rootroot0000000000000052 comment=bb69170290f2a6711eb7b636d639714d4f60a8ce Radicale-3.0.6/000077500000000000000000000000001372774262700132455ustar00rootroot00000000000000Radicale-3.0.6/.github/000077500000000000000000000000001372774262700146055ustar00rootroot00000000000000Radicale-3.0.6/.github/workflows/000077500000000000000000000000001372774262700166425ustar00rootroot00000000000000Radicale-3.0.6/.github/workflows/generate-documentation.yml000066400000000000000000000004241372774262700240260ustar00rootroot00000000000000name: 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.0.6/.github/workflows/pypi-publish.yml000066400000000000000000000010341372774262700220100ustar00rootroot00000000000000name: 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 dependencies run: python -m pip install wheel - name: Build run: python setup.py sdist bdist_wheel - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@master with: user: __token__ password: ${{ secrets.pypi_password }} Radicale-3.0.6/.github/workflows/test.yml000066400000000000000000000022751372774262700203520ustar00rootroot00000000000000name: Test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: [3.5, 3.6, 3.7, 3.8, '3.9.0-alpha - 3.9', pypy3] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install from source run: python -m pip install --editable .[test,bcrypt] - name: Run tests run: python setup.py test - name: Upload coverage to Coveralls if: github.event_name == 'push' env: COVERALLS_PARALLEL: true GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | python -m pip install coveralls python -m coveralls 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: Finish Coveralls parallel builds env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | python -m pip install coveralls python -m coveralls --finish Radicale-3.0.6/.gitignore000066400000000000000000000003151372774262700152340ustar00rootroot00000000000000*~ .*.swp *.pyc __pycache__ /MANIFEST /build /dist /*.egg-info /_site coverage.xml .pytest_cache .cache .coverage .coverage.* .eggs .project .pydevproject .settings .tox .vscode .sass-cache Gemfile.lock Radicale-3.0.6/COPYING000066400000000000000000001045131372774262700143040ustar00rootroot00000000000000 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.0.6/DOCUMENTATION.md000066400000000000000000001321061372774262700156030ustar00rootroot00000000000000# 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](#documentation/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 http://localhost:5232/ in your browser! You can login with any username and password. Want more? Check the [tutorials](#tutorials) and the [documentation](#documentation). ### What's New? Read the [changelog on GitHub.](https://github.com/Kozea/Radicale/blob/3.0.x/NEWS.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](#documentation/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 user name and password. If Radicale fits your needs, it may be time for [some basic configuration](#tutorials/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 http://localhost:5232/ 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 C:\Users\User> python -m pip install --upgrade radicale C:\Users\User> python -m radicale --storage-filesystem-folder=~/radicale/collections ``` Victory! Open http://localhost:5232/ 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](#tutorials/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](#documentation/configuration) section. ### Authentication In its default configuration Radicale doesn't check user names or passwords. If the server is reachable over a network, you should change this. First a `users` file with all user names 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 user name 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](#tutorials/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 user name 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' --workers 8 --env 'RADICALE_CONFIG=/etc/radicale/config' radicale ``` ### Manage user accounts with the WSGI server Set the configuration option `type` in the `auth` section to `remote_user`. Radicale uses the user name 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: ``` .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 --htpasswd-filename ~/.config/radicale/users --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 user name 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 user name from the `REMOTE_USER` environment variable and disables HTTP authentication. This can be used to provide the user name from a WSGI server. `http_x_remote_user` : Takes the user name from the `X-Remote-User` HTTP header and disables HTTP authentication. This can be used to provide the user name from a reverse proxy. Default: `none` #### htpasswd_filename Path to the htpasswd file. Default: #### 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](#documentation/authentication-and-rights) section. ### storage #### type The backend that is used to store data. Available backends: `multifilesystem` : Stores the data in the filesystem. 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](#tutorials/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. http://localhost:5232) 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 user name. 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 user name. 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 user name. Clicking on the search button will list the existing calendars and address books. ### Thunderbird #### CardBook Add a new address book on the network with CardDAV. You have to enter the full URL of the collection (e.g. `http://localhost:5232/user/addressbook`) and your user name. #### Lightning Add a new calendar on the network with `CalDAV`. (Don't use `iCalendar (ICS)`!) You have to enter the full URL of the collection (e.g. `http://localhost:5232/user/calendar`). If you want to add calendars from different users on the same server, you can specify the user name in the URL (e.g. `http://user@localhost...`) ### 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 user name) [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 user name and the path of the collection. Permissions from the first matching section are used. If no section matches, access gets denied. The user name 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](#tutorials/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/3.0.x/DOCUMENTATION.md). # Download ### PyPI Radicale is [available on PyPI](https://pypi.python.org/pypi/Radicale/). To install, just type as superuser: $ 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: $ 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](https://admin.fedoraproject.org/pkgdb/package/radicale/) by Jorti * [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](#tutorials/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.0.6/Dockerfile000066400000000000000000000012611372774262700152370ustar00rootroot00000000000000FROM python:3-alpine # Version of Radicale ARG VERSION=3.0.x # Persistent storage for data (Mount it somewhere on the host!) VOLUME /var/lib/radicale # Configuration data (Put the "config" file here!) VOLUME /etc/radicale # TCP port of Radicale (Publish it on a host interface!) EXPOSE 5232 # Run Radicale (Configure it here or provide a "config" file!) CMD ["radicale", "--hosts", "0.0.0.0:5232"] # Install dependencies RUN apk add --no-cache gcc musl-dev libffi-dev ca-certificates openssl # Install Radicale RUN pip install --no-cache-dir "Radicale[bcrypt] @ https://github.com/Kozea/Radicale/archive/${VERSION}.tar.gz" # Remove build dependencies RUN apk del gcc musl-dev libffi-dev Radicale-3.0.6/MANIFEST.in000066400000000000000000000001371372774262700150040ustar00rootroot00000000000000include COPYING DOCUMENTATION.md NEWS.md README.md include config rights include radicale.wsgi Radicale-3.0.6/NEWS.md000066400000000000000000000403631372774262700143510ustar00rootroot00000000000000# News ## 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.0.6/README.md000066400000000000000000000006371372774262700145320ustar00rootroot00000000000000# Read Me ![Test](https://github.com/Kozea/Radicale/workflows/Test/badge.svg?branch=3.0.x) [![Coverage Status](https://coveralls.io/repos/github/Kozea/Radicale/badge.svg?branch=3.0.x)](https://coveralls.io/github/Kozea/Radicale?branch=3.0.x) Radicale is a free and open-source CalDAV and CardDAV server. For the complete documentation, please visit [Radicale "3.0" Documentation](https://radicale.org/3.0.html). Radicale-3.0.6/config000066400000000000000000000045451372774262700144450ustar00rootroot00000000000000# -*- 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 #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.0.6/radicale.wsgi000077500000000000000000000001361372774262700157070ustar00rootroot00000000000000""" Radicale WSGI file (mod_wsgi and uWSGI compliant). """ from radicale import application Radicale-3.0.6/radicale/000077500000000000000000000000001372774262700150115ustar00rootroot00000000000000Radicale-3.0.6/radicale/__init__.py000066400000000000000000000051401372774262700171220ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 import pkg_resources from radicale import config, log from radicale.app import Application from radicale.log import logger VERSION = pkg_resources.get_distribution("radicale").version _application = None _application_config_path = None _application_lock = threading.Lock() def _init_application(config_path, wsgi_errors): global _application, _application_config_path with _application_lock: if _application is not None: return 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(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 = Application(configuration) def application(environ, start_response): """Entry point for external WSGI servers.""" config_path = environ.get("RADICALE_CONFIG", os.environ.get("RADICALE_CONFIG")) if _application is None: _init_application(config_path, environ["wsgi.errors"]) if _application_config_path != config_path: raise ValueError("RADICALE_CONFIG must not change: %s != %s" % (repr(config_path), repr(_application_config_path))) return _application(environ, start_response) Radicale-3.0.6/radicale/__main__.py000066400000000000000000000131211372774262700171010ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 sys from radicale import VERSION, config, log, server, storage from radicale.log import logger def run(): """Run Radicale as a standalone server.""" # Raise SystemExit when signal arrives to run cleanup code # (like destructors, try-finish etc.), otherwise the process exits # without running any of them def signal_handler(signal_number, stack_frame): sys.exit(1) signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) if os.name == "posix": signal.signal(signal.SIGHUP, signal_handler) log.setup() # Get command-line arguments parser = argparse.ArgumentParser( prog="radicale", usage="%(prog)s [OPTIONS]") 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_true", help="print debug information") groups = {} for section, values in config.DEFAULT_CONFIG_SCHEMA.items(): if section.startswith("_"): continue group = parser.add_argument_group(section) groups[group] = [] for option, data in values.items(): if option.startswith("_"): continue kwargs = data.copy() long_name = "--%s-%s" % (section, option.replace("_", "-")) args = kwargs.pop("aliases", []) args.append(long_name) kwargs["dest"] = "%s_%s" % (section, option) groups[group].append(kwargs["dest"]) del kwargs["value"] with contextlib.suppress(KeyError): del kwargs["internal"] if kwargs["type"] == bool: del kwargs["type"] kwargs["action"] = "store_const" kwargs["const"] = "True" opposite_args = kwargs.pop("opposite", []) opposite_args.append("--no%s" % long_name[1:]) group.add_argument(*args, **kwargs) kwargs["const"] = "False" kwargs["help"] = "do not %s (opposite of %s)" % ( kwargs["help"], long_name) group.add_argument(*opposite_args, **kwargs) else: del kwargs["type"] group.add_argument(*args, **kwargs) args = parser.parse_args() # Preliminary configure logging if args.debug: args.logging_level = "debug" with contextlib.suppress(ValueError): log.set_level(config.DEFAULT_CONFIG_SCHEMA["logging"]["level"]["type"]( args.logging_level)) # Update Radicale configuration according to arguments arguments_config = {} for group, actions in groups.items(): section = group.title section_config = {} for action in actions: value = getattr(args, action) if value is not None: section_config[action.split('_', 1)[1]] = value if section_config: arguments_config[section] = section_config try: configuration = config.load(config.parse_compound_paths( config.DEFAULT_CONFIG_PATH, os.environ.get("RADICALE_CONFIG"), os.pathsep.join(args.config) if args.config else None)) if arguments_config: configuration.update(arguments_config, "arguments") except Exception as e: logger.fatal("Invalid configuration: %s", e, exc_info=True) sys.exit(1) # Configure logging log.set_level(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.verify_storage: logger.info("Verifying storage") try: storage_ = storage.load(configuration) with storage_.acquire_lock("r"): if not storage_.verify(): logger.fatal("Storage verifcation failed") sys.exit(1) except Exception as e: logger.fatal("An exception occurred during storage verification: " "%s", e, exc_info=True) sys.exit(1) return try: server.serve(configuration) except Exception as e: logger.fatal("An exception occurred during server startup: %s", e, exc_info=True) sys.exit(1) if __name__ == "__main__": run() Radicale-3.0.6/radicale/app/000077500000000000000000000000001372774262700155715ustar00rootroot00000000000000Radicale-3.0.6/radicale/app/__init__.py000066400000000000000000000365451372774262700177170ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 io import logging import posixpath import pprint import random import time import zlib from http import client from xml.etree import ElementTree as ET import defusedxml.ElementTree as DefusedET import pkg_resources from radicale import (auth, httputils, log, pathutils, rights, storage, web, xmlutils) from radicale.app.delete import ApplicationDeleteMixin from radicale.app.get import ApplicationGetMixin from radicale.app.head import ApplicationHeadMixin from radicale.app.mkcalendar import ApplicationMkcalendarMixin from radicale.app.mkcol import ApplicationMkcolMixin from radicale.app.move import ApplicationMoveMixin from radicale.app.options import ApplicationOptionsMixin from radicale.app.post import ApplicationPostMixin from radicale.app.propfind import ApplicationPropfindMixin from radicale.app.proppatch import ApplicationProppatchMixin from radicale.app.put import ApplicationPutMixin from radicale.app.report import ApplicationReportMixin from radicale.log import logger VERSION = pkg_resources.get_distribution("radicale").version class Application( ApplicationDeleteMixin, ApplicationGetMixin, ApplicationHeadMixin, ApplicationMkcalendarMixin, ApplicationMkcolMixin, ApplicationMoveMixin, ApplicationOptionsMixin, ApplicationPropfindMixin, ApplicationProppatchMixin, ApplicationPostMixin, ApplicationPutMixin, ApplicationReportMixin): """WSGI application.""" def __init__(self, configuration): """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__() 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 _headers_log(self, environ): """Sanitize headers for logging.""" request_environ = dict(environ) # Mask passwords mask_passwords = self.configuration.get("logging", "mask_passwords") authorization = request_environ.get("HTTP_AUTHORIZATION", "") if mask_passwords and authorization.startswith("Basic"): request_environ["HTTP_AUTHORIZATION"] = "Basic **masked**" if request_environ.get("HTTP_COOKIE"): request_environ["HTTP_COOKIE"] = "**masked**" return request_environ def __call__(self, environ, start_response): with log.register_stream(environ["wsgi.errors"]): try: status, headers, answers = self._handle_request(environ) except Exception as e: try: method = str(environ["REQUEST_METHOD"]) except Exception: method = "unknown" try: path = str(environ.get("PATH_INFO", "")) except Exception: path = "" logger.error("An exception occurred during %s request on %r: " "%s", method, path, e, exc_info=True) status, headers, answer = httputils.INTERNAL_SERVER_ERROR answer = answer.encode("ascii") status = "%d %s" % ( status.value, client.responses.get(status, "Unknown")) headers = [ ("Content-Length", str(len(answer)))] + list(headers) answers = [answer] start_response(status, headers) return answers def _handle_request(self, environ): """Manage a request.""" def response(status, headers=(), answer=None): headers = dict(headers) # Set content length if answer: if hasattr(answer, "encode"): 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)) # Add extra headers set in configuration for key in self.configuration.options("headers"): headers[key] = self.configuration.get("headers", key) # Start response time_end = datetime.datetime.now() status = "%d %s" % ( status, client.responses.get(status, "Unknown")) logger.info( "%s response status for %r%s in %.3f seconds: %s", environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), depthinfo, (time_end - time_begin).total_seconds(), status) # Return response content return status, list(headers.items()), [answer] if answer else [] 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 = "%r (forwarded by %s)" % ( environ["HTTP_X_FORWARDED_FOR"], remote_host) 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"] time_begin = datetime.datetime.now() logger.info( "%s request for %r%s received from %s%s", environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), depthinfo, remote_host, remote_useragent) headers = pprint.pformat(self._headers_log(environ)) logger.debug("Request headers:\n%s", headers) # Let reverse proxies overwrite SCRIPT_NAME if "HTTP_X_SCRIPT_NAME" in environ: # script_name must be removed from PATH_INFO by the client. unsafe_base_prefix = environ["HTTP_X_SCRIPT_NAME"] logger.debug("Script name overwritten by client: %r", unsafe_base_prefix) else: # SCRIPT_NAME is already removed from PATH_INFO, according to the # WSGI specification. unsafe_base_prefix = environ.get("SCRIPT_NAME", "") # Sanitize base prefix base_prefix = pathutils.sanitize_path(unsafe_base_prefix).rstrip("/") logger.debug("Sanitized script name: %r", 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(environ.get("PATH_INFO", "")) logger.debug("Sanitized path: %r", path) # Get function corresponding to method function = getattr( self, "do_%s" % environ["REQUEST_METHOD"].upper(), None) if not function: return response(*httputils.METHOD_NOT_ALLOWED) # If "/.well-known" is not available, clients query "/" if path == "/.well-known" or path.startswith("/.well-known/"): 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.info("Failed login attempt: %r", login) # Random delay to avoid timing oracles and bruteforce attacks delay = self.configuration.get("auth", "delay") if delay > 0: random_delay = 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(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.configuration.get("server", "_internal_server"): # Verify content length content_length = int(environ.get("CONTENT_LENGTH") or 0) if content_length: max_content_length = self.configuration.get( "server", "max_content_length") if max_content_length and content_length > 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 realm = self.configuration.get("auth", "realm") headers = dict(headers) headers.update({ "WWW-Authenticate": "Basic realm=\"%s\"" % realm}) return response(status, headers, answer) def _read_xml_request_body(self, environ): 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): 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, human_tag): """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""" def __init__(self, rights, user, path): 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): 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, item=None): 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.get_meta("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.0.6/radicale/app/delete.py000066400000000000000000000052701372774262700174110ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 xml.etree import ElementTree as ET from radicale import app, httputils, storage, xmlutils def xml_delete(base_prefix, path, collection, href=None): """Read and answer DELETE requests. Read rfc4918-9.6 for info. """ collection.delete(href) 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) status = ET.Element(xmlutils.make_clark("D:status")) status.text = xmlutils.make_response(200) response.append(status) return multistatus class ApplicationDeleteMixin: def do_DELETE(self, environ, base_prefix, path, user): """Manage DELETE request.""" access = app.Access(self._rights, user, path) if not access.check("w"): return httputils.NOT_ALLOWED with self._storage.acquire_lock("w", user): item = next(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: 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.0.6/radicale/app/get.py000066400000000000000000000104171372774262700167250ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 app, httputils, pathutils, storage, xmlutils from radicale.log import logger def propose_filename(collection): """Propose a filename for a collection.""" tag = collection.get_meta("tag") if tag == "VADDRESSBOOK": fallback_title = "Address book" suffix = ".vcf" elif 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 ApplicationGetMixin: def _content_disposition_attachement(self, filename): 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, base_prefix, path, user): """Manage GET request.""" # Redirect to .web if the root URL is requested if not pathutils.strip_path(path): web_path = ".web" if not environ.get("PATH_INFO"): web_path = posixpath.join(posixpath.basename(base_prefix), web_path) return (client.FOUND, {"Location": web_path, "Content-Type": "text/plain"}, "Redirected to %s" % web_path) # Dispatch .web URL to web module if path == "/.web" or path.startswith("/.web/"): return self._web.get(environ, base_prefix, path, user) access = app.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(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): tag = item.get_meta("tag") if not tag: return (httputils.NOT_ALLOWED if limited_access else httputils.DIRECTORY_LISTING) content_type = xmlutils.MIMETYPES[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 = "" 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.0.6/radicale/app/head.py000066400000000000000000000020741372774262700170470ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 . class ApplicationHeadMixin: def do_HEAD(self, environ, base_prefix, path, user): """Manage HEAD request.""" status, headers, _ = self.do_GET(environ, base_prefix, path, user) return status, headers, None Radicale-3.0.6/radicale/app/mkcalendar.py000066400000000000000000000060461372774262700202520ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 from radicale import httputils from radicale import item as radicale_item from radicale import pathutils, storage, xmlutils from radicale.log import logger class ApplicationMkcalendarMixin: def do_MKCALENDAR(self, environ, base_prefix, path, user): """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 = xmlutils.props_from_request(xml_content) props["tag"] = "VCALENDAR" # TODO: use this? # timezone = props.get("C:calendar-timezone") try: radicale_item.check_and_sanitize_props(props) except ValueError as e: logger.warning( "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) with self._storage.acquire_lock("w", user): item = next(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(self._storage.discover(parent_path), None) if not parent_item: return httputils.CONFLICT if (not isinstance(parent_item, storage.BaseCollection) or parent_item.get_meta("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.0.6/radicale/app/mkcol.py000066400000000000000000000061371372774262700172570ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 from radicale import httputils from radicale import item as radicale_item from radicale import pathutils, rights, storage, xmlutils from radicale.log import logger class ApplicationMkcolMixin: def do_MKCOL(self, environ, base_prefix, path, user): """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 = xmlutils.props_from_request(xml_content) try: radicale_item.check_and_sanitize_props(props) 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(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(self._storage.discover(parent_path), None) if not parent_item: return httputils.CONFLICT if (not isinstance(parent_item, storage.BaseCollection) or parent_item.get_meta("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.0.6/radicale/app/move.py000066400000000000000000000100151372774262700171060ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 app, httputils, pathutils, storage from radicale.log import logger class ApplicationMoveMixin: def do_MOVE(self, environ, base_prefix, path, user): """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 = app.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 = app.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(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(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( self._storage.discover(to_parent_path), None) if not to_collection: return httputils.CONFLICT tag = item.collection.get_meta("tag") if not tag or tag != to_collection.get_meta("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 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.0.6/radicale/app/options.py000066400000000000000000000023371372774262700176430ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 class ApplicationOptionsMixin: def do_OPTIONS(self, environ, base_prefix, path, user): """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.0.6/radicale/app/post.py000066400000000000000000000023051372774262700171300ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 class ApplicationPostMixin: def do_POST(self, environ, base_prefix, path, user): """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.0.6/radicale/app/propfind.py000066400000000000000000000401601372774262700177650ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 from http import client from xml.etree import ElementTree as ET from radicale import app, httputils, pathutils, rights, storage, xmlutils from radicale.log import logger def xml_propfind(base_prefix, path, xml_request, allowed_items, user, encoding): """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_tag = (xml_request[0] if xml_request is not None else ET.Element(xmlutils.make_clark("D:allprop"))) props = () allprop = False propname = False if top_tag.tag == xmlutils.make_clark("D:allprop"): allprop = True elif top_tag.tag == xmlutils.make_clark("D:propname"): propname = True elif top_tag.tag == xmlutils.make_clark("D:prop"): props = [prop.tag for prop in top_tag] 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 client.FORBIDDEN, 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 client.MULTI_STATUS, multistatus def xml_propfind_response(base_prefix, path, item, props, user, encoding, write=False, propname=False, allprop=False): """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") is_collection = isinstance(item, storage.BaseCollection) if is_collection: is_leaf = item.get_meta("tag") in ("VADDRESSBOOK", "VCALENDAR") collection = item else: collection = item.collection response = ET.Element(xmlutils.make_clark("D:response")) href = ET.Element(xmlutils.make_clark("D:href")) if is_collection: # Some clients expect collections to end with / uri = pathutils.unstrip_path(item.path, True) else: uri = pathutils.unstrip_path( posixpath.join(collection.path, item.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.get_meta("tag") == "VCALENDAR": props.append(xmlutils.make_clark("CS:getctag")) props.append( xmlutils.make_clark("C:supported-calendar-component-set")) meta = item.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 = 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"): tag = ET.Element(xmlutils.make_clark("D:href")) tag.text = xmlutils.make_href(base_prefix, "/") element.append(tag) 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 collection.is_principal and is_collection): tag = ET.Element(xmlutils.make_clark("D:href")) tag.text = xmlutils.make_href(base_prefix, path) element.append(tag) elif tag == xmlutils.make_clark("C:supported-calendar-component-set"): human_tag = xmlutils.make_human_tag(tag) if is_collection and is_leaf: meta = item.get_meta(human_tag) if meta: components = meta.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: tag = ET.Element(xmlutils.make_clark("D:href")) tag.text = xmlutils.make_href(base_prefix, "/%s/" % user) element.append(tag) 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 item.get_meta("tag") == "VADDRESSBOOK": reports.append("CR:addressbook-multiget") reports.append("CR:addressbook-query") elif item.get_meta("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_tag = ET.Element(xmlutils.make_clark("D:report")) report_tag.append(ET.Element(xmlutils.make_clark(human_tag))) supported_report.append(report_tag) 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: tag = ET.Element(xmlutils.make_clark("D:href")) tag.text = xmlutils.make_href( base_prefix, "/%s/" % collection.owner) element.append(tag) elif is_collection: if tag == xmlutils.make_clark("D:getcontenttype"): if is_leaf: element.text = xmlutils.MIMETYPES[item.get_meta("tag")] else: is404 = True elif tag == xmlutils.make_clark("D:resourcetype"): if item.is_principal: tag = ET.Element(xmlutils.make_clark("D:principal")) element.append(tag) if is_leaf: if item.get_meta("tag") == "VADDRESSBOOK": tag = ET.Element( xmlutils.make_clark("CR:addressbook")) element.append(tag) elif item.get_meta("tag") == "VCALENDAR": tag = ET.Element(xmlutils.make_clark("C:calendar")) element.append(tag) tag = ET.Element(xmlutils.make_clark("D:collection")) element.append(tag) elif tag == xmlutils.make_clark("RADICALE:displayname"): # Only for internal use by the web interface displayname = item.get_meta("D:displayname") if displayname is not None: element.text = displayname else: is404 = True elif tag == xmlutils.make_clark("D:displayname"): displayname = item.get_meta("D:displayname") if not displayname and is_leaf: displayname = item.path if displayname is not None: element.text = displayname else: is404 = True elif tag == xmlutils.make_clark("CS:getctag"): if is_leaf: element.text = item.etag else: is404 = True elif tag == xmlutils.make_clark("D:sync-token"): if is_leaf: element.text, _ = item.sync() else: is404 = True else: human_tag = xmlutils.make_human_tag(tag) meta = item.get_meta(human_tag) if meta is not None: element.text = meta else: is404 = True # Not for collections elif tag == xmlutils.make_clark("D:getcontenttype"): 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 ApplicationPropfindMixin: def _collect_allowed_items(self, items, user): """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.get_meta("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: 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, base_prefix, path, user): """Manage PROPFIND request.""" access = app.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 = self._storage.discover( path, environ.get("HTTP_DEPTH", "0")) # take root item for rights checking item = next(items, None) if not item: return httputils.NOT_FOUND if not access.check("r", item): return httputils.NOT_ALLOWED # put item back items = itertools.chain([item], items) allowed_items = self._collect_allowed_items(items, user) headers = {"DAV": httputils.DAV_HEADERS, "Content-Type": "text/xml; charset=%s" % self._encoding} status, xml_answer = xml_propfind( base_prefix, path, xml_content, allowed_items, user, self._encoding) if status == client.FORBIDDEN and xml_answer is None: return httputils.NOT_ALLOWED return status, headers, self._xml_response(xml_answer) Radicale-3.0.6/radicale/app/proppatch.py000066400000000000000000000106251372774262700201470ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 from http import client from xml.etree import ElementTree as ET from radicale import app, httputils from radicale import item as radicale_item from radicale import storage, xmlutils from radicale.log import logger def xml_add_propstat_to(element, tag, status_number): """Add a PROPSTAT response structure to an element. The PROPSTAT answer structure is defined in rfc4918-9.1. It is added to the given ``element``, for the following ``tag`` with the given ``status_number``. """ propstat = ET.Element(xmlutils.make_clark("D:propstat")) element.append(propstat) prop = ET.Element(xmlutils.make_clark("D:prop")) propstat.append(prop) clark_tag = xmlutils.make_clark(tag) prop_tag = ET.Element(clark_tag) prop.append(prop_tag) status = ET.Element(xmlutils.make_clark("D:status")) status.text = xmlutils.make_response(status_number) propstat.append(status) def xml_proppatch(base_prefix, path, xml_request, collection): """Read and answer PROPPATCH requests. Read rfc4918-9.2 for info. """ props_to_set = xmlutils.props_from_request(xml_request, actions=("set",)) props_to_remove = xmlutils.props_from_request(xml_request, actions=("remove",)) 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) new_props = collection.get_meta() for short_name, value in props_to_set.items(): new_props[short_name] = value xml_add_propstat_to(response, short_name, 200) for short_name in props_to_remove: try: del new_props[short_name] except KeyError: pass xml_add_propstat_to(response, short_name, 200) radicale_item.check_and_sanitize_props(new_props) collection.set_meta(new_props) return multistatus class ApplicationProppatchMixin: def do_PROPPATCH(self, environ, base_prefix, path, user): """Manage PROPPATCH request.""" access = app.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(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.0.6/radicale/app/put.py000066400000000000000000000226201372774262700167550ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 import vobject from radicale import app, httputils from radicale import item as radicale_item from radicale import pathutils, rights, storage, xmlutils from radicale.log import logger MIMETYPE_TAGS = {value: key for key, value in xmlutils.MIMETYPES.items()} def prepare(vobject_items, path, content_type, permissions, parent_permissions, tag=None, write_whole_collection=None): if (write_whole_collection or permissions and not parent_permissions): 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 permissions and parent_permissions): 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 = None stored_exc_info = None items = [] try: if tag: radicale_item.check_and_sanitize_items( vobject_items, is_collection=write_whole_collection, tag=tag) if write_whole_collection and tag == "VCALENDAR": vobject_components = [] 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 radicale_item.check_and_sanitize_props(props) except Exception: stored_exc_info = sys.exc_info() # Use generator for items and delete references to free memory # early def items_generator(): while items: yield items.pop(0) return (items_generator(), tag, write_whole_collection, props, stored_exc_info) class ApplicationPutMixin: def do_PUT(self, environ, base_prefix, path, user): """Manage PUT request.""" access = app.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(";")[0] try: vobject_items = tuple(vobject.readComponents(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(self._storage.discover(path), None) parent_item = next( self._storage.discover(access.parent_path), None) if not parent_item: return httputils.CONFLICT write_whole_collection = ( isinstance(item, storage.BaseCollection) or not parent_item.get_meta("tag")) if write_whole_collection: tag = prepared_tag else: tag = parent_item.get_meta("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: 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.0.6/radicale/app/report.py000066400000000000000000000315501372774262700174620ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 from http import client from urllib.parse import unquote, urlparse from xml.etree import ElementTree as ET from radicale import app, httputils, pathutils, storage, xmlutils from radicale.item import filter as radicale_filter from radicale.log import logger def xml_report(base_prefix, path, xml_request, collection, encoding, unlock_storage_fn): """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.get_meta("tag") != "VCALENDAR" or root.tag == xmlutils.make_clark("CR:addressbook-multiget") and collection.get_meta("tag") != "VADDRESSBOOK" or root.tag == xmlutils.make_clark("D:sync-collection") and collection.get_meta("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 []) 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")): href_path = pathutils.sanitize_path( unquote(urlparse(href_element.text).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("./%s" % xmlutils.make_clark("C:filter")) + root.findall("./%s" % xmlutils.make_clark("CR:filter"))) def retrieve_items(collection, hreferences, multistatus): """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(): """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) # Retrieve everything required for finishing the request. retrieved_items = list(retrieve_items(collection, hreferences, multistatus)) collection_tag = collection.get_meta("tag") # Don't access storage after this! unlock_storage_fn() def match(item, filter_): tag = collection_tag if (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 (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, tag)) 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(match(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) 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, href, found_props=(), not_found_props=(), found_item=True): response = ET.Element(xmlutils.make_clark("D:response")) href_tag = ET.Element(xmlutils.make_clark("D:href")) href_tag.text = xmlutils.make_href(base_prefix, href) response.append(href_tag) 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_tag = ET.Element(xmlutils.make_clark("D:prop")) for prop in props: prop_tag.append(prop) propstat.append(prop_tag) 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 class ApplicationReportMixin: def do_REPORT(self, environ, base_prefix, path, user): """Manage REPORT request.""" access = app.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(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: collection = item.collection headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} 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 return status, headers, self._xml_response(xml_answer) Radicale-3.0.6/radicale/auth/000077500000000000000000000000001372774262700157525ustar00rootroot00000000000000Radicale-3.0.6/radicale/auth/__init__.py000066400000000000000000000045631372774262700200730ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 radicale import utils INTERNAL_TYPES = ("none", "remote_user", "http_x_remote_user", "htpasswd") def load(configuration): """Load the authentication module chosen in configuration.""" return utils.load_plugin(INTERNAL_TYPES, "auth", "Auth", configuration) class BaseAuth: def __init__(self, configuration): """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): """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, password): """Check credentials and map login to internal user ``login`` the login name ``password`` the password Returns the user name or ``""`` for invalid credentials. """ raise NotImplementedError Radicale-3.0.6/radicale/auth/htpasswd.py000066400000000000000000000125101372774262700201600ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 passlib.hash import apr_md5_crypt from radicale import auth class Auth(auth.BaseAuth): def __init__(self, configuration): super().__init__(configuration) self._filename = configuration.get("auth", "htpasswd_filename") self._encoding = self.configuration.get("encoding", "stock") encryption = 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, password): """Check if ``hash_value`` and ``password`` match, plain method.""" return hmac.compare_digest(hash_value.encode(), password.encode()) def _bcrypt(self, bcrypt, hash_value, password): return bcrypt.verify(password, hash_value.strip()) def _md5apr1(self, hash_value, password): return apr_md5_crypt.verify(password, hash_value.strip()) def login(self, login, password): """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.0.6/radicale/auth/http_x_remote_user.py000066400000000000000000000023231372774262700222430ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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. """ import radicale.auth.none as none class Auth(none.Auth): def get_external_login(self, environ): return environ.get("HTTP_X_REMOTE_USER", ""), "" Radicale-3.0.6/radicale/auth/none.py000066400000000000000000000020071372774262700172620ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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, password): return login Radicale-3.0.6/radicale/auth/remote_user.py000066400000000000000000000022151372774262700206550ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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. """ import radicale.auth.none as none class Auth(none.Auth): def get_external_login(self, environ): return environ.get("REMOTE_USER", ""), "" Radicale-3.0.6/radicale/config.py000066400000000000000000000360331372774262700166350ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 from collections import OrderedDict from configparser import RawConfigParser from radicale import auth, rights, storage, web DEFAULT_CONFIG_PATH = os.pathsep.join([ "?/etc/radicale/config", "?~/.config/radicale/config"]) def positive_int(value): value = int(value) if value < 0: raise ValueError("value is negative: %d" % value) return value def positive_float(value): 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): if value not in ("debug", "info", "warning", "error", "critical"): raise ValueError("unsupported level: %r" % value) return value def filepath(value): if not value: return "" value = os.path.expanduser(value) if os.name == "nt": value = os.path.expandvars(value) return os.path.abspath(value) def list_of_ip_address(value): 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): if callable(value): return value return str(value) def unspecified_type(value): return value def _convert_to_bool(value): if value.lower() not in RawConfigParser.BOOLEAN_STATES: raise ValueError("Not a boolean: %r" % value) return RawConfigParser.BOOLEAN_STATES[value.lower()] INTERNAL_OPTIONS = ("_allow_extra",) # Default configuration DEFAULT_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_int}), ("ssl", { "value": "False", "help": "use SSL connection", "aliases": ["-s", "--ssl"], "opposite": ["-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): """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=()): """ 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()``. """ configuration = Configuration(DEFAULT_CONFIG_SCHEMA) for path, ignore_if_missing in paths: parser = RawConfigParser() config_source = "config file %r" % path try: if not parser.read(path): config = Configuration.SOURCE_MISSING if not ignore_if_missing: raise RuntimeError("No such file: %r" % path) else: config = {s: {o: parser[s][o] for o in parser.options(s)} for s in parser.sections()} except Exception as e: raise RuntimeError( "Failed to load %s: %s" % (config_source, e)) from e configuration.update(config, config_source) return configuration class Configuration: SOURCE_MISSING = {} def __init__(self, schema): """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, source=None, privileged=False): """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 "_". """ source = source or "unspecified config" new_values = {} 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, option): """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, option): """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, option): """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 all sections.""" return self._values.keys() def options(self, section): """List all options in ``section``""" return self._values[section].keys() def sources(self): """List all config sources.""" return [(source, config is self.SOURCE_MISSING) for config, source, _ in self._configs] def copy(self, plugin_schema=None): """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: schema = self._schema.copy() for section, options in plugin_schema.items(): if (section not in schema or "type" not in schema[section] or "internal" not in schema[section]["type"]): raise ValueError("not a plugin section: %r" % section) schema[section] = schema[section].copy() schema[section]["type"] = schema[section]["type"].copy() schema[section]["type"]["internal"] = [ self.get(section, "type")] for option, value in options.items(): if option in schema[section]: raise ValueError("option already exists in %r: %r" % ( section, option)) schema[section][option] = value copy = type(self)(schema) for config, source, privileged in self._configs: copy.update(config, source, privileged) return copy Radicale-3.0.6/radicale/httputils.py000066400000000000000000000100411372774262700174170ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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. """ from http import client from radicale.log import logger NOT_ALLOWED = ( client.FORBIDDEN, (("Content-Type", "text/plain"),), "Access to the requested resource forbidden.") FORBIDDEN = ( client.FORBIDDEN, (("Content-Type", "text/plain"),), "Action on the requested resource refused.") BAD_REQUEST = ( client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request") NOT_FOUND = ( client.NOT_FOUND, (("Content-Type", "text/plain"),), "The requested resource could not be found.") CONFLICT = ( client.CONFLICT, (("Content-Type", "text/plain"),), "Conflict in the request.") METHOD_NOT_ALLOWED = ( client.METHOD_NOT_ALLOWED, (("Content-Type", "text/plain"),), "The method is not allowed on the requested resource.") PRECONDITION_FAILED = ( client.PRECONDITION_FAILED, (("Content-Type", "text/plain"),), "Precondition failed.") REQUEST_TIMEOUT = ( client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),), "Connection timed out.") REQUEST_ENTITY_TOO_LARGE = ( client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),), "Request body too large.") REMOTE_DESTINATION = ( client.BAD_GATEWAY, (("Content-Type", "text/plain"),), "Remote destination not supported.") DIRECTORY_LISTING = ( client.FORBIDDEN, (("Content-Type", "text/plain"),), "Directory listings are not supported.") INTERNAL_SERVER_ERROR = ( client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),), "A server error occurred. Please contact the administrator.") DAV_HEADERS = "1, 2, 3, calendar-access, addressbook, extended-mkcol" def decode_request(configuration, environ, text): """Try to magically decode ``text`` according to given ``environ``.""" # List of charsets to try charsets = [] # 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(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: try: return text.decode(charset) except UnicodeDecodeError: pass raise UnicodeDecodeError("decode_request", text, 0, len(text), "all codecs failed [%s]" % ", ".join(charsets)) def read_raw_request_body(configuration, environ): 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, environ): content = decode_request( configuration, environ, read_raw_request_body(configuration, environ)) logger.debug("Request content:\n%s", content) return content Radicale-3.0.6/radicale/item/000077500000000000000000000000001372774262700157475ustar00rootroot00000000000000Radicale-3.0.6/radicale/item/__init__.py000066400000000000000000000346721372774262700200740ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 math import os import sys from datetime import timedelta from hashlib import sha256 import vobject from radicale import pathutils from radicale.item import filter as radicale_filter from radicale.log import logger def predict_tag_of_parent_collection(vobject_items): if len(vobject_items) != 1: return "" if vobject_items[0].name == "VCALENDAR": return "VCALENDAR" if vobject_items[0].name in ("VCARD", "VLIST"): return "VADDRESSBOOK" return "" def predict_tag_of_whole_collection(vobject_items, fallback_tag=None): 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, is_collection=False, tag=None): """Check vobject items for common errors and add missing UIDs. ``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 # vobject interprets recurrence rules on demand try: component.rruleset except Exception as e: raise ValueError("invalid recurrence rules in %s" % component.name) 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 i in vobject_items: raise ValueError("Item type %r not supported in %s collection" % (i.name, repr(tag) if tag else "generic")) def check_and_sanitize_props(props): """Check collection properties for common errors.""" tag = props.get("tag") if tag and tag not in ("VCALENDAR", "VADDRESSBOOK"): raise ValueError("Unsupported collection tag: %r" % tag) def find_available_uid(exists_fn, suffix=""): """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 raise RuntimeError("No unique random sequence found") def get_etag(text): """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): """UID value of an item if defined.""" return (vobject_component.uid.value if hasattr(vobject_component, "uid") else None) def get_uid_from_object(vobject_item): """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 None def find_tag(vobject_item): """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_tag_and_time_range(vobject_item): """Find component name and enclosing time range from ``vobject item``. Returns a tuple (``tag``, ``start``, ``end``) where ``tag`` is a string and ``start`` and ``end`` are POSIX timestamps (as int). This is intened to be used for matching against simplified prefilters. """ tag = find_tag(vobject_item) if not tag: return ( tag, radicale_filter.TIMESTAMP_MIN, radicale_filter.TIMESTAMP_MAX) start = end = None def range_fn(range_start, range_end, is_recurrence): 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): 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 try: return tag, math.floor(start.timestamp()), math.ceil(end.timestamp()) except ValueError as e: if str(e) == ("offset must be a timedelta representing a whole " "number of minutes") and sys.version_info < (3, 6): raise RuntimeError("Unsupported in Python < 3.6: %s" % e) from e raise class Item: """Class for address book and calendar entries.""" def __init__(self, collection_path=None, collection=None, vobject_item=None, href=None, last_modified=None, text=None, etag=None, uid=None, name=None, component_name=None, time_range=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_tag_and_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): 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): """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): if self._uid is None: self._uid = get_uid_from_object(self.vobject_item) return self._uid @property def name(self): if self._name is None: self._name = self.vobject_item.name or "" return self._name @property def component_name(self): if self._component_name is not None: return self._component_name return find_tag(self.vobject_item) @property def time_range(self): if self._time_range is None: self._component_name, *self._time_range = ( find_tag_and_time_range(self.vobject_item)) return self._time_range def prepare(self): """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.0.6/radicale/item/filter.py000066400000000000000000000505051372774262700176130ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 from datetime import date, datetime, timedelta, timezone from itertools import chain from radicale import xmlutils from radicale.log import logger DAY = timedelta(days=1) SECOND = timedelta(seconds=1) DATETIME_MIN = datetime.min.replace(tzinfo=timezone.utc) DATETIME_MAX = datetime.max.replace(tzinfo=timezone.utc) TIMESTAMP_MIN = math.floor(DATETIME_MIN.timestamp()) TIMESTAMP_MAX = math.ceil(DATETIME_MAX.timestamp()) def date_to_datetime(date_): """Transform a date to a UTC datetime. If date_ is a datetime without timezone, return as UTC datetime. If date_ is already a datetime with timezone, return as is. """ if not isinstance(date_, datetime): date_ = datetime.combine(date_, datetime.min.time()) if not date_.tzinfo: date_ = date_.replace(tzinfo=timezone.utc) return date_ def comp_match(item, filter_, level=0): """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, filter_, ns): """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("C:is-not-defined"): # 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, filter_, child_name): """Check whether the component/property ``child_name`` of ``vobject_item`` matches the time-range ``filter_``.""" start = filter_.get("start") end = filter_.get("end") if not start and not end: return False if start: start = datetime.strptime(start, "%Y%m%dT%H%M%SZ") else: start = datetime.min if end: end = datetime.strptime(end, "%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, range_end, is_recurrence): 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): return False visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn) return matched def visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn): """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 infiite 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, ignore=()): if (hasattr(child, "rrule") and ";UNTIL=" not in child.rrule.value.upper() and ";COUNT=" not in child.rrule.value.upper()): 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): 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): range_fn(child, child + DAY, False) elif isinstance(child, datetime): range_fn(child, child + SECOND, False) def text_match(vobject_item, filter_, child_name, ns, attrib_name=None): """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): 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: 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, filter_, parent_name, ns): """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, collection_tag="VCALENDAR"): """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 = tuple(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 = time_filter.get("start") end = time_filter.get("end") if start: start = math.floor(datetime.strptime( start, "%Y%m%dT%H%M%SZ").replace( tzinfo=timezone.utc).timestamp()) else: start = TIMESTAMP_MIN if end: end = math.ceil(datetime.strptime( end, "%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.0.6/radicale/log.py000066400000000000000000000074101372774262700161460ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 contextlib import logging import os import sys import threading LOGGER_NAME = "radicale" LOGGER_FORMAT = "[%(asctime)s] [%(ident)s] [%(levelname)s] %(message)s" DATE_FORMAT = "%Y-%m-%d %H:%M:%S %z" logger = logging.getLogger(LOGGER_NAME) class RemoveTracebackFilter(logging.Filter): def filter(self, record): record.exc_info = None return True REMOVE_TRACEBACK_FILTER = RemoveTracebackFilter() class IdentLogRecordFactory: """LogRecordFactory that adds ``ident`` attribute.""" def __init__(self, upstream_factory): self.upstream_factory = upstream_factory def __call__(self, *args, **kwargs): 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 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 = "\n" def __init__(self): super().__init__() self._streams = {} def emit(self, record): 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) @contextlib.contextmanager def register_stream(self, stream): """Register stream for logging output of the current thread.""" key = threading.get_ident() self._streams[key] = stream try: yield finally: del self._streams[key] @contextlib.contextmanager def register_stream(stream): """Register stream for logging output of the current thread.""" yield def setup(): """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): """Set logging level for global logger.""" if isinstance(level, str): level = getattr(logging, level.upper()) logger.setLevel(level) if level == logging.DEBUG: logger.removeFilter(REMOVE_TRACEBACK_FILTER) else: logger.addFilter(REMOVE_TRACEBACK_FILTER) Radicale-3.0.6/radicale/pathutils.py000066400000000000000000000172171372774262700174100ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 contextlib import os import posixpath import threading if os.name == "nt": import ctypes import ctypes.wintypes import msvcrt LOCKFILE_EXCLUSIVE_LOCK = 2 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)] lock_file_ex = ctypes.windll.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 = ctypes.windll.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 elif os.name == "posix": import fcntl class RwLock: """A readers-Writer lock that locks a file.""" def __init__(self, path): self._path = path self._readers = 0 self._writer = False self._lock = threading.Lock() @property def locked(self): with self._lock: if self._readers > 0: return "r" if self._writer: return "w" return "" @contextlib.contextmanager def acquire(self, mode): if mode not in "rw": raise ValueError("Invalid mode: %r" % mode) with open(self._path, "w+") as lock_file: if os.name == "nt": handle = msvcrt.get_osfhandle(lock_file.fileno()) flags = LOCKFILE_EXCLUSIVE_LOCK if mode == "w" else 0 overlapped = Overlapped() if not lock_file_ex(handle, flags, 0, 1, 0, overlapped): raise RuntimeError("Locking the storage failed: %s" % ctypes.FormatError()) elif os.name == "posix": _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 else: raise RuntimeError("Locking the storage failed: " "Unsupported operating system") 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 fsync(fd): if os.name == "posix" and hasattr(fcntl, "F_FULLFSYNC"): fcntl.fcntl(fd, fcntl.F_FULLFSYNC) else: os.fsync(fd) def strip_path(path): assert sanitize_path(path) == path return path.strip("/") def unstrip_path(stripped_path, trailing_slash=False): 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): """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): """Check if path is a single component of a path. Check that the path is safe to join too. """ return path and "/" not in path and path not in (".", "..") def is_safe_filesystem_path_component(path): """Check if path is a single component of a local and posix filesystem path. Check that the path is safe to join too. """ return ( path and not os.path.splitdrive(path)[0] and 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, sane_path): """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): message = "Can't translate name safely to filesystem: %r" % path super().__init__(message) class CollidingPathError(ValueError): def __init__(self, path): message = "File name collision: %r" % path super().__init__(message) def name_from_path(path, collection): """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.0.6/radicale/rights/000077500000000000000000000000001372774262700163115ustar00rootroot00000000000000Radicale-3.0.6/radicale/rights/__init__.py000066400000000000000000000045751372774262700204350ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 radicale import utils INTERNAL_TYPES = ("authenticated", "owner_write", "owner_only", "from_file") def load(configuration): """Load the rights module chosen in configuration.""" return utils.load_plugin(INTERNAL_TYPES, "rights", "Rights", configuration) def intersect(a, b): """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): """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, path): """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.0.6/radicale/rights/authenticated.py000066400000000000000000000026071372774262700215120ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 pathutils, rights class Rights(rights.BaseRights): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._verify_user = self.configuration.get("auth", "type") != "none" def authorization(self, user, path): 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.0.6/radicale/rights/from_file.py000066400000000000000000000072221372774262700206300ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 pathutils, rights from radicale.log import logger class Rights(rights.BaseRights): def __init__(self, configuration): super().__init__(configuration) self._filename = configuration.get("rights", "file") def authorization(self, user, path): user = user or "" sane_path = pathutils.strip_path(path) # Prevent "regex injection" escaped_user = re.escape(user) rights_config = configparser.ConfigParser() try: if not rights_config.read(self._filename): raise RuntimeError("No such file: %r" % self._filename) 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( *map(re.escape, 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.0.6/radicale/rights/owner_only.py000066400000000000000000000026621372774262700210640ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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, path): 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.0.6/radicale/rights/owner_write.py000066400000000000000000000030211372774262700212230ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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, path): 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.0.6/radicale/server.py000066400000000000000000000277201372774262700167010ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 os import select import socket import socketserver import ssl import sys import wsgiref.simple_server from urllib.parse import unquote from radicale import Application, config from radicale.log import logger if hasattr(socket, "EAI_ADDRFAMILY"): COMPAT_EAI_ADDRFAMILY = socket.EAI_ADDRFAMILY elif hasattr(socket, "EAI_NONAME"): # Windows and BSD don't have a special error code for this COMPAT_EAI_ADDRFAMILY = socket.EAI_NONAME 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 if hasattr(socket, "IPPROTO_IPV6"): COMPAT_IPPROTO_IPV6 = socket.IPPROTO_IPV6 elif os.name == "nt": # Workaround: https://bugs.python.org/issue29515 COMPAT_IPPROTO_IPV6 = 41 def format_address(address): return "[%s]:%d" % address[:2] class ParallelHTTPServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGIServer): # We wait for child threads ourself block_on_close = False def __init__(self, configuration, family, address, RequestHandlerClass): self.configuration = configuration self.address_family = family super().__init__(address, RequestHandlerClass) self.client_sockets = set() def server_bind(self): 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(self): # Set timeout for client request, client_address = super().get_request() timeout = self.configuration.get("server", "timeout") if timeout: request.settimeout(timeout) client_socket, client_socket_out = socket.socketpair() self.client_sockets.add(client_socket_out) return request, (*client_address, client_socket) def finish_request_locked(self, request, client_address): return super().finish_request(request, client_address) def finish_request(self, request, client_address): *client_address, client_socket = client_address client_address = tuple(client_address) try: return self.finish_request_locked(request, client_address) finally: client_socket.close() def handle_error(self, request, client_address): if issubclass(sys.exc_info()[0], 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): super().server_bind() # Wrap the TCP socket in an SSL socket certfile = self.configuration.get("server", "certificate") keyfile = self.configuration.get("server", "key") cafile = 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(self, request, client_address): 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) 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 = {} def log_exception(self, exc_info): logger.error("An exception occurred during request: %s", exc_info[1], exc_info=exc_info) class RequestHandler(wsgiref.simple_server.WSGIRequestHandler): """HTTP requests handler.""" def log_request(self, code="-", size="-"): pass # Disable request logging. def log_error(self, format_, *args): logger.error("An error occurred during request: %s", format_ % args) def get_environ(self): env = super().get_environ() if hasattr(self.connection, "getpeercert"): # 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): """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 handler.run(self.server.get_app()) def serve(configuration, shutdown_socket=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 = configuration.get("server", "ssl") server_class = ParallelHTTPSServer if use_ssl else ParallelHTTPServer application = Application(configuration) servers = {} try: for address in configuration.get("server", "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 "") assert servers, "no servers started" # Mainloop select_timeout = None if os.name == "nt": # Fallback to busy waiting. (select(...) blocks SIGINT on Windows.) select_timeout = 1.0 max_connections = configuration.get("server", "max_connections") logger.info("Radicale server ready") while True: rlist = [] # Wait for finished clients for server in servers.values(): rlist.extend(server.client_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) rlist = set(rlist) if shutdown_socket in rlist: logger.info("Stopping Radicale") break for server in servers.values(): finished_sockets = server.client_sockets.intersection(rlist) for s in finished_sockets: s.close() server.client_sockets.remove(s) rlist.remove(s) if finished_sockets: server.service_actions() if rlist: server = servers.get(rlist.pop()) if server: server.handle_request() finally: # Wait for clients to finish and close servers for server in servers.values(): for s in server.client_sockets: s.recv(1) s.close() server.server_close() Radicale-3.0.6/radicale/storage/000077500000000000000000000000001372774262700164555ustar00rootroot00000000000000Radicale-3.0.6/radicale/storage/__init__.py000066400000000000000000000253751372774262700206020ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 contextlib import json from hashlib import sha256 import pkg_resources import vobject from radicale import utils from radicale.item import filter as radicale_filter INTERNAL_TYPES = ("multifilesystem",) CACHE_DEPS = ("radicale", "vobject", "python-dateutil",) CACHE_VERSION = (";".join(pkg_resources.get_distribution(pkg).version for pkg in CACHE_DEPS) + ";").encode() def load(configuration): """Load the storage module chosen in configuration.""" return utils.load_plugin( INTERNAL_TYPES, "storage", "Storage", configuration) class ComponentExistsError(ValueError): def __init__(self, path): message = "Component already exists: %r" % path super().__init__(message) class ComponentNotFoundError(ValueError): def __init__(self, path): message = "Component doesn't exist: %r" % path super().__init__(message) class BaseCollection: @property def path(self): """The sanitized path of the collection without leading or trailing ``/``.""" raise NotImplementedError @property def owner(self): """The owner of the collection.""" return self.path.split("/", maxsplit=1)[0] @property def is_principal(self): """Collection is a principal.""" return bool(self.path) and "/" not in self.path @property def etag(self): """Encoded as quoted-string (see RFC 2616).""" etag = sha256() for item in self.get_all(): etag.update((item.href + "/" + item.etag).encode()) etag.update(json.dumps(self.get_meta(), sort_keys=True).encode()) return '"%s"' % etag.hexdigest() def sync(self, old_token=None): """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 missing, all items are returned. ValueError is raised for invalid or old tokens. WARNING: This simple default implementation treats all sync-token as invalid. """ token = "http://radicale.org/ns/sync/%s" % self.etag.strip("\"") if old_token: raise ValueError("Sync token are not supported") return token, (item.href for item in self.get_all()) def get_multi(self, hrefs): """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): """Fetch all items.""" raise NotImplementedError def get_filtered(self, filters): """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. """ tag, start, end, simple = radicale_filter.simplify_prefilters( filters, collection_tag=self.get_meta("tag")) for item in self.get_all(): if tag: if tag != item.component_name: continue istart, iend = item.time_range if istart >= end or iend <= start: continue item_simple = simple and (start <= istart or iend <= end) else: item_simple = simple yield item, item_simple def has_uid(self, uid): """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, item): """Upload a new or replace an existing item.""" raise NotImplementedError def delete(self, href=None): """Delete an item. When ``href`` is ``None``, delete the collection. """ raise NotImplementedError def get_meta(self, key=None): """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): """Set metadata values for collection. ``props`` a dict with values for properties. """ raise NotImplementedError @property def last_modified(self): """Get the HTTP-datetime of when the collection was modified.""" raise NotImplementedError def serialize(self): """Get the unicode string representing the whole collection.""" if self.get_meta("tag") == "VCALENDAR": in_vcalendar = False vtimezones = "" included_tzids = 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 errornous ouput. # 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) 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.get_meta("tag") == "VADDRESSBOOK": return "".join((item.serialize() for item in self.get_all())) return "" class BaseStorage: def __init__(self, configuration): """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, depth="0"): """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, to_collection, to_href): """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, items=None, props=None): """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, it is guessed from the collection. """ raise NotImplementedError @contextlib.contextmanager def acquire_lock(self, mode, user=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): """Check the storage for errors.""" raise NotImplementedError Radicale-3.0.6/radicale/storage/multifilesystem/000077500000000000000000000000001372774262700217145ustar00rootroot00000000000000Radicale-3.0.6/radicale/storage/multifilesystem/__init__.py000066400000000000000000000143561372774262700240360ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 contextlib import os import time from itertools import chain from tempfile import TemporaryDirectory from radicale import pathutils, storage from radicale.storage.multifilesystem.cache import CollectionCacheMixin from radicale.storage.multifilesystem.create_collection import \ StorageCreateCollectionMixin from radicale.storage.multifilesystem.delete import CollectionDeleteMixin from radicale.storage.multifilesystem.discover import StorageDiscoverMixin from radicale.storage.multifilesystem.get import CollectionGetMixin from radicale.storage.multifilesystem.history import CollectionHistoryMixin from radicale.storage.multifilesystem.lock import (CollectionLockMixin, StorageLockMixin) from radicale.storage.multifilesystem.meta import CollectionMetaMixin from radicale.storage.multifilesystem.move import StorageMoveMixin from radicale.storage.multifilesystem.sync import CollectionSyncMixin from radicale.storage.multifilesystem.upload import CollectionUploadMixin from radicale.storage.multifilesystem.verify import StorageVerifyMixin class Collection( CollectionCacheMixin, CollectionDeleteMixin, CollectionGetMixin, CollectionHistoryMixin, CollectionLockMixin, CollectionMetaMixin, CollectionSyncMixin, CollectionUploadMixin, storage.BaseCollection): def __init__(self, storage_, path, filesystem_path=None): self._storage = storage_ folder = self._storage._get_collection_root_folder() # Path should already be sanitized self._path = pathutils.strip_path(path) self._encoding = self._storage.configuration.get("encoding", "stock") if filesystem_path is None: filesystem_path = pathutils.path_to_filesystem(folder, self.path) self._filesystem_path = filesystem_path self._etag_cache = None super().__init__() @property def path(self): return self._path @contextlib.contextmanager def _atomic_write(self, path, mode="w", newline=None): 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) @property def last_modified(self): relevant_files = chain( (self._filesystem_path,), (self._props_path,) if os.path.exists(self._props_path) else (), (os.path.join(self._filesystem_path, h) for h in self._list())) last = max(map(os.path.getmtime, relevant_files)) return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last)) @property def etag(self): # 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( StorageCreateCollectionMixin, StorageDiscoverMixin, StorageLockMixin, StorageMoveMixin, StorageVerifyMixin, storage.BaseStorage): _collection_class = Collection def __init__(self, configuration): super().__init__(configuration) folder = configuration.get("storage", "filesystem_folder") self._makedirs_synced(folder) def _get_collection_root_folder(self): filesystem_folder = self.configuration.get( "storage", "filesystem_folder") return os.path.join(filesystem_folder, "collection-root") def _fsync(self, f): if self.configuration.get("storage", "_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): """Sync directory to disk. This only works on POSIX and does nothing on other systems. """ if not self.configuration.get("storage", "_filesystem_fsync"): return if os.name == "posix": 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): """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.0.6/radicale/storage/multifilesystem/cache.py000066400000000000000000000104401372774262700233300ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 pickle import time from hashlib import sha256 from radicale import pathutils, storage from radicale.log import logger class CollectionCacheMixin: def _clean_cache(self, folder, names, max_age=None): """Delete all ``names`` in ``folder`` that are older than ``max_age``. """ age_limit = time.time() - max_age if max_age is not None else None 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): _hash = sha256() _hash.update(storage.CACHE_VERSION) _hash.update(raw_text) return _hash.hexdigest() def _item_cache_content(self, item, cache_hash=None): text = item.serialize() if cache_hash is None: cache_hash = self._item_cache_hash(text.encode(self._encoding)) return (cache_hash, item.uid, item.etag, text, item.name, item.component_name, *item.time_range) def _store_item_cache(self, href, item, cache_hash=None): cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache", "item") content = self._item_cache_content(item, cache_hash) self._storage._makedirs_synced(cache_folder) try: # Race: Other processes might have created and locked the # file. with self._atomic_write(os.path.join(cache_folder, href), "wb") as f: pickle.dump(content, f) except PermissionError: pass return content def _load_item_cache(self, href, input_hash): cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache", "item") cache_hash = uid = etag = text = name = tag = start = end = None try: with open(os.path.join(cache_folder, href), "rb") as f: cache_hash, *content = pickle.load(f) if cache_hash == input_hash: uid, etag, text, name, tag, start, end = content 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 cache_hash, uid, etag, text, name, tag, start, end def _clean_item_cache(self): 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.0.6/radicale/storage/multifilesystem/create_collection.py000066400000000000000000000054421372774262700257510ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 radicale import pathutils class StorageCreateCollectionMixin: def create_collection(self, href, items=None, props=None): 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( 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( 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") # This operation is not atomic on the filesystem level but it's # very unlikely that one rename operations succeeds while the # other fails or that only one gets written to disk. if os.path.exists(filesystem_path): os.rename(filesystem_path, os.path.join(tmp_dir, "delete")) os.rename(tmp_filesystem_path, filesystem_path) self._sync_directory(parent_dir) return self._collection_class( self, pathutils.unstrip_path(sane_path, True)) Radicale-3.0.6/radicale/storage/multifilesystem/delete.py000066400000000000000000000041271372774262700235340ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 radicale import pathutils, storage class CollectionDeleteMixin: def delete(self, href=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.0.6/radicale/storage/multifilesystem/discover.py000066400000000000000000000057671372774262700241230ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 posixpath from radicale import pathutils from radicale.log import logger class StorageDiscoverMixin: def discover(self, path, depth="0", child_context_manager=( lambda path, href=None: contextlib.ExitStack())): # 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 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( self, pathutils.unstrip_path(sane_path, True)) if href: yield collection._get(href) return yield collection if depth == "0": return for href in collection._list(): with child_context_manager(sane_path, href): yield collection._get(href) 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): yield self._collection_class(self, child_path) Radicale-3.0.6/radicale/storage/multifilesystem/get.py000066400000000000000000000137271372774262700230570ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 time import vobject from radicale import item as radicale_item from radicale import pathutils from radicale.log import logger class CollectionGetMixin: def __init__(self): super().__init__() self._item_cache_cleaned = False def _list(self): 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, verify_href=True): 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 (os.name == "nt" 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. input_hash = self._item_cache_hash(raw_text) cache_hash, uid, etag, text, name, tag, start, end = \ self._load_item_cache(href, input_hash) if input_hash != cache_hash: 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_hash, uid, etag, text, name, tag, start, end = \ self._load_item_cache(href, input_hash) if input_hash != cache_hash: try: vobject_items = tuple(vobject.readComponents( raw_text.decode(self._encoding))) radicale_item.check_and_sanitize_items( vobject_items, tag=self.get_meta("tag")) vobject_item, = vobject_items temp_item = radicale_item.Item( collection=self, vobject_item=vobject_item) cache_hash, uid, etag, text, name, tag, start, end = \ self._store_item_cache( href, temp_item, input_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=etag, text=text, uid=uid, name=name, component_name=tag, time_range=(start, end)) def get_multi(self, hrefs): # 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): # We don't need to check for collissions, because the the file names # are from os.listdir. return (self._get(href, verify_href=False) for href in self._list()) Radicale-3.0.6/radicale/storage/multifilesystem/history.py000066400000000000000000000074751372774262700240040ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 os import pickle from radicale import item as radicale_item from radicale import pathutils from radicale.log import logger class CollectionHistoryMixin: 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("\"") try: # Race: Other processes might have created and locked the file. with self._atomic_write(os.path.join(history_folder, href), "wb") as f: pickle.dump([etag, history_etag], f) except PermissionError: pass 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") try: 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 except FileNotFoundError: pass 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._storage.configuration.get( "storage", "max_sync_token_age")) Radicale-3.0.6/radicale/storage/multifilesystem/lock.py000066400000000000000000000074361372774262700232300ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 from radicale import pathutils from radicale.log import logger class CollectionLockMixin: def _acquire_cache_lock(self, ns=""): if self._storage._lock.locked == "w": return contextlib.ExitStack() 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) return lock.acquire("w") class StorageLockMixin: def __init__(self, configuration): super().__init__(configuration) folder = self.configuration.get("storage", "filesystem_folder") lock_path = os.path.join(folder, ".Radicale.lock") self._lock = pathutils.RwLock(lock_path) @contextlib.contextmanager def acquire_lock(self, mode, user=None): with self._lock.acquire(mode): yield # execute hook hook = self.configuration.get("storage", "hook") if mode == "w" and hook: folder = self.configuration.get("storage", "filesystem_folder") debug = logger.isEnabledFor(logging.DEBUG) popen_kwargs = dict( stdin=subprocess.DEVNULL, stdout=subprocess.PIPE if debug else subprocess.DEVNULL, stderr=subprocess.PIPE if debug else subprocess.DEVNULL, shell=True, universal_newlines=True, cwd=folder) # Use new process group for child to prevent terminals # from sending SIGINT etc. if os.name == "posix": # Process group is also used to identify child processes popen_kwargs["preexec_fn"] = os.setpgrp elif os.name == "nt": popen_kwargs["creationflags"] = ( subprocess.CREATE_NEW_PROCESS_GROUP) command = hook % {"user": shlex.quote(user or "Anonymous")} logger.debug("Running hook") p = subprocess.Popen(command, **popen_kwargs) try: stdout_data, stderr_data = p.communicate() except BaseException: # e.g. KeyboardInterrupt or SystemExit p.kill() raise finally: if os.name == "posix": # Try to kill child processes with contextlib.suppress(OSError): os.killpg(p.pid, signal.SIGKILL) if stdout_data: logger.debug("Captured stdout hook:\n%s", stdout_data) if stderr_data: logger.debug("Captured stderr hook:\n%s", stderr_data) if p.returncode != 0: raise subprocess.CalledProcessError(p.returncode, p.args) Radicale-3.0.6/radicale/storage/multifilesystem/meta.py000066400000000000000000000036771372774262700232310ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 radicale import item as radicale_item class CollectionMetaMixin: def __init__(self): super().__init__() self._meta_cache = None self._props_path = os.path.join( self._filesystem_path, ".Radicale.props") def get_meta(self, key=None): # 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: self._meta_cache = json.load(f) except FileNotFoundError: self._meta_cache = {} radicale_item.check_and_sanitize_props(self._meta_cache) except ValueError as e: raise RuntimeError("Failed to load properties of collection " "%r: %s" % (self.path, e)) from e return self._meta_cache.get(key) if key else self._meta_cache def set_meta(self, props): with self._atomic_write(self._props_path, "w") as f: json.dump(props, f, sort_keys=True) Radicale-3.0.6/radicale/storage/multifilesystem/move.py000066400000000000000000000047151372774262700232430ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 pathutils class StorageMoveMixin: def move(self, item, to_collection, to_href): if not pathutils.is_safe_filesystem_path_component(to_href): raise pathutils.UnsafePathError(to_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.0.6/radicale/storage/multifilesystem/sync.py000066400000000000000000000123171372774262700232460ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 itertools import os import pickle from hashlib import sha256 from radicale.log import logger class CollectionSyncMixin: def sync(self, old_token=None): # 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): if len(token_name) != 64: return False for c in token_name: if c not in "0123456789abcdef": return False return True old_token_name = None 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 try: os.remove(old_token_path) except (FileNotFoundError, PermissionError): pass 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 f: pickle.dump(state, f) except PermissionError: pass else: # clean up old sync tokens and item cache self._clean_cache(token_folder, os.listdir(token_folder), max_age=self._storage.configuration.get( "storage", "max_sync_token_age")) self._clean_history() else: # Try to update the modification time try: # Race: Another process might have deleted the file. os.utime(token_path) except FileNotFoundError: pass 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.0.6/radicale/storage/multifilesystem/upload.py000066400000000000000000000104651372774262700235600ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 pickle from radicale import item as radicale_item from radicale import pathutils class CollectionUploadMixin: def upload(self, href, 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 fd: fd.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() return self._get(href, verify_href=False) def _upload_all_nonatomic(self, items, suffix=""): """Upload a new set of items. This takes a list of vobject items and uploads them nonatomic and without existence checks. """ cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache", "item") self._storage._makedirs_synced(cache_folder) hrefs = set() 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 href_candidate_funtions = [] if os.name in ("nt", "posix"): href_candidate_funtions.append( lambda: uid if uid.lower().endswith(suffix.lower()) else uid + suffix) href_candidate_funtions.extend(( lambda: radicale_item.get_etag(uid).strip('"') + suffix, lambda: radicale_item.find_available_uid(hrefs.__contains__, suffix))) href = f = None while href_candidate_funtions: href = href_candidate_funtions.pop(0)() if href in hrefs: continue if not pathutils.is_safe_filesystem_path_component(href): if not href_candidate_funtions: raise pathutils.UnsafePathError(href) continue try: f = open(pathutils.path_to_filesystem( self._filesystem_path, href), "w", newline="", encoding=self._encoding) break except OSError as e: if href_candidate_funtions and ( os.name == "posix" and e.errno == 22 or os.name == "nt" and e.errno == 123): continue raise with f: f.write(item.serialize()) f.flush() self._storage._fsync(f) hrefs.add(href) with open(os.path.join(cache_folder, href), "wb") as f: pickle.dump(cache_content, f) f.flush() self._storage._fsync(f) self._storage._sync_directory(cache_folder) self._storage._sync_directory(self._filesystem_path) Radicale-3.0.6/radicale/storage/multifilesystem/verify.py000066400000000000000000000061711372774262700235770ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 from radicale import pathutils, storage from radicale.log import logger class StorageVerifyMixin: def verify(self): item_errors = collection_errors = 0 @contextlib.contextmanager def exception_cm(sane_path, href=None): nonlocal item_errors, collection_errors try: yield except Exception as e: if href: 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): saved_item_errors = item_errors collection = None uids = set() has_child_collections = False for item in self.discover(path, "1", exception_cm): if not collection: 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) if item_errors == saved_item_errors: collection.sync() if has_child_collections and collection.get_meta("tag"): logger.error("Invalid collection %r: %r must not have " "child collections", sane_path, collection.get_meta("tag")) return item_errors == 0 and collection_errors == 0 Radicale-3.0.6/radicale/tests/000077500000000000000000000000001372774262700161535ustar00rootroot00000000000000Radicale-3.0.6/radicale/tests/__init__.py000066400000000000000000000145051372774262700202710ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 sys from io import BytesIO import defusedxml.ElementTree as DefusedET import radicale from radicale import xmlutils # Enable debug output radicale.log.logger.setLevel(logging.DEBUG) class BaseTest: """Base class for tests.""" def request(self, method, path, data=None, login=None, **args): """Send a request.""" for key in args: args[key.upper()] = args[key] if login: args["HTTP_AUTHORIZATION"] = "Basic " + base64.b64encode( login.encode()).decode() args["REQUEST_METHOD"] = method.upper() args["PATH_INFO"] = path if data: data = data.encode() args["wsgi.input"] = BytesIO(data) args["CONTENT_LENGTH"] = str(len(data)) args["wsgi.errors"] = sys.stderr status = headers = None def start_response(status_, headers_): nonlocal status, headers status = status_ headers = headers_ answer = self.application(args, start_response) return (int(status.split()[0]), dict(headers), answer[0].decode() if answer else None) @staticmethod def parse_responses(text): xml = DefusedET.fromstring(text) assert xml.tag == xmlutils.make_clark("D:multistatus") path_responses = {} for response in xml.findall(xmlutils.make_clark("D:response")): href = response.find(xmlutils.make_clark("D:href")) assert href.text not in path_responses prop_respones = {} 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 prop in propstat.findall(xmlutils.make_clark("D:prop")): for element in 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 @staticmethod def _check_status(status, good_status, check=True): if check is True: assert status == good_status elif check is not False: assert status == check return status == good_status def get(self, path, check=True, **args): status, _, answer = self.request("GET", path, **args) self._check_status(status, 200, check) return status, answer def post(self, path, data=None, check=True, **args): status, _, answer = self.request("POST", path, data, **args) self._check_status(status, 200, check) return status, answer def put(self, path, data, check=True, **args): status, _, answer = self.request("PUT", path, data, **args) self._check_status(status, 201, check) return status, answer def propfind(self, path, data=None, check=True, **args): status, _, answer = self.request("PROPFIND", path, data, **args) if not self._check_status(status, 207, check): return status, None responses = self.parse_responses(answer) if args.get("HTTP_DEPTH", 0) == 0: assert len(responses) == 1 and path in responses return status, responses def proppatch(self, path, data=None, check=True, **args): status, _, answer = self.request("PROPPATCH", path, data, **args) if not self._check_status(status, 207, check): return status, None responses = self.parse_responses(answer) assert len(responses) == 1 and path in responses return status, responses def report(self, path, data, check=True, **args): status, _, answer = self.request("REPORT", path, data, **args) if not self._check_status(status, 207, check): return status, None return status, self.parse_responses(answer) def delete(self, path, check=True, **args): status, _, answer = self.request("DELETE", path, **args) if not self._check_status(status, 200, check): return status, None responses = self.parse_responses(answer) assert len(responses) == 1 and path in responses return status, responses def mkcalendar(self, path, data=None, check=True, **args): status, _, answer = self.request("MKCALENDAR", path, data, **args) self._check_status(status, 201, check) return status, answer def mkcol(self, path, data=None, check=True, **args): status, _, _ = self.request("MKCOL", path, data, **args) self._check_status(status, 201, check) return status def create_addressbook(self, path, check=True, **args): return self.mkcol(path, """\ """, check=check, **args) Radicale-3.0.6/radicale/tests/custom/000077500000000000000000000000001372774262700174655ustar00rootroot00000000000000Radicale-3.0.6/radicale/tests/custom/__init__.py000066400000000000000000000000001372774262700215640ustar00rootroot00000000000000Radicale-3.0.6/radicale/tests/custom/auth.py000066400000000000000000000020701372774262700207770ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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, password): if login == "tmp": return login return "" Radicale-3.0.6/radicale/tests/custom/rights.py000066400000000000000000000020051372774262700213340ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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, path): sane_path = pathutils.strip_path(path) if sane_path not in ("tmp", "other"): return "" return "RrWw" Radicale-3.0.6/radicale/tests/custom/storage_simple_sync.py000066400000000000000000000021551372774262700241130ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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.0.6/radicale/tests/custom/web.py000066400000000000000000000022371372774262700206200ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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, web class Web(web.BaseWeb): def get(self, environ, base_prefix, path, user): return client.OK, {"Content-Type": "text/plain"}, "custom" def post(self, environ, base_prefix, path, user): content = httputils.read_request_body(self.configuration, environ) return client.OK, {"Content-Type": "text/plain"}, "echo:" + content Radicale-3.0.6/radicale/tests/helpers.py000066400000000000000000000032431372774262700201710ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 EXAMPLES_FOLDER = os.path.join(os.path.dirname(__file__), "static") def get_file_path(file_name): return os.path.join(EXAMPLES_FOLDER, file_name) def get_file_content(file_name): try: with open(get_file_path(file_name), encoding="utf-8") as fd: return fd.read() except IOError: print("Couldn't open the file %s" % file_name) def configuration_to_dict(configuration): """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.0.6/radicale/tests/static/000077500000000000000000000000001372774262700174425ustar00rootroot00000000000000Radicale-3.0.6/radicale/tests/static/allprop.xml000066400000000000000000000001411372774262700216310ustar00rootroot00000000000000 Radicale-3.0.6/radicale/tests/static/broken-vcard.vcf000066400000000000000000000003741372774262700225230ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/broken-vevent.ics000066400000000000000000000007471372774262700227370ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/cert.pem000066400000000000000000000023101372774262700210760ustar00rootroot00000000000000-----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.0.6/radicale/tests/static/contact1.vcf000066400000000000000000000001261372774262700216550ustar00rootroot00000000000000BEGIN:VCARD VERSION:3.0 UID:contact1 N:Contact;;;; FN:Contact NICKNAME:test END:VCARD Radicale-3.0.6/radicale/tests/static/contact_multiple.vcf000066400000000000000000000002241372774262700235060ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/event1.ics000066400000000000000000000016221372774262700213450ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/event1_modified.ics000066400000000000000000000016221372774262700232050ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/event2.ics000066400000000000000000000016161372774262700213510ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/event3.ics000066400000000000000000000011701372774262700213450ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/event4.ics000066400000000000000000000011611372774262700213460ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/event5.ics000066400000000000000000000011521372774262700213470ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/event6.ics000066400000000000000000000017671372774262700213640ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/event7.ics000066400000000000000000000024211372774262700213510ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/event8.ics000066400000000000000000000013061372774262700213530ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/event9.ics000066400000000000000000000012341372774262700213540ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/event_multiple.ics000066400000000000000000000012521372774262700231760ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/event_timezone_seconds.ics000066400000000000000000000013111372774262700247070ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/journal1.ics000066400000000000000000000011231372774262700216720ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/journal2.ics000066400000000000000000000011701372774262700216750ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/journal3.ics000066400000000000000000000011351372774262700216770ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/journal4.ics000066400000000000000000000007051372774262700217020ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/journal5.ics000066400000000000000000000007051372774262700217030ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/key.pem000066400000000000000000000032501372774262700207350ustar00rootroot00000000000000-----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.0.6/radicale/tests/static/propfind1.xml000066400000000000000000000002441372774262700220660ustar00rootroot00000000000000 Radicale-3.0.6/radicale/tests/static/propname.xml000066400000000000000000000001421372774262700220020ustar00rootroot00000000000000 Radicale-3.0.6/radicale/tests/static/proppatch1.xml000066400000000000000000000003431372774262700222450ustar00rootroot00000000000000 #BADA55 Radicale-3.0.6/radicale/tests/static/todo1.ics000066400000000000000000000010501372774262700211640ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/todo2.ics000066400000000000000000000011071372774262700211700ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/todo3.ics000066400000000000000000000010161372774262700211700ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/todo4.ics000066400000000000000000000010121372774262700211650ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/todo5.ics000066400000000000000000000010721372774262700211740ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/todo6.ics000066400000000000000000000010201372774262700211660ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/todo7.ics000066400000000000000000000010161372774262700211740ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/todo8.ics000066400000000000000000000007441372774262700212040ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/static/todo9.ics000066400000000000000000000004411372774262700211770ustar00rootroot00000000000000BEGIN: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.0.6/radicale/tests/test_auth.py000066400000000000000000000152561372774262700205360ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 shutil import tempfile import pytest from radicale import Application, config, 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 setup(self): self.configuration = config.load() self.colpath = tempfile.mkdtemp() self.configuration.update({ "storage": {"filesystem_folder": self.colpath, # Disable syncing to disk for better performance "_filesystem_fsync": "False"}, # Set incorrect authentication delay to a very low value "auth": {"delay": "0.002"}}, "test", privileged=True) def teardown(self): shutil.rmtree(self.colpath) def _test_htpasswd(self, htpasswd_encryption, htpasswd_content, test_matrix="ascii"): """Test htpasswd authentication with user "tmp" and password "bepo" for ``test_matrix`` "ascii" or user "😀" and password "🔑" for ``test_matrix`` "unicode".""" if htpasswd_encryption == "bcrypt": try: from passlib.exc import MissingBackendError from passlib.hash import bcrypt except ImportError: pytest.skip("passlib[bcrypt] is not installed") try: bcrypt.hash("test-bcrypt-backend") except MissingBackendError: pytest.skip("bcrypt backend for passlib is not installed") htpasswd_file_path = os.path.join(self.colpath, ".htpasswd") encoding = self.configuration.get("encoding", "stock") with open(htpasswd_file_path, "w", encoding=encoding) as f: f.write(htpasswd_content) self.configuration.update({ "auth": {"type": "htpasswd", "htpasswd_filename": htpasswd_file_path, "htpasswd_encryption": htpasswd_encryption}}, "test") self.application = Application(self.configuration) 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)) 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): self._test_htpasswd("plain", "tmp:bepo") def test_htpasswd_plain_password_split(self): self._test_htpasswd("plain", "tmp:be:po", ( ("tmp", "be:po", True), ("tmp", "bepo", False))) def test_htpasswd_plain_unicode(self): self._test_htpasswd("plain", "😀:🔑", "unicode") def test_htpasswd_md5(self): 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): self._test_htpasswd("bcrypt", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3V" "NTRI3w5KDnj8NTUKJNWfVpvRq") def test_htpasswd_bcrypt_unicode(self): self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK" "6U9Sqlzr.W1mMVCS8wJUftnW", "unicode") def test_htpasswd_multi(self): self._test_htpasswd("plain", "ign:ign\ntmp:bepo") @pytest.mark.skipif(os.name == "nt", reason="leading and trailing " "whitespaces not allowed in file names") def test_htpasswd_whitespace_user(self): 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): for password in (" bepo", "bepo ", " bepo "): self._test_htpasswd("plain", "tmp:%s" % password, ( ("tmp", password, True), ("tmp", "bepo", False))) def test_htpasswd_comment(self): self._test_htpasswd("plain", "#comment\n #comment\n \ntmp:bepo\n\n") def test_remote_user(self): self.configuration.update({"auth": {"type": "remote_user"}}, "test") self.application = Application(self.configuration) _, responses = self.propfind("/", """\ """, REMOTE_USER="test") status, prop = responses["/"]["D:current-user-principal"] assert status == 200 assert prop.find(xmlutils.make_clark("D:href")).text == "/test/" def test_http_x_remote_user(self): self.configuration.update( {"auth": {"type": "http_x_remote_user"}}, "test") self.application = Application(self.configuration) _, responses = self.propfind("/", """\ """, HTTP_X_REMOTE_USER="test") status, prop = responses["/"]["D:current-user-principal"] assert status == 200 assert prop.find(xmlutils.make_clark("D:href")).text == "/test/" def test_custom(self): """Custom authentication.""" self.configuration.update( {"auth": {"type": "radicale.tests.custom.auth"}}, "test") self.application = Application(self.configuration) self.propfind("/tmp/", login="tmp:") Radicale-3.0.6/radicale/tests/test_base.py000066400000000000000000002036611372774262700205060ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 import shutil import sys import tempfile import defusedxml.ElementTree as DefusedET import pytest import radicale.tests.custom.storage_simple_sync from radicale import Application, config, storage, xmlutils from radicale.tests import BaseTest from radicale.tests.helpers import get_file_content class BaseRequestsMixIn: """Tests with simple requests.""" # Allow skipping sync-token tests, when not fully supported by the backend full_sync_token_support = True def test_root(self): """GET request at "/".""" _, answer = self.get("/", check=302) assert answer == "Redirected to .web" def test_script_name(self): """GET request at "/" with SCRIPT_NAME.""" _, answer = self.get("/", check=302, SCRIPT_NAME="/radicale") assert answer == "Redirected to .web" _, answer = self.get("", check=302, SCRIPT_NAME="/radicale") assert answer == "Redirected to radicale/.web" def test_add_event(self): """Add an event.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") path = "/calendar.ics/event1.ics" self.put(path, event) status, headers, answer = self.request("GET", path) assert status == 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): """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): """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=False) 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_todo(self): """Add a todo.""" self.mkcalendar("/calendar.ics/") todo = get_file_content("todo1.ics") path = "/calendar.ics/todo1.ics" self.put(path, todo) status, headers, answer = self.request("GET", path) assert status == 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): """Add a contact.""" self.create_addressbook("/contacts.vcf/") contact = get_file_content("contact1.vcf") path = "/contacts.vcf/contact.vcf" self.put(path, contact) status, headers, answer = self.request("GET", path) assert status == 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_without_uid(self): """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): """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): """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=False) 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): """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): """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_addressbook(self): """Create and overwrite a whole addressbook.""" contacts = get_file_content("contact_multiple.vcf") self.put("/contacts.vcf/", contacts) _, answer = self.get("/contacts.vcf/") assert ("\r\nUID:contact1\r\n" in answer and "\r\nUID:contact2\r\n" in answer) def test_put_whole_addressbook_without_uids(self): """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): """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): """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): """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): """Make a calendar.""" self.mkcalendar("/calendar.ics/") status, answer = self.mkcalendar("/calendar.ics/", check=False) 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_move(self): """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) status, _, _ = self.request( "MOVE", path1, HTTP_DESTINATION=path2, HTTP_HOST="") assert status == 201 self.get(path1, check=404) self.get(path2) def test_move_between_colections(self): """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) status, _, _ = self.request( "MOVE", path1, HTTP_DESTINATION=path2, HTTP_HOST="") assert status == 201 self.get(path1, check=404) self.get(path2) def test_move_between_colections_duplicate_uid(self): """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): """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) status, _, _ = self.request( "MOVE", path1, HTTP_DESTINATION=path2, HTTP_HOST="") assert status == 412 status, _, _ = self.request("MOVE", path1, HTTP_DESTINATION=path2, HTTP_HOST="", HTTP_OVERWRITE="T") assert status == 204 def test_move_between_colections_overwrite_uid_conflict(self): """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): status, _, _ = self.request("HEAD", "/") assert status == 302 def test_options(self): status, headers, _ = self.request("OPTIONS", "/") assert status == 200 assert "DAV" in headers def test_delete_collection(self): """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): """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): 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): 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) status, prop = responses["/calendar.ics/"]["D:sync-token"] assert status == 200 and not prop.text _, responses = self.propfind("/calendar.ics/event.ics", propfind) status, prop = responses["/calendar.ics/event.ics"]["D:getetag"] assert status == 200 and not prop.text def test_propfind_allprop(self): 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) status, prop = responses["/calendar.ics/"]["D:sync-token"] assert status == 200 and prop.text _, responses = self.propfind("/calendar.ics/event.ics", propfind) status, prop = responses["/calendar.ics/event.ics"]["D:getetag"] assert status == 200 and prop.text def test_propfind_nonexistent(self): """Read a property that does not exist.""" self.mkcalendar("/calendar.ics/") propfind = get_file_content("propfind1.xml") _, responses = self.propfind("/calendar.ics/", propfind) assert len(responses["/calendar.ics/"]) == 1 status, prop = responses["/calendar.ics/"]["ICAL:calendar-color"] assert status == 404 and not prop.text def test_proppatch(self): """Write a property and read it back.""" self.mkcalendar("/calendar.ics/") proppatch = get_file_content("proppatch1.xml") _, responses = self.proppatch("/calendar.ics/", proppatch) assert len(responses["/calendar.ics/"]) == 1 status, prop = responses["/calendar.ics/"]["ICAL:calendar-color"] assert status == 200 and not prop.text # Read property back propfind = get_file_content("propfind1.xml") _, responses = self.propfind("/calendar.ics/", propfind) assert len(responses["/calendar.ics/"]) == 1 status, prop = responses["/calendar.ics/"]["ICAL:calendar-color"] assert status == 200 and prop.text == "#BADA55" propfind = get_file_content("allprop.xml") _, responses = self.propfind("/calendar.ics/", propfind) status, prop = responses["/calendar.ics/"]["ICAL:calendar-color"] assert status == 200 and prop.text == "#BADA55" def test_put_whole_calendar_multiple_events_with_same_uid(self): """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 status, prop = responses["/calendar.ics/event2.ics"]["D:getetag"] assert status == 200 and prop.text _, answer = self.get("/calendar.ics/") assert answer.count("BEGIN:VEVENT") == 2 def _test_filter(self, filters, kind="event", test=None, items=(1,)): filter_template = "%s" 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=False) 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)) paths = [] for path, props in responses.items(): assert 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): self._test_filter([""], kind="contact") def test_addressbook_prop_filter(self): 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): 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): 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): self._test_filter([""]) def test_calendar_tag_filter(self): """Report request with tag-based filter on calendar.""" assert "/calendar.ics/event1.ics" in self._test_filter(["""\ """]) def test_item_tag_filter(self): """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): """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): """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): """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): """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): """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): """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): """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): """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): """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): """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): """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): """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): """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 status, prop = responses[event_path]["D:getetag"] assert status == 200 and prop.text def _report_sync_token(self, calendar_path, sync_token=None): 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 None, None 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): """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): """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): """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): """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): """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): """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 status, _, _ = self.request( "MOVE", event1_path, HTTP_DESTINATION=event2_path, HTTP_HOST="") assert status == 201 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): """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 status, _, _ = self.request( "MOVE", event1_path, HTTP_DESTINATION=event2_path, HTTP_HOST="") assert status == 201 status, _, _ = self.request( "MOVE", event2_path, HTTP_DESTINATION=event1_path, HTTP_HOST="") assert status == 201 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): """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): """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) status, sync_token = responses[calendar_path]["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) status, new_sync_token = responses[calendar_path]["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): """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) status, sync_token = responses[calendar_path]["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): """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=False) assert status in (200, 404) self.put("/test/test.ics", event) _, responses = self.report("/test/", """\ """) assert len(responses) == 1 and len( responses["/test/test.ics"]) == 1 status, prop = responses["/test/test.ics"]["D:getcontenttype"] assert status == 200 and prop.text == ( "text/calendar;charset=utf-8;component=V%s" % component.upper()) def test_addressbook_getcontenttype(self): """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 and len(responses["/test/test.vcf"]) == 1 status, prop = responses["/test/test.vcf"]["D:getcontenttype"] assert status == 200 and prop.text == "text/vcard;charset=utf-8" def test_authorization(self): _, responses = self.propfind("/", """\ """, login="user:") assert len(responses["/"]) == 1 status, prop = responses["/"]["D:current-user-principal"] assert status == 200 and len(prop) == 1 assert prop.find(xmlutils.make_clark("D:href")).text == "/user/" def test_authentication(self): """Test if server sends authentication request.""" self.configuration.update({ "auth": {"type": "htpasswd", "htpasswd_filename": os.devnull, "htpasswd_encryption": "plain"}, "rights": {"type": "owner_only"}}, "test") self.application = Application(self.configuration) status, headers, _ = self.request("MKCOL", "/user/") assert status in (401, 403) assert headers.get("WWW-Authenticate") def test_principal_collection_creation(self): """Verify existence of the principal collection.""" self.propfind("/user/", login="user:") def test_authentication_current_user_principal_workaround(self): """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): """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_custom_headers(self): self.configuration.update({"headers": {"test": "123"}}, "test") self.application = Application(self.configuration) # Test if header is set on success status, headers, _ = self.request("OPTIONS", "/") assert status == 200 assert headers.get("test") == "123" # Test if header is set on failure status, headers, _ = self.request("GET", "/.well-known/does not exist") assert status == 404 assert headers.get("test") == "123" @pytest.mark.skipif(sys.version_info < (3, 6), reason="Unsupported in Python < 3.6") def test_timezone_seconds(self): """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) class BaseFileSystemTest(BaseTest): """Base class for filesystem backend tests.""" storage_type = None def setup(self): self.configuration = config.load() self.colpath = tempfile.mkdtemp() # Allow access to anything for tests 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.configuration.update({ "storage": {"type": self.storage_type, "filesystem_folder": self.colpath, # Disable syncing to disk for better performance "_filesystem_fsync": "False"}, "rights": {"file": rights_file_path, "type": "from_file"}}, "test", privileged=True) self.application = Application(self.configuration) def teardown(self): shutil.rmtree(self.colpath) class TestMultiFileSystem(BaseFileSystemTest, BaseRequestsMixIn): """Test BaseRequests on multifilesystem.""" storage_type = "multifilesystem" def test_folder_creation(self): """Verify that the folder is created.""" folder = os.path.join(self.colpath, "subfolder") self.configuration.update( {"storage": {"filesystem_folder": folder}}, "test") self.application = Application(self.configuration) assert os.path.isdir(folder) def test_fsync(self): """Create a directory and file with syncing enabled.""" self.configuration.update({"storage": {"_filesystem_fsync": "True"}}, "test", privileged=True) self.application = Application(self.configuration) self.mkcalendar("/calendar.ics/") def test_hook(self): """Run hook.""" self.configuration.update({"storage": { "hook": ("mkdir %s" % os.path.join( "collection-root", "created_by_hook"))}}, "test") self.application = Application(self.configuration) self.mkcalendar("/calendar.ics/") self.propfind("/created_by_hook/") def test_hook_read_access(self): """Verify that hook is not run for read accesses.""" self.configuration.update({"storage": { "hook": ("mkdir %s" % os.path.join( "collection-root", "created_by_hook"))}}, "test") self.application = Application(self.configuration) 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): """Verify that the storage is locked when the hook runs.""" self.configuration.update({"storage": {"hook": ( "flock -n .Radicale.lock || exit 0; exit 1")}}, "test") self.application = Application(self.configuration) self.mkcalendar("/calendar.ics/") def test_hook_principal_collection_creation(self): """Verify that the hooks runs when a new user is created.""" self.configuration.update({"storage": { "hook": ("mkdir %s" % os.path.join( "collection-root", "created_by_hook"))}}, "test") self.application = Application(self.configuration) self.propfind("/", login="user:") self.propfind("/created_by_hook/") def test_hook_fail(self): """Verify that a request fails if the hook fails.""" self.configuration.update({"storage": {"hook": "exit 1"}}, "test") self.application = Application(self.configuration) self.mkcalendar("/calendar.ics/", check=500) def test_item_cache_rebuild(self): """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")) @pytest.mark.skipif(os.name not in ("nt", "posix"), reason="Only supported on 'nt' and 'posix'") def test_put_whole_calendar_uids_used_as_file_names(self): """Test if UIDs are used as file names.""" BaseRequestsMixIn.test_put_whole_calendar(self) for uid in ("todo", "event"): _, answer = self.get("/calendar.ics/%s.ics" % uid) assert "\r\nUID:%s\r\n" % uid in answer @pytest.mark.skipif(os.name not in ("nt", "posix"), reason="Only supported on 'nt' and 'posix'") def test_put_whole_calendar_random_uids_used_as_file_names(self): """Test if UIDs are used as file names.""" BaseRequestsMixIn.test_put_whole_calendar_without_uids(self) _, answer = self.get("/calendar.ics") 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 "\r\nUID:%s\r\n" % uid in answer @pytest.mark.skipif(os.name not in ("nt", "posix"), reason="Only supported on 'nt' and 'posix'") def test_put_whole_addressbook_uids_used_as_file_names(self): """Test if UIDs are used as file names.""" BaseRequestsMixIn.test_put_whole_addressbook(self) for uid in ("contact1", "contact2"): _, answer = self.get("/contacts.vcf/%s.vcf" % uid) assert "\r\nUID:%s\r\n" % uid in answer @pytest.mark.skipif(os.name not in ("nt", "posix"), reason="Only supported on 'nt' and 'posix'") def test_put_whole_addressbook_random_uids_used_as_file_names(self): """Test if UIDs are used as file names.""" BaseRequestsMixIn.test_put_whole_addressbook_without_uids(self) _, answer = self.get("/contacts.vcf") 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 "\r\nUID:%s\r\n" % uid in answer class TestCustomStorageSystem(BaseFileSystemTest): """Test custom backend loading.""" storage_type = "radicale.tests.custom.storage_simple_sync" full_sync_token_support = False test_root = BaseRequestsMixIn.test_root _report_sync_token = BaseRequestsMixIn._report_sync_token # include tests related to sync token s = None for s in dir(BaseRequestsMixIn): if s.startswith("test_") and ("_sync_" in s or s.endswith("_sync")): locals()[s] = getattr(BaseRequestsMixIn, s) del s class TestCustomStorageSystemCallable(BaseFileSystemTest): """Test custom backend loading with ``callable``.""" storage_type = radicale.tests.custom.storage_simple_sync.Storage test_add_event = BaseRequestsMixIn.test_add_event Radicale-3.0.6/radicale/tests/test_config.py000066400000000000000000000166731372774262700210460ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 import pytest from radicale import config from radicale.tests.helpers import configuration_to_dict class TestConfig: """Test the configuration.""" def setup(self): self.colpath = tempfile.mkdtemp() def teardown(self): shutil.rmtree(self.colpath) def _write_config(self, config_dict, name): 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): 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): config_path = self._write_config({}, "config") config.load([(config_path, False)]) def test_load_full(self): config_path = self._write_config( configuration_to_dict(config.load()), "config") config.load([(config_path, False)]) def test_load_missing(self): 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): 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)]) assert len(configuration.get("server", "hosts")) == 1 assert configuration.get("server", "hosts")[0] == ("192.0.2.1", 1111) assert configuration.get("server", "max_connections") == 1111 def test_copy(self): 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): 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): 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): 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): 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): 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): plugin_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): plugin_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): plugin_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): plugin_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.0.6/radicale/tests/test_rights.py000066400000000000000000000204201372774262700210620ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 import shutil import tempfile from radicale import Application, config from radicale.tests import BaseTest from radicale.tests.helpers import get_file_content class TestBaseRightsRequests(BaseTest): """Tests basic requests with rights.""" def setup(self): self.configuration = config.load() self.colpath = tempfile.mkdtemp() self.configuration.update({ "storage": {"filesystem_folder": self.colpath, # Disable syncing to disk for better performance "_filesystem_fsync": "False"}}, "test", privileged=True) def teardown(self): shutil.rmtree(self.colpath) def _test_rights(self, rights_type, user, path, mode, expected_status, with_auth=True): 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.configuration.update({ "rights": {"type": rights_type}, "auth": {"type": "htpasswd" if with_auth else "none", "htpasswd_filename": htpasswd_file_path, "htpasswd_encryption": "plain"}}, "test") self.application = Application(self.configuration) 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): 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): 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): 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): 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): 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): 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): 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.configuration.update( {"rights": {"file": rights_file_path}}, "test") 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.configuration.update( {"rights": {"type": "from_file", "file": rights_file_path}}, "test") self.application = Application(self.configuration) 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): """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): """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.application = Application(self.configuration) 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): """Test rights for creation of calendars and items with PUT.""" self.application = Application(self.configuration) 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.0.6/radicale/tests/test_server.py000066400000000000000000000173751372774262700211070ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 shutil import socket import ssl import subprocess import sys import tempfile import threading import time from configparser import RawConfigParser 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 http_error_302(self, req, fp, code, msg, headers): raise HTTPError(req.full_url, code, msg, headers, fp) http_error_301 = http_error_303 = http_error_307 = http_error_302 class TestBaseServerRequests(BaseTest): """Test the internal server.""" def setup(self): self.configuration = config.load() self.colpath = tempfile.mkdtemp() 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.configuration.update({ "storage": {"filesystem_folder": self.colpath, # Disable syncing to disk for better performance "_filesystem_fsync": "False"}, "server": {"hosts": "[%s]:%d" % self.sockname}, # Enable debugging for new processes "logging": {"level": "debug"}}, "test", privileged=True) 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): self.shutdown_socket.close() try: self.thread.join() except RuntimeError: # Thread never started pass shutil.rmtree(self.colpath) def request(self, method, path, data=None, is_alive_fn=None, **headers): """Send a request.""" if is_alive_fn is None: is_alive_fn = self.thread.is_alive scheme = ("https" if self.configuration.get("server", "ssl") else "http") req = request.Request( "%s://[%s]:%d%s" % (scheme, *self.sockname, path), data=data, headers=headers, method=method) while True: assert is_alive_fn() try: with self.opener.open(req) as f: return f.getcode(), f.info(), f.read().decode() except HTTPError as e: return e.code, e.headers, e.read().decode() except URLError as e: if not isinstance(e.reason, ConnectionRefusedError): raise time.sleep(0.1) def test_root(self): self.thread.start() self.get("/", check=302) def test_ssl(self): self.configuration.update({ "server": {"ssl": "True", "certificate": get_file_path("cert.pem"), "key": get_file_path("key.pem")}}, "test") self.thread.start() self.get("/", check=302) def test_bind_fail(self): 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): 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.configuration.update({ "server": {"hosts": "[%s]:%d" % self.sockname}}, "test") self.thread.start() self.get("/", check=302) def test_command_line_interface(self): config_args = [] for section, values in config.DEFAULT_CONFIG_SCHEMA.items(): if section.startswith("_"): continue for option, data in values.items(): if option.startswith("_"): continue long_name = "--%s-%s" % (section, option.replace("_", "-")) if data["type"] == bool: if not self.configuration.get(section, option): long_name = "--no%s" % long_name[1:] config_args.append(long_name) else: config_args.append(long_name) config_args.append( self.configuration.get_raw(section, option)) p = subprocess.Popen( [sys.executable, "-m", "radicale"] + config_args, env={**os.environ, "PYTHONPATH": os.pathsep.join(sys.path)}) try: self.get("/", is_alive_fn=lambda: p.poll() is None, check=302) finally: p.terminate() p.wait() def test_wsgi_server(self): 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 p = subprocess.Popen([ sys.executable, "-m", "waitress", "--listen", self.configuration.get_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.0.6/radicale/tests/test_web.py000066400000000000000000000045151372774262700203460ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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. """ import shutil import tempfile from radicale import Application, config from radicale.tests import BaseTest class TestBaseWebRequests(BaseTest): """Test web plugin.""" def setup(self): self.configuration = config.load() self.colpath = tempfile.mkdtemp() self.configuration.update({ "storage": {"filesystem_folder": self.colpath, # Disable syncing to disk for better performance "_filesystem_fsync": "False"}}, "test", privileged=True) self.application = Application(self.configuration) def teardown(self): shutil.rmtree(self.colpath) def test_internal(self): status, headers, _ = self.request("GET", "/.web") assert status == 302 assert headers.get("Location") == ".web/" _, answer = self.get("/.web/") assert answer self.post("/.web", check=405) def test_none(self): self.configuration.update({"web": {"type": "none"}}, "test") self.application = Application(self.configuration) _, answer = self.get("/.web") assert answer self.get("/.web/", check=404) self.post("/.web", check=405) def test_custom(self): """Custom web plugin.""" self.configuration.update({ "web": {"type": "radicale.tests.custom.web"}}, "test") self.application = Application(self.configuration) _, answer = self.get("/.web") assert answer == "custom" _, answer = self.post("/.web", "body content") assert answer == "echo:body content" Radicale-3.0.6/radicale/utils.py000066400000000000000000000030301372774262700165170ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 importlib import import_module from radicale.log import logger def load_plugin(internal_types, module_name, class_name, configuration): type_ = 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) Radicale-3.0.6/radicale/web/000077500000000000000000000000001372774262700155665ustar00rootroot00000000000000Radicale-3.0.6/radicale/web/__init__.py000066400000000000000000000041461372774262700177040ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 radicale import httputils, utils INTERNAL_TYPES = ("none", "internal") def load(configuration): """Load the web module chosen in configuration.""" return utils.load_plugin(INTERNAL_TYPES, "web", "Web", configuration) class BaseWeb: def __init__(self, configuration): """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, base_prefix, path, user): """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, base_prefix, path, user): """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.0.6/radicale/web/internal.py000066400000000000000000000063411372774262700177600ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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. """ import os import posixpath import time from http import client import pkg_resources from radicale import httputils, pathutils, web from radicale.log import logger MIMETYPES = { ".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 = "application/octet-stream" class Web(web.BaseWeb): def __init__(self, configuration): super().__init__(configuration) self.folder = pkg_resources.resource_filename(__name__, "internal_data") def get(self, environ, base_prefix, path, user): assert path == "/.web" or path.startswith("/.web/") assert pathutils.sanitize_path(path) == path try: filesystem_path = pathutils.path_to_filesystem( self.folder, path[len("/.web"):].strip("/")) except ValueError as e: logger.debug("Web content with unsafe path %r requested: %s", path, e, exc_info=True) return httputils.NOT_FOUND if os.path.isdir(filesystem_path) and not path.endswith("/"): location = posixpath.basename(path) + "/" return (client.FOUND, {"Location": location, "Content-Type": "text/plain"}, "Redirected to %s" % location) if os.path.isdir(filesystem_path): filesystem_path = os.path.join(filesystem_path, "index.html") if not os.path.isfile(filesystem_path): return httputils.NOT_FOUND content_type = MIMETYPES.get( os.path.splitext(filesystem_path)[1].lower(), FALLBACK_MIMETYPE) with open(filesystem_path, "rb") as f: answer = f.read() last_modified = time.strftime( "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(os.fstat(f.fileno()).st_mtime)) headers = { "Content-Type": content_type, "Last-Modified": last_modified} return client.OK, headers, answer Radicale-3.0.6/radicale/web/internal_data/000077500000000000000000000000001372774262700203735ustar00rootroot00000000000000Radicale-3.0.6/radicale/web/internal_data/css/000077500000000000000000000000001372774262700211635ustar00rootroot00000000000000Radicale-3.0.6/radicale/web/internal_data/css/icon.png000066400000000000000000000020551372774262700226230ustar00rootroot00000000000000PNG  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.0.6/radicale/web/internal_data/css/main.css000066400000000000000000000043611372774262700226250ustar00rootroot00000000000000body{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.0.6/radicale/web/internal_data/fn.js000066400000000000000000001173421372774262700213440ustar00rootroot00000000000000/** * 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.protocol + '//' + location.hostname + (location.port ? ':' + location.port : '')); /** * Path of the root collection on the server (must end with /) * @const * @type {string} */ const ROOT_PATH = location.pathname.replace(new RegExp("/+[^/]+/*(/index\\.html?)?$"), "") + '/'; /** * 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.0.6/radicale/web/internal_data/index.html000066400000000000000000000131101372774262700223640ustar00rootroot00000000000000 Radicale Web Interface

Loading

Please wait...

Radicale-3.0.6/radicale/web/none.py000066400000000000000000000022671372774262700171060ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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, web class Web(web.BaseWeb): def get(self, environ, base_prefix, path, user): assert path == "/.web" or path.startswith("/.web/") assert pathutils.sanitize_path(path) == path if path != "/.web": return httputils.NOT_FOUND return client.OK, {"Content-Type": "text/plain"}, "Radicale works!" Radicale-3.0.6/radicale/xmlutils.py000066400000000000000000000140441372774262700172470ustar00rootroot00000000000000# This file is part of Radicale Server - Calendar 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 urllib.parse import quote from radicale import pathutils MIMETYPES = { "VADDRESSBOOK": "text/vcard", "VCALENDAR": "text/calendar"} OBJECT_MIMETYPES = { "VCARD": "text/vcard", "VLIST": "text/x-vlist", "VCALENDAR": "text/calendar"} NAMESPACES = { "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 = {} for short, url in NAMESPACES.items(): NAMESPACES_REV[url] = short ET.register_namespace("" if short == "D" else short, url) def pretty_xml(element): """Indent an ElementTree ``element`` and its children.""" def pretty_xml_recursive(element, level): 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): """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): """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): """Return full W3C names from HTTP status codes.""" return "HTTP/1.1 %i %s" % (code, client.responses[code]) def make_href(base_prefix, href): """Return prefixed href.""" assert href == pathutils.sanitize_path(href) return quote("%s%s" % (base_prefix, href)) def webdav_error(human_tag): """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, encoding): """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, actions=("set", "remove")): """Return a list of properties as a dictionary.""" result = OrderedDict() if xml_request is None: return result for action in actions: action_element = xml_request.find(make_clark("D:%s" % action)) if action_element is not None: break else: action_element = xml_request prop_element = action_element.find(make_clark("D:prop")) if prop_element is not None: for prop in prop_element: if prop.tag == make_clark("D:resourcetype"): for resource_type in prop: if resource_type.tag == make_clark("C:calendar"): result["tag"] = "VCALENDAR" break if resource_type.tag == make_clark("CR:addressbook"): result["tag"] = "VADDRESSBOOK" break elif prop.tag == make_clark("C:supported-calendar-component-set"): result[make_human_tag(prop.tag)] = ",".join( supported_comp.attrib["name"] for supported_comp in prop if supported_comp.tag == make_clark("C:comp")) else: result[make_human_tag(prop.tag)] = prop.text return result Radicale-3.0.6/rights000066400000000000000000000051501372774262700144710ustar00rootroot00000000000000# -*- 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 user name) #[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.0.6/setup.cfg000066400000000000000000000045321372774262700150720ustar00rootroot00000000000000[aliases] test = pytest [bdist_wheel] python-tag = py3 [tool:pytest] addopts = --flake8 --isort --cov --cov-report=term --cov-report=xml -r s norecursedirs = dist .cache .git build Radicale.egg-info .eggs venv [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 [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.0.6/setup.py000077500000000000000000000071771372774262700147760ustar00rootroot00000000000000#!/usr/bin/env python3 # This file is part of Radicale Server - Calendar 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 . """ Radicale CalDAV and CardDAV server ================================== The Radicale Project is a CalDAV (calendar) and CardDAV (contact) server. It aims to be a light solution, easy to use, easy to install, easy to configure. As a consequence, it requires few software dependances and is pre-configured to work out-of-the-box. The Radicale Project runs on most of the UNIX-like platforms (Linux, BSD, MacOS X) and Windows. It is known to work with Evolution, Lightning, iPhone and Android clients. It is free and open-source software, released under GPL version 3. For further information, please visit the `Radicale Website `_. """ import sys from setuptools import find_packages, setup # When the version is updated, a new section in the NEWS.md file must be # added too. VERSION = "3.0.6" WEB_FILES = ["web/internal_data/css/icon.png", "web/internal_data/css/main.css", "web/internal_data/fn.js", "web/internal_data/index.html"] needs_pytest = {"pytest", "test", "ptr"}.intersection(sys.argv) pytest_runner = ["pytest-runner"] if needs_pytest else [] tests_require = ["pytest-runner", "pytest", "pytest-cov", "pytest-flake8", "pytest-isort", "waitress"] setup( name="Radicale", version=VERSION, description="CalDAV and CardDAV Server", long_description=__doc__, author="Guillaume Ayoub", author_email="guillaume.ayoub@kozea.fr", url="https://radicale.org/", download_url=("https://pypi.python.org/packages/source/R/Radicale/" "Radicale-%s.tar.gz" % VERSION), license="GNU GPL v3", platforms="Any", packages=find_packages( exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), package_data={"radicale": WEB_FILES}, entry_points={"console_scripts": ["radicale = radicale.__main__:run"]}, install_requires=["defusedxml", "passlib", "vobject>=0.9.6", "python-dateutil>=2.7.3"], setup_requires=pytest_runner, tests_require=tests_require, extras_require={"test": tests_require, "bcrypt": ["passlib[bcrypt]", "bcrypt"]}, keywords=["calendar", "addressbook", "CalDAV", "CardDAV"], python_requires=">=3.5.2", 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.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Topic :: Office/Business :: Groupware"])