diff --git a/.github/workflows/generate-documentation.yml b/.github/workflows/generate-documentation.yml index bdc6394d..3b94427e 100644 --- a/.github/workflows/generate-documentation.yml +++ b/.github/workflows/generate-documentation.yml @@ -8,7 +8,7 @@ jobs: generate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: ref: gh-pages - name: Run generator diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index a73ca0b6..be98f3bb 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -5,18 +5,17 @@ on: jobs: publish: + permissions: + id-token: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: 3.x - - name: Install dependencies - run: python -m pip install wheel + - name: Install Build dependencies + run: pip install build - name: Build - run: python setup.py sdist bdist_wheel + run: python -m build --sdist --wheel - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@master - with: - user: __token__ - password: ${{ secrets.pypi_password }} + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aef95600..32961e86 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,42 +6,55 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', pypy-3.7, pypy-3.8] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12.3', '3.13.0-beta.4', pypy-3.8, pypy-3.9] exclude: - - os: windows-latest - python-version: pypy-3.7 - os: windows-latest python-version: pypy-3.8 + - os: windows-latest + python-version: pypy-3.9 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install from source - run: python -m pip install --editable .[test,bcrypt] - - name: Run tests - run: python setup.py test + - name: Install Test dependencies + run: pip install tox + - name: Test + run: tox -e py + - name: Install Coveralls + if: github.event_name == 'push' + run: pip install coveralls - name: Upload coverage to Coveralls if: github.event_name == 'push' env: COVERALLS_PARALLEL: true GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - python -m pip install coveralls - python -m coveralls --service=github + run: coveralls --service=github coveralls-finish: needs: test if: github.event_name == 'push' runs-on: ubuntu-latest steps: - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v5 with: python-version: 3.x + - name: Install Coveralls + run: pip install coveralls - name: Finish Coveralls parallel builds env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - python -m pip install coveralls - python -m coveralls --service=github --finish + run: coveralls --service=github --finish + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install tox + run: pip install tox + - name: Lint + run: tox -e flake8,mypy,isort diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ad2ca76..87d3fd2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,81 @@ # Changelog -## master +## 3.dev +* Fix: Using icalendar's tzinfo on created datetime to fix issue with icalendar +* Enhancement: Added free-busy report +* Enhancement: Added 'max_freebusy_occurrences` setting to avoid potential DOS on reports +* Enhancement: remove unexpected control codes from uploaded items +* Drop: remove unused requirement "typeguard" +* Improve: Refactored some date parsing code + +## 3.2.2 +* Enhancement: add support for auth.type=denyall (will be default for security reasons in upcoming releases) +* Enhancement: display warning in case only default config is active +* Enhancement: display warning in case no user authentication is active +* Enhancement: add option to skip broken item to avoid triggering exception (default: enabled) +* Enhancement: add support for predefined collections for new users +* Enhancement: add options to enable several parts in debug log like backtrace, request_header, request_content, response_content (default: disabled) +* Enhancement: rights/from_file: display resulting permission of a match in debug log +* Enhancement: add Apache config file example (see contrib directory) +* Fix: "verify-collection" skips non-collection directories, logging improved + +## 3.2.1 + +* Enhancement: add option for logging bad PUT request content +* Enhancement: extend logging with step where bad PUT request failed +* Fix: support for recurrence "full day" +* Fix: list of web_files related to HTML pages +* Test: update/adjustments for workflows (pytest>=7, typeguard<4.3) + +## 3.2.0 + +* Enhancement: add hook support for event changes+deletion hooks (initial support: "rabbitmq") +* Dependency: pika >= 1.1.0 +* Enhancement: add support for webcal subscriptions +* Enhancement: major update of WebUI (design+features) +* Adjust: change default loglevel to "info" +* Enhancement: support "expand-property" on REPORT request +* Drop: support for Python 3.7 (EOSL, can't be tested anymore) +* Fix: allow quoted-printable encoding for vObjects + +## 3.1.9 + +* Add: support for Python 3.11 + 3.12 +* Drop: support for Python 3.6 +* Fix: MOVE in case listen on non-standard ports or behind reverse proxy +* Fix: stricter requirements of Python 3.11 +* Fix: HTML pages +* Fix: Main Component is missing when only recurrence id exists +* Fix: passlib don't support bcrypt>=4.1 +* Fix: web login now proper encodes passwords containing %XX (hexdigits) +* Enhancement: user-selectable log formats +* Enhancement: autodetect logging to systemd journal +* Enhancement: test code +* Enhancement: option for global permit to delete collection +* Enhancement: auth type 'htpasswd' supports now 'htpasswd_encryption' sha256/sha512 and "autodetect" for smooth transition +* Improve: Dockerfiles +* Improve: server socket listen code + address format in log +* Update: documentations + examples +* Dependency: limit typegard version < 3 +* General: code cosmetics + +## 3.1.8 + +* Fix setuptools requirement if installing wheel +* Tests: Switch from `python setup.py test` to `tox` +* Small changes to build system configuration and tests + +## 3.1.7 + +* Fix random href fallback + +## 3.1.6 + +* Ignore `Not a directory` error for optional config paths +* Fix upload of whole address book/calendar with UIDs that collide on + case-insensitive filesystem +* Remove runtime dependency on setuptools for Python>=3.9 +* Windows: Block ADS paths ## 3.1.5 diff --git a/COPYING b/COPYING deleted file mode 100644 index 94a9ed02..00000000 --- a/COPYING +++ /dev/null @@ -1,674 +0,0 @@ - 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 -. diff --git a/COPYING.md b/COPYING.md new file mode 100644 index 00000000..2fb2e74d --- /dev/null +++ b/COPYING.md @@ -0,0 +1,675 @@ +### 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 . diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index c9efc6d4..d34afb50 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -24,7 +24,7 @@ Radicale is really easy to install and works out-of-the-box. ```bash python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz -python3 -m radicale --storage-filesystem-folder=~/.var/lib/radicale/collections +python3 -m radicale --logging-level info --storage-filesystem-folder=~/.var/lib/radicale/collections ``` When the server is launched, open in your browser! @@ -216,6 +216,8 @@ requirements. #### Linux with systemd system-wide +Recommendation: check support by [Linux Distribution Packages](#linux-distribution-packages) instead of manual setup / initial configuration. + Create the **radicale** user and group for the Radicale service. (Run `useradd --system --user-group --home-dir / --shell /sbin/nologin radicale` as root.) The storage folder must be writable by **radicale**. (Run @@ -328,9 +330,13 @@ start the **Radicale** service. ### Reverse Proxy -When a reverse proxy is used, the path at which Radicale is available must -be provided via the `X-Script-Name` header. The proxy must remove the location -from the URL path that is forwarded to Radicale. +When a reverse proxy is used, and Radicale should be made available at a path +below the root (such as `/radicale/`), then this path must be provided via +the `X-Script-Name` header (without a trailing `/`). The proxy must remove +the location from the URL path that is forwarded to Radicale. If Radicale +should be made available at the root of the web server (in the nginx case +using `location /`), then the setting of the `X-Script-Name` header should be +removed from the example below. Example **nginx** configuration: @@ -344,6 +350,17 @@ location /radicale/ { # The trailing / is important! } ``` +Example **Caddy** configuration: + +``` +handle_path /radicale/* { + uri strip_prefix /radicale + reverse_proxy localhost:5232 { + header_up X-Script-Name /radicale + } +} +``` + Example **Apache** configuration: ```apache @@ -354,6 +371,11 @@ RewriteRule ^/radicale$ /radicale/ [R,L] ProxyPass http://localhost:5232/ retry=0 ProxyPassReverse http://localhost:5232/ RequestHeader set X-Script-Name /radicale + RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" + RequestHeader unset X-Forwarded-Proto + + RequestHeader set X-Forwarded-Proto "https" + ``` @@ -366,6 +388,28 @@ RewriteRule ^(.*)$ http://localhost:5232/$1 [P,L] # Set to directory of .htaccess file: RequestHeader set X-Script-Name /radicale +RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" +RequestHeader unset X-Forwarded-Proto + +RequestHeader set X-Forwarded-Proto "https" + +``` + +Example **lighttpd** configuration: + +```lighttpd +server.modules += ( "mod_proxy" , "mod_setenv", "mod_rewrite" ) + +$HTTP["url"] =~ "^/radicale/" { + proxy.server = ( "" => (( "host" => "127.0.0.1", "port" => "5232" )) ) + proxy.header = ( "map-urlpath" => ( "/radicale/" => "/" )) + + setenv.add-request-header = ( + "X-Script-Name" => "/radicale", + "Script-Name" => "/radicale", + ) + url.rewrite-once = ( "^/radicale/radicale/(.*)" => "/radicale/$1" ) +} ``` Be reminded that Radicale's default configuration enforces limits on the @@ -393,6 +437,21 @@ location /radicale/ { } ``` +Example **Caddy** configuration: + +``` +handle_path /radicale/* { + uri strip_prefix /radicale + basicauth { + USER HASH + } + reverse_proxy localhost:5232 { + header_up X-Script-Name /radicale + header_up X-remote-user {http.auth.user.id} + } +} +``` + Example **Apache** configuration: ```apache @@ -458,6 +517,15 @@ key = /path/to/server_key.pem certificate_authority = /path/to/client_cert.pem ``` +If you're using the Let's Encrypt's Certbot, the configuration should look similar to this: + +```ini +[server] +ssl = True +certificate = /etc/letsencrypt/live/{Your Domain}/fullchain.pem +key = /etc/letsencrypt/live/{Your Domain}/privkey.pem +``` + Example **nginx** configuration: ```nginx @@ -522,12 +590,22 @@ The configuration option `hook` in the `storage` section must be set to the following command: ```bash -git add -A && (git diff --cached --quiet || git commit -m "Changes by "%(user)s) +git add -A && (git diff --cached --quiet || git commit -m "Changes by \"%(user)s\"") ``` The command gets executed after every change to the storage and commits the changes into the **git** repository. +For the hook to not cause errors either **git** user details need to be set and match the owner of the collections directory or the repository needs to be marked as safe. + +When using the systemd unit file from the [Running as a service](#running-as-a-service) section this **cannot** be done via a `.gitconfig` file in the users home directory, as Radicale won't have read permissions! + +In `/var/lib/radicale/collections/.git` run: +```bash +git config user.name "radicale" +git config user.email "radicale@example.com" +``` + ## Documentation ### Configuration @@ -676,7 +754,7 @@ Default: `none` Path to the htpasswd file. -Default: +Default: `/etc/radicale/users` ##### htpasswd_encryption @@ -697,10 +775,19 @@ Available methods: `bcrypt` : This uses a modified version of the Blowfish stream cipher. It's very secure. - The installation of **radicale[bcrypt]** is required for this. + The installation of **bcrypt** is required for this. `md5` -: This uses an iterated md5 digest of the password with a salt. +: This uses an iterated MD5 digest of the password with a salt. + +`sha256` +: This uses an iterated SHA-256 digest of the password with a salt. + +`sha512` +: This uses an iterated SHA-512 digest of the password with a salt. + +`autodetect` +: This selects autodetection of method per entry. Default: `md5` @@ -752,6 +839,19 @@ Load the ldap groups of the authenticated user. These groups can be used later o Default: False +##### lc_username + +Сonvert username to lowercase, must be true for case-insensitive auth +providers like ldap, kerberos + +Default: `False` + +##### strip_domain + +Strip domain from username + +Default: `False` + #### rights ##### type @@ -787,6 +887,12 @@ Default: `owner_only` File for the rights backend `from_file`. See the [Rights](#authentication-and-rights) section. +##### permit_delete_collection + +(New since 3.1.9) + +Global control of permission to delete complete collection (default: True) + #### storage ##### type @@ -816,6 +922,12 @@ Delete sync-token that are older than the specified time. (seconds) Default: `2592000` +##### skip_broken_item + +Skip broken item instead of triggering an exception + +Default: `True` + ##### hook Command that is run after changes to storage. Take a look at the @@ -823,6 +935,26 @@ Command that is run after changes to storage. Take a look at the Default: +##### predefined_collections + +Create predefined user collections + + Example: + + { + "def-addressbook": { + "D:displayname": "Personal Address Book", + "tag": "VADDRESSBOOK" + }, + "def-calendar": { + "C:supported-calendar-component-set": "VEVENT,VJOURNAL,VTODO", + "D:displayname": "Personal Calendar", + "tag": "VCALENDAR" + } + } + +Default: + #### web ##### type @@ -855,6 +987,36 @@ Don't include passwords in logs. Default: `True` +##### bad_put_request_content + +Log bad PUT request content (for further diagnostics) + +Default: `False` + +##### backtrace_on_debug + +Log backtrace on level=debug + +Default: `False` + +##### request_header_on_debug + +Log request on level=debug + +Default: `False` + +##### request_content_on_debug + +Log request on level=debug + +Default: `False` + +##### response_content_on_debug = True + +Log response on level=debug + +Default: `False` + #### headers In this section additional HTTP headers that are sent to clients can be @@ -866,7 +1028,53 @@ An example to relax the same-origin policy: Access-Control-Allow-Origin = * ``` -### Supported Clients +#### hook +##### type + +Hook binding for event changes and deletion notifications. + +Available types: + +`none` +: Disabled. Nothing will be notified. + +`rabbitmq` +: Push the message to the rabbitmq server. + +Default: `none` + +#### rabbitmq_endpoint + +End-point address for rabbitmq server. +Ex: amqp://user:password@localhost:5672/ + +Default: + +#### rabbitmq_topic + +RabbitMQ topic to publish message. + +Default: + +#### rabbitmq_queue_type + +RabbitMQ queue type for the topic. + +Default: classic + +#### reporting +##### max_freebusy_occurrence + +When returning a free-busy report, a list of busy time occurrences are +generated based on a given time frame. Large time frames could +generate a lot of occurrences based on the time frame supplied. This +setting limits the lookup to prevent potential denial of service +attacks on large time frames. If the limit is reached, an HTTP error +is thrown instead of returning the results. + +Default: 10000 + +## Supported Clients Radicale has been tested with: @@ -897,16 +1105,21 @@ Enter the URL of the Radicale server (e.g. `http://localhost:5232`) and your username. DAVx⁵ will show all existing calendars and address books and you can create new. -#### GNOME Calendar, Contacts and Evolution +#### GNOME Calendar, Contacts -**GNOME Calendar** and **Contacts** do not support adding WebDAV calendars -and address books directly, but you can add them in **Evolution**. +GNOME 46 added CalDAV and CardDAV support to _GNOME Online Accounts_. + +Open GNOME Settings, navigate to _Online Accounts_ > _Connect an Account_ > _Calendar, Contacts and Files_. Enter the URL (e.g. `https://example.com/radicale`) and your credentials then click _Sign In_. In the pop-up dialog, turn off _Files_. After adding Radicale in _GNOME Online Accounts_, it should be available in GNOME Contacts and GNOME Calendar. + +#### Evolution In **Evolution** add a new calendar and address book respectively with WebDAV. Enter the URL of the Radicale server (e.g. `http://localhost:5232`) and your username. Clicking on the search button will list the existing calendars and address books. +Adding CalDAV and CardDAV accounts in Evolution will automatically make them available in GNOME Contacts and GNOME Calendar. + #### Thunderbird Add a new calendar on the network. Enter your username and the URL of the @@ -993,6 +1206,8 @@ Delete the collections by running something like: curl -u user -X DELETE 'http://localhost:5232/user/calendar' ``` +Note: requires config/option `permit_delete_collection = True` + ### Authentication and Rights This section describes the format of the rights file for the `from_file` @@ -1012,7 +1227,7 @@ An example rights file: [root] user: .+ collection: -permissions: R +permissions: r # Allow reading and writing principal collection (same as username) [principal] @@ -1369,10 +1584,6 @@ The module must contain a class `Storage` that extends ## Contribute -#### Chat with Us on IRC - -Want to say something? Join our IRC room: `##kozea` on Freenode. - #### Report Bugs Found a bug? Want a new feature? Report a new issue on the @@ -1427,7 +1638,7 @@ Radicale has been packaged for: * [Debian](http://packages.debian.org/radicale) by Jonas Smedegaard * [Gentoo](https://packages.gentoo.org/packages/www-apps/radicale) by René Neumann, Maxim Koltsov and Manuel Rüger -* [Fedora/RHEL/CentOS](https://src.fedoraproject.org/rpms/radicale) by Jorti +* [Fedora/EnterpriseLinux](https://src.fedoraproject.org/rpms/radicale) by Jorti and Peter Bieringer * [Mageia](http://madb.mageia.org/package/show/application/0/name/radicale) by Jani Välimaa diff --git a/Dockerfile b/Dockerfile index 1bfc82ac..914d06a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,34 @@ # This file is intended to be used apart from the containing source code tree. -FROM python:3-alpine +FROM python:3-alpine as builder # Version of Radicale (e.g. v3) ARG VERSION=master + +# Optional dependencies (e.g. bcrypt) +ARG DEPENDENCIES=bcrypt + +RUN apk add --no-cache --virtual gcc libffi-dev musl-dev \ + && python -m venv /app/venv \ + && /app/venv/bin/pip install --no-cache-dir "Radicale[${DEPENDENCIES}] @ https://github.com/Kozea/Radicale/archive/${VERSION}.tar.gz" + + +FROM python:3-alpine + +WORKDIR /app + +RUN addgroup -g 1000 radicale \ + && adduser radicale --home /var/lib/radicale --system --uid 1000 --disabled-password -G radicale \ + && apk add --no-cache ca-certificates openssl + +COPY --chown=radicale:radicale --from=builder /app/venv /app + # Persistent storage for data VOLUME /var/lib/radicale # TCP port of Radicale EXPOSE 5232 # Run Radicale -CMD ["radicale", "--hosts", "0.0.0.0:5232"] +ENTRYPOINT [ "/app/bin/python", "/app/bin/radicale"] +CMD ["--hosts", "0.0.0.0:5232,[::]:5232"] -RUN apk add --no-cache ca-certificates openssl \ - && apk add --no-cache --virtual .build-deps gcc libffi-dev musl-dev \ - && pip install --no-cache-dir "Radicale[bcrypt] @ https://github.com/Kozea/Radicale/archive/${VERSION}.tar.gz" \ - && apk del .build-deps +USER radicale diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..36ff98e5 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,32 @@ +FROM python:3-alpine as builder + +# Optional dependencies (e.g. bcrypt) +ARG DEPENDENCIES=bcrypt + +COPY . /app + +WORKDIR /app + +RUN apk add --no-cache --virtual gcc libffi-dev musl-dev \ + && python -m venv /app/venv \ + && /app/venv/bin/pip install --no-cache-dir .[${DEPENDENCIES}] + +FROM python:3-alpine + +WORKDIR /app + +RUN addgroup -g 1000 radicale \ + && adduser radicale --home /var/lib/radicale --system --uid 1000 --disabled-password -G radicale \ + && apk add --no-cache ca-certificates openssl + +COPY --chown=radicale:radicale --from=builder /app/venv /app + +# Persistent storage for data +VOLUME /var/lib/radicale +# TCP port of Radicale +EXPOSE 5232 +# Run Radicale +ENTRYPOINT [ "/app/bin/python", "/app/bin/radicale"] +CMD ["--hosts", "0.0.0.0:5232"] + +USER radicale diff --git a/MANIFEST.in b/MANIFEST.in index 3b23cda0..339fc61d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,3 @@ -include CHANGELOG.md COPYING DOCUMENTATION.md README.md +include CHANGELOG.md COPYING.md DOCUMENTATION.md README.md include config rights include radicale.wsgi diff --git a/README.md b/README.md index 98ee6602..acbde25b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,28 @@ -# Read Me +# Radicale [![Test](https://github.com/Kozea/Radicale/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/Kozea/Radicale/actions/workflows/test.yml) [![Coverage Status](https://coveralls.io/repos/github/Kozea/Radicale/badge.svg?branch=master)](https://coveralls.io/github/Kozea/Radicale?branch=master) -Radicale is a free and open-source CalDAV and CardDAV server. +Radicale is a small but powerful CalDAV (calendars, to-do lists) and CardDAV +(contacts) server, that: + +* Shares calendars and contact lists through CalDAV, CardDAV and HTTP. +* Supports events, todos, journal entries and business cards. +* Works out-of-the-box, no complicated setup or configuration required. +* Can limit access by authentication. +* Can secure connections with TLS. +* Works with many CalDAV and CardDAV clients +* Stores all data on the file system in a simple folder structure. +* Can be extended with plugins. +* Is GPLv3-licensed free software. For the complete documentation, please visit [Radicale master Documentation](https://radicale.org/master.html). + +Additional hints can be found +* [Radicale Wiki](https://github.com/Kozea/Radicale/wiki) +* [Radicale Issues](https://github.com/Kozea/Radicale/issues) +* [Radicale Discussions](https://github.com/Kozea/Radicale/discussions) + +Before reporting an issue, please check +* [Radicale Wiki / Reporting Issues](https://github.com/Kozea/Radicale/wiki/Reporting-Issues) diff --git a/config b/config index d16b5e9d..9ffbd72f 100644 --- a/config +++ b/config @@ -16,6 +16,9 @@ # IPv6 syntax: [address]:port # For example: 0.0.0.0:9999, [::]:9999 hosts = 0.0.0.0:5232 +# Hostname syntax (using "getaddrinfo" to resolve to IPv4/IPv6 adress(es)): hostname:port +# For example: 0.0.0.0:9999, [::]:9999, localhost:9999 +#hosts = localhost:5232 # Max parallel connections #max_connections = 8 @@ -70,12 +73,15 @@ ldap_secret = ossreader # If the ldap groups of the user need to be loaded ldap_load_groups = True +# Value: none | htpasswd | remote_user | http_x_remote_user | denyall +#type = none + # Htpasswd filename #htpasswd_filename = /etc/radicale/users # Htpasswd encryption method -# Value: plain | bcrypt | md5 -# bcrypt requires the installation of radicale[bcrypt]. +# Value: plain | bcrypt | md5 | sha256 | sha512 | autodetect +# bcrypt requires the installation of 'bcrypt' module. #htpasswd_encryption = md5 # Incorrect authentication delay (seconds) @@ -84,6 +90,11 @@ ldap_load_groups = True # Message displayed in the client when a password is needed #realm = Radicale - Password Required +# Convert username to lowercase, must be true for case-insensitive auth providers +#lc_username = False + +# Strip domain name from username +#strip_domain = False [rights] @@ -94,6 +105,9 @@ ldap_load_groups = True # File for rights management from_file file = /etc/radicale/rights +# Permit delete of a collection (global) +#permit_delete_collection = True + [storage] @@ -107,10 +121,31 @@ file = /etc/radicale/rights # Delete sync token that are older (seconds) #max_sync_token_age = 2592000 +# Skip broken item instead of triggering an exception +#skip_broken_item = True + # Command that is run after changes to storage -# Example: ([ -d .git ] || git init) && git add -A && (git diff --cached --quiet || git commit -m "Changes by "%(user)s) +# Example: ([ -d .git ] || git init) && git add -A && (git diff --cached --quiet || git commit -m "Changes by \"%(user)s\"") #hook = +# Create predefined user collections +# +# json format: +# +# { +# "def-addressbook": { +# "D:displayname": "Personal Address Book", +# "tag": "VADDRESSBOOK" +# }, +# "def-calendar": { +# "C:supported-calendar-component-set": "VEVENT,VJOURNAL,VTODO", +# "D:displayname": "Personal Calendar", +# "tag": "VCALENDAR" +# } +# } +# +#predefined_collections = + [web] @@ -123,13 +158,43 @@ file = /etc/radicale/rights # Threshold for the logger # Value: debug | info | warning | error | critical -#level = warning +#level = info # Don't include passwords in logs #mask_passwords = True +# Log bad PUT request content +#bad_put_request_content = False + +# Log backtrace on level=debug +#backtrace_on_debug = False + +# Log request header on level=debug +#request_header_on_debug = False + +# Log request content on level=debug +#request_content_on_debug = False + +# Log response content on level=debug +#response_content_on_debug = False + [headers] # Additional HTTP headers #Access-Control-Allow-Origin = * + +[hook] + +# Hook types +# Value: none | rabbitmq +#type = none +#rabbitmq_endpoint = +#rabbitmq_topic = +#rabbitmq_queue_type = classic + +[reporting] + +# When returning a free-busy report, limit the number of returned +# occurences per event to prevent DOS attacks. +#max_freebusy_occurrence = 10000 diff --git a/contrib/apache/radicale.conf b/contrib/apache/radicale.conf new file mode 100644 index 00000000..7499be61 --- /dev/null +++ b/contrib/apache/radicale.conf @@ -0,0 +1,246 @@ +### Define how Apache should serve "radicale" +## !!! Do not enable both at the same time !!! + +## Apache acting as reverse proxy and forward requests via ProxyPass to a running "radicale" server +# SELinux WARNING: To use this correctly, you will need to set: +# setsebool -P httpd_can_network_connect=1 +#Define RADICALE_SERVER_REVERSE_PROXY + + +## Apache starting WSGI server running with "radicale" application +# MAY CONFLICT with other WSG servers on same system -> use then inside a VirtualHost +# SELinux WARNING: To use this correctly, you will need to set: +# setsebool -P httpd_can_read_write_radicale=1 +#Define RADICALE_SERVER_WSGI + + +### Extra options +## Apache starting a dedicated VHOST with SSL +#Define RADICALE_SERVER_VHOST_SSL + + +### permit public access to "radicale" +#Define RADICALE_PERMIT_PUBLIC_ACCESS + + +### enforce SSL on default host +#Define RADICALE_ENFORCE_SSL + + +### Particular configuration EXAMPLES, adjust/extend/override to your needs + +########################## +### default host +########################## + + +## RADICALE_SERVER_REVERSE_PROXY + + RewriteEngine On + RewriteRule ^/radicale$ /radicale/ [R,L] + + + RequestHeader set X-Script-Name /radicale + + RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" + RequestHeader unset X-Forwarded-Proto + + RequestHeader set X-Forwarded-Proto "https" + + + ProxyPass http://localhost:5232/ retry=0 + ProxyPassReverse http://localhost:5232/ + + ## User authentication handled by "radicale" + Require local + + Require all granted + + + ## You may want to use apache's authentication (config: [auth] type = remote_user) + #AuthBasicProvider file + #AuthType Basic + #AuthName "Enter your credentials" + #AuthUserFile /path/to/httpdfile/ + #AuthGroupFile /dev/null + #Require valid-user + + + + Error "RADICALE_ENFORCE_SSL selected but ssl module not loaded/enabled" + + SSLRequireSSL + + + + + +## RADICALE_SERVER_WSGI +# For more information, visit: +# http://radicale.org/user_documentation/#idapache-and-mod-wsgi + + + + + SetHandler wsgi-script + + Require local + + Require all granted + + + + WSGIDaemonProcess radicale user=radicale group=radicale threads=1 umask=0027 + WSGIProcessGroup radicale + WSGIApplicationGroup %{GLOBAL} + WSGIPassAuthorization On + + WSGIScriptAlias /radicale /usr/share/radicale/radicale.wsgi + + + RequestHeader set X-Script-Name /radicale + + ## User authentication handled by "radicale" + Require local + + Require all granted + + + ## You may want to use apache's authentication (config: [auth] type = remote_user) + #AuthBasicProvider file + #AuthType Basic + #AuthName "Enter your credentials" + #AuthUserFile /path/to/httpdfile/ + #AuthGroupFile /dev/null + #Require valid-user + + + + Error "RADICALE_ENFORCE_SSL selected but ssl module not loaded/enabled" + + SSLRequireSSL + + + + + Error "RADICALE_SERVER_WSGI selected but wsgi module not loaded/enabled" + + + + + + +########################## +### VHOST with SSL +########################## + + + +Listen 8443 https + + +## taken from ssl.conf + +#ServerName www.example.com:443 +ErrorLog logs/ssl_error_log +TransferLog logs/ssl_access_log +LogLevel warn +SSLEngine on +SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 +SSLProxyProtocol all -SSLv3 -TLSv1 -TLSv1.1 +SSLHonorCipherOrder on +SSLCipherSuite PROFILE=SYSTEM +SSLProxyCipherSuite PROFILE=SYSTEM +SSLCertificateFile /etc/pki/tls/certs/localhost.crt +SSLCertificateKeyFile /etc/pki/tls/private/localhost.key +#SSLCertificateChainFile /etc/pki/tls/certs/server-chain.crt +#SSLCACertificateFile /etc/pki/tls/certs/ca-bundle.crt +#SSLVerifyClient require +#SSLVerifyDepth 10 +#SSLOptions +FakeBasicAuth +ExportCertData +StrictRequire +BrowserMatch "MSIE [2-5]" \ nokeepalive ssl-unclean-shutdown \ downgrade-1.0 force-response-1.0 +CustomLog logs/ssl_request_log "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b" + + +## RADICALE_SERVER_REVERSE_PROXY + + + RequestHeader set X-Script-Name / + + RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" + RequestHeader set X-Forwarded-Proto "https" + + ProxyPass http://localhost:5232/ retry=0 + ProxyPassReverse http://localhost:5232/ + + ## User authentication handled by "radicale" + Require local + + Require all granted + + + ## You may want to use apache's authentication (config: [auth] type = remote_user) + #AuthBasicProvider file + #AuthType Basic + #AuthName "Enter your credentials" + #AuthUserFile /path/to/httpdfile/ + #AuthGroupFile /dev/null + #Require valid-user + + + + +## RADICALE_SERVER_WSGI +# For more information, visit: +# http://radicale.org/user_documentation/#idapache-and-mod-wsgi + + + + + SetHandler wsgi-script + + Require local + + Require all granted + + + + WSGIDaemonProcess radicale user=radicale group=radicale threads=1 umask=0027 + WSGIProcessGroup radicale + WSGIApplicationGroup %{GLOBAL} + WSGIPassAuthorization On + + WSGIScriptAlias / /usr/share/radicale/radicale.wsgi + + + RequestHeader set X-Script-Name / + + ## User authentication handled by "radicale" + Require local + + Require all granted + + + ## You may want to use apache's authentication (config: [auth] type = remote_user) + #AuthBasicProvider file + #AuthType Basic + #AuthName "Enter your credentials" + #AuthUserFile /path/to/httpdfile/ + #AuthGroupFile /dev/null + #Require valid-user + + + + Error "RADICALE_SERVER_WSGI selected but wsgi module not loaded/enabled" + + + + + + + + + Error "RADICALE_SERVER_VHOST_SSL selected but ssl module not loaded/enabled" + + + diff --git a/radicale.wsgi b/radicale.wsgi old mode 100755 new mode 100644 diff --git a/radicale/__init__.py b/radicale/__init__.py index 1f107739..2554e5b2 100644 --- a/radicale/__init__.py +++ b/radicale/__init__.py @@ -2,7 +2,8 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2019 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -29,13 +30,11 @@ import os import threading from typing import Iterable, Optional, cast -import pkg_resources - -from radicale import config, log, types +from radicale import config, log, types, utils from radicale.app import Application from radicale.log import logger -VERSION: str = pkg_resources.get_distribution("radicale").version +VERSION: str = utils.package_version("radicale") _application_instance: Optional[Application] = None _application_config_path: Optional[str] = None @@ -53,11 +52,16 @@ def _get_application_instance(config_path: str, wsgi_errors: types.ErrorStream configuration = config.load(config.parse_compound_paths( config.DEFAULT_CONFIG_PATH, config_path)) - log.set_level(cast(str, configuration.get("logging", "level"))) + log.set_level(cast(str, configuration.get("logging", "level")), configuration.get("logging", "backtrace_on_debug")) # Log configuration after logger is configured + default_config_active = True for source, miss in configuration.sources(): - logger.info("%s %s", "Skipped missing" if miss + logger.info("%s %s", "Skipped missing/unreadable" if miss else "Loaded", source) + if not miss and source != "default config": + default_config_active = False + if default_config_active: + logger.warning("%s", "No config file found/readable - only default config is active") _application_instance = Application(configuration) if _application_config_path != config_path: raise ValueError("RADICALE_CONFIG must not change: %r != %r" % diff --git a/radicale/__main__.py b/radicale/__main__.py index 209348f1..25d2b853 100644 --- a/radicale/__main__.py +++ b/radicale/__main__.py @@ -1,6 +1,7 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2011-2017 Guillaume Ayoub -# Copyright © 2017-2019 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -141,7 +142,7 @@ def run() -> None: # Preliminary configure logging with contextlib.suppress(ValueError): log.set_level(config.DEFAULT_CONFIG_SCHEMA["logging"]["level"]["type"]( - vars(args_ns).get("c:logging:level", ""))) + vars(args_ns).get("c:logging:level", "")), True) # Update Radicale configuration according to arguments arguments_config: types.MUTABLE_CONFIG = {} @@ -164,11 +165,17 @@ def run() -> None: sys.exit(1) # Configure logging - log.set_level(cast(str, configuration.get("logging", "level"))) + log.set_level(cast(str, configuration.get("logging", "level")), configuration.get("logging", "backtrace_on_debug")) # Log configuration after logger is configured + default_config_active = True for source, miss in configuration.sources(): - logger.info("%s %s", "Skipped missing" if miss else "Loaded", source) + logger.info("%s %s", "Skipped missing/unreadable" if miss else "Loaded", source) + if not miss and source != "default config": + default_config_active = False + + if default_config_active: + logger.warning("%s", "No config file found/readable - only default config is active") if args_ns.verify_storage: logger.info("Verifying storage") @@ -176,7 +183,7 @@ def run() -> None: storage_ = storage.load(configuration) with storage_.acquire_lock("r"): if not storage_.verify(): - logger.critical("Storage verifcation failed") + logger.critical("Storage verification failed") sys.exit(1) except Exception as e: logger.critical("An exception occurred during storage " @@ -198,7 +205,7 @@ def run() -> None: server.serve(configuration, shutdown_socket_out) except Exception as e: logger.critical("An exception occurred during server startup: %s", e, - exc_info=True) + exc_info=False) sys.exit(1) diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index ffcb56ca..bdb70772 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -3,6 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2019 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -68,6 +69,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, _max_content_length: int _auth_realm: str _extra_headers: Mapping[str, str] + _permit_delete_collection: bool def __init__(self, configuration: config.Configuration) -> None: """Initialize Application. @@ -79,11 +81,16 @@ class Application(ApplicationPartDelete, ApplicationPartHead, """ super().__init__(configuration) self._mask_passwords = configuration.get("logging", "mask_passwords") + self._bad_put_request_content = configuration.get("logging", "bad_put_request_content") + self._request_header_on_debug = configuration.get("logging", "request_header_on_debug") + self._response_content_on_debug = configuration.get("logging", "response_content_on_debug") self._auth_delay = configuration.get("auth", "delay") self._internal_server = configuration.get("server", "_internal_server") self._max_content_length = configuration.get( "server", "max_content_length") self._auth_realm = configuration.get("auth", "realm") + self._permit_delete_collection = configuration.get("rights", "permit_delete_collection") + logger.info("permit delete of collection: %s", self._permit_delete_collection) self._extra_headers = dict() for key in self.configuration.options("headers"): self._extra_headers[key] = configuration.get("headers", key) @@ -136,7 +143,10 @@ class Application(ApplicationPartDelete, ApplicationPartHead, answers = [] if answer is not None: if isinstance(answer, str): - logger.debug("Response content:\n%s", answer) + if self._response_content_on_debug: + logger.debug("Response content:\n%s", answer) + else: + logger.debug("Response content: suppressed by config/option [auth] response_content_on_debug") headers["Content-Type"] += "; charset=%s" % self._encoding answer = answer.encode(self._encoding) accept_encoding = [ @@ -182,8 +192,11 @@ class Application(ApplicationPartDelete, ApplicationPartHead, logger.info("%s request for %r%s received from %s%s", request_method, unsafe_path, depthinfo, remote_host, remote_useragent) - logger.debug("Request headers:\n%s", - pprint.pformat(self._scrub_headers(environ))) + if self._request_header_on_debug: + logger.debug("Request header:\n%s", + pprint.pformat(self._scrub_headers(environ))) + else: + logger.debug("Request header: suppressed by config/option [auth] request_header_on_debug") # SCRIPT_NAME is already removed from PATH_INFO, according to the # WSGI specification. @@ -219,7 +232,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, path.rstrip("/").endswith("/.well-known/carddav")): return response(*httputils.redirect( base_prefix + "/", client.MOVED_PERMANENTLY)) - # Return NOT FOUND for all other paths containing ".well-knwon" + # Return NOT FOUND for all other paths containing ".well-known" if path.endswith("/.well-known") or "/.well-known/" in path: return response(*httputils.NOT_FOUND) @@ -270,7 +283,14 @@ class Application(ApplicationPartDelete, ApplicationPartHead, if "W" in self._rights.authorization(user, principal_path): with self._storage.acquire_lock("w", user): try: - self._storage.create_collection(principal_path) + new_coll = self._storage.create_collection(principal_path) + if new_coll: + jsn_coll = self.configuration.get("storage", "predefined_collections") + for (name_coll, props) in jsn_coll.items(): + try: + self._storage.create_collection(principal_path + name_coll, props=props) + except ValueError as e: + logger.warning("Failed to create predefined collection %r: %s", name_coll, e) except ValueError as e: logger.warning("Failed to create principal " "collection %r: %s", user, e) diff --git a/radicale/app/base.py b/radicale/app/base.py index 4316117d..15b5a1df 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -1,5 +1,6 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2020 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -21,8 +22,8 @@ import sys import xml.etree.ElementTree as ET from typing import Optional -from radicale import (auth, config, httputils, pathutils, rights, storage, - types, web, xmlutils) +from radicale import (auth, config, hook, httputils, pathutils, rights, + storage, types, web, xmlutils) from radicale.log import logger # HACK: https://github.com/tiran/defusedxml/issues/54 @@ -38,6 +39,8 @@ class ApplicationBase: _rights: rights.BaseRights _web: web.BaseWeb _encoding: str + _permit_delete_collection: bool + _hook: hook.BaseHook def __init__(self, configuration: config.Configuration) -> None: self.configuration = configuration @@ -46,6 +49,9 @@ class ApplicationBase: self._rights = rights.load(configuration) self._web = web.load(configuration) self._encoding = configuration.get("encoding", "request") + self._log_bad_put_request_content = configuration.get("logging", "bad_put_request_content") + self._response_content_on_debug = configuration.get("logging", "response_content_on_debug") + self._hook = hook.load(configuration) def _read_xml_request_body(self, environ: types.WSGIEnviron ) -> Optional[ET.Element]: @@ -66,8 +72,11 @@ class ApplicationBase: def _xml_response(self, xml_content: ET.Element) -> bytes: if logger.isEnabledFor(logging.DEBUG): - logger.debug("Response content:\n%s", - xmlutils.pretty_xml(xml_content)) + if self._response_content_on_debug: + logger.debug("Response content:\n%s", + xmlutils.pretty_xml(xml_content)) + else: + logger.debug("Response content: suppressed by config/option [auth] response_content_on_debug") f = io.BytesIO() ET.ElementTree(xml_content).write(f, encoding=self._encoding, xml_declaration=True) diff --git a/radicale/app/delete.py b/radicale/app/delete.py index 69ae5ab4..53d9bfd3 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -23,6 +23,7 @@ from typing import Optional from radicale import httputils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase +from radicale.hook import HookNotificationItem, HookNotificationItemTypes def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection, @@ -67,12 +68,33 @@ class ApplicationPartDelete(ApplicationBase): if if_match not in ("*", item.etag): # ETag precondition not verified, do not delete item return httputils.PRECONDITION_FAILED + hook_notification_item_list = [] if isinstance(item, storage.BaseCollection): - xml_answer = xml_delete(base_prefix, path, item) + if self._permit_delete_collection: + for i in item.get_all(): + hook_notification_item_list.append( + HookNotificationItem( + HookNotificationItemTypes.DELETE, + access.path, + i.uid + ) + ) + xml_answer = xml_delete(base_prefix, path, item) + else: + return httputils.NOT_ALLOWED else: assert item.collection is not None assert item.href is not None + hook_notification_item_list.append( + HookNotificationItem( + HookNotificationItemTypes.DELETE, + access.path, + item.uid + ) + ) xml_answer = xml_delete( base_prefix, path, item.collection, item.href) + for notification_item in hook_notification_item_list: + self._hook.notify(notification_item) headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} return client.OK, headers, self._xml_response(xml_answer) diff --git a/radicale/app/get.py b/radicale/app/get.py index 7e5feeb4..d8b01520 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -45,8 +45,8 @@ def propose_filename(collection: storage.BaseCollection) -> str: class ApplicationPartGet(ApplicationBase): - def _content_disposition_attachement(self, filename: str) -> str: - value = "attachement" + def _content_disposition_attachment(self, filename: str) -> str: + value = "attachment" try: encoded_filename = quote(filename, encoding=self._encoding) except UnicodeEncodeError: @@ -91,7 +91,7 @@ class ApplicationPartGet(ApplicationBase): return (httputils.NOT_ALLOWED if limited_access else httputils.DIRECTORY_LISTING) content_type = xmlutils.MIMETYPES[item.tag] - content_disposition = self._content_disposition_attachement( + content_disposition = self._content_disposition_attachment( propose_filename(item)) elif limited_access: return httputils.NOT_ALLOWED diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index 94207e32..5bccc50c 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -52,8 +52,12 @@ class ApplicationPartMkcol(ApplicationBase): logger.warning( "Bad MKCOL request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST - if (props.get("tag") and "w" not in permissions or - not props.get("tag") and "W" not in permissions): + collection_type = props.get("tag") or "UNKNOWN" + if props.get("tag") and "w" not in permissions: + logger.warning("MKCOL request %r (type:%s): %s", path, collection_type, "rejected because of missing rights 'w'") + return httputils.NOT_ALLOWED + if not props.get("tag") and "W" not in permissions: + logger.warning("MKCOL request %r (type:%s): %s", path, collection_type, "rejected because of missing rights 'W'") return httputils.NOT_ALLOWED with self._storage.acquire_lock("w", user): item = next(iter(self._storage.discover(path)), None) @@ -71,6 +75,7 @@ class ApplicationPartMkcol(ApplicationBase): self._storage.create_collection(path, props=props) except ValueError as e: logger.warning( - "Bad MKCOL request on %r: %s", path, e, exc_info=True) + "Bad MKCOL request on %r (type:%s): %s", path, collection_type, e, exc_info=True) return httputils.BAD_REQUEST + logger.info("MKCOL request %r (type:%s): %s", path, collection_type, "successful") return client.CREATED, {}, None diff --git a/radicale/app/move.py b/radicale/app/move.py index fda85257..5bd8a579 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -18,6 +18,7 @@ # along with Radicale. If not, see . import posixpath +import re from http import client from urllib.parse import urlparse @@ -26,6 +27,22 @@ from radicale.app.base import Access, ApplicationBase from radicale.log import logger +def get_server_netloc(environ: types.WSGIEnviron, force_port: bool = False): + if environ.get("HTTP_X_FORWARDED_HOST"): + host = environ["HTTP_X_FORWARDED_HOST"] + proto = environ.get("HTTP_X_FORWARDED_PROTO") or "http" + port = "443" if proto == "https" else "80" + port = environ["HTTP_X_FORWARDED_PORT"] or port + else: + host = environ.get("HTTP_HOST") or environ["SERVER_NAME"] + proto = environ["wsgi.url_scheme"] + port = environ["SERVER_PORT"] + if (not force_port and port == ("443" if proto == "https" else "80") or + re.search(r":\d+$", host)): + return host + return host + ":" + port + + class ApplicationPartMove(ApplicationBase): def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, @@ -33,7 +50,11 @@ class ApplicationPartMove(ApplicationBase): """Manage MOVE request.""" raw_dest = environ.get("HTTP_DESTINATION", "") to_url = urlparse(raw_dest) - if to_url.netloc != environ["HTTP_HOST"]: + to_netloc_with_port = to_url.netloc + if to_url.port is None: + to_netloc_with_port += (":443" if to_url.scheme == "https" + else ":80") + if to_netloc_with_port != get_server_netloc(environ, force_port=True): logger.info("Unsupported destination address: %r", raw_dest) # Remote destination server, not supported return httputils.REMOTE_DESTINATION diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 52d0b00b..009c61dc 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -85,7 +85,7 @@ def xml_propfind_response( if isinstance(item, storage.BaseCollection): is_collection = True - is_leaf = item.tag in ("VADDRESSBOOK", "VCALENDAR") + is_leaf = item.tag in ("VADDRESSBOOK", "VCALENDAR", "VSUBSCRIBED") collection = item # Some clients expect collections to end with `/` uri = pathutils.unstrip_path(item.path, True) @@ -259,6 +259,10 @@ def xml_propfind_response( child_element = ET.Element( xmlutils.make_clark("C:calendar")) element.append(child_element) + elif collection.tag == "VSUBSCRIBED": + child_element = ET.Element( + xmlutils.make_clark("CS:subscribed")) + element.append(child_element) child_element = ET.Element(xmlutils.make_clark("D:collection")) element.append(child_element) elif tag == xmlutils.make_clark("RADICALE:displayname"): @@ -268,6 +272,12 @@ def xml_propfind_response( element.text = displayname else: is404 = True + elif tag == xmlutils.make_clark("RADICALE:getcontentcount"): + # Only for internal use by the web interface + if isinstance(item, storage.BaseCollection) and not collection.is_principal: + element.text = str(sum(1 for x in item.get_all())) + else: + is404 = True elif tag == xmlutils.make_clark("D:displayname"): displayname = collection.get_meta("D:displayname") if not displayname and is_leaf: @@ -286,6 +296,13 @@ def xml_propfind_response( element.text, _ = collection.sync() else: is404 = True + elif tag == xmlutils.make_clark("CS:source"): + if is_leaf: + child_element = ET.Element(xmlutils.make_clark("D:href")) + child_element.text = collection.get_meta('CS:source') + element.append(child_element) + else: + is404 = True else: human_tag = xmlutils.make_human_tag(tag) tag_text = collection.get_meta(human_tag) @@ -305,13 +322,13 @@ def xml_propfind_response( responses[404 if is404 else 200].append(element) - for status_code, childs in responses.items(): - if not childs: + for status_code, children in responses.items(): + if not children: continue propstat = ET.Element(xmlutils.make_clark("D:propstat")) response.append(propstat) prop = ET.Element(xmlutils.make_clark("D:prop")) - prop.extend(childs) + prop.extend(children) propstat.append(prop) status = ET.Element(xmlutils.make_clark("D:status")) status.text = xmlutils.make_response(status_code) diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index 934f53b7..c15fddfe 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -22,9 +22,12 @@ import xml.etree.ElementTree as ET from http import client from typing import Dict, Optional, cast +import defusedxml.ElementTree as DefusedET + import radicale.item as radicale_item from radicale import httputils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase +from radicale.hook import HookNotificationItem, HookNotificationItemTypes from radicale.log import logger @@ -93,6 +96,16 @@ class ApplicationPartProppatch(ApplicationBase): try: xml_answer = xml_proppatch(base_prefix, path, xml_content, item) + if xml_content is not None: + hook_notification_item = HookNotificationItem( + HookNotificationItemTypes.CPATCH, + access.path, + DefusedET.tostring( + xml_content, + encoding=self._encoding + ).decode(encoding=self._encoding) + ) + self._hook.notify(hook_notification_item) except ValueError as e: logger.warning( "Bad PROPPATCH request on %r: %s", path, e, exc_info=True) diff --git a/radicale/app/put.py b/radicale/app/put.py index ec495878..e30c4e07 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -3,6 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2018 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -30,6 +31,7 @@ import vobject import radicale.item as radicale_item from radicale import httputils, pathutils, rights, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase +from radicale.hook import HookNotificationItem, HookNotificationItemTypes from radicale.log import logger MIMETYPE_TAGS: Mapping[str, str] = {value: key for key, value in @@ -132,7 +134,7 @@ class ApplicationPartPut(ApplicationBase): try: content = httputils.read_request_body(self.configuration, environ) except RuntimeError as e: - logger.warning("Bad PUT request on %r: %s", path, e, exc_info=True) + logger.warning("Bad PUT request on %r (read_request_body): %s", path, e, exc_info=True) return httputils.BAD_REQUEST except socket.timeout: logger.debug("Client timed out", exc_info=True) @@ -144,7 +146,11 @@ class ApplicationPartPut(ApplicationBase): vobject_items = radicale_item.read_components(content or "") except Exception as e: logger.warning( - "Bad PUT request on %r: %s", path, e, exc_info=True) + "Bad PUT request on %r (read_components): %s", path, e, exc_info=True) + if self._log_bad_put_request_content: + logger.warning("Bad PUT request content of %r:\n%s", path, content) + else: + logger.debug("Bad PUT request content: suppressed by config/option [auth] bad_put_request_content") return httputils.BAD_REQUEST (prepared_items, prepared_tag, prepared_write_whole_collection, prepared_props, prepared_exc_info) = prepare( @@ -198,7 +204,7 @@ class ApplicationPartPut(ApplicationBase): props = prepared_props if prepared_exc_info: logger.warning( - "Bad PUT request on %r: %s", path, prepared_exc_info[1], + "Bad PUT request on %r (prepare): %s", path, prepared_exc_info[1], exc_info=prepared_exc_info) return httputils.BAD_REQUEST @@ -206,9 +212,16 @@ class ApplicationPartPut(ApplicationBase): try: etag = self._storage.create_collection( path, prepared_items, props).etag + for item in prepared_items: + hook_notification_item = HookNotificationItem( + HookNotificationItemTypes.UPSERT, + access.path, + item.serialize() + ) + self._hook.notify(hook_notification_item) except ValueError as e: logger.warning( - "Bad PUT request on %r: %s", path, e, exc_info=True) + "Bad PUT request on %r (create_collection): %s", path, e, exc_info=True) return httputils.BAD_REQUEST else: assert not isinstance(item, storage.BaseCollection) @@ -222,9 +235,15 @@ class ApplicationPartPut(ApplicationBase): href = posixpath.basename(pathutils.strip_path(path)) try: etag = parent_item.upload(href, prepared_item).etag + hook_notification_item = HookNotificationItem( + HookNotificationItemTypes.UPSERT, + access.path, + prepared_item.serialize() + ) + self._hook.notify(hook_notification_item) except ValueError as e: logger.warning( - "Bad PUT request on %r: %s", path, e, exc_info=True) + "Bad PUT request on %r (upload): %s", path, e, exc_info=True) return httputils.BAD_REQUEST headers = {"ETag": etag} diff --git a/radicale/app/report.py b/radicale/app/report.py index 5807f6e6..9d57b389 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -18,13 +18,20 @@ # along with Radicale. If not, see . import contextlib +import copy +import datetime import posixpath import socket import xml.etree.ElementTree as ET from http import client -from typing import Callable, Iterable, Iterator, Optional, Sequence, Tuple +from typing import (Any, Callable, Iterable, Iterator, List, Optional, + Sequence, Tuple, Union) from urllib.parse import unquote, urlparse +import vobject +import vobject.base +from vobject.base import ContentLine + import radicale.item as radicale_item from radicale import httputils, pathutils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase @@ -32,11 +39,110 @@ from radicale.item import filter as radicale_filter from radicale.log import logger +def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], + collection: storage.BaseCollection, encoding: str, + unlock_storage_fn: Callable[[], None], + max_occurrence: int + ) -> Tuple[int, Union[ET.Element, str]]: + # NOTE: this function returns both an Element and a string because + # free-busy reports are an edge-case on the return type according + # to the spec. + + multistatus = ET.Element(xmlutils.make_clark("D:multistatus")) + if xml_request is None: + return client.MULTI_STATUS, multistatus + root = xml_request + if (root.tag == xmlutils.make_clark("C:free-busy-query") and + collection.tag != "VCALENDAR"): + logger.warning("Invalid REPORT method %r on %r requested", + xmlutils.make_human_tag(root.tag), path) + return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report") + + time_range_element = root.find(xmlutils.make_clark("C:time-range")) + assert isinstance(time_range_element, ET.Element) + + # Build a single filter from the free busy query for retrieval + # TODO: filter for VFREEBUSY in additional to VEVENT but + # test_filter doesn't support that yet. + vevent_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"), + attrib={'name': 'VEVENT'}) + vevent_cf_element.append(time_range_element) + vcalendar_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"), + attrib={'name': 'VCALENDAR'}) + vcalendar_cf_element.append(vevent_cf_element) + filter_element = ET.Element(xmlutils.make_clark("C:filter")) + filter_element.append(vcalendar_cf_element) + filters = (filter_element,) + + # First pull from storage + retrieved_items = list(collection.get_filtered(filters)) + # !!! Don't access storage after this !!! + unlock_storage_fn() + + cal = vobject.iCalendar() + collection_tag = collection.tag + while retrieved_items: + # Second filtering before evaluating occurrences. + # ``item.vobject_item`` might be accessed during filtering. + # Don't keep reference to ``item``, because VObject requires a lot of + # memory. + item, filter_matched = retrieved_items.pop(0) + if not filter_matched: + try: + if not test_filter(collection_tag, item, filter_element): + continue + except ValueError as e: + raise ValueError("Failed to free-busy filter item %r from %r: %s" % + (item.href, collection.path, e)) from e + except Exception as e: + raise RuntimeError("Failed to free-busy filter item %r from %r: %s" % + (item.href, collection.path, e)) from e + + fbtype = None + if item.component_name == 'VEVENT': + transp = getattr(item.vobject_item.vevent, 'transp', None) + if transp and transp.value != 'OPAQUE': + continue + + status = getattr(item.vobject_item.vevent, 'status', None) + if not status or status.value == 'CONFIRMED': + fbtype = 'BUSY' + elif status.value == 'CANCELLED': + fbtype = 'FREE' + elif status.value == 'TENTATIVE': + fbtype = 'BUSY-TENTATIVE' + else: + # Could do fbtype = status.value for x-name, I prefer this + fbtype = 'BUSY' + + # TODO: coalesce overlapping periods + + if max_occurrence > 0: + n_occurrences = max_occurrence+1 + else: + n_occurrences = 0 + occurrences = radicale_filter.time_range_fill(item.vobject_item, + time_range_element, + "VEVENT", + n=n_occurrences) + if len(occurrences) >= max_occurrence: + raise ValueError("FREEBUSY occurrences limit of {} hit" + .format(max_occurrence)) + + for occurrence in occurrences: + vfb = cal.add('vfreebusy') + vfb.add('dtstamp').value = item.vobject_item.vevent.dtstamp.value + vfb.add('dtstart').value, vfb.add('dtend').value = occurrence + if fbtype: + vfb.add('fbtype').value = fbtype + return (client.OK, cal.serialize()) + + def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], collection: storage.BaseCollection, encoding: str, unlock_storage_fn: Callable[[], None] ) -> Tuple[int, ET.Element]: - """Read and answer REPORT requests. + """Read and answer REPORT requests that return XML. Read rfc3253-3.6 for info. @@ -64,9 +170,8 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], logger.warning("Invalid REPORT method %r on %r requested", xmlutils.make_human_tag(root.tag), path) return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report") - prop_element = root.find(xmlutils.make_clark("D:prop")) - props = ([prop.tag for prop in prop_element] - if prop_element is not None else []) + + props: Union[ET.Element, List] = root.find(xmlutils.make_clark("D:prop")) or [] hreferences: Iterable[str] if root.tag in ( @@ -138,19 +243,40 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], found_props = [] not_found_props = [] - for tag in props: - element = ET.Element(tag) - if tag == xmlutils.make_clark("D:getetag"): + for prop in props: + element = ET.Element(prop.tag) + if prop.tag == xmlutils.make_clark("D:getetag"): element.text = item.etag found_props.append(element) - elif tag == xmlutils.make_clark("D:getcontenttype"): + elif prop.tag == xmlutils.make_clark("D:getcontenttype"): element.text = xmlutils.get_content_type(item, encoding) found_props.append(element) - elif tag in ( + elif prop.tag in ( xmlutils.make_clark("C:calendar-data"), xmlutils.make_clark("CR:address-data")): element.text = item.serialize() - found_props.append(element) + + expand = prop.find(xmlutils.make_clark("C:expand")) + if expand is not None: + start = expand.get('start') + end = expand.get('end') + + if (start is None) or (end is None): + return client.FORBIDDEN, \ + xmlutils.webdav_error("C:expand") + + start = datetime.datetime.strptime( + start, '%Y%m%dT%H%M%SZ' + ).replace(tzinfo=datetime.timezone.utc) + end = datetime.datetime.strptime( + end, '%Y%m%dT%H%M%SZ' + ).replace(tzinfo=datetime.timezone.utc) + + expanded_element = _expand( + element, copy.copy(item), start, end) + found_props.append(expanded_element) + else: + found_props.append(element) else: not_found_props.append(element) @@ -164,6 +290,111 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], return client.MULTI_STATUS, multistatus +def _expand( + element: ET.Element, + item: radicale_item.Item, + start: datetime.datetime, + end: datetime.datetime, +) -> ET.Element: + dt_format = '%Y%m%dT%H%M%SZ' + + if type(item.vobject_item.vevent.dtstart.value) is datetime.date: + # If an event comes to us with a dt_start specified as a date + # then in the response we return the date, not datetime + dt_format = '%Y%m%d' + + expanded_item, rruleset = _make_vobject_expanded_item(item, dt_format) + + if rruleset: + recurrences = rruleset.between(start, end, inc=True) + + expanded: vobject.base.Component = copy.copy(expanded_item.vobject_item) + is_expanded_filled: bool = False + + for recurrence_dt in recurrences: + recurrence_utc = recurrence_dt.astimezone(datetime.timezone.utc) + + vevent = copy.deepcopy(expanded.vevent) + vevent.recurrence_id = ContentLine( + name='RECURRENCE-ID', + value=recurrence_utc.strftime(dt_format), params={} + ) + + if is_expanded_filled is False: + expanded.vevent = vevent + is_expanded_filled = True + else: + expanded.add(vevent) + + element.text = expanded.serialize() + else: + element.text = expanded_item.vobject_item.serialize() + + return element + + +def _make_vobject_expanded_item( + item: radicale_item.Item, + dt_format: str, +) -> Tuple[radicale_item.Item, Optional[Any]]: + # https://www.rfc-editor.org/rfc/rfc4791#section-9.6.5 + # The returned calendar components MUST NOT use recurrence + # properties (i.e., EXDATE, EXRULE, RDATE, and RRULE) and MUST NOT + # have reference to or include VTIMEZONE components. Date and local + # time with reference to time zone information MUST be converted + # into date with UTC time. + + item = copy.copy(item) + vevent = item.vobject_item.vevent + + if type(vevent.dtstart.value) is datetime.date: + start_utc = datetime.datetime.fromordinal( + vevent.dtstart.value.toordinal() + ).replace(tzinfo=datetime.timezone.utc) + else: + start_utc = vevent.dtstart.value.astimezone(datetime.timezone.utc) + + vevent.dtstart = ContentLine(name='DTSTART', value=start_utc, params=[]) + + dt_end = getattr(vevent, 'dtend', None) + if dt_end is not None: + if type(vevent.dtend.value) is datetime.date: + end_utc = datetime.datetime.fromordinal( + dt_end.value.toordinal() + ).replace(tzinfo=datetime.timezone.utc) + else: + end_utc = dt_end.value.astimezone(datetime.timezone.utc) + + vevent.dtend = ContentLine(name='DTEND', value=end_utc, params={}) + + rruleset = None + if hasattr(item.vobject_item.vevent, 'rrule'): + rruleset = vevent.getrruleset() + + # There is something strange behaviour during serialization native datetime, so converting manually + vevent.dtstart.value = vevent.dtstart.value.strftime(dt_format) + if dt_end is not None: + vevent.dtend.value = vevent.dtend.value.strftime(dt_format) + + timezones_to_remove = [] + for component in item.vobject_item.components(): + if component.name == 'VTIMEZONE': + timezones_to_remove.append(component) + + for timezone in timezones_to_remove: + item.vobject_item.remove(timezone) + + try: + delattr(item.vobject_item.vevent, 'rrule') + delattr(item.vobject_item.vevent, 'exdate') + delattr(item.vobject_item.vevent, 'exrule') + delattr(item.vobject_item.vevent, 'rdate') + except AttributeError: + pass + + return item, rruleset + + def xml_item_response(base_prefix: str, href: str, found_props: Sequence[ET.Element] = (), not_found_props: Sequence[ET.Element] = (), @@ -295,13 +526,28 @@ class ApplicationPartReport(ApplicationBase): else: assert item.collection is not None collection = item.collection - try: - status, xml_answer = xml_report( - base_prefix, path, xml_content, collection, self._encoding, - lock_stack.close) - except ValueError as e: - logger.warning( - "Bad REPORT request on %r: %s", path, e, exc_info=True) - return httputils.BAD_REQUEST - headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} - return status, headers, self._xml_response(xml_answer) + + if xml_content is not None and \ + xml_content.tag == xmlutils.make_clark("C:free-busy-query"): + max_occurrence = self.configuration.get("reporting", "max_freebusy_occurrence") + try: + status, body = free_busy_report( + base_prefix, path, xml_content, collection, self._encoding, + lock_stack.close, max_occurrence) + except ValueError as e: + logger.warning( + "Bad REPORT request on %r: %s", path, e, exc_info=True) + return httputils.BAD_REQUEST + headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding} + return status, headers, str(body) + else: + try: + status, xml_answer = xml_report( + base_prefix, path, xml_content, collection, self._encoding, + lock_stack.close) + except ValueError as e: + logger.warning( + "Bad REPORT request on %r: %s", path, e, exc_info=True) + return httputils.BAD_REQUEST + headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} + return status, headers, self._xml_response(xml_answer) diff --git a/radicale/auth/__init__.py b/radicale/auth/__init__.py index 2ee64509..f15bd020 100644 --- a/radicale/auth/__init__.py +++ b/radicale/auth/__init__.py @@ -2,7 +2,8 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -31,13 +32,20 @@ Take a look at the class ``BaseAuth`` if you want to implement your own. from typing import Sequence, Tuple, Union from radicale import config, types, utils +from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user", - "htpasswd", "ldap") + "denyall", + "htpasswd", + "ldap") def load(configuration: "config.Configuration") -> "BaseAuth": """Load the authentication module chosen in configuration.""" + if configuration.get("auth", "type") == "none": + logger.warning("No user authentication is selected: '[auth] type=none' (insecure)") + if configuration.get("auth", "type") == "denyall": + logger.warning("All access is blocked by: '[auth] type=denyall'") return utils.load_plugin(INTERNAL_TYPES, "auth", "Auth", BaseAuth, configuration) @@ -45,6 +53,8 @@ def load(configuration: "config.Configuration") -> "BaseAuth": class BaseAuth: _ldap_groups: set + _lc_username: bool + _strip_domain: bool def __init__(self, configuration: "config.Configuration") -> None: """Initialize BaseAuth. @@ -55,6 +65,8 @@ class BaseAuth: """ self.configuration = configuration + self._lc_username = configuration.get("auth", "lc_username") + self._strip_domain = configuration.get("auth", "strip_domain") def get_external_login(self, environ: types.WSGIEnviron) -> Union[ Tuple[()], Tuple[str, str]]: @@ -69,7 +81,7 @@ class BaseAuth: """ return () - def login(self, login: str, password: str) -> str: + def _login(self, login: str, password: str) -> str: """Check credentials and map login to internal user ``login`` the login name @@ -81,3 +93,10 @@ class BaseAuth: """ raise NotImplementedError + + def login(self, login: str, password: str) -> str: + if self._lc_username: + login = login.lower() + if self._strip_domain: + login = login.split('@')[0] + return self._login(login, password) diff --git a/radicale/auth/denyall.py b/radicale/auth/denyall.py new file mode 100644 index 00000000..5a047e35 --- /dev/null +++ b/radicale/auth/denyall.py @@ -0,0 +1,30 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2024-2024 Peter Bieringer +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +A dummy backend that denies any username and password. + +Used as default for security reasons. + +""" + +from radicale import auth + + +class Auth(auth.BaseAuth): + + def _login(self, login: str, password: str) -> str: + return "" diff --git a/radicale/auth/htpasswd.py b/radicale/auth/htpasswd.py index 872f7277..7422e16d 100644 --- a/radicale/auth/htpasswd.py +++ b/radicale/auth/htpasswd.py @@ -3,6 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2019 Unrud +# Copyright © 2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -22,12 +23,12 @@ 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 +different the methods BCRYPT/SHA256/SHA512 or MD5-APR1 (a version of MD5 modified for +Apache). MD5-APR1 provides medium security as of 2015. Only BCRYPT/SHA256/SHA512 can be considered secure by current standards. MD5-APR1-encrypted credentials can be written by all versions of htpasswd (it -is the default, in fact), whereas BCRYPT requires htpasswd 2.4.x or newer. +is the default, in fact), whereas BCRYPT/SHA256/SHA512 requires htpasswd 2.4.x or newer. The `is_authenticated(user, password)` function provided by this module verifies the user-given credentials by parsing the htpasswd credential file @@ -35,15 +36,15 @@ 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 +The following htpasswd password encryption methods are supported by Radicale out-of-the-box: + - plain-text (created by htpasswd -p ...) -- INSECURE + - MD5-APR1 (htpasswd -m ...) -- htpasswd's default method, INSECURE + - SHA256 (htpasswd -2 ...) + - SHA512 (htpasswd -5 ...) - - plain-text (created by htpasswd -p...) -- INSECURE - - MD5-APR1 (htpasswd -m...) -- htpasswd's default method - -When passlib[bcrypt] is installed: - - - BCRYPT (htpasswd -B...) -- Requires htpasswd 2.4.x +When bcrypt is installed: + - BCRYPT (htpasswd -B ...) -- Requires htpasswd 2.4.x """ @@ -51,9 +52,9 @@ import functools import hmac from typing import Any -from passlib.hash import apr_md5_crypt +from passlib.hash import apr_md5_crypt, sha256_crypt, sha512_crypt -from radicale import auth, config +from radicale import auth, config, logger class Auth(auth.BaseAuth): @@ -67,22 +68,28 @@ class Auth(auth.BaseAuth): self._encoding = configuration.get("encoding", "stock") encryption: str = configuration.get("auth", "htpasswd_encryption") + logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s'", encryption) + if encryption == "plain": self._verify = self._plain elif encryption == "md5": self._verify = self._md5apr1 - elif encryption == "bcrypt": + elif encryption == "sha256": + self._verify = self._sha256 + elif encryption == "sha512": + self._verify = self._sha512 + elif encryption == "bcrypt" or encryption == "autodetect": try: - from passlib.hash import bcrypt + import bcrypt except ImportError as e: raise RuntimeError( - "The htpasswd encryption method 'bcrypt' requires " - "the passlib[bcrypt] module.") from e - # A call to `encrypt` raises passlib.exc.MissingBackendError with a - # good error message if bcrypt backend is not available. Trigger - # this here. - bcrypt.hash("test-bcrypt-backend") - self._verify = functools.partial(self._bcrypt, bcrypt) + "The htpasswd encryption method 'bcrypt' or 'autodetect' requires " + "the bcrypt module.") from e + if encryption == "bcrypt": + self._verify = functools.partial(self._bcrypt, bcrypt) + else: + self._verify = self._autodetect + self._verify_bcrypt = functools.partial(self._bcrypt, bcrypt) else: raise RuntimeError("The htpasswd encryption method %r is not " "supported." % encryption) @@ -92,12 +99,35 @@ class Auth(auth.BaseAuth): return hmac.compare_digest(hash_value.encode(), password.encode()) def _bcrypt(self, bcrypt: Any, hash_value: str, password: str) -> bool: - return bcrypt.verify(password, hash_value.strip()) + return bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hash_value.encode()) def _md5apr1(self, hash_value: str, password: str) -> bool: return apr_md5_crypt.verify(password, hash_value.strip()) - def login(self, login: str, password: str) -> str: + def _sha256(self, hash_value: str, password: str) -> bool: + return sha256_crypt.verify(password, hash_value.strip()) + + def _sha512(self, hash_value: str, password: str) -> bool: + return sha512_crypt.verify(password, hash_value.strip()) + + def _autodetect(self, hash_value: str, password: str) -> bool: + if hash_value.startswith("$apr1$", 0, 6) and len(hash_value) == 37: + # MD5-APR1 + return self._md5apr1(hash_value, password) + elif hash_value.startswith("$2y$", 0, 4) and len(hash_value) == 60: + # BCRYPT + return self._verify_bcrypt(hash_value, password) + elif hash_value.startswith("$5$", 0, 3) and len(hash_value) == 63: + # SHA-256 + return self._sha256(hash_value, password) + elif hash_value.startswith("$6$", 0, 3) and len(hash_value) == 106: + # SHA-512 + return self._sha512(hash_value, password) + else: + # assumed plaintext + return self._plain(hash_value, password) + + def _login(self, login: str, password: str) -> str: """Validate credentials. Iterate through htpasswd credential file until login matches, extract diff --git a/radicale/auth/none.py b/radicale/auth/none.py index ce2b1c86..be451feb 100644 --- a/radicale/auth/none.py +++ b/radicale/auth/none.py @@ -27,5 +27,5 @@ from radicale import auth class Auth(auth.BaseAuth): - def login(self, login: str, password: str) -> str: + def _login(self, login: str, password: str) -> str: return login diff --git a/radicale/config.py b/radicale/config.py index 238bd3b6..34e7f4e5 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -2,7 +2,8 @@ # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter -# Copyright © 2017-2019 Unrud +# Copyright © 2017-2020 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -26,6 +27,7 @@ Use ``load()`` to obtain an instance of ``Configuration`` for use with """ import contextlib +import json import math import os import string @@ -35,7 +37,8 @@ from configparser import RawConfigParser from typing import (Any, Callable, ClassVar, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union) -from radicale import auth, rights, storage, types, web +from radicale import auth, hook, rights, storage, types, web +from radicale.item import check_and_sanitize_props DEFAULT_CONFIG_PATH: str = os.pathsep.join([ "?/etc/radicale/config", @@ -101,6 +104,16 @@ def _convert_to_bool(value: Any) -> bool: return RawConfigParser.BOOLEAN_STATES[value.lower()] +def json_str(value: Any) -> dict: + if not value: + return {} + ret = json.loads(value) + for (name_coll, props) in ret.items(): + checked_props = check_and_sanitize_props(props) + ret[name_coll] = checked_props + return ret + + INTERNAL_OPTIONS: Sequence[str] = ("_allow_extra",) # Default configuration DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ @@ -202,13 +215,24 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "False", "help": "load the ldap groups of the authenticated user", "type": bool}), - ])), + ("strip_domain", { + "value": "False", + "help": "strip domain from username", + "type": bool}), + ("lc_username", { + "value": "False", + "help": "convert username to lowercase, must be true for case-insensitive auth providers", + "type": bool})])), ("rights", OrderedDict([ ("type", { "value": "owner_only", "help": "rights backend", "type": str_or_callable, "internal": rights.INTERNAL_TYPES}), + ("permit_delete_collection", { + "value": "True", + "help": "permit delete of a collection", + "type": bool}), ("file", { "value": "/etc/radicale/rights", "help": "file for rights management from_file", @@ -227,6 +251,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "2592000", # 30 days "help": "delete sync token that are older", "type": positive_int}), + ("skip_broken_item", { + "value": "True", + "help": "skip broken item instead of triggering exception", + "type": bool}), ("hook", { "value": "", "help": "command that is run after changes to storage", @@ -234,7 +262,29 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ ("_filesystem_fsync", { "value": "True", "help": "sync all changes to filesystem during requests", - "type": bool})])), + "type": bool}), + ("predefined_collections", { + "value": "", + "help": "predefined user collections", + "type": json_str})])), + ("hook", OrderedDict([ + ("type", { + "value": "none", + "help": "hook backend", + "type": str, + "internal": hook.INTERNAL_TYPES}), + ("rabbitmq_endpoint", { + "value": "", + "help": "endpoint where rabbitmq server is running", + "type": str}), + ("rabbitmq_topic", { + "value": "", + "help": "topic to declare queue", + "type": str}), + ("rabbitmq_queue_type", { + "value": "", + "help": "queue type for topic declaration", + "type": str})])), ("web", OrderedDict([ ("type", { "value": "internal", @@ -243,15 +293,41 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "internal": web.INTERNAL_TYPES})])), ("logging", OrderedDict([ ("level", { - "value": "warning", + "value": "info", "help": "threshold for the logger", "type": logging_level}), + ("bad_put_request_content", { + "value": "False", + "help": "log bad PUT request content", + "type": bool}), + ("backtrace_on_debug", { + "value": "False", + "help": "log backtrace on level=debug", + "type": bool}), + ("request_header_on_debug", { + "value": "False", + "help": "log request header on level=debug", + "type": bool}), + ("request_content_on_debug", { + "value": "False", + "help": "log request content on level=debug", + "type": bool}), + ("response_content_on_debug", { + "value": "False", + "help": "log response content on level=debug", + "type": bool}), ("mask_passwords", { "value": "True", "help": "mask passwords in logs", "type": bool})])), ("headers", OrderedDict([ - ("_allow_extra", str)]))]) + ("_allow_extra", str)])), + ("reporting", OrderedDict([ + ("max_freebusy_occurrence", { + "value": "10000", + "help": "number of occurrences per event when reporting", + "type": positive_int})])) + ]) def parse_compound_paths(*compound_paths: Optional[str] @@ -308,8 +384,8 @@ def load(paths: Optional[Iterable[Tuple[str, bool]]] = None config = {s: {o: parser[s][o] for o in parser.options(s)} for s in parser.sections()} except Exception as e: - if not (ignore_if_missing and - isinstance(e, (FileNotFoundError, PermissionError))): + if not (ignore_if_missing and isinstance(e, ( + FileNotFoundError, NotADirectoryError, PermissionError))): raise RuntimeError("Failed to load %s: %s" % (config_source, e) ) from e config = Configuration.SOURCE_MISSING diff --git a/radicale/hook/__init__.py b/radicale/hook/__init__.py new file mode 100644 index 00000000..e31befc1 --- /dev/null +++ b/radicale/hook/__init__.py @@ -0,0 +1,69 @@ +import json +from enum import Enum +from typing import Sequence + +from radicale import pathutils, utils +from radicale.log import logger + +INTERNAL_TYPES: Sequence[str] = ("none", "rabbitmq") + + +def load(configuration): + """Load the storage module chosen in configuration.""" + try: + return utils.load_plugin( + INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration) + except Exception as e: + logger.warn(e) + logger.warn("Hook \"%s\" failed to load, falling back to \"none\"." % configuration.get("hook", "type")) + configuration = configuration.copy() + configuration.update({"hook": {"type": "none"}}, "hook", privileged=True) + return utils.load_plugin( + INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration) + + +class BaseHook: + def __init__(self, configuration): + """Initialize BaseHook. + + ``configuration`` see ``radicale.config`` module. + The ``configuration`` must not change during the lifetime of + this object, it is kept as an internal reference. + + """ + self.configuration = configuration + + def notify(self, notification_item): + """Upload a new or replace an existing item.""" + raise NotImplementedError + + +class HookNotificationItemTypes(Enum): + CPATCH = "cpatch" + UPSERT = "upsert" + DELETE = "delete" + + +def _cleanup(path): + sane_path = pathutils.strip_path(path) + attributes = sane_path.split("/") if sane_path else [] + + if len(attributes) < 2: + return "" + return attributes[0] + "/" + attributes[1] + + +class HookNotificationItem: + + def __init__(self, notification_item_type, path, content): + self.type = notification_item_type.value + self.point = _cleanup(path) + self.content = content + + def to_json(self): + return json.dumps( + self, + default=lambda o: o.__dict__, + sort_keys=True, + indent=4 + ) diff --git a/radicale/hook/none.py b/radicale/hook/none.py new file mode 100644 index 00000000..b770ab67 --- /dev/null +++ b/radicale/hook/none.py @@ -0,0 +1,6 @@ +from radicale import hook + + +class Hook(hook.BaseHook): + def notify(self, notification_item): + """Notify nothing. Empty hook.""" diff --git a/radicale/hook/rabbitmq/__init__.py b/radicale/hook/rabbitmq/__init__.py new file mode 100644 index 00000000..2323ed43 --- /dev/null +++ b/radicale/hook/rabbitmq/__init__.py @@ -0,0 +1,50 @@ +import pika +from pika.exceptions import ChannelWrongStateError, StreamLostError + +from radicale import hook +from radicale.hook import HookNotificationItem +from radicale.log import logger + + +class Hook(hook.BaseHook): + + def __init__(self, configuration): + super().__init__(configuration) + self._endpoint = configuration.get("hook", "rabbitmq_endpoint") + self._topic = configuration.get("hook", "rabbitmq_topic") + self._queue_type = configuration.get("hook", "rabbitmq_queue_type") + self._encoding = configuration.get("encoding", "stock") + + self._make_connection_synced() + self._make_declare_queue_synced() + + def _make_connection_synced(self): + parameters = pika.URLParameters(self._endpoint) + connection = pika.BlockingConnection(parameters) + self._channel = connection.channel() + + def _make_declare_queue_synced(self): + self._channel.queue_declare(queue=self._topic, durable=True, arguments={"x-queue-type": self._queue_type}) + + def notify(self, notification_item): + if isinstance(notification_item, HookNotificationItem): + self._notify(notification_item, True) + + def _notify(self, notification_item, recall): + try: + self._channel.basic_publish( + exchange='', + routing_key=self._topic, + body=notification_item.to_json().encode( + encoding=self._encoding + ) + ) + except Exception as e: + if (isinstance(e, ChannelWrongStateError) or + isinstance(e, StreamLostError)) and recall: + self._make_connection_synced() + self._notify(notification_item, False) + return + logger.error("An exception occurred during " + "publishing hook notification item: %s", + e, exc_info=True) diff --git a/radicale/httputils.py b/radicale/httputils.py index 1bf25135..a9565293 100644 --- a/radicale/httputils.py +++ b/radicale/httputils.py @@ -2,7 +2,8 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -24,13 +25,25 @@ Helper functions for HTTP. import contextlib import os +import pathlib +import sys import time from http import client -from typing import List, Mapping, cast +from typing import List, Mapping, Union, cast from radicale import config, pathutils, types from radicale.log import logger +if sys.version_info < (3, 9): + import pkg_resources + + _TRAVERSABLE_LIKE_TYPE = pathlib.Path +else: + import importlib.abc + from importlib import resources + + _TRAVERSABLE_LIKE_TYPE = Union[importlib.abc.Traversable, pathlib.Path] + NOT_ALLOWED: types.WSGIResponse = ( client.FORBIDDEN, (("Content-Type", "text/plain"),), "Access to the requested resource forbidden.") @@ -130,7 +143,10 @@ def read_request_body(configuration: "config.Configuration", environ: types.WSGIEnviron) -> str: content = decode_request(configuration, environ, read_raw_request_body(configuration, environ)) - logger.debug("Request content:\n%s", content) + if configuration.get("logging", "request_content_on_debug"): + logger.debug("Request content:\n%s", content) + else: + logger.debug("Request content: suppressed by config/option [auth] request_content_on_debug") return content @@ -140,36 +156,63 @@ def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse: "Redirected to %s" % location) -def serve_folder(folder: str, base_prefix: str, path: str, - path_prefix: str = "/.web", index_file: str = "index.html", - mimetypes: Mapping[str, str] = MIMETYPES, - fallback_mimetype: str = FALLBACK_MIMETYPE, - ) -> types.WSGIResponse: +def _serve_traversable( + traversable: _TRAVERSABLE_LIKE_TYPE, base_prefix: str, path: str, + path_prefix: str, index_file: str, mimetypes: Mapping[str, str], + fallback_mimetype: str) -> types.WSGIResponse: if path != path_prefix and not path.startswith(path_prefix): raise ValueError("path must start with path_prefix: %r --> %r" % (path_prefix, path)) assert pathutils.sanitize_path(path) == path - try: - filesystem_path = pathutils.path_to_filesystem( - folder, path[len(path_prefix):].strip("/")) - except ValueError as e: - logger.debug("Web content with unsafe path %r requested: %s", - path, e, exc_info=True) - return NOT_FOUND - if os.path.isdir(filesystem_path) and not path.endswith("/"): - return redirect(base_prefix + path + "/") - if os.path.isdir(filesystem_path) and index_file: - filesystem_path = os.path.join(filesystem_path, index_file) - if not os.path.isfile(filesystem_path): + parts_path = path[len(path_prefix):].strip('/') + parts = parts_path.split("/") if parts_path else [] + for part in parts: + if not pathutils.is_safe_filesystem_path_component(part): + logger.debug("Web content with unsafe path %r requested", path) + return NOT_FOUND + if (not traversable.is_dir() or + all(part != entry.name for entry in traversable.iterdir())): + return NOT_FOUND + traversable = traversable.joinpath(part) + if traversable.is_dir(): + if not path.endswith("/"): + return redirect(base_prefix + path + "/") + if not index_file: + return NOT_FOUND + traversable = traversable.joinpath(index_file) + if not traversable.is_file(): return NOT_FOUND content_type = MIMETYPES.get( - os.path.splitext(filesystem_path)[1].lower(), FALLBACK_MIMETYPE) - with open(filesystem_path, "rb") as f: - answer = f.read() - last_modified = time.strftime( + os.path.splitext(traversable.name)[1].lower(), FALLBACK_MIMETYPE) + headers = {"Content-Type": content_type} + if isinstance(traversable, pathlib.Path): + headers["Last-Modified"] = time.strftime( "%a, %d %b %Y %H:%M:%S GMT", - time.gmtime(os.fstat(f.fileno()).st_mtime)) - headers = { - "Content-Type": content_type, - "Last-Modified": last_modified} + time.gmtime(traversable.stat().st_mtime)) + answer = traversable.read_bytes() return client.OK, headers, answer + + +def serve_resource( + package: str, resource: str, base_prefix: str, path: str, + path_prefix: str = "/.web", index_file: str = "index.html", + mimetypes: Mapping[str, str] = MIMETYPES, + fallback_mimetype: str = FALLBACK_MIMETYPE) -> types.WSGIResponse: + if sys.version_info < (3, 9): + traversable = pathlib.Path( + pkg_resources.resource_filename(package, resource)) + else: + traversable = resources.files(package).joinpath(resource) + return _serve_traversable(traversable, base_prefix, path, path_prefix, + index_file, mimetypes, fallback_mimetype) + + +def serve_folder( + folder: str, base_prefix: str, path: str, + path_prefix: str = "/.web", index_file: str = "index.html", + mimetypes: Mapping[str, str] = MIMETYPES, + fallback_mimetype: str = FALLBACK_MIMETYPE) -> types.WSGIResponse: + # deprecated: use `serve_resource` instead + traversable = pathlib.Path(folder) + return _serve_traversable(traversable, base_prefix, path, path_prefix, + index_file, mimetypes, fallback_mimetype) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 04362b71..a05304ff 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -49,7 +49,13 @@ def read_components(s: str) -> List[vobject.base.Component]: s = re.sub(r"^(PHOTO(?:;[^:\r\n]*)?;ENCODING=b(?:;[^:\r\n]*)?:)" r"data:[^;,\r\n]*;base64,", r"\1", s, flags=re.MULTILINE | re.IGNORECASE) - return list(vobject.readComponents(s)) + # Workaround for bug with malformed ICS files containing control codes + # Filter out all control codes except those we expect to find: + # * 0x09 Horizontal Tab + # * 0x0A Line Feed + # * 0x0D Carriage Return + s = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F]', '', s) + return list(vobject.readComponents(s, allowQP=True)) def predict_tag_of_parent_collection( @@ -91,7 +97,7 @@ def check_and_sanitize_items( The ``tag`` of the collection. """ - if tag and tag not in ("VCALENDAR", "VADDRESSBOOK"): + if tag and tag not in ("VCALENDAR", "VADDRESSBOOK", "VSUBSCRIBED"): raise ValueError("Unsupported collection tag: %r" % tag) if not is_collection and len(vobject_items) != 1: raise ValueError("Item contains %d components" % len(vobject_items)) @@ -164,7 +170,7 @@ def check_and_sanitize_items( ref_value_param = component.dtstart.params.get("VALUE") for dates in chain(component.contents.get("exdate", []), component.contents.get("rdate", [])): - if all(type(d) == type(ref_date) for d in dates.value): + if all(type(d) is type(ref_date) for d in dates.value): continue for i, date in enumerate(dates.value): dates.value[i] = ref_date.replace( @@ -230,7 +236,7 @@ def check_and_sanitize_props(props: MutableMapping[Any, Any] raise ValueError("Value of %r must be %r not %r: %r" % ( k, str.__name__, type(v).__name__, v)) if k == "tag": - if v not in ("", "VCALENDAR", "VADDRESSBOOK"): + if v not in ("", "VCALENDAR", "VADDRESSBOOK", "VSUBSCRIBED"): raise ValueError("Unsupported collection tag: %r" % v) return props @@ -245,8 +251,8 @@ def find_available_uid(exists_fn: Callable[[str], bool], suffix: str = "" 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") + # Something is wrong with the PRNG or `exists_fn` + raise RuntimeError("No available random UID found") def get_etag(text: str) -> str: @@ -298,7 +304,7 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str Returns a tuple (``start``, ``end``) where ``start`` and ``end`` are POSIX timestamps. - This is intened to be used for matching against simplified prefilters. + This is intended to be used for matching against simplified prefilters. """ if not tag: diff --git a/radicale/item/filter.py b/radicale/item/filter.py index 587dc367..cb3e8cdb 100644 --- a/radicale/item/filter.py +++ b/radicale/item/filter.py @@ -48,10 +48,34 @@ def date_to_datetime(d: date) -> datetime: if not isinstance(d, datetime): d = datetime.combine(d, datetime.min.time()) if not d.tzinfo: - d = d.replace(tzinfo=timezone.utc) + # NOTE: using vobject's UTC as it wasn't playing well with datetime's. + d = d.replace(tzinfo=vobject.icalendar.utc) return d +def parse_time_range(time_filter: ET.Element) -> Tuple[datetime, datetime]: + start_text = time_filter.get("start") + end_text = time_filter.get("end") + if start_text: + start = datetime.strptime( + start_text, "%Y%m%dT%H%M%SZ").replace( + tzinfo=timezone.utc) + else: + start = DATETIME_MIN + if end_text: + end = datetime.strptime( + end_text, "%Y%m%dT%H%M%SZ").replace( + tzinfo=timezone.utc) + else: + end = DATETIME_MAX + return start, end + + +def time_range_timestamps(time_filter: ET.Element) -> Tuple[int, int]: + start, end = parse_time_range(time_filter) + return (math.floor(start.timestamp()), math.ceil(end.timestamp())) + + def comp_match(item: "item.Item", filter_: ET.Element, level: int = 0) -> bool: """Check whether the ``item`` matches the comp ``filter_``. @@ -147,21 +171,10 @@ def time_range_match(vobject_item: vobject.base.Component, """Check whether the component/property ``child_name`` of ``vobject_item`` matches the time-range ``filter_``.""" - start_text = filter_.get("start") - end_text = filter_.get("end") - if not start_text and not end_text: + if not filter_.get("start") and not filter_.get("end"): return False - if start_text: - start = datetime.strptime(start_text, "%Y%m%dT%H%M%SZ") - else: - start = datetime.min - if end_text: - end = datetime.strptime(end_text, "%Y%m%dT%H%M%SZ") - else: - end = datetime.max - start = start.replace(tzinfo=timezone.utc) - end = end.replace(tzinfo=timezone.utc) + start, end = parse_time_range(filter_) matched = False def range_fn(range_start: datetime, range_end: datetime, @@ -181,6 +194,35 @@ def time_range_match(vobject_item: vobject.base.Component, return matched +def time_range_fill(vobject_item: vobject.base.Component, + filter_: ET.Element, child_name: str, n: int = 1 + ) -> List[Tuple[datetime, datetime]]: + """Create a list of ``n`` occurances from the component/property ``child_name`` + of ``vobject_item``.""" + if not filter_.get("start") and not filter_.get("end"): + return [] + + start, end = parse_time_range(filter_) + ranges: List[Tuple[datetime, datetime]] = [] + + def range_fn(range_start: datetime, range_end: datetime, + is_recurrence: bool) -> bool: + nonlocal ranges + if start < range_end and range_start < end: + ranges.append((range_start, range_end)) + if n > 0 and len(ranges) >= n: + return True + if end < range_start and not is_recurrence: + return True + return False + + def infinity_fn(range_start: datetime) -> bool: + return False + + visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn) + return ranges + + def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str, range_fn: Callable[[datetime, datetime, bool], bool], infinity_fn: Callable[[datetime], bool]) -> None: @@ -199,7 +241,7 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str, """ - # HACK: According to rfc5545-3.8.4.4 an recurrance that is resheduled + # HACK: According to rfc5545-3.8.4.4 a recurrence that is rescheduled # with Recurrence ID affects the recurrence itself and all following # recurrences too. This is not respected and client don't seem to bother # either. @@ -225,6 +267,7 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str, def get_children(components: Iterable[vobject.base.Component]) -> Iterator[ Tuple[vobject.base.Component, bool, List[date]]]: main = None + rec_main = None recurrences = [] for comp in components: if hasattr(comp, "recurrence_id") and comp.recurrence_id.value: @@ -232,11 +275,14 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str, if comp.rruleset: # Prevent possible infinite loop raise ValueError("Overwritten recurrence with RRULESET") + rec_main = comp yield comp, True, [] else: if main is not None: raise ValueError("Multiple main components") main = comp + if main is None and len(recurrences) == 1: + main = rec_main if main is None: raise ValueError("Main component missing") yield main, False, recurrences @@ -468,7 +514,15 @@ def text_match(vobject_item: vobject.base.Component, match(attrib) for child in children for attrib in child.params.get(attrib_name, [])) else: - condition = any(match(child.value) for child in children) + res = [] + for child in children: + # Some filters such as CATEGORIES provide a list in child.value + if type(child.value) is list: + for value in child.value: + res.append(match(value)) + else: + res.append(match(child.value)) + condition = any(res) if filter_.get("negate-condition") == "yes": return not condition return condition @@ -531,20 +585,7 @@ def simplify_prefilters(filters: Iterable[ET.Element], collection_tag: str if time_filter.tag != xmlutils.make_clark("C:time-range"): simple = False continue - start_text = time_filter.get("start") - end_text = time_filter.get("end") - if start_text: - start = math.floor(datetime.strptime( - start_text, "%Y%m%dT%H%M%SZ").replace( - tzinfo=timezone.utc).timestamp()) - else: - start = TIMESTAMP_MIN - if end_text: - end = math.ceil(datetime.strptime( - end_text, "%Y%m%dT%H%M%SZ").replace( - tzinfo=timezone.utc).timestamp()) - else: - end = TIMESTAMP_MAX + start, end = time_range_timestamps(time_filter) return tag, start, end, simple return tag, TIMESTAMP_MIN, TIMESTAMP_MAX, simple return None, TIMESTAMP_MIN, TIMESTAMP_MAX, simple diff --git a/radicale/log.py b/radicale/log.py index eaa842bf..313b4933 100644 --- a/radicale/log.py +++ b/radicale/log.py @@ -1,6 +1,7 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2011-2017 Guillaume Ayoub -# Copyright © 2017-2019 Unrud +# Copyright © 2017-2023 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -25,16 +26,25 @@ Log messages are sent to the first available target of: """ +import contextlib +import io import logging import os +import socket +import struct import sys import threading -from typing import Any, Callable, ClassVar, Dict, Iterator, Union +import time +from typing import (Any, Callable, ClassVar, Dict, Iterator, Mapping, Optional, + Tuple, Union, cast) from radicale import types LOGGER_NAME: str = "radicale" -LOGGER_FORMAT: str = "[%(asctime)s] [%(ident)s] [%(levelname)s] %(message)s" +LOGGER_FORMATS: Mapping[str, str] = { + "verbose": "[%(asctime)s] [%(ident)s] [%(levelname)s] %(message)s", + "journal": "[%(ident)s] [%(levelname)s] %(message)s", +} DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S %z" logger: logging.Logger = logging.getLogger(LOGGER_NAME) @@ -59,12 +69,17 @@ class IdentLogRecordFactory: def __call__(self, *args: Any, **kwargs: Any) -> logging.LogRecord: record = self._upstream_factory(*args, **kwargs) - ident = "%d" % os.getpid() - main_thread = threading.main_thread() - current_thread = threading.current_thread() - if current_thread.name and main_thread != current_thread: - ident += "/%s" % current_thread.name + ident = ("%d" % record.process if record.process is not None + else record.processName or "unknown") + tid = None + if record.thread is not None: + if record.thread != threading.main_thread().ident: + ident += "/%s" % (record.threadName or "unknown") + if (sys.version_info >= (3, 8) and + record.thread == threading.get_ident()): + tid = threading.get_native_id() record.ident = ident # type:ignore[attr-defined] + record.tid = tid # type:ignore[attr-defined] return record @@ -75,19 +90,102 @@ class ThreadedStreamHandler(logging.Handler): terminator: ClassVar[str] = "\n" _streams: Dict[int, types.ErrorStream] + _journal_stream_id: Optional[Tuple[int, int]] + _journal_socket: Optional[socket.socket] + _journal_socket_failed: bool + _formatters: Mapping[str, logging.Formatter] + _formatter: Optional[logging.Formatter] - def __init__(self) -> None: + def __init__(self, format_name: Optional[str] = None) -> None: super().__init__() self._streams = {} + self._journal_stream_id = None + with contextlib.suppress(TypeError, ValueError): + dev, inode = os.environ.get("JOURNAL_STREAM", "").split(":", 1) + self._journal_stream_id = (int(dev), int(inode)) + self._journal_socket = None + self._journal_socket_failed = False + self._formatters = {name: logging.Formatter(fmt, DATE_FORMAT) + for name, fmt in LOGGER_FORMATS.items()} + self._formatter = (self._formatters[format_name] + if format_name is not None else None) + + def _get_formatter(self, default_format_name: str) -> logging.Formatter: + return self._formatter or self._formatters[default_format_name] + + def _detect_journal(self, stream: types.ErrorStream) -> bool: + if not self._journal_stream_id or not isinstance(stream, io.IOBase): + return False + try: + stat = os.fstat(stream.fileno()) + except OSError: + return False + return self._journal_stream_id == (stat.st_dev, stat.st_ino) + + @staticmethod + def _encode_journal(data: Mapping[str, Optional[Union[str, int]]] + ) -> bytes: + msg = b"" + for key, value in data.items(): + if value is None: + continue + keyb = key.encode() + valueb = str(value).encode() + if b"\n" in valueb: + msg += (keyb + b"\n" + + struct.pack(" bool: + if not self._journal_socket: + # Try to connect to systemd journal socket + if self._journal_socket_failed or not hasattr(socket, "AF_UNIX"): + return False + journal_socket = None + try: + journal_socket = socket.socket( + socket.AF_UNIX, socket.SOCK_DGRAM) + journal_socket.connect("/run/systemd/journal/socket") + except OSError as e: + self._journal_socket_failed = True + if journal_socket: + journal_socket.close() + # Log after setting `_journal_socket_failed` to prevent loop! + logger.error("Failed to connect to systemd journal: %s", + e, exc_info=True) + return False + self._journal_socket = journal_socket + + priority = {"DEBUG": 7, + "INFO": 6, + "WARNING": 4, + "ERROR": 3, + "CRITICAL": 2}.get(record.levelname, 4) + timestamp = time.strftime("%Y-%m-%dT%H:%M:%S.%%03dZ", + time.gmtime(record.created)) % record.msecs + data = {"PRIORITY": priority, + "TID": cast(Optional[int], getattr(record, "tid", None)), + "SYSLOG_IDENTIFIER": record.name, + "SYSLOG_FACILITY": 1, + "SYSLOG_PID": record.process, + "SYSLOG_TIMESTAMP": timestamp, + "CODE_FILE": record.pathname, + "CODE_LINE": record.lineno, + "CODE_FUNC": record.funcName, + "MESSAGE": self._get_formatter("journal").format(record)} + self._journal_socket.sendall(self._encode_journal(data)) + return True def emit(self, record: logging.LogRecord) -> None: try: stream = self._streams.get(threading.get_ident(), sys.stderr) - msg = self.format(record) - stream.write(msg) - stream.write(self.terminator) - if hasattr(stream, "flush"): - stream.flush() + if self._detect_journal(stream) and self._try_emit_journal(record): + return + msg = self._get_formatter("verbose").format(record) + stream.write(msg + self.terminator) + stream.flush() except Exception: self.handleError(record) @@ -111,21 +209,30 @@ def register_stream(stream: types.ErrorStream) -> Iterator[None]: def setup() -> None: """Set global logging up.""" global register_stream - handler = ThreadedStreamHandler() - logging.basicConfig(format=LOGGER_FORMAT, datefmt=DATE_FORMAT, - handlers=[handler]) + format_name = os.environ.get("RADICALE_LOG_FORMAT") or None + sane_format_name = format_name if format_name in LOGGER_FORMATS else None + handler = ThreadedStreamHandler(sane_format_name) + logging.basicConfig(handlers=[handler]) register_stream = handler.register_stream log_record_factory = IdentLogRecordFactory(logging.getLogRecordFactory()) logging.setLogRecordFactory(log_record_factory) - set_level(logging.WARNING) + set_level(logging.INFO, True) + if format_name != sane_format_name: + logger.error("Invalid RADICALE_LOG_FORMAT: %r", format_name) -def set_level(level: Union[int, str]) -> None: +def set_level(level: Union[int, str], backtrace_on_debug: bool) -> None: """Set logging level for global logger.""" if isinstance(level, str): level = getattr(logging, level.upper()) assert isinstance(level, int) logger.setLevel(level) - logger.removeFilter(REMOVE_TRACEBACK_FILTER) if level > logging.DEBUG: + logger.info("Logging of backtrace is disabled in this loglevel") logger.addFilter(REMOVE_TRACEBACK_FILTER) + else: + if not backtrace_on_debug: + logger.debug("Logging of backtrace is disabled by option in this loglevel") + logger.addFilter(REMOVE_TRACEBACK_FILTER) + else: + logger.removeFilter(REMOVE_TRACEBACK_FILTER) diff --git a/radicale/pathutils.py b/radicale/pathutils.py index ff4565e8..46306b2e 100644 --- a/radicale/pathutils.py +++ b/radicale/pathutils.py @@ -257,6 +257,7 @@ def is_safe_filesystem_path_component(path: str) -> bool: """ return ( bool(path) and not os.path.splitdrive(path)[0] and + (sys.platform != "win32" or ":" not in path) and # Block NTFS-ADS not os.path.split(path)[0] and path not in (os.curdir, os.pardir) and not path.startswith(".") and not path.endswith("~") and is_safe_path_component(path)) diff --git a/radicale/rights/from_file.py b/radicale/rights/from_file.py index 9ce625d5..74651a18 100644 --- a/radicale/rights/from_file.py +++ b/radicale/rights/from_file.py @@ -22,7 +22,7 @@ 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 `}}` +In consequence of the parameter substitution you have to write `{{` and `}}` if you want to use regular curly braces in the "user" and "collection" regexes. For example, for the "user" key, ".+" means "authenticated user" and ".*" @@ -98,6 +98,12 @@ class Rights(rights.BaseRights): group_match, sane_path, collection_pattern, section) return self._rights_config.get(section, "permissions") +#if user_match and collection_match: +# permission = rights_config.get(section, "permissions") +# logger.debug("Rule %r:%r matches %r:%r from section %r permission %r", +# user, sane_path, user_pattern, +# collection_pattern, section, permission) +# return permission logger.debug("Rule %r:%r doesn't match %r:%r from section %r", user, sane_path, user_pattern, collection_pattern, section) diff --git a/radicale/server.py b/radicale/server.py index 459c3385..600a31ac 100644 --- a/radicale/server.py +++ b/radicale/server.py @@ -3,6 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2019 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -22,7 +23,6 @@ Built-in WSGI server. """ -import errno import http import select import socket @@ -58,11 +58,19 @@ elif sys.platform == "win32": # IPv4 (host, port) and IPv6 (host, port, flowinfo, scopeid) -ADDRESS_TYPE = Union[Tuple[str, int], Tuple[str, int, int, int]] +ADDRESS_TYPE = Union[Tuple[Union[str, bytes, bytearray], int], + Tuple[str, int, int, int]] def format_address(address: ADDRESS_TYPE) -> str: - return "[%s]:%d" % address[:2] + host, port, *_ = address + if not isinstance(host, str): + raise NotImplementedError("Unsupported address format: %r" % + (address,)) + if host.find(":") == -1: + return "%s:%d" % (host, port) + else: + return "[%s]:%d" % (host, port) class ParallelHTTPServer(socketserver.ThreadingMixIn, @@ -206,7 +214,7 @@ class ServerHandler(wsgiref.simple_server.ServerHandler): # Don't pollute WSGI environ with OS environment os_environ: MutableMapping[str, str] = {} - def log_exception(self, exc_info: "wsgiref.handlers._exc_info") -> None: + def log_exception(self, exc_info) -> None: logger.error("An exception occurred during request: %s", exc_info[1], exc_info=exc_info) # type:ignore[arg-type] @@ -278,41 +286,22 @@ def serve(configuration: config.Configuration, servers = {} try: hosts: List[Tuple[str, int]] = configuration.get("server", "hosts") - for address in hosts: - # Try to bind sockets for IPv4 and IPv6 - possible_families = (socket.AF_INET, socket.AF_INET6) - bind_ok = False - for i, family in enumerate(possible_families): - is_last = i == len(possible_families) - 1 + for address_port in hosts: + # retrieve IPv4/IPv6 address of address + try: + getaddrinfo = socket.getaddrinfo(address_port[0], address_port[1], 0, socket.SOCK_STREAM, socket.IPPROTO_TCP) + except OSError as e: + logger.warning("cannot retrieve IPv4 or IPv6 address of '%s': %s" % (format_address(address_port), e)) + continue + logger.debug("getaddrinfo of '%s': %s" % (format_address(address_port), getaddrinfo)) + for (address_family, socket_kind, socket_proto, socket_flags, socket_address) in getaddrinfo: + logger.debug("try to create server socket on '%s'" % (format_address(socket_address))) try: - server = server_class(configuration, family, address, - RequestHandler) + server = server_class(configuration, address_family, (socket_address[0], socket_address[1]), RequestHandler) except OSError as e: - # Ignore unsupported families (only one must work) - if ((bind_ok or not is_last) and ( - isinstance(e, socket.gaierror) and ( - # Hostname does not exist or doesn't have - # address for address family - # macOS: IPv6 address for INET address family - e.errno == socket.EAI_NONAME or - # Address not for address family - e.errno == COMPAT_EAI_ADDRFAMILY or - e.errno == COMPAT_EAI_NODATA) or - # Workaround for PyPy - str(e) == "address family mismatched" or - # Address family not available (e.g. IPv6 disabled) - # macOS: IPv4 address for INET6 address family with - # IPV6_V6ONLY set - e.errno == errno.EADDRNOTAVAIL or - # Address family not supported - e.errno == errno.EAFNOSUPPORT or - # Protocol not supported - e.errno == errno.EPROTONOSUPPORT)): - continue - raise RuntimeError("Failed to start server %r: %s" % ( - format_address(address), e)) from e + logger.warning("cannot create server socket on '%s': %s" % (format_address(socket_address), e)) + continue servers[server.socket] = server - bind_ok = True server.set_app(application) logger.info("Listening on %r%s", format_address(server.server_address), diff --git a/radicale/storage/__init__.py b/radicale/storage/__init__.py index db6a8718..6946f59b 100644 --- a/radicale/storage/__init__.py +++ b/radicale/storage/__init__.py @@ -29,7 +29,6 @@ from hashlib import sha256 from typing import (Iterable, Iterator, Mapping, Optional, Sequence, Set, Tuple, Union, overload) -import pkg_resources import vobject from radicale import config @@ -41,7 +40,7 @@ INTERNAL_TYPES: Sequence[str] = ("multifilesystem", "multifilesystem_nolock",) CACHE_DEPS: Sequence[str] = ("radicale", "vobject", "python-dateutil",) CACHE_VERSION: bytes = "".join( - "%s=%s;" % (pkg, pkg_resources.get_distribution(pkg).version) + "%s=%s;" % (pkg, utils.package_version(pkg)) for pkg in CACHE_DEPS).encode() diff --git a/radicale/storage/multifilesystem/base.py b/radicale/storage/multifilesystem/base.py index 7b1b7d28..a7cc0bee 100644 --- a/radicale/storage/multifilesystem/base.py +++ b/radicale/storage/multifilesystem/base.py @@ -1,7 +1,8 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub -# Copyright © 2017-2019 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -40,11 +41,13 @@ class CollectionBase(storage.BaseCollection): # Path should already be sanitized self._path = pathutils.strip_path(path) self._encoding = storage_.configuration.get("encoding", "stock") + self._skip_broken_item = storage_.configuration.get("storage", "skip_broken_item") if filesystem_path is None: filesystem_path = pathutils.path_to_filesystem(folder, self.path) self._filesystem_path = filesystem_path - @types.contextmanager + # TODO: better fix for "mypy" + @types.contextmanager # type: ignore def _atomic_write(self, path: str, mode: str = "w", newline: Optional[str] = None) -> Iterator[IO[AnyStr]]: # TODO: Overload with Literal when dropping support for Python < 3.8 diff --git a/radicale/storage/multifilesystem/cache.py b/radicale/storage/multifilesystem/cache.py index 9cb4dda6..31ab4715 100644 --- a/radicale/storage/multifilesystem/cache.py +++ b/radicale/storage/multifilesystem/cache.py @@ -86,7 +86,8 @@ class CollectionPartCache(CollectionBase): content = self._item_cache_content(item) self._storage._makedirs_synced(cache_folder) # Race: Other processes might have created and locked the file. - with contextlib.suppress(PermissionError), self._atomic_write( + # TODO: better fix for "mypy" + with contextlib.suppress(PermissionError), self._atomic_write( # type: ignore os.path.join(cache_folder, href), "wb") as fo: fb = cast(BinaryIO, fo) pickle.dump((cache_hash, *content), fb) diff --git a/radicale/storage/multifilesystem/get.py b/radicale/storage/multifilesystem/get.py index 0a1fd73f..f5d25816 100644 --- a/radicale/storage/multifilesystem/get.py +++ b/radicale/storage/multifilesystem/get.py @@ -1,7 +1,8 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -83,7 +84,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock, cache_content = self._load_item_cache(href, cache_hash) if cache_content is None: with self._acquire_cache_lock("item"): - # Lock the item cache to prevent multpile processes from + # Lock the item cache to prevent multiple processes from # generating the same data in parallel. # This improves the performance for multiple requests. if self._storage._lock.locked == "r": @@ -101,8 +102,12 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock, cache_content = self._store_item_cache( href, temp_item, cache_hash) except Exception as e: - raise RuntimeError("Failed to load item %r in %r: %s" % - (href, self.path, e)) from e + if self._skip_broken_item: + logger.warning("Skip broken item %r in %r: %s", href, self.path, e) + return None + else: + raise RuntimeError("Failed to load item %r in %r: %s" % + (href, self.path, e)) from e # Clean cache entries once after the data in the file # system was edited externally. if not self._item_cache_cleaned: @@ -122,7 +127,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock, def get_multi(self, hrefs: Iterable[str] ) -> Iterator[Tuple[str, Optional[radicale_item.Item]]]: - # It's faster to check for file name collissions here, because + # It's faster to check for file name collisions here, because # we only need to call os.listdir once. files = None for href in hrefs: @@ -141,7 +146,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock, def get_all(self) -> Iterator[radicale_item.Item]: for href in self._list(): - # We don't need to check for collissions, because the file names + # We don't need to check for collisions, because the file names # are from os.listdir. item = self._get(href, verify_href=False) if item is not None: diff --git a/radicale/storage/multifilesystem/meta.py b/radicale/storage/multifilesystem/meta.py index edce6513..b95fb162 100644 --- a/radicale/storage/multifilesystem/meta.py +++ b/radicale/storage/multifilesystem/meta.py @@ -61,6 +61,7 @@ class CollectionPartMeta(CollectionBase): return self._meta_cache if key is None else self._meta_cache.get(key) def set_meta(self, props: Mapping[str, str]) -> None: - with self._atomic_write(self._props_path, "w") as fo: + # TODO: better fix for "mypy" + with self._atomic_write(self._props_path, "w") as fo: # type: ignore f = cast(TextIO, fo) json.dump(props, f, sort_keys=True) diff --git a/radicale/storage/multifilesystem/sync.py b/radicale/storage/multifilesystem/sync.py index 83cbe2a0..ae703c91 100644 --- a/radicale/storage/multifilesystem/sync.py +++ b/radicale/storage/multifilesystem/sync.py @@ -95,7 +95,8 @@ class CollectionPartSync(CollectionPartCache, CollectionPartHistory, self._storage._makedirs_synced(token_folder) try: # Race: Other processes might have created and locked the file. - with self._atomic_write(token_path, "wb") as fo: + # TODO: better fix for "mypy" + with self._atomic_write(token_path, "wb") as fo: # type: ignore fb = cast(BinaryIO, fo) pickle.dump(state, fb) except PermissionError: diff --git a/radicale/storage/multifilesystem/upload.py b/radicale/storage/multifilesystem/upload.py index 2d6cd891..a9fcdc2c 100644 --- a/radicale/storage/multifilesystem/upload.py +++ b/radicale/storage/multifilesystem/upload.py @@ -20,7 +20,7 @@ import errno import os import pickle import sys -from typing import Iterable, Set, TextIO, cast +from typing import Iterable, Iterator, TextIO, cast import radicale.item as radicale_item from radicale import pathutils @@ -43,7 +43,8 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache, raise ValueError("Failed to store item %r in collection %r: %s" % (href, self.path, e)) from e path = pathutils.path_to_filesystem(self._filesystem_path, href) - with self._atomic_write(path, newline="") as fo: + # TODO: better fix for "mypy" + with self._atomic_write(path, newline="") as fo: # type: ignore f = cast(TextIO, fo) f.write(item.serialize()) # Clean the cache after the actual item is stored, or the cache entry @@ -59,16 +60,24 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache, def _upload_all_nonatomic(self, items: Iterable[radicale_item.Item], suffix: str = "") -> None: - """Upload a new set of items. + """Upload a new set of items non-atomic""" + def is_safe_free_href(href: str) -> bool: + return (pathutils.is_safe_filesystem_path_component(href) and + not os.path.lexists( + os.path.join(self._filesystem_path, href))) - This takes a list of vobject items and - uploads them nonatomic and without existence checks. + def get_safe_free_hrefs(uid: str) -> Iterator[str]: + for href in [uid if uid.lower().endswith(suffix.lower()) + else uid + suffix, + radicale_item.get_etag(uid).strip('"') + suffix]: + if is_safe_free_href(href): + yield href + yield radicale_item.find_available_uid( + lambda href: not is_safe_free_href(href), suffix) - """ cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache", "item") self._storage._makedirs_synced(cache_folder) - hrefs: Set[str] = set() for item in items: uid = item.uid try: @@ -77,39 +86,24 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache, raise ValueError( "Failed to store item %r in temporary collection %r: %s" % (uid, self.path, e)) from e - href_candidate_funtions = [ - lambda: uid if uid.lower().endswith(suffix.lower()) - else uid + suffix, - 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 + for href in get_safe_free_hrefs(uid): try: - f = open(pathutils.path_to_filesystem( - self._filesystem_path, href), - "w", newline="", encoding=self._encoding) - break + f = open(os.path.join(self._filesystem_path, href), + "w", newline="", encoding=self._encoding) except OSError as e: - if href_candidate_funtions and ( - sys.platform != "win32" and - e.errno == errno.EINVAL or + if (sys.platform != "win32" and e.errno == errno.EINVAL or sys.platform == "win32" and e.errno == 123): + # not a valid filename continue raise - assert href is not None and f is not None + break + else: + raise RuntimeError("No href found for item %r in temporary " + "collection %r" % (uid, self.path)) with f: f.write(item.serialize()) f.flush() self._storage._fsync(f) - hrefs.add(href) with open(os.path.join(cache_folder, href), "wb") as fb: pickle.dump(cache_content, fb) fb.flush() diff --git a/radicale/storage/multifilesystem/verify.py b/radicale/storage/multifilesystem/verify.py index d25d4fe7..776f1bfd 100644 --- a/radicale/storage/multifilesystem/verify.py +++ b/radicale/storage/multifilesystem/verify.py @@ -1,7 +1,8 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2021 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -48,7 +49,9 @@ class StoragePartVerify(StoragePartDiscover, StorageBase): while remaining_sane_paths: sane_path = remaining_sane_paths.pop(0) path = pathutils.unstrip_path(sane_path, True) - logger.debug("Verifying collection %r", sane_path) + logger.info("Verifying path %r", sane_path) + count = 0 + is_collection = True with exception_cm(sane_path, None): saved_item_errors = item_errors collection: Optional[storage.BaseCollection] = None @@ -59,6 +62,9 @@ class StoragePartVerify(StoragePartDiscover, StorageBase): assert isinstance(item, storage.BaseCollection) collection = item collection.get_meta() + if not collection.tag: + is_collection = False + logger.info("Skip !collection %r", sane_path) continue if isinstance(item, storage.BaseCollection): has_child_collections = True @@ -68,13 +74,17 @@ class StoragePartVerify(StoragePartDiscover, StorageBase): item.href, sane_path, item.uid) else: uids.add(item.uid) - logger.debug("Verified item %r in %r", - item.href, sane_path) + count += 1 + logger.debug("Verified in %r item %r", + sane_path, item.href) assert collection if item_errors == saved_item_errors: - collection.sync() + if is_collection: + collection.sync() if has_child_collections and collection.tag: logger.error("Invalid collection %r: %r must not have " "child collections", sane_path, collection.tag) + if is_collection: + logger.info("Verified collect %r (items: %d)", sane_path, count) return item_errors == 0 and collection_errors == 0 diff --git a/radicale/tests/__init__.py b/radicale/tests/__init__.py index 2e132560..ceb155b4 100644 --- a/radicale/tests/__init__.py +++ b/radicale/tests/__init__.py @@ -25,16 +25,18 @@ import logging import shutil import sys import tempfile +import wsgiref.util import xml.etree.ElementTree as ET from io import BytesIO from typing import Any, Dict, List, Optional, Tuple, Union import defusedxml.ElementTree as DefusedET +import vobject import radicale from radicale import app, config, types, xmlutils -RESPONSES = Dict[str, Union[int, Dict[str, Tuple[int, ET.Element]]]] +RESPONSES = Dict[str, Union[int, Dict[str, Tuple[int, ET.Element]], vobject.base.Component]] # Enable debug output radicale.log.logger.setLevel(logging.DEBUG) @@ -47,7 +49,7 @@ class BaseTest: configuration: config.Configuration application: app.Application - def setup(self) -> None: + def setup_method(self) -> None: self.configuration = config.load() self.colpath = tempfile.mkdtemp() self.configure({ @@ -61,7 +63,7 @@ class BaseTest: self.configuration.update(config_, "test", privileged=True) self.application = app.Application(self.configuration) - def teardown(self) -> None: + def teardown_method(self) -> None: shutil.rmtree(self.colpath) def request(self, method: str, path: str, data: Optional[str] = None, @@ -83,11 +85,12 @@ class BaseTest: login.encode(encoding)).decode() environ["REQUEST_METHOD"] = method.upper() environ["PATH_INFO"] = path - if data: + if data is not None: data_bytes = data.encode(encoding) environ["wsgi.input"] = BytesIO(data_bytes) environ["CONTENT_LENGTH"] = str(len(data_bytes)) environ["wsgi.errors"] = sys.stderr + wsgiref.util.setup_testing_defaults(environ) status = headers = None def start_response(status_: str, headers_: List[Tuple[str, str]] @@ -105,12 +108,11 @@ class BaseTest: def parse_responses(text: str) -> RESPONSES: xml = DefusedET.fromstring(text) assert xml.tag == xmlutils.make_clark("D:multistatus") - path_responses: Dict[str, Union[ - int, Dict[str, Tuple[int, ET.Element]]]] = {} + path_responses: RESPONSES = {} for response in xml.findall(xmlutils.make_clark("D:response")): href = response.find(xmlutils.make_clark("D:href")) assert href.text not in path_responses - prop_respones: Dict[str, Tuple[int, ET.Element]] = {} + prop_responses: Dict[str, Tuple[int, ET.Element]] = {} for propstat in response.findall( xmlutils.make_clark("D:propstat")): status = propstat.find(xmlutils.make_clark("D:status")) @@ -119,16 +121,22 @@ class BaseTest: for element in propstat.findall( "./%s/*" % xmlutils.make_clark("D:prop")): human_tag = xmlutils.make_human_tag(element.tag) - assert human_tag not in prop_respones - prop_respones[human_tag] = (status_code, element) + assert human_tag not in prop_responses + prop_responses[human_tag] = (status_code, element) status = response.find(xmlutils.make_clark("D:status")) if status is not None: - assert not prop_respones + assert not prop_responses assert status.text.startswith("HTTP/1.1 ") status_code = int(status.text.split(" ")[1]) path_responses[href.text] = status_code else: - path_responses[href.text] = prop_respones + path_responses[href.text] = prop_responses + return path_responses + + @staticmethod + def parse_free_busy(text: str) -> RESPONSES: + path_responses: RESPONSES = {} + path_responses[""] = vobject.readOne(text) return path_responses def get(self, path: str, check: Optional[int] = 200, **kwargs @@ -137,8 +145,8 @@ class BaseTest: status, _, answer = self.request("GET", path, check=check, **kwargs) return status, answer - def post(self, path: str, data: str = None, check: Optional[int] = 200, - **kwargs) -> Tuple[int, str]: + def post(self, path: str, data: Optional[str] = None, + check: Optional[int] = 200, **kwargs) -> Tuple[int, str]: status, _, answer = self.request("POST", path, data, check=check, **kwargs) return status, answer @@ -175,13 +183,18 @@ class BaseTest: return status, responses def report(self, path: str, data: str, check: Optional[int] = 207, + is_xml: Optional[bool] = True, **kwargs) -> Tuple[int, RESPONSES]: status, _, answer = self.request("REPORT", path, data, check=check, **kwargs) if status < 200 or 300 <= status: return status, {} assert answer is not None - return status, self.parse_responses(answer) + if is_xml: + parsed = self.parse_responses(answer) + else: + parsed = self.parse_free_busy(answer) + return status, parsed def delete(self, path: str, check: Optional[int] = 200, **kwargs ) -> Tuple[int, RESPONSES]: diff --git a/radicale/tests/static/event1.ics b/radicale/tests/static/event1.ics index bc04d80a..4e669175 100644 --- a/radicale/tests/static/event1.ics +++ b/radicale/tests/static/event1.ics @@ -25,6 +25,7 @@ LAST-MODIFIED:20130902T150158Z DTSTAMP:20130902T150158Z UID:event1 SUMMARY:Event +CATEGORIES:some_category1,another_category2 ORGANIZER:mailto:unclesam@example.com ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com diff --git a/radicale/tests/static/event10.ics b/radicale/tests/static/event10.ics new file mode 100644 index 00000000..3faa034d --- /dev/null +++ b/radicale/tests/static/event10.ics @@ -0,0 +1,36 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Paris +X-LIC-LOCATION:Europe/Paris +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20130902T150157Z +LAST-MODIFIED:20130902T150158Z +DTSTAMP:20130902T150158Z +UID:event10 +SUMMARY:Event +CATEGORIES:some_category1,another_category2 +ORGANIZER:mailto:unclesam@example.com +ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com +ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com +DTSTART;TZID=Europe/Paris:20130901T180000 +DTEND;TZID=Europe/Paris:20130901T190000 +STATUS:CANCELLED +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/event_daily_rrule.ics b/radicale/tests/static/event_daily_rrule.ics new file mode 100644 index 00000000..362a18e4 --- /dev/null +++ b/radicale/tests/static/event_daily_rrule.ics @@ -0,0 +1,28 @@ +BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060102T120000 +DURATION:PT1H +RRULE:FREQ=DAILY;COUNT=5 +SUMMARY:Recurring event +UID:event_daily_rrule +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/static/event_full_day_rrule.ics b/radicale/tests/static/event_full_day_rrule.ics new file mode 100644 index 00000000..88f81c7d --- /dev/null +++ b/radicale/tests/static/event_full_day_rrule.ics @@ -0,0 +1,31 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060102 +DTEND;TZID=US/Eastern:20060103 +RRULE:FREQ=DAILY;COUNT=5 +SUMMARY:Recurring event +UID:event_full_day_rrule +DTSTAMP:20060102T094829Z +END:VEVENT +END:VCALENDAR + diff --git a/radicale/tests/static/event_multiple_case_sensitive_uids.ics b/radicale/tests/static/event_multiple_case_sensitive_uids.ics new file mode 100644 index 00000000..ffca585b --- /dev/null +++ b/radicale/tests/static/event_multiple_case_sensitive_uids.ics @@ -0,0 +1,16 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VEVENT +UID:event +SUMMARY:Event 1 +DTSTART:20130901T190000 +DTEND:20130901T200000 +END:VEVENT +BEGIN:VEVENT +UID:EVENT +SUMMARY:Event 2 +DTSTART:20130901T200000 +DTEND:20130901T210000 +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/test_auth.py b/radicale/tests/test_auth.py index 516a6f8e..3604e2f9 100644 --- a/radicale/tests/test_auth.py +++ b/radicale/tests/test_auth.py @@ -1,7 +1,8 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2012-2016 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub -# Copyright © 2017-2019 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -44,16 +45,6 @@ class TestBaseAuthRequests(BaseTest): """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: str = self.configuration.get("encoding", "stock") with open(htpasswd_file_path, "w", encoding=encoding) as f: @@ -92,6 +83,12 @@ class TestBaseAuthRequests(BaseTest): self._test_htpasswd( "md5", "😀:$apr1$w4ev89r1$29xO8EvJmS2HEAadQ5qy11", "unicode") + def test_htpasswd_sha256(self) -> None: + self._test_htpasswd("sha256", "tmp:$5$i4Ni4TQq6L5FKss5$ilpTjkmnxkwZeV35GB9cYSsDXTALBn6KtWRJAzNlCL/") + + def test_htpasswd_sha512(self) -> None: + self._test_htpasswd("sha512", "tmp:$6$3Qhl8r6FLagYdHYa$UCH9yXCed4A.J9FQsFPYAOXImzZUMfvLa0lwcWOxWYLOF5sE/lF99auQ4jKvHY2vijxmefl7G6kMqZ8JPdhIJ/") + def test_htpasswd_bcrypt(self) -> None: self._test_htpasswd("bcrypt", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3V" "NTRI3w5KDnj8NTUKJNWfVpvRq") @@ -118,6 +115,16 @@ class TestBaseAuthRequests(BaseTest): def test_htpasswd_comment(self) -> None: self._test_htpasswd("plain", "#comment\n #comment\n \ntmp:bepo\n\n") + def test_htpasswd_lc_username(self) -> None: + self.configure({"auth": {"lc_username": "True"}}) + self._test_htpasswd("plain", "tmp:bepo", ( + ("tmp", "bepo", True), ("TMP", "bepo", True), ("tmp1", "bepo", False))) + + def test_htpasswd_strip_domain(self) -> None: + self.configure({"auth": {"strip_domain": "True"}}) + self._test_htpasswd("plain", "tmp:bepo", ( + ("tmp", "bepo", True), ("tmp@domain.example", "bepo", True), ("tmp1", "bepo", False))) + def test_remote_user(self) -> None: self.configure({"auth": {"type": "remote_user"}}) _, responses = self.propfind("/", """\ @@ -156,3 +163,11 @@ class TestBaseAuthRequests(BaseTest): """Custom authentication.""" self.configure({"auth": {"type": "radicale.tests.custom.auth"}}) self.propfind("/tmp/", login="tmp:") + + def test_none(self) -> None: + self.configure({"auth": {"type": "none"}}) + self.propfind("/tmp/", login="tmp:") + + def test_denyall(self) -> None: + self.configure({"auth": {"type": "denyall"}}) + self.propfind("/tmp/", login="tmp:", check=401) diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index c0254931..fc708ebc 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -25,6 +25,7 @@ import posixpath from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple import defusedxml.ElementTree as DefusedET +import vobject from radicale import storage, xmlutils from radicale.tests import RESPONSES, BaseTest @@ -37,8 +38,8 @@ class TestBaseRequests(BaseTest): # Allow skipping sync-token tests, when not fully supported by the backend full_sync_token_support: ClassVar[bool] = True - def setup(self) -> None: - BaseTest.setup(self) + def setup_method(self) -> None: + BaseTest.setup_method(self) rights_file_path = os.path.join(self.colpath, "rights") with open(rights_file_path, "w") as f: f.write("""\ @@ -243,6 +244,13 @@ permissions: RrWw""") for uid2 in uids[i + 1:]: assert uid1 != uid2 + def test_put_whole_calendar_case_sensitive_uids(self) -> None: + """Create a whole calendar with case-sensitive UIDs.""" + events = get_file_content("event_multiple_case_sensitive_uids.ics") + self.put("/calendar.ics/", events) + _, answer = self.get("/calendar.ics/") + assert "\r\nUID:event\r\n" in answer and "\r\nUID:EVENT\r\n" in answer + def test_put_whole_addressbook(self) -> None: """Create and overwrite a whole addressbook.""" contacts = get_file_content("contact_multiple.vcf") @@ -348,11 +356,11 @@ permissions: RrWw""") path2 = "/calendar.ics/event2.ics" self.put(path1, event) self.request("MOVE", path1, check=201, - HTTP_DESTINATION=path2, HTTP_HOST="") + HTTP_DESTINATION="http://127.0.0.1/"+path2) self.get(path1, check=404) self.get(path2) - def test_move_between_colections(self) -> None: + def test_move_between_collections(self) -> None: """Move a item.""" self.mkcalendar("/calendar1.ics/") self.mkcalendar("/calendar2.ics/") @@ -361,11 +369,11 @@ permissions: RrWw""") path2 = "/calendar2.ics/event2.ics" self.put(path1, event) self.request("MOVE", path1, check=201, - HTTP_DESTINATION=path2, HTTP_HOST="") + HTTP_DESTINATION="http://127.0.0.1/"+path2) self.get(path1, check=404) self.get(path2) - def test_move_between_colections_duplicate_uid(self) -> None: + def test_move_between_collections_duplicate_uid(self) -> None: """Move a item to a collection which already contains the UID.""" self.mkcalendar("/calendar1.ics/") self.mkcalendar("/calendar2.ics/") @@ -375,13 +383,13 @@ permissions: RrWw""") self.put(path1, event) self.put("/calendar2.ics/event1.ics", event) status, _, answer = self.request( - "MOVE", path1, HTTP_DESTINATION=path2, HTTP_HOST="") + "MOVE", path1, HTTP_DESTINATION="http://127.0.0.1/"+path2) assert status in (403, 409) xml = DefusedET.fromstring(answer) assert xml.tag == xmlutils.make_clark("D:error") assert xml.find(xmlutils.make_clark("C:no-uid-conflict")) is not None - def test_move_between_colections_overwrite(self) -> None: + def test_move_between_collections_overwrite(self) -> None: """Move a item to a collection which already contains the item.""" self.mkcalendar("/calendar1.ics/") self.mkcalendar("/calendar2.ics/") @@ -391,12 +399,12 @@ permissions: RrWw""") self.put(path1, event) self.put(path2, event) self.request("MOVE", path1, check=412, - HTTP_DESTINATION=path2, HTTP_HOST="") - self.request("MOVE", path1, check=204, - HTTP_DESTINATION=path2, HTTP_HOST="", HTTP_OVERWRITE="T") + HTTP_DESTINATION="http://127.0.0.1/"+path2) + self.request("MOVE", path1, check=204, HTTP_OVERWRITE="T", + HTTP_DESTINATION="http://127.0.0.1/"+path2) - def test_move_between_colections_overwrite_uid_conflict(self) -> None: - """Move a item to a collection which already contains the item with + def test_move_between_collections_overwrite_uid_conflict(self) -> None: + """Move an item to a collection which already contains the item with a different UID.""" self.mkcalendar("/calendar1.ics/") self.mkcalendar("/calendar2.ics/") @@ -406,8 +414,9 @@ permissions: RrWw""") path2 = "/calendar2.ics/event2.ics" self.put(path1, event1) self.put(path2, event2) - status, _, answer = self.request("MOVE", path1, HTTP_DESTINATION=path2, - HTTP_HOST="", HTTP_OVERWRITE="T") + status, _, answer = self.request( + "MOVE", path1, HTTP_OVERWRITE="T", + HTTP_DESTINATION="http://127.0.0.1/"+path2) assert status in (403, 409) xml = DefusedET.fromstring(answer) assert xml.tag == xmlutils.make_clark("D:error") @@ -909,6 +918,22 @@ permissions: RrWw""") event +"""]) + assert "/calendar.ics/event1.ics" in self._test_filter(["""\ + + + + some_category1 + + +"""]) + assert "/calendar.ics/event1.ics" in self._test_filter(["""\ + + + + some_category1 + + """]) assert "/calendar.ics/event1.ics" not in self._test_filter(["""\ @@ -1336,10 +1361,45 @@ permissions: RrWw""") """) assert len(responses) == 1 response = responses[event_path] - assert not isinstance(response, int) + assert isinstance(response, dict) status, prop = response["D:getetag"] assert status == 200 and prop.text + def test_report_free_busy(self) -> None: + """Test free busy report on a few items""" + calendar_path = "/calendar.ics/" + self.mkcalendar(calendar_path) + for i in (1, 2, 10): + filename = "event{}.ics".format(i) + event = get_file_content(filename) + self.put(posixpath.join(calendar_path, filename), event) + code, responses = self.report(calendar_path, """\ + + + +""", 200, is_xml=False) + for response in responses.values(): + assert isinstance(response, vobject.base.Component) + assert len(responses) == 1 + vcalendar = list(responses.values())[0] + assert isinstance(vcalendar, vobject.base.Component) + assert len(vcalendar.vfreebusy_list) == 3 + types = {} + for vfb in vcalendar.vfreebusy_list: + fbtype_val = vfb.fbtype.value + if fbtype_val not in types: + types[fbtype_val] = 0 + types[fbtype_val] += 1 + assert types == {'BUSY': 2, 'FREE': 1} + + # Test max_freebusy_occurrence limit + self.configure({"reporting": {"max_freebusy_occurrence": 1}}) + code, responses = self.report(calendar_path, """\ + + + +""", 400, is_xml=False) + def _report_sync_token( self, calendar_path: str, sync_token: Optional[str] = None ) -> Tuple[str, RESPONSES]: @@ -1464,7 +1524,7 @@ permissions: RrWw""") sync_token, responses = self._report_sync_token(calendar_path) assert len(responses) == 1 and responses[event1_path] == 200 self.request("MOVE", event1_path, check=201, - HTTP_DESTINATION=event2_path, HTTP_HOST="") + HTTP_DESTINATION="http://127.0.0.1/"+event2_path) sync_token, responses = self._report_sync_token( calendar_path, sync_token) if not self.full_sync_token_support and not sync_token: @@ -1483,9 +1543,9 @@ permissions: RrWw""") sync_token, responses = self._report_sync_token(calendar_path) assert len(responses) == 1 and responses[event1_path] == 200 self.request("MOVE", event1_path, check=201, - HTTP_DESTINATION=event2_path, HTTP_HOST="") + HTTP_DESTINATION="http://127.0.0.1/"+event2_path) self.request("MOVE", event2_path, check=201, - HTTP_DESTINATION=event1_path, HTTP_HOST="") + HTTP_DESTINATION="http://127.0.0.1/"+event1_path) sync_token, responses = self._report_sync_token( calendar_path, sync_token) if not self.full_sync_token_support and not sync_token: @@ -1501,6 +1561,184 @@ permissions: RrWw""") calendar_path, "http://radicale.org/ns/sync/INVALID") assert not sync_token + def test_report_with_expand_property(self) -> None: + """Test report with expand property""" + self.put("/calendar.ics/", get_file_content("event_daily_rrule.ics")) + req_body_without_expand = \ + """ + + + + + + + + + + + + + + """ + _, responses = self.report("/calendar.ics/", req_body_without_expand) + assert len(responses) == 1 + + response_without_expand = responses['/calendar.ics/event_daily_rrule.ics'] + assert not isinstance(response_without_expand, int) + status, element = response_without_expand["C:calendar-data"] + + assert status == 200 and element.text + + assert "RRULE" in element.text + assert "BEGIN:VTIMEZONE" in element.text + assert "RECURRENCE-ID" not in element.text + + uids: List[str] = [] + for line in element.text.split("\n"): + if line.startswith("UID:"): + uid = line[len("UID:"):] + assert uid == "event_daily_rrule" + uids.append(uid) + + assert len(uids) == 1 + + req_body_with_expand = \ + """ + + + + + + + + + + + + + + + """ + + _, responses = self.report("/calendar.ics/", req_body_with_expand) + + assert len(responses) == 1 + + response_with_expand = responses['/calendar.ics/event_daily_rrule.ics'] + assert not isinstance(response_with_expand, int) + status, element = response_with_expand["C:calendar-data"] + + assert status == 200 and element.text + assert "RRULE" not in element.text + assert "BEGIN:VTIMEZONE" not in element.text + + uids = [] + recurrence_ids = [] + for line in element.text.split("\n"): + if line.startswith("UID:"): + assert line == "UID:event_daily_rrule" + uids.append(line) + + if line.startswith("RECURRENCE-ID:"): + assert line in ["RECURRENCE-ID:20060103T170000Z", "RECURRENCE-ID:20060104T170000Z"] + recurrence_ids.append(line) + + if line.startswith("DTSTART:"): + assert line == "DTSTART:20060102T170000Z" + + assert len(uids) == 2 + assert len(set(recurrence_ids)) == 2 + + def test_report_with_expand_property_all_day_event(self) -> None: + """Test report with expand property""" + self.put("/calendar.ics/", get_file_content("event_full_day_rrule.ics")) + req_body_without_expand = \ + """ + + + + + + + + + + + + + + """ + _, responses = self.report("/calendar.ics/", req_body_without_expand) + assert len(responses) == 1 + + response_without_expand = responses['/calendar.ics/event_full_day_rrule.ics'] + assert not isinstance(response_without_expand, int) + status, element = response_without_expand["C:calendar-data"] + + assert status == 200 and element.text + + assert "RRULE" in element.text + assert "RECURRENCE-ID" not in element.text + + uids: List[str] = [] + for line in element.text.split("\n"): + if line.startswith("UID:"): + uid = line[len("UID:"):] + assert uid == "event_full_day_rrule" + uids.append(uid) + + assert len(uids) == 1 + + req_body_with_expand = \ + """ + + + + + + + + + + + + + + + """ + + _, responses = self.report("/calendar.ics/", req_body_with_expand) + + assert len(responses) == 1 + + response_with_expand = responses['/calendar.ics/event_full_day_rrule.ics'] + assert not isinstance(response_with_expand, int) + status, element = response_with_expand["C:calendar-data"] + + assert status == 200 and element.text + assert "RRULE" not in element.text + assert "BEGIN:VTIMEZONE" not in element.text + + uids = [] + recurrence_ids = [] + for line in element.text.split("\n"): + if line.startswith("UID:"): + assert line == "UID:event_full_day_rrule" + uids.append(line) + + if line.startswith("RECURRENCE-ID:"): + assert line in ["RECURRENCE-ID:20060103", "RECURRENCE-ID:20060104", "RECURRENCE-ID:20060105"] + recurrence_ids.append(line) + + if line.startswith("DTSTART:"): + assert line == "DTSTART:20060102" + + if line.startswith("DTEND:"): + assert line == "DTEND:20060103" + + assert len(uids) == 3 + assert len(set(recurrence_ids)) == 3 + def test_propfind_sync_token(self) -> None: """Retrieve the sync-token with a propfind request""" calendar_path = "/calendar.ics/" diff --git a/radicale/tests/test_config.py b/radicale/tests/test_config.py index 32a87ec2..92ece9a6 100644 --- a/radicale/tests/test_config.py +++ b/radicale/tests/test_config.py @@ -31,10 +31,10 @@ class TestConfig: colpath: str - def setup(self) -> None: + def setup_method(self) -> None: self.colpath = tempfile.mkdtemp() - def teardown(self) -> None: + def teardown_method(self) -> None: shutil.rmtree(self.colpath) def _write_config(self, config_dict: types.CONFIG, name: str) -> str: diff --git a/radicale/tests/test_server.py b/radicale/tests/test_server.py index 4b252e81..ecc493a4 100644 --- a/radicale/tests/test_server.py +++ b/radicale/tests/test_server.py @@ -28,7 +28,8 @@ import sys import threading import time from configparser import RawConfigParser -from typing import Callable, Dict, NoReturn, Optional, Tuple, cast +from http.client import HTTPMessage +from typing import IO, Callable, Dict, Optional, Tuple, cast from urllib import request from urllib.error import HTTPError, URLError @@ -40,26 +41,10 @@ from radicale.tests.helpers import configuration_to_dict, get_file_path class DisabledRedirectHandler(request.HTTPRedirectHandler): - - # HACK: typeshed annotation are wrong for `fp` and `msg` - # (https://github.com/python/typeshed/pull/5728) - # `headers` is incompatible with `http.client.HTTPMessage` - # (https://github.com/python/typeshed/issues/5729) - def http_error_301(self, req: request.Request, fp, code: int, - msg, headers) -> NoReturn: - raise HTTPError(req.full_url, code, msg, headers, fp) - - def http_error_302(self, req: request.Request, fp, code: int, - msg, headers) -> NoReturn: - raise HTTPError(req.full_url, code, msg, headers, fp) - - def http_error_303(self, req: request.Request, fp, code: int, - msg, headers) -> NoReturn: - raise HTTPError(req.full_url, code, msg, headers, fp) - - def http_error_307(self, req: request.Request, fp, code: int, - msg, headers) -> NoReturn: - raise HTTPError(req.full_url, code, msg, headers, fp) + def redirect_request( + self, req: request.Request, fp: IO[bytes], code: int, msg: str, + headers: HTTPMessage, newurl: str) -> None: + return None class TestBaseServerRequests(BaseTest): @@ -69,14 +54,15 @@ class TestBaseServerRequests(BaseTest): thread: threading.Thread opener: request.OpenerDirector - def setup(self) -> None: - super().setup() + def setup_method(self) -> None: + super().setup_method() self.shutdown_socket, shutdown_socket_out = socket.socketpair() with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: # Find available port sock.bind(("127.0.0.1", 0)) + self.sockfamily = socket.AF_INET self.sockname = sock.getsockname() - self.configure({"server": {"hosts": "[%s]:%d" % self.sockname}, + self.configure({"server": {"hosts": "%s:%d" % self.sockname}, # Enable debugging for new processes "logging": {"level": "debug"}}) self.thread = threading.Thread(target=server.serve, args=( @@ -88,13 +74,13 @@ class TestBaseServerRequests(BaseTest): request.HTTPSHandler(context=ssl_context), DisabledRedirectHandler) - def teardown(self) -> None: + def teardown_method(self) -> None: self.shutdown_socket.close() try: self.thread.join() except RuntimeError: # Thread never started pass - super().teardown() + super().teardown_method() def request(self, method: str, path: str, data: Optional[str] = None, check: Optional[int] = None, **kwargs @@ -120,8 +106,12 @@ class TestBaseServerRequests(BaseTest): data_bytes = None if data: data_bytes = data.encode(encoding) + if self.sockfamily == socket.AF_INET6: + req_host = ("[%s]" % self.sockname[0]) + else: + req_host = self.sockname[0] req = request.Request( - "%s://[%s]:%d%s" % (scheme, *self.sockname, path), + "%s://%s:%d%s" % (scheme, req_host, self.sockname[1], path), data=data_bytes, headers=headers, method=method) while True: assert is_alive_fn() @@ -176,6 +166,7 @@ class TestBaseServerRequests(BaseTest): server.COMPAT_IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) # Find available port sock.bind(("::1", 0)) + self.sockfamily = socket.AF_INET6 self.sockname = sock.getsockname()[:2] except OSError as e: if e.errno in (errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT, diff --git a/radicale/tests/test_storage.py b/radicale/tests/test_storage.py index 35479e98..9072a354 100644 --- a/radicale/tests/test_storage.py +++ b/radicale/tests/test_storage.py @@ -35,8 +35,8 @@ from radicale.tests.test_base import TestBaseRequests as _TestBaseRequests class TestMultiFileSystem(BaseTest): """Tests for multifilesystem.""" - def setup(self) -> None: - _TestBaseRequests.setup(cast(_TestBaseRequests, self)) + def setup_method(self) -> None: + _TestBaseRequests.setup_method(cast(_TestBaseRequests, self)) self.configure({"storage": {"type": "multifilesystem"}}) def test_folder_creation(self) -> None: @@ -150,8 +150,8 @@ class TestMultiFileSystem(BaseTest): class TestMultiFileSystemNoLock(BaseTest): """Tests for multifilesystem_nolock.""" - def setup(self) -> None: - _TestBaseRequests.setup(cast(_TestBaseRequests, self)) + def setup_method(self) -> None: + _TestBaseRequests.setup_method(cast(_TestBaseRequests, self)) self.configure({"storage": {"type": "multifilesystem_nolock"}}) test_add_event = _TestBaseRequests.test_add_event @@ -161,8 +161,8 @@ class TestMultiFileSystemNoLock(BaseTest): class TestCustomStorageSystem(BaseTest): """Test custom backend loading.""" - def setup(self) -> None: - _TestBaseRequests.setup(cast(_TestBaseRequests, self)) + def setup_method(self) -> None: + _TestBaseRequests.setup_method(cast(_TestBaseRequests, self)) self.configure({"storage": { "type": "radicale.tests.custom.storage_simple_sync"}}) @@ -181,8 +181,8 @@ class TestCustomStorageSystem(BaseTest): class TestCustomStorageSystemCallable(BaseTest): """Test custom backend loading with ``callable``.""" - def setup(self) -> None: - _TestBaseRequests.setup(cast(_TestBaseRequests, self)) + def setup_method(self) -> None: + _TestBaseRequests.setup_method(cast(_TestBaseRequests, self)) self.configure({"storage": { "type": radicale.tests.custom.storage_simple_sync.Storage}}) diff --git a/radicale/types.py b/radicale/types.py index 0eb3fd6a..c7e1904a 100644 --- a/radicale/types.py +++ b/radicale/types.py @@ -50,8 +50,8 @@ if sys.version_info >= (3, 8): @runtime_checkable class ErrorStream(Protocol): - def flush(self) -> None: ... - def write(self, s: str) -> None: ... + def flush(self) -> object: ... + def write(self, s: str) -> object: ... else: ErrorStream = Any InputStream = Any diff --git a/radicale/utils.py b/radicale/utils.py index 33c77354..6125792a 100644 --- a/radicale/utils.py +++ b/radicale/utils.py @@ -16,12 +16,18 @@ # You should have received a copy of the GNU General Public License # along with Radicale. If not, see . +import sys from importlib import import_module from typing import Callable, Sequence, Type, TypeVar, Union from radicale import config from radicale.log import logger +if sys.version_info < (3, 8): + import pkg_resources +else: + from importlib import metadata + _T_co = TypeVar("_T_co", covariant=True) @@ -43,3 +49,9 @@ def load_plugin(internal_types: Sequence[str], module_name: str, (module_name, module, e)) from e logger.info("%s type is %r", module_name, module) return class_(configuration) + + +def package_version(name): + if sys.version_info < (3, 8): + return pkg_resources.get_distribution(name).version + return metadata.version(name) diff --git a/radicale/web/internal.py b/radicale/web/internal.py index f21d2bcc..01516b5b 100644 --- a/radicale/web/internal.py +++ b/radicale/web/internal.py @@ -25,9 +25,7 @@ Features: """ -import pkg_resources - -from radicale import config, httputils, types, web +from radicale import httputils, types, web MIMETYPES = httputils.MIMETYPES # deprecated FALLBACK_MIMETYPE = httputils.FALLBACK_MIMETYPE # deprecated @@ -35,13 +33,7 @@ FALLBACK_MIMETYPE = httputils.FALLBACK_MIMETYPE # deprecated class Web(web.BaseWeb): - folder: str - - def __init__(self, configuration: config.Configuration) -> None: - super().__init__(configuration) - self.folder = pkg_resources.resource_filename( - __name__, "internal_data") - def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: - return httputils.serve_folder(self.folder, base_prefix, path) + return httputils.serve_resource("radicale.web", "internal_data", + base_prefix, path) diff --git a/radicale/web/internal_data/css/icons/delete.svg b/radicale/web/internal_data/css/icons/delete.svg new file mode 100644 index 00000000..f8aa7856 --- /dev/null +++ b/radicale/web/internal_data/css/icons/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/download.svg b/radicale/web/internal_data/css/icons/download.svg new file mode 100644 index 00000000..1ee311b5 --- /dev/null +++ b/radicale/web/internal_data/css/icons/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/edit.svg b/radicale/web/internal_data/css/icons/edit.svg new file mode 100644 index 00000000..0cfe935e --- /dev/null +++ b/radicale/web/internal_data/css/icons/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/new.svg b/radicale/web/internal_data/css/icons/new.svg new file mode 100644 index 00000000..d8448b8e --- /dev/null +++ b/radicale/web/internal_data/css/icons/new.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/upload.svg b/radicale/web/internal_data/css/icons/upload.svg new file mode 100644 index 00000000..2e05b18c --- /dev/null +++ b/radicale/web/internal_data/css/icons/upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/loading.svg b/radicale/web/internal_data/css/loading.svg new file mode 100644 index 00000000..3513ff67 --- /dev/null +++ b/radicale/web/internal_data/css/loading.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/radicale/web/internal_data/css/logo.svg b/radicale/web/internal_data/css/logo.svg new file mode 100644 index 00000000..546d3d10 --- /dev/null +++ b/radicale/web/internal_data/css/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css index 726b9a19..a6d7da72 100644 --- a/radicale/web/internal_data/css/main.css +++ b/radicale/web/internal_data/css/main.css @@ -1 +1,428 @@ -body{background:#e4e9f6;color:#424247;display:flex;flex-direction:column;font-family:sans;font-size:14pt;line-height:1.4;margin:0;min-height:100vh}a{color:inherit}nav,footer{background:#a40000;color:#fff;padding:0 20%}nav ul,footer ul{display:flex;flex-wrap:wrap;margin:0;padding:0}nav ul li,footer ul li{display:block;padding:0 1em 0 0}nav ul li a,footer ul li a{color:inherit;display:block;padding:1em .5em 1em 0;text-decoration:inherit;transition:.2s}nav ul li a:hover,nav ul li a:focus,footer ul li a:hover,footer ul li a:focus{color:#000;outline:none}header{background:url(logo.svg),linear-gradient(to bottom right, #050a02, #000);background-position:22% 45%;background-repeat:no-repeat;color:#efdddd;font-size:1.5em;min-height:250px;overflow:auto;padding:3em 22%;text-shadow:.2em .2em .2em rgba(0,0,0,0.5)}header>*{padding-left:220px}header h1{font-size:2.5em;font-weight:lighter;margin:.5em 0}main{flex:1}section{padding:0 20% 2em}section:not(:last-child){border-bottom:1px dashed #ccc}section h1{background:linear-gradient(to bottom right, #050a02, #000);color:#e5dddd;font-size:2.5em;margin:0 -33.33% 1em;padding:1em 33.33%}section h2,section h3,section h4{font-weight:lighter;margin:1.5em 0 1em}article{border-top:1px solid transparent;position:relative;margin:3em 0}article aside{box-sizing:border-box;color:#aaa;font-size:.8em;right:-30%;top:.5em;position:absolute}article:before{border-top:1px dashed #ccc;content:"";display:block;left:-33.33%;position:absolute;right:-33.33%}pre{border-radius:3px;background:#000;color:#d3d5db;margin:0 -1em;overflow-x:auto;padding:1em}table{border-collapse:collapse;font-size:.8em;margin:auto}table td{border:1px solid #ccc;padding:.5em}dl dt{margin-bottom:.5em;margin-top:1em}p>code,li>code,dt>code{background:#d1daf0}@media (max-width: 800px){body{font-size:12pt}header,section{padding-left:2em;padding-right:2em}nav,footer{padding-left:0;padding-right:0}nav ul,footer ul{justify-content:center}nav ul li,footer ul li{padding:0 .5em}nav ul li a,footer ul li a{padding:1em 0}header{background-position:50% 30px,0 0;padding-bottom:0;padding-top:330px;text-align:center}header>*{margin:0;padding-left:0}section h1{margin:0 -.8em 1.3em;padding:.5em 0;text-align:center}article aside{top:.5em;right:-1.5em}article:before{left:-2em;right:-2em}} +body{ + background: #ffffff; + color: #424247; + font-family: sans-serif; + font-size: 14pt; + margin: 0; + min-height: 100vh; + display: flex; + flex-wrap: wrap; + flex-direction: row; + align-content: center; + align-items: flex-start; + justify-content: space-around; +} + +main{ + width: 100%; +} + +.container{ + height: auto; + min-height: 450px; + width: 350px; + transition: .2s; + overflow: hidden; + padding: 20px 40px; + background: #fff; + border: 1px solid #dadce0; + border-radius: 8px; + display: block; + flex-shrink: 0; + margin: 0 auto; +} + +.container h1{ + margin: 0; + width: 100%; + text-align: center; + color: #484848; +} + +#loginscene input{ +} + + +#loginscene .logocontainer{ + width: 100%; + text-align: center; +} + +#loginscene .logocontainer img{ + width: 75px; +} + +#loginscene h1{ + text-align: center; + font-family: sans-serif; + font-weight: normal; +} + +#loginscene button{ + float: right; +} + +#loadingscene{ + width: 100%; + height: 100%; + background: rgb(237 237 237); + position: absolute; + top: 0; + left: 0; + display: flex; + flex-wrap: nowrap; + justify-content: center; + align-items: center; + flex-direction: column; + overflow: hidden; + z-index: 999; +} + +#loadingscene h2{ + font-size: 2em; + font-weight: bold; +} + +#logoutview{ + width: 100%; + display: block; + background: white; + text-align: center; + padding: 10px 0px; + color: #666; + border-bottom: 2px solid #dadce0; + position: fixed; +} + +#logoutview span{ + width: calc(100% - 60px); + display: inline-block; +} + +#logoutview a{ + color: white; + text-decoration: none; + padding: 3px 10px; + position: relative; + border-radius: 4px; +} + +#logoutview a[data-name=logout]{ + right: 25px; + float: right; +} + +#logoutview a[data-name=refresh]{ + left: 25px; + float: left; +} + +#collectionsscene{ + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-content: flex-start; + align-items: center; + margin-top: 50px; + width: 100%; + height: 100vh; +} + +#collectionsscene article{ + width: 275px; + background: rgb(250, 250, 250); + border-radius: 8px; + box-shadow: 2px 2px 3px #0000001a; + border: 1px solid #dadce0; + padding: 5px 10px; + padding-top: 0; + margin: 10px; + float: left; + min-height: 375px; + overflow: hidden; +} + +#collectionsscene article .colorbar{ + width: 500%; + height: 15px; + margin: 0px -100%; + background: #000000; +} + +#collectionsscene article .title{ + width: 100%; + text-align: center; + font-size: 1.5em; + display: block; + padding: 10px 0; + margin: 0; +} + +#collectionsscene article small{ + font-size: 15px; + float: left; + font-weight: normal; + font-style: italic; + padding-bottom: 10px; + width: 100%; + text-align: center; +} + +#collectionsscene article input[type=text]{ + margin-bottom: 0 !important; +} + +#collectionsscene article p{ + font-size: 1em; + max-height: 130px; + overflow: overlay; +} + +#collectionsscene article:hover ul{ + visibility: visible; +} + +#collectionsscene ul{ + visibility: hidden; + display: flex; + justify-content: space-evenly; + width: 60%; + margin: 0 20%; + padding: 0; +} + +#collectionsscene li{ + list-style: none; + display: block; +} + +#collectionsscene li a{ + text-decoration: none !important; + padding: 5px; + float: left; + border-radius: 5px; + width: 25px; + height: 25px; + text-align: center; +} + +#collectionsscene article small[data-name=contentcount]{ + font-weight: bold; + font-style: normal; +} + +#editcollectionscene p span{ + word-wrap:break-word; + font-weight: bold; + color: #4e9a06; +} + +#deletecollectionscene p span{ + word-wrap:break-word; + font-weight: bold; + color: #a40000; +} + +#uploadcollectionscene ul{ + margin: 10px -30px; + max-height: 600px; + overflow-y: scroll; +} + +#uploadcollectionscene li{ + border-bottom: 1px dashed #d5d5d5; + margin-bottom: 10px; + padding-bottom: 10px; +} + +#uploadcollectionscene div[data-name=pending]{ + width: 100%; + text-align: center; +} + +#uploadcollectionscene .successmessage{ + color: #4e9a06; + width: 100%; + text-align: center; + display: block; + margin-top: 15px; +} + +.deleteconfirmationtxt{ + text-align: center; + font-size: 1em; + font-weight: bold; +} + +.fabcontainer{ + display: flex; + flex-direction: column-reverse; + position: fixed; + bottom: 5px; + right: 0; +} + +.fabcontainer a{ + width: 30px; + height: 30px; + text-decoration: none; + color: white; + border: none !important; + border-radius: 100%; + margin: 5px 10px; + background: black; + text-align: center; + display: flex; + align-content: center; + justify-content: center; + align-items: center; + font-size: 30px; + padding: 10px; + box-shadow: 2px 2px 7px #000000d6; +} + +.title{ + word-wrap: break-word; + font-weight: bold; +} + +.icon{ + width: 100%; + height: 100%; + filter: invert(1); +} + +.smalltext{ + font-size: 75% !important; +} + +.error{ + width: 100%; + display: block; + text-align: center; + color: rgb(217,48,37); + font-family: sans-serif; + clear: both; + padding-top: 15px; +} + +img.loading{ + width: 150px; + height: 150px; +} + +.error::before{ + content: "!"; + height: 1em; + color: white; + background: rgb(217,48,37); + font-weight: bold; + border-radius: 100%; + display: inline-block; + width: 1.1em; + margin-right: 5px; + font-size: 1em; + text-align: center; +} + +button{ + font-size: 1em; + padding: 7px 21px; + color: white; + border-radius: 4px; + float: right; + margin-left: 10px; + background: black; + cursor: pointer; +} + +input, select{ + width: 100%; + height: 3em; + border-style: solid; + border-color: #e6e6e6; + border-width: 1px; + border-radius: 7px; + margin-bottom: 25px; + padding-left: 15px; + padding-right: 15px; + outline: none !important; +} + +input[type=text], input[type=password]{ + width: calc(100% - 30px); +} + +input:active, input:focus, input:focus-visible{ + border-color: #2494fe !important; + border-width: 1px !important; +} + +p.red, span.red{ + color: #b50202; +} + +button.red, a.red{ + background: #b50202; + border: 1px solid #a40000; +} + +button.red:hover, a.red:hover{ + background: #a40000; +} + +button.red:active, a.red:active{ + background: #8f0000; +} + +button.green, a.green{ + background: #4e9a06; + border: 1px solid #377200; +} + +button.green:hover, a.green:hover{ + background: #377200; +} + +button.green:active, a.green:active{ + background: #285200; +} + +button.blue, a.blue{ + background: #2494fe; + border: 1px solid #055fb5; +} + +button.blue:hover, a.blue:hover{ + background: #1578d6; + cursor: pointer !important; +} + +button.blue:active, a.blue:active{ + background: #055fb5; + cursor: pointer !important; +} + +@media only screen and (max-width: 600px) { + #collectionsscene{ + flex-direction: column !important; + flex-wrap: nowrap; + } + + #collectionsscene article{ + height: auto; + min-height: 375px; + } + + .container{ + max-width: 280px !important; + } + + #collectionsscene ul{ + visibility: visible !important; + } + + #logoutview span{ + padding: 0 5px; + } +} diff --git a/radicale/web/internal_data/fn.js b/radicale/web/internal_data/fn.js index 82651a36..c297f9ac 100644 --- a/radicale/web/internal_data/fn.js +++ b/radicale/web/internal_data/fn.js @@ -1,6 +1,6 @@ /** * This file is part of Radicale Server - Calendar Server - * Copyright © 2017-2018 Unrud + * Copyright © 2017-2024 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 @@ -28,7 +28,7 @@ const SERVER = location.origin; * @const * @type {string} */ -const ROOT_PATH = (new URL("..", location.href)).pathname; +const ROOT_PATH = location.pathname.replace(new RegExp("/+[^/]+/*(/index\\.html?)?$"), "") + '/'; /** * Regex to match and normalize color @@ -36,6 +36,13 @@ const ROOT_PATH = (new URL("..", location.href)).pathname; */ const COLOR_RE = new RegExp("^(#[0-9A-Fa-f]{6})(?:[0-9A-Fa-f]{2})?$"); + +/** + * The text needed to confirm deleting a collection + * @const + */ +const DELETE_CONFIRMATION_TEXT = "DELETE"; + /** * Escape string for usage in XML * @param {string} s @@ -63,6 +70,7 @@ const CollectionType = { CALENDAR: "CALENDAR", JOURNAL: "JOURNAL", TASKS: "TASKS", + WEBCAL: "WEBCAL", is_subset: function(a, b) { let components = a.split("_"); for (let i = 0; i < components.length; i++) { @@ -89,7 +97,27 @@ const CollectionType = { if (a.search(this.TASKS) !== -1 || b.search(this.TASKS) !== -1) { union.push(this.TASKS); } + if (a.search(this.WEBCAL) !== -1 || b.search(this.WEBCAL) !== -1) { + union.push(this.WEBCAL); + } return union.join("_"); + }, + valid_options_for_type: function(a){ + a = a.trim().toUpperCase(); + switch(a){ + case CollectionType.CALENDAR_JOURNAL_TASKS: + case CollectionType.CALENDAR_JOURNAL: + case CollectionType.CALENDAR_TASKS: + case CollectionType.JOURNAL_TASKS: + case CollectionType.CALENDAR: + case CollectionType.JOURNAL: + case CollectionType.TASKS: + return [CollectionType.CALENDAR_JOURNAL_TASKS, CollectionType.CALENDAR_JOURNAL, CollectionType.CALENDAR_TASKS, CollectionType.JOURNAL_TASKS, CollectionType.CALENDAR, CollectionType.JOURNAL, CollectionType.TASKS]; + case CollectionType.ADDRESSBOOK: + case CollectionType.WEBCAL: + default: + return [a]; + } } }; @@ -102,12 +130,15 @@ const CollectionType = { * @param {string} description * @param {string} color */ -function Collection(href, type, displayname, description, color) { +function Collection(href, type, displayname, description, color, contentcount, size, source) { this.href = href; this.type = type; this.displayname = displayname; this.color = color; this.description = description; + this.source = source; + this.contentcount = contentcount; + this.size = size; } /** @@ -119,7 +150,7 @@ function Collection(href, type, displayname, description, color) { */ function get_principal(user, password, callback) { let request = new XMLHttpRequest(); - request.open("PROPFIND", SERVER + ROOT_PATH, true, user, password); + request.open("PROPFIND", SERVER + ROOT_PATH, true, user, encodeURIComponent(password)); request.onreadystatechange = function() { if (request.readyState !== 4) { return; @@ -134,6 +165,7 @@ function get_principal(user, password, callback) { CollectionType.PRINCIPAL, displayname_element ? displayname_element.textContent : "", "", + 0, ""), null); } else { callback(null, "Internal error"); @@ -162,7 +194,7 @@ function get_principal(user, password, callback) { */ function get_collections(user, password, collection, callback) { let request = new XMLHttpRequest(); - request.open("PROPFIND", SERVER + collection.href, true, user, password); + request.open("PROPFIND", SERVER + collection.href, true, user, encodeURIComponent(password)); request.setRequestHeader("depth", "1"); request.onreadystatechange = function() { if (request.readyState !== 4) { @@ -183,6 +215,9 @@ function get_collections(user, password, collection, callback) { let addressbookcolor_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-color"); let calendardesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|calendar-description"); let addressbookdesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-description"); + let contentcount_element = response.querySelector(response_query + " > *|propstat > *|prop > *|getcontentcount"); + let contentlength_element = response.querySelector(response_query + " > *|propstat > *|prop > *|getcontentlength"); + let webcalsource_element = response.querySelector(response_query + " > *|propstat > *|prop > *|source"); let components_query = response_query + " > *|propstat > *|prop > *|supported-calendar-component-set"; let components_element = response.querySelector(components_query); let href = href_element ? href_element.textContent : ""; @@ -190,11 +225,21 @@ function get_collections(user, password, collection, callback) { let type = ""; let color = ""; let description = ""; + let source = ""; + let count = 0; + let size = 0; if (resourcetype_element) { if (resourcetype_element.querySelector(resourcetype_query + " > *|addressbook")) { type = CollectionType.ADDRESSBOOK; color = addressbookcolor_element ? addressbookcolor_element.textContent : ""; description = addressbookdesc_element ? addressbookdesc_element.textContent : ""; + count = contentcount_element ? parseInt(contentcount_element.textContent) : 0; + size = contentlength_element ? parseInt(contentlength_element.textContent) : 0; + } else if (resourcetype_element.querySelector(resourcetype_query + " > *|subscribed")) { + type = CollectionType.WEBCAL; + source = webcalsource_element ? webcalsource_element.textContent : ""; + color = calendarcolor_element ? calendarcolor_element.textContent : ""; + description = calendardesc_element ? calendardesc_element.textContent : ""; } else if (resourcetype_element.querySelector(resourcetype_query + " > *|calendar")) { if (components_element) { if (components_element.querySelector(components_query + " > *|comp[name=VEVENT]")) { @@ -209,6 +254,8 @@ function get_collections(user, password, collection, callback) { } color = calendarcolor_element ? calendarcolor_element.textContent : ""; description = calendardesc_element ? calendardesc_element.textContent : ""; + count = contentcount_element ? parseInt(contentcount_element.textContent) : 0; + size = contentlength_element ? parseInt(contentlength_element.textContent) : 0; } } let sane_color = color.trim(); @@ -221,7 +268,7 @@ function get_collections(user, password, collection, callback) { } } if (href.substr(-1) === "/" && href !== collection.href && type) { - collections.push(new Collection(href, type, displayname, description, sane_color)); + collections.push(new Collection(href, type, displayname, description, sane_color, count, size, source)); } } collections.sort(function(a, b) { @@ -235,11 +282,15 @@ function get_collections(user, password, collection, callback) { } }; request.send('' + - '' + + 'xmlns:RADICALE="http://radicale.org/ns/"' + + '>' + '' + '' + '' + @@ -248,6 +299,9 @@ function get_collections(user, password, collection, callback) { '' + '' + '' + + '' + + '' + + '' + '' + ''); return request; @@ -263,7 +317,7 @@ function get_collections(user, password, collection, callback) { */ function upload_collection(user, password, collection_href, file, callback) { let request = new XMLHttpRequest(); - request.open("PUT", SERVER + collection_href, true, user, password); + request.open("PUT", SERVER + collection_href, true, user, encodeURIComponent(password)); request.onreadystatechange = function() { if (request.readyState !== 4) { return; @@ -288,7 +342,7 @@ function upload_collection(user, password, collection_href, file, callback) { */ function delete_collection(user, password, collection, callback) { let request = new XMLHttpRequest(); - request.open("DELETE", SERVER + collection.href, true, user, password); + request.open("DELETE", SERVER + collection.href, true, user, encodeURIComponent(password)); request.onreadystatechange = function() { if (request.readyState !== 4) { return; @@ -313,7 +367,7 @@ function delete_collection(user, password, collection, callback) { */ function create_edit_collection(user, password, collection, create, callback) { let request = new XMLHttpRequest(); - request.open(create ? "MKCOL" : "PROPPATCH", SERVER + collection.href, true, user, password); + request.open(create ? "MKCOL" : "PROPPATCH", SERVER + collection.href, true, user, encodeURIComponent(password)); request.onreadystatechange = function() { if (request.readyState !== 4) { return; @@ -329,12 +383,18 @@ function create_edit_collection(user, password, collection, create, callback) { let addressbook_color = ""; let calendar_description = ""; let addressbook_description = ""; + let calendar_source = ""; let resourcetype; let components = ""; if (collection.type === CollectionType.ADDRESSBOOK) { addressbook_color = escape_xml(collection.color + (collection.color ? "ff" : "")); addressbook_description = escape_xml(collection.description); resourcetype = ''; + } else if (collection.type === CollectionType.WEBCAL) { + calendar_color = escape_xml(collection.color + (collection.color ? "ff" : "")); + calendar_description = escape_xml(collection.description); + resourcetype = ''; + calendar_source = collection.source; } else { calendar_color = escape_xml(collection.color + (collection.color ? "ff" : "")); calendar_description = escape_xml(collection.description); @@ -351,7 +411,7 @@ function create_edit_collection(user, password, collection, create, callback) { } let xml_request = create ? "mkcol" : "propertyupdate"; request.send('' + - '<' + xml_request + ' xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:I="http://apple.com/ns/ical/" xmlns:INF="http://inf-it.com/ns/ab/">' + + '<' + xml_request + ' xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:I="http://apple.com/ns/ical/" xmlns:INF="http://inf-it.com/ns/ab/">' + '' + '' + (create ? '' + resourcetype + '' : '') + @@ -361,6 +421,7 @@ function create_edit_collection(user, password, collection, create, callback) { (addressbook_color ? '' + addressbook_color + '' : '') + (addressbook_description ? '' + addressbook_description + '' : '') + (calendar_description ? '' + calendar_description + '' : '') + + (calendar_source ? '' + calendar_source + '' : '') + '' + '' + (!create ? ('' + @@ -481,7 +542,8 @@ function LoginScene() { let error_form = html_scene.querySelector("[data-name=error]"); let logout_view = document.getElementById("logoutview"); let logout_user_form = logout_view.querySelector("[data-name=user]"); - let logout_btn = logout_view.querySelector("[data-name=link]"); + let logout_btn = logout_view.querySelector("[data-name=logout]"); + let refresh_btn = logout_view.querySelector("[data-name=refresh]"); /** @type {?number} */ let scene_index = null; let user = ""; @@ -495,7 +557,12 @@ function LoginScene() { function fill_form() { user_form.value = user; password_form.value = ""; - error_form.textContent = error ? "Error: " + error : ""; + if(error){ + error_form.textContent = "Error: " + error; + error_form.classList.remove("hidden"); + }else{ + error_form.classList.add("hidden"); + } } function onlogin() { @@ -507,7 +574,8 @@ function LoginScene() { // setup logout logout_view.classList.remove("hidden"); logout_btn.onclick = onlogout; - logout_user_form.textContent = user; + refresh_btn.onclick = refresh; + logout_user_form.textContent = user + "'s Collections"; // Fetch principal let loading_scene = new LoadingScene(); push_scene(loading_scene, false); @@ -557,9 +625,17 @@ function LoginScene() { function remove_logout() { logout_view.classList.add("hidden"); logout_btn.onclick = null; + refresh_btn.onclick = null; logout_user_form.textContent = ""; } + function refresh(){ + //The easiest way to refresh is to push a LoadingScene onto the stack and then pop it + //forcing the scene below it, the Collections Scene to refresh itself. + push_scene(new LoadingScene(), false); + pop_scene(scene_stack.length-2); + } + this.show = function() { remove_logout(); fill_form(); @@ -618,12 +694,6 @@ function CollectionsScene(user, password, collection, onerror) { /** @type {?XMLHttpRequest} */ let collections_req = null; /** @type {?Array} */ let collections = null; /** @type {Array} */ let nodes = []; - let filesInput = document.createElement("input"); - filesInput.setAttribute("type", "file"); - filesInput.setAttribute("accept", ".ics, .vcf"); - filesInput.setAttribute("multiple", ""); - let filesInputForm = document.createElement("form"); - filesInputForm.appendChild(filesInput); function onnew() { try { @@ -636,17 +706,9 @@ function CollectionsScene(user, password, collection, onerror) { } function onupload() { - filesInput.click(); - return false; - } - - function onfileschange() { try { - let files = filesInput.files; - if (files.length > 0) { - let upload_scene = new UploadCollectionScene(user, password, collection, files); - push_scene(upload_scene); - } + let upload_scene = new UploadCollectionScene(user, password, collection); + push_scene(upload_scene); } catch(err) { console.error(err); } @@ -674,21 +736,24 @@ function CollectionsScene(user, password, collection, onerror) { } function show_collections(collections) { + let heightOfNavBar = document.querySelector("#logoutview").offsetHeight + "px"; + html_scene.style.marginTop = heightOfNavBar; + html_scene.style.height = "calc(100vh - " + heightOfNavBar +")"; collections.forEach(function (collection) { let node = template.cloneNode(true); node.classList.remove("hidden"); let title_form = node.querySelector("[data-name=title]"); let description_form = node.querySelector("[data-name=description]"); + let contentcount_form = node.querySelector("[data-name=contentcount]"); let url_form = node.querySelector("[data-name=url]"); let color_form = node.querySelector("[data-name=color]"); let delete_btn = node.querySelector("[data-name=delete]"); let edit_btn = node.querySelector("[data-name=edit]"); + let download_btn = node.querySelector("[data-name=download]"); if (collection.color) { - color_form.style.color = collection.color; - } else { - color_form.classList.add("hidden"); + color_form.style.background = collection.color; } - let possible_types = [CollectionType.ADDRESSBOOK]; + let possible_types = [CollectionType.ADDRESSBOOK, CollectionType.WEBCAL]; [CollectionType.CALENDAR, ""].forEach(function(e) { [CollectionType.union(e, CollectionType.JOURNAL), e].forEach(function(e) { [CollectionType.union(e, CollectionType.TASKS), e].forEach(function(e) { @@ -704,10 +769,26 @@ function CollectionsScene(user, password, collection, onerror) { } }); title_form.textContent = collection.displayname || collection.href; + if(title_form.textContent.length > 30){ + title_form.classList.add("smalltext"); + } description_form.textContent = collection.description; + if(description_form.textContent.length > 150){ + description_form.classList.add("smalltext"); + } + if(collection.type != CollectionType.WEBCAL){ + let contentcount_form_txt = (collection.contentcount > 0 ? Number(collection.contentcount).toLocaleString() : "No") + " item" + (collection.contentcount == 1 ? "" : "s") + " in collection"; + if(collection.contentcount > 0){ + contentcount_form_txt += " (" + bytesToHumanReadable(collection.size) + ")"; + } + contentcount_form.textContent = contentcount_form_txt; + } let href = SERVER + collection.href; - url_form.href = href; - url_form.textContent = href; + url_form.value = href; + download_btn.href = href; + if(collection.type == CollectionType.WEBCAL){ + download_btn.parentElement.classList.add("hidden"); + } delete_btn.onclick = function() {return ondelete(collection);}; edit_btn.onclick = function() {return onedit(collection);}; node.classList.remove("hidden"); @@ -738,8 +819,6 @@ function CollectionsScene(user, password, collection, onerror) { html_scene.classList.remove("hidden"); new_btn.onclick = onnew; upload_btn.onclick = onupload; - filesInputForm.reset(); - filesInput.onchange = onfileschange; if (collections === null) { update(); } else { @@ -752,7 +831,6 @@ function CollectionsScene(user, password, collection, onerror) { scene_index = scene_stack.length - 1; new_btn.onclick = null; upload_btn.onclick = null; - filesInput.onchange = null; collections = null; // remove collection nodes.forEach(function(node) { @@ -767,7 +845,6 @@ function CollectionsScene(user, password, collection, onerror) { collections_req = null; } collections = null; - filesInputForm.reset(); }; } @@ -779,43 +856,89 @@ function CollectionsScene(user, password, collection, onerror) { * @param {Collection} collection parent collection * @param {Array} files */ -function UploadCollectionScene(user, password, collection, files) { +function UploadCollectionScene(user, password, collection) { let html_scene = document.getElementById("uploadcollectionscene"); let template = html_scene.querySelector("[data-name=filetemplate]"); + let upload_btn = html_scene.querySelector("[data-name=submit]"); let close_btn = html_scene.querySelector("[data-name=close]"); + let uploadfile_form = html_scene.querySelector("[data-name=uploadfile]"); + let uploadfile_lbl = html_scene.querySelector("label[for=uploadfile]"); + let href_form = html_scene.querySelector("[data-name=href]"); + let href_label = html_scene.querySelector("label[for=href]"); + let hreflimitmsg_html = html_scene.querySelector("[data-name=hreflimitmsg]"); + let pending_html = html_scene.querySelector("[data-name=pending]"); + + let files = uploadfile_form.files; + href_form.addEventListener("keydown", cleanHREFinput); + upload_btn.onclick = upload_start; + uploadfile_form.onchange = onfileschange; + + let href = random_uuid(); + href_form.value = href; /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let upload_req = null; - /** @type {Array} */ let errors = []; + /** @type {Array} */ let results = []; /** @type {?Array} */ let nodes = null; - function upload_next() { + function upload_start() { try { - if (files.length === errors.length) { - if (errors.every(error => error === null)) { - pop_scene(scene_index - 1); - } else { - close_btn.classList.remove("hidden"); - } - } 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(); - }); + if(!read_form()){ + return false; } + uploadfile_form.classList.add("hidden"); + uploadfile_lbl.classList.add("hidden"); + href_form.classList.add("hidden"); + href_label.classList.add("hidden"); + hreflimitmsg_html.classList.add("hidden"); + upload_btn.classList.add("hidden"); + close_btn.classList.add("hidden"); + + pending_html.classList.remove("hidden"); + + nodes = []; + for (let i = 0; i < files.length; i++) { + let file = files[i]; + let node = template.cloneNode(true); + node.classList.remove("hidden"); + let name_form = node.querySelector("[data-name=name]"); + name_form.textContent = file.name; + node.classList.remove("hidden"); + nodes.push(node); + updateFileStatus(i); + template.parentNode.insertBefore(node, template); + } + upload_next(); } catch(err) { console.error(err); } return false; } + function upload_next(){ + try{ + if (files.length === results.length) { + pending_html.classList.add("hidden"); + close_btn.classList.remove("hidden"); + return; + } else { + let file = files[results.length]; + if(files.length > 1 || href.length == 0){ + href = random_uuid(); + } + let upload_href = collection.href + "/" + href + "/"; + upload_req = upload_collection(user, password, upload_href, file, function(result) { + upload_req = null; + results.push(result); + updateFileStatus(results.length - 1); + upload_next(); + }); + } + }catch(err){ + console.error(err); + } + } + function onclose() { try { pop_scene(scene_index - 1); @@ -829,54 +952,77 @@ function UploadCollectionScene(user, password, collection, files) { if (nodes === null) { return; } - let pending_form = nodes[i].querySelector("[data-name=pending]"); let success_form = nodes[i].querySelector("[data-name=success]"); let error_form = nodes[i].querySelector("[data-name=error]"); - if (errors.length > i) { - pending_form.classList.add("hidden"); - if (errors[i]) { + if (results.length > i) { + if (results[i]) { success_form.classList.add("hidden"); - error_form.textContent = "Error: " + errors[i]; + error_form.textContent = "Error: " + results[i]; error_form.classList.remove("hidden"); } else { success_form.classList.remove("hidden"); error_form.classList.add("hidden"); } } else { - pending_form.classList.remove("hidden"); success_form.classList.add("hidden"); error_form.classList.add("hidden"); } } + function read_form() { + cleanHREFinput(href_form); + let newhreftxtvalue = href_form.value.trim().toLowerCase(); + if(!isValidHREF(newhreftxtvalue)){ + alert("You must enter a valid HREF"); + return false; + } + href = newhreftxtvalue; + + if(uploadfile_form.files.length == 0){ + alert("You must select at least one file to upload"); + return false; + } + files = uploadfile_form.files; + return true; + } + + function onfileschange() { + files = uploadfile_form.files; + if(files.length > 1){ + hreflimitmsg_html.classList.remove("hidden"); + href_form.classList.add("hidden"); + href_label.classList.add("hidden"); + }else{ + hreflimitmsg_html.classList.add("hidden"); + href_form.classList.remove("hidden"); + href_label.classList.remove("hidden"); + } + return false; + } + this.show = function() { + scene_index = scene_stack.length - 1; html_scene.classList.remove("hidden"); - 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"); + upload_btn.classList.remove("hidden"); + uploadfile_form.classList.remove("hidden"); + uploadfile_lbl.classList.remove("hidden"); + href_form.classList.remove("hidden"); + href_label.classList.remove("hidden"); + hreflimitmsg_html.classList.add("hidden"); + pending_html.classList.add("hidden"); close_btn.onclick = null; + upload_btn.onclick = null; + href_form.value = ""; + uploadfile_form.value = ""; + if(nodes == null){ + return; + } nodes.forEach(function(node) { node.parentNode.removeChild(node); }); @@ -902,14 +1048,25 @@ function DeleteCollectionScene(user, password, collection) { let html_scene = document.getElementById("deletecollectionscene"); let title_form = html_scene.querySelector("[data-name=title]"); let error_form = html_scene.querySelector("[data-name=error]"); + let confirmation_txt = html_scene.querySelector("[data-name=confirmationtxt]"); + let delete_confirmation_lbl = html_scene.querySelector("[data-name=deleteconfirmationtext]"); let delete_btn = html_scene.querySelector("[data-name=delete]"); let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + delete_confirmation_lbl.innerHTML = DELETE_CONFIRMATION_TEXT; + confirmation_txt.value = ""; + confirmation_txt.addEventListener("keydown", onkeydown); + /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let delete_req = null; let error = ""; function ondelete() { + let confirmation_text_value = confirmation_txt.value; + if(confirmation_text_value != DELETE_CONFIRMATION_TEXT){ + alert("Please type the confirmation text to delete this collection."); + return; + } try { let loading_scene = new LoadingScene(); push_scene(loading_scene); @@ -940,14 +1097,27 @@ function DeleteCollectionScene(user, password, collection) { return false; } + function onkeydown(event){ + if (event.keyCode !== 13) { + return; + } + ondelete(); + } + this.show = function() { this.release(); scene_index = scene_stack.length - 1; html_scene.classList.remove("hidden"); title_form.textContent = collection.displayname || collection.href; - error_form.textContent = error ? "Error: " + error : ""; delete_btn.onclick = ondelete; cancel_btn.onclick = oncancel; + if(error){ + error_form.textContent = "Error: " + error; + error_form.classList.remove("hidden"); + }else{ + error_form.classList.add("hidden"); + } + }; this.hide = function() { html_scene.classList.add("hidden"); @@ -988,13 +1158,22 @@ function CreateEditCollectionScene(user, password, collection) { let html_scene = document.getElementById(edit ? "editcollectionscene" : "createcollectionscene"); let title_form = edit ? html_scene.querySelector("[data-name=title]") : null; let error_form = html_scene.querySelector("[data-name=error]"); + let href_form = html_scene.querySelector("[data-name=href]"); + let href_label = html_scene.querySelector("label[for=href]"); let displayname_form = html_scene.querySelector("[data-name=displayname]"); + let displayname_label = html_scene.querySelector("label[for=displayname]"); let description_form = html_scene.querySelector("[data-name=description]"); + let description_label = html_scene.querySelector("label[for=description]"); + let source_form = html_scene.querySelector("[data-name=source]"); + let source_label = html_scene.querySelector("label[for=source]"); let type_form = html_scene.querySelector("[data-name=type]"); + let type_label = html_scene.querySelector("label[for=type]"); let color_form = html_scene.querySelector("[data-name=color]"); + let color_label = html_scene.querySelector("label[for=color]"); let submit_btn = html_scene.querySelector("[data-name=submit]"); let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let create_edit_req = null; let error = ""; @@ -1003,40 +1182,69 @@ function CreateEditCollectionScene(user, password, collection) { let href = edit ? collection.href : collection.href + random_uuid() + "/"; let displayname = edit ? collection.displayname : ""; let description = edit ? collection.description : ""; + let source = edit ? collection.source : ""; let type = edit ? collection.type : CollectionType.CALENDAR_JOURNAL_TASKS; let color = edit && collection.color ? collection.color : "#" + random_hex(6); + if(!edit){ + href_form.addEventListener("keydown", cleanHREFinput); + } + function remove_invalid_types() { if (!edit) { return; } /** @type {HTMLOptionsCollection} */ let options = type_form.options; // remove all options that are not supersets + let valid_type_options = CollectionType.valid_options_for_type(type); for (let i = options.length - 1; i >= 0; i--) { - if (!CollectionType.is_subset(type, options[i].value)) { + if (valid_type_options.indexOf(options[i].value) < 0) { options.remove(i); } } } function read_form() { + if(!edit){ + cleanHREFinput(href_form); + let newhreftxtvalue = href_form.value.trim().toLowerCase(); + if(!isValidHREF(newhreftxtvalue)){ + alert("You must enter a valid HREF"); + return false; + } + href = collection.href + "/" + newhreftxtvalue + "/"; + } displayname = displayname_form.value; description = description_form.value; + source = source_form.value; type = type_form.value; color = color_form.value; + return true; } function fill_form() { + if(!edit){ + href_form.value = random_uuid(); + } displayname_form.value = displayname; description_form.value = description; + source_form.value = source; type_form.value = type; color_form.value = color; - error_form.textContent = error ? "Error: " + error : ""; + if(error){ + error_form.textContent = "Error: " + error; + error_form.classList.remove("hidden"); + } + error_form.classList.add("hidden"); + onTypeChange(); + type_form.addEventListener("change", onTypeChange); } function onsubmit() { try { - read_form(); + if(!read_form()){ + return false; + } let sane_color = color.trim(); if (sane_color) { let color_match = COLOR_RE.exec(sane_color); @@ -1049,7 +1257,7 @@ function CreateEditCollectionScene(user, password, collection) { } let loading_scene = new LoadingScene(); push_scene(loading_scene); - let collection = new Collection(href, type, displayname, description, sane_color); + let collection = new Collection(href, type, displayname, description, sane_color, 0, 0, source); let callback = function(error1) { if (scene_index === null) { return; @@ -1082,6 +1290,17 @@ function CreateEditCollectionScene(user, password, collection) { return false; } + + function onTypeChange(e){ + if(type_form.value == CollectionType.WEBCAL){ + source_label.classList.remove("hidden"); + source_form.classList.remove("hidden"); + }else{ + source_label.classList.add("hidden"); + source_form.classList.add("hidden"); + } + } + this.show = function() { this.release(); scene_index = scene_stack.length - 1; @@ -1117,6 +1336,57 @@ function CreateEditCollectionScene(user, password, collection) { }; } +/** + * Removed invalid HREF characters for a collection HREF. + * + * @param a A valid Input element or an onchange Event of an Input element. + */ +function cleanHREFinput(a) { + let href_form = a; + if (a.target) { + href_form = a.target; + } + let currentTxtVal = href_form.value.trim().toLowerCase(); + //Clean the HREF to remove non lowercase letters and dashes + currentTxtVal = currentTxtVal.replace(/(?![0-9a-z\-\_])./g, ''); + href_form.value = currentTxtVal; +} + +/** + * Checks if a proposed HREF for a collection has a valid format and syntax. + * + * @param href String of the porposed HREF. + * + * @return Boolean results if the HREF is valid. + */ +function isValidHREF(href) { + if (href.length < 1) { + return false; + } + if (href.indexOf("/") != -1) { + return false; + } + + return true; +} + +/** + * Format bytes to human-readable text. + * + * @param bytes Number of bytes. + * + * @return Formatted string. + */ +function bytesToHumanReadable(bytes, dp=1) { + let isNumber = !isNaN(parseFloat(bytes)) && !isNaN(bytes - 0); + if(!isNumber){ + return ""; + } + var i = bytes == 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(dp) * 1 + ' ' + ['b', 'kb', 'mb', 'gb', 'tb'][i]; +} + + function main() { // Hide startup loading message document.getElementById("loadingscene").classList.add("hidden"); diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index c526195a..7806765f 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -1,130 +1,192 @@ + + + + + Radicale Web Interface + + + + + - - - - -Radicale Web Interface - - - + + - +
+
+ Loading... +

Loading

+

Please wait...

+ +
-
-

Loading

-

Please wait...

- -
+ - + - + +
+ + + + + + + +
+ - + - - - - - +
+ + diff --git a/radicale/xmlutils.py b/radicale/xmlutils.py index 09508d9c..4b9c51bf 100644 --- a/radicale/xmlutils.py +++ b/radicale/xmlutils.py @@ -33,7 +33,8 @@ from radicale import item, pathutils MIMETYPES: Mapping[str, str] = { "VADDRESSBOOK": "text/vcard", - "VCALENDAR": "text/calendar"} + "VCALENDAR": "text/calendar", + "VSUBSCRIBED": "text/calendar"} OBJECT_MIMETYPES: Mapping[str, str] = { "VCARD": "text/vcard", @@ -177,6 +178,9 @@ def props_from_request(xml_request: Optional[ET.Element] if resource_type.tag == make_clark("C:calendar"): value = "VCALENDAR" break + if resource_type.tag == make_clark("CS:subscribed"): + value = "VSUBSCRIBED" + break if resource_type.tag == make_clark("CR:addressbook"): value = "VADDRESSBOOK" break diff --git a/setup.cfg b/setup.cfg index 35c2c8ea..94a39915 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,13 +1,31 @@ -[aliases] -test = pytest - -[bdist_wheel] -python-tag = py3 - [tool:pytest] -# More options are set in `setup.py` via environment variable `PYTEST_ADDOPTS` -addopts = --flake8 --isort --typeguard-packages=radicale --cov --cov-report=term --cov-report=xml -r s -norecursedirs = dist .cache .git build Radicale.egg-info .eggs venv + +[tox:tox] +min_version = 4.0 +envlist = py, flake8, isort, mypy + +[testenv] +extras = + test +deps = + pytest + pytest-cov +commands = pytest -r s --cov --cov-report=term --cov-report=xml . + +[testenv:flake8] +deps = flake8==7.1.0 +commands = flake8 . +skip_install = True + +[testenv:isort] +deps = isort==5.13.2 +commands = isort --check --diff . +skip_install = True + +[testenv:mypy] +deps = mypy==1.11.0 +commands = mypy . +skip_install = True [tool:isort] known_standard_library = _dummy_thread,_thread,abc,aifc,argparse,array,ast,asynchat,asyncio,asyncore,atexit,audioop,base64,bdb,binascii,binhex,bisect,builtins,bz2,cProfile,calendar,cgi,cgitb,chunk,cmath,cmd,code,codecs,codeop,collections,colorsys,compileall,concurrent,configparser,contextlib,contextvars,copy,copyreg,crypt,csv,ctypes,curses,dataclasses,datetime,dbm,decimal,difflib,dis,distutils,doctest,dummy_threading,email,encodings,ensurepip,enum,errno,faulthandler,fcntl,filecmp,fileinput,fnmatch,formatter,fpectl,fractions,ftplib,functools,gc,getopt,getpass,gettext,glob,grp,gzip,hashlib,heapq,hmac,html,http,imaplib,imghdr,imp,importlib,inspect,io,ipaddress,itertools,json,keyword,lib2to3,linecache,locale,logging,lzma,macpath,mailbox,mailcap,marshal,math,mimetypes,mmap,modulefinder,msilib,msvcrt,multiprocessing,netrc,nis,nntplib,ntpath,numbers,operator,optparse,os,ossaudiodev,parser,pathlib,pdb,pickle,pickletools,pipes,pkgutil,platform,plistlib,poplib,posix,posixpath,pprint,profile,pstats,pty,pwd,py_compile,pyclbr,pydoc,queue,quopri,random,re,readline,reprlib,resource,rlcompleter,runpy,sched,secrets,select,selectors,shelve,shlex,shutil,signal,site,smtpd,smtplib,sndhdr,socket,socketserver,spwd,sqlite3,sre,sre_compile,sre_constants,sre_parse,ssl,stat,statistics,string,stringprep,struct,subprocess,sunau,symbol,symtable,sys,sysconfig,syslog,tabnanny,tarfile,telnetlib,tempfile,termios,test,textwrap,threading,time,timeit,tkinter,token,tokenize,trace,traceback,tracemalloc,tty,turtle,turtledemo,types,typing,unicodedata,unittest,urllib,uu,uuid,venv,warnings,wave,weakref,webbrowser,winreg,winsound,wsgiref,xdrlib,xml,xmlrpc,zipapp,zipfile,zipimport,zlib @@ -15,12 +33,15 @@ known_third_party = defusedxml,passlib,pkg_resources,pytest,vobject [flake8] # Only enable default tests (https://github.com/PyCQA/flake8/issues/790#issuecomment-812823398) -select = E,F,W,C90,DOES-NOT-EXIST -ignore = E121,E123,E126,E226,E24,E704,W503,W504,DOES-NOT-EXIST +# DNE: DOES-NOT-EXIST +select = E,F,W,C90,DNE000 +ignore = E121,E123,E126,E226,E24,E704,W503,W504,DNE000,E501 +extend-exclude = build [mypy] ignore_missing_imports = True show_error_codes = True +exclude = (^|/)build($|/) [coverage:run] branch = True diff --git a/setup.py b/setup.py index 17cd7437..68e36398 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2009-2017 Guillaume Ayoub # Copyright © 2017-2018 Unrud @@ -17,73 +15,52 @@ # 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 os -import sys - from setuptools import find_packages, setup # When the version is updated, a new section in the CHANGELOG.md file must be # added too. -VERSION = "master" -WEB_FILES = ["web/internal_data/css/icon.png", +VERSION = "3.dev" + +with open("README.md", encoding="utf-8") as f: + long_description = f.read() +web_files = ["web/internal_data/css/icon.png", + "web/internal_data/css/loading.svg", + "web/internal_data/css/logo.svg", "web/internal_data/css/main.css", + "web/internal_data/css/icons/delete.svg", + "web/internal_data/css/icons/download.svg", + "web/internal_data/css/icons/edit.svg", + "web/internal_data/css/icons/new.svg", + "web/internal_data/css/icons/upload.svg", "web/internal_data/fn.js", "web/internal_data/index.html"] -setup_requires = [] -if {"pytest", "test", "ptr"}.intersection(sys.argv): - setup_requires.append("pytest-runner") -tests_require = ["pytest-runner", "pytest<7", "pytest-cov", "pytest-flake8", - "pytest-isort", "typeguard", "waitress"] -os.environ["PYTEST_ADDOPTS"] = os.environ.get("PYTEST_ADDOPTS", "") -# Mypy only supports CPython -if sys.implementation.name == "cpython": - tests_require.extend(["pytest-mypy", "types-setuptools"]) - os.environ["PYTEST_ADDOPTS"] += " --mypy" +install_requires = ["defusedxml", "passlib", "vobject>=0.9.6", + "python-dateutil>=2.7.3", + "pika>=1.1.0", + ] +bcrypt_requires = ["bcrypt"] +test_requires = ["pytest>=7", "waitress", *bcrypt_requires] setup( name="Radicale", version=VERSION, description="CalDAV and CardDAV Server", - long_description=__doc__, + long_description=long_description, + long_description_content_type="text/markdown", 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, "py.typed"]}, + package_data={"radicale": [*web_files, "py.typed"]}, entry_points={"console_scripts": ["radicale = radicale.__main__:run"]}, - install_requires=["defusedxml", "passlib", "vobject>=0.9.6", - "python-dateutil>=2.7.3", "setuptools"], - setup_requires=setup_requires, - tests_require=tests_require, - extras_require={"test": tests_require, - "bcrypt": ["passlib[bcrypt]", "bcrypt"]}, + install_requires=install_requires, + extras_require={"test": test_requires, "bcrypt": bcrypt_requires}, keywords=["calendar", "addressbook", "CalDAV", "CardDAV"], - python_requires=">=3.6.0", + python_requires=">=3.8.0", classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Console", @@ -93,11 +70,12 @@ setup( "License :: OSI Approved :: GNU General Public License (GPL)", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Office/Business :: Groupware"])