Compare commits

...

187 commits

Author SHA1 Message Date
Peter Bieringer
8cdf262560 revert back to ubuntu-latest for https://github.com/Kozea/Radicale/issues/1594 2025-03-31 07:00:18 +02:00
Peter Bieringer
69587d3f5c
Merge pull request #1751 from pbiering/fix-1515
Fix 1515
2025-03-29 08:57:33 +01:00
Peter Bieringer
f41533cca7 fix for https://github.com/Kozea/Radicale/issues/1515 2025-03-29 08:38:16 +01:00
Peter Bieringer
393a26814b extend copyright 2025-03-29 08:37:57 +01:00
Peter Bieringer
3bdcbbdc56
Merge pull request #1750 from pbiering/lock-location-review
Lock location review
2025-03-29 07:11:04 +01:00
Peter Bieringer
9ca82a8aa2 base folder log+create 2025-03-29 07:04:32 +01:00
Peter Bieringer
ffe5fcc6f3 lock review changelog 2025-03-29 07:03:29 +01:00
Peter Bieringer
ecaed3188c change location of main lock file back to original 2025-03-29 06:57:10 +01:00
Peter Bieringer
c23821ad0c conditionally create missing collection* folders on startup 2025-03-28 07:36:17 +01:00
Peter Bieringer
b744e9658c use collection-root location for lock instead of base directory 2025-03-28 07:35:43 +01:00
Peter Bieringer
3ee5433397 use proper cache location for lock 2025-03-28 07:35:15 +01:00
Peter Bieringer
29915b20c8 add clarification about external auth methods 2025-03-28 06:19:03 +01:00
Peter Bieringer
c91b8e49d5
Merge pull request #1747 from pbiering/hook-ext-path-upload-file
Hook extension for path of uploaded file
2025-03-27 08:39:45 +01:00
Peter Bieringer
14fb50954c extend changelog 2025-03-27 08:36:27 +01:00
Peter Bieringer
312e26977b storage hook extend changelog 2025-03-27 08:32:35 +01:00
Peter Bieringer
3bdc438283 storage hook extend doc 2025-03-27 08:32:23 +01:00
Peter Bieringer
3eb61a82a6 add support for cwd+path 2025-03-27 08:30:48 +01:00
Peter Bieringer
fb986ea02e fix for flake8 2025-03-27 08:30:22 +01:00
Peter Bieringer
af09d532c3 catch unsupported placeholder 2025-03-27 07:57:28 +01:00
Peter Bieringer
70b66ddfe2 extend copyright 2025-03-27 07:57:13 +01:00
Peter Bieringer
6b83c409d4
Merge pull request #1742 from BastelBaus/patch-2
Update ldap.py
2025-03-26 05:50:28 +01:00
Peter Bieringer
7fcf473662 ldap_ignore_attribute_create_modify_timestamp changelog + cosmetics in description 2025-03-25 07:11:36 +01:00
Peter Bieringer
d25786c190
Merge pull request #1740 from BastelBaus/master
added configuration to enable radicale LDAP with Authentik
2025-03-25 07:08:15 +01:00
BastelBaus@gmail.com
5d5b12c124 fixed flake8 errors 2025-03-24 22:14:29 +01:00
BastelBaus
23387fa2f3
Merge branch 'Kozea:master' into master 2025-03-24 21:48:53 +01:00
Peter Bieringer
e0a24b14b4 add requirement for requests module 2025-03-24 21:38:24 +01:00
BastelBaus
2439266d0e
Update ldap.py
Bugfix, user_entry['attributes'][self._ldap_user_attr] is already the string so user_entry['attributes'][self._ldap_user_attr][0] would give only the first character and not the full user attribute
2025-03-24 20:25:51 +01:00
BastelBaus
9f7941d428
Update DOCUMENTATION.md 2025-03-24 20:19:28 +01:00
BastelBaus
3af690fcb6
Update ldap.py 2025-03-24 20:13:38 +01:00
BastelBaus
0d1dcec61a
Update config 2025-03-24 20:12:45 +01:00
BastelBaus
98152062df
Update ldap.py 2025-03-24 20:11:40 +01:00
BastelBaus
bcbf0918a9
Update ldap.py 2025-03-24 20:10:53 +01:00
BastelBaus
f40c4d6e9b
Update config.py 2025-03-24 20:10:10 +01:00
BastelBaus
633dfbc875
Update config.py 2025-03-24 20:09:35 +01:00
BastelBaus
34f51033b7
Update config
added in default config file
2025-03-23 18:10:27 +01:00
BastelBaus
94ad295124
Update config.py
added ldap_authentik_timestamp_hack to config file
2025-03-23 18:08:00 +01:00
BastelBaus
7399286ec9
Update ldap.py
timestamp hack
2025-03-23 18:04:53 +01:00
Peter Bieringer
7d351d6692 bugfix 2025-03-22 18:24:15 +01:00
Peter Bieringer
d4e23e6731 add additional links 2025-03-22 18:24:05 +01:00
Peter Bieringer
de527632e0 add note about authentication 2025-03-22 07:14:24 +01:00
Peter Bieringer
217978e9d5 review, add hint about virtualenv 2025-03-22 07:10:16 +01:00
Peter Bieringer
2772305dde
Merge pull request #1737 from pbiering/pytest-brcrpt-fix
Fix: auth/htpasswd related to detection and use of bcrypt
2025-03-19 06:26:25 +01:00
Peter Bieringer
2ef99e5e85 Fix: auth/htpasswd related to detection and use of bcrypt 2025-03-19 06:17:34 +01:00
Peter Bieringer
26eab43f40 update version to 3.5.0 2025-03-16 06:54:54 +01:00
Peter Bieringer
a3880480a9
Merge pull request #1733 from pbiering/auth-default-denyall
Auth default type changed to denyall
2025-03-15 14:43:06 +01:00
Peter Bieringer
9f8ac21130 reflect change of default for auth.type 2025-03-15 14:36:40 +01:00
Peter Bieringer
e8c974a72a add versions when option was introduced 2025-03-15 14:35:30 +01:00
Peter Bieringer
be43ce5161 change default of authentication type to "denyall" for secure-by-default 2025-03-15 14:34:51 +01:00
Peter Bieringer
7bb4beeae2 add note when was introduced 2025-03-15 14:33:55 +01:00
Peter Bieringer
c9ffde27d8 add forgotten entry 2025-03-15 14:32:10 +01:00
Peter Bieringer
dc56d67c33
Merge pull request #1731 from pbiering/add-remote-auth-warn-if-not-loopback
Add remote auth warn if not loopback
2025-03-14 21:55:42 +01:00
Peter Bieringer
081b8a7fcc extension related to https://github.com/Kozea/Radicale/issues/1529 2025-03-14 21:39:20 +01:00
Peter Bieringer
76753d271a extend changelog 2025-03-14 21:35:31 +01:00
Peter Bieringer
69f85a0bdf only display warning if not started as wsgi 2025-03-14 21:33:36 +01:00
Peter Bieringer
820691ca53 set env to be honored later 2025-03-14 21:32:38 +01:00
Peter Bieringer
358ae55540 add warning in case authentication based on environment is selected and server is not listen to loopback addresses only 2025-03-13 21:48:14 +01:00
Peter Bieringer
e22fbe282b centralize format_address 2025-03-13 21:47:44 +01:00
Peter Bieringer
b0d649f8b9 adjust copyright 2025-03-13 21:31:50 +01:00
Peter Bieringer
8f2099baf8 add note about unpatched htpasswd related to https://github.com/Kozea/Radicale/issues/1721 2025-03-13 06:43:27 +01:00
Peter Bieringer
3a13ffbc51 forgotten bcrypt pattern extension 2025-03-10 06:00:30 +01:00
Peter Bieringer
0f67336987
Merge pull request #1729 from pbiering/htpasswd-cosmetic-bcrypt-extensions
Htpasswd cosmetic bcrypt extensions
2025-03-09 08:59:24 +01:00
Peter Bieringer
cf727101f8 update related to htpasswd auth 2025-03-09 08:53:49 +01:00
Peter Bieringer
9f0385fd67 add some autodetect cases, add 2 additional bcrypt algo 2025-03-09 08:51:20 +01:00
Peter Bieringer
3963bb4d82 extend logging, adjust loglevel for hash error 2025-03-09 08:50:53 +01:00
Peter Bieringer
cffb2aaae3 add support for additional bcrypt algo on autodetect, improve autodetect logic and log not matching hash length 2025-03-09 08:49:30 +01:00
Peter Bieringer
4f0e607583
Merge pull request #1728 from pbiering/catch-invalid-salt
Catch invalid salt
2025-03-08 17:35:56 +01:00
Peter Bieringer
2f1db01083 update 2025-03-08 17:29:47 +01:00
Peter Bieringer
95a8899002 quote error message 2025-03-08 17:28:35 +01:00
Peter Bieringer
41ab96e142 catch ValueError on verify, adjust log level for failed logins 2025-03-08 17:27:02 +01:00
Peter Bieringer
a284d18c16 make encryption visible to other functions 2025-03-08 17:26:28 +01:00
Peter Bieringer
30664f9346
Merge pull request #1726 from pbiering/extend-https-info
Extend https info in log
2025-03-08 17:04:43 +01:00
Peter Bieringer
36aba7a8b9 update related to SSL logging 2025-03-08 17:00:01 +01:00
Peter Bieringer
914320826f extend request log with HTTPS info 2025-03-08 16:50:35 +01:00
Peter Bieringer
9372344bb1 extend header information with HTTPS info 2025-03-08 16:49:28 +01:00
Peter Bieringer
c4a48828d3 extend copyright 2025-03-08 16:48:59 +01:00
Peter Bieringer
ebe0418a4c extend changelog regarding https://github.com/Kozea/Radicale/pull/1725 2025-03-07 07:40:39 +01:00
Peter Bieringer
c3c78db8ae
Merge pull request #1724 from pbiering/support-for-bundled-InfCloud-client
Support for bundled InfCloud client
2025-03-07 07:38:54 +01:00
Peter Bieringer
0fa50210c9
Merge pull request #1725 from przemub/imap-auth-plain
Use AUTHENTICATE PLAIN instead of LOGIN
2025-03-06 18:31:50 +01:00
Przemysław Buczkowski
25402ab641 Use AUTHENTICATE PLAIN instead of LOGIN
Makes imaplib use more modern AUTHENTICATE verb
rather than LOGIN.
The immediate benefit is that now the credentials
can be non-ASCII.
In the future, it may be used to add other
authentication methods, such as OAuth.

References:
* https://datatracker.ietf.org/doc/html/rfc6855.html#page-5
* https://bugs.python.org/issue13700
2025-03-06 13:08:51 +00:00
Peter Bieringer
76281ad1ff tox fixes 2025-03-06 08:52:54 +01:00
Peter Bieringer
1d0ff9e84a tox fix 2025-03-06 08:51:56 +01:00
Peter Bieringer
e52056dea3 InfCloud: update related to support of bundled package 2025-03-06 08:32:27 +01:00
Peter Bieringer
75711b46dc add specific version 2025-03-06 08:32:09 +01:00
Peter Bieringer
45df5a3b94 InfCloud support 2025-03-06 08:23:50 +01:00
Peter Bieringer
2ae1762daa Infcloud: on-the-fly link activation (if available) and default content adjustment 2025-03-06 08:22:34 +01:00
Peter Bieringer
7839ac5783 InfCloud: conditional display of link 2025-03-06 08:22:34 +01:00
Peter Bieringer
4086665d16 InfCloud: extension for link 2025-03-06 08:22:34 +01:00
Peter Bieringer
78dccbdc92 fix for lint 2025-03-05 20:59:41 +01:00
Peter Bieringer
63b98913e0 add client IP in case of SSL error 2025-03-05 19:57:25 +01:00
Peter Bieringer
b729a4c192
Merge pull request #1720 from pbiering/improvements-2
Adjustments related to reverse proxy
2025-03-02 10:35:22 +01:00
Peter Bieringer
a3eb754967 fix typo 2025-03-02 10:28:46 +01:00
Peter Bieringer
d89ada0c17 Review: Apache reverse proxy config example 2025-03-02 09:14:13 +01:00
Peter Bieringer
7afff7ad2b Review: Apache reverse proxy config example 2025-03-02 09:14:02 +01:00
Peter Bieringer
451712d01d push version 2025-03-02 09:10:29 +01:00
Peter Bieringer
d7013ce726 update changelog 2025-03-02 09:09:11 +01:00
Peter Bieringer
280968e694 remove base prefix from path (required for proper handling if called by reverse proxy) 2025-03-02 09:06:30 +01:00
Peter Bieringer
7b4da3a128 detect called by reverse proxy 2025-03-02 09:05:41 +01:00
Peter Bieringer
c6bd129fa2 script_name: config check 2025-03-02 09:05:12 +01:00
Peter Bieringer
bc2444bb9a script_name: DOC 2025-03-02 09:04:36 +01:00
Peter Bieringer
dc35d4d0ad script_name: config example 2025-03-02 09:02:37 +01:00
Peter Bieringer
68f0eafe7d script_name: add config option, fixes https://github.com/Kozea/Radicale/issues/1275 2025-03-02 09:02:10 +01:00
Peter Bieringer
aa248f2b97 move: catch OSerror 2025-03-02 07:54:52 +01:00
Peter Bieringer
a2cd430f64 move: log error in case cache file cannot be moved 2025-03-02 07:54:25 +01:00
Peter Bieringer
36e33ffee1 extend copyright 2025-03-02 07:53:07 +01:00
Peter Bieringer
b8c2bc29ec display internal authentication types in online help 2025-03-01 13:16:57 +01:00
Peter Bieringer
65ce0c57e5
Merge pull request #1719 from pbiering/webui-fixes-2
Webui fixes 2
2025-02-27 21:09:32 +01:00
Peter Bieringer
2958201454 update for WebUI 2025-02-27 21:03:01 +01:00
Peter Bieringer
73681a7767 extend copyright 2025-02-27 20:13:10 +01:00
Peter Bieringer
cdbad007b6 catch OSerror on metadata update 2025-02-27 20:08:19 +01:00
Peter Bieringer
78b94b1d4d add html to display error 2025-02-27 19:40:49 +01:00
Peter Bieringer
e3ae7b3ab5 add copyright 2025-02-27 19:40:37 +01:00
Peter Bieringer
4419aa2285 display error 2025-02-27 19:40:24 +01:00
Peter Bieringer
eb8dc61952 extend copyright 2025-02-27 19:40:11 +01:00
Peter Bieringer
3a4ec11733
Merge pull request #1718 from pbiering/webui-adjustments
Webui adjustments
2025-02-27 08:52:24 +01:00
Peter Bieringer
7318f592c8 use basename of uploaded file as default collection name, support https://github.com/Kozea/Radicale/issues/1633 2025-02-27 08:32:26 +01:00
Peter Bieringer
3910457a8d remove double / for PUT 2025-02-27 08:26:19 +01:00
Peter Bieringer
fcaee51ceb remove double / for MKCOL 2025-02-27 08:09:05 +01:00
Peter Bieringer
c2013ec901 permit dot inside collection name, but not as first char, fixes https://github.com/Kozea/Radicale/issues/1632 2025-02-27 07:50:41 +01:00
Peter Bieringer
29b1da4652 takeover hints from https://github.com/Kozea/Radicale/issues/1168 2025-02-25 06:33:50 +01:00
Peter Bieringer
36a0501484
Merge pull request #1717 from pbiering/extend-module-info-list
Extend module info list
2025-02-25 06:25:45 +01:00
Peter Bieringer
0b5dd82109 extend changelog 2025-02-25 06:21:15 +01:00
Peter Bieringer
9b671beceb extend module list to display version on start 2025-02-25 06:20:14 +01:00
Peter Bieringer
50f5d2e5ef extend copyright 2025-02-25 06:20:03 +01:00
Peter Bieringer
8218081f58 fix loglevel 2025-02-25 06:19:51 +01:00
Peter Bieringer
16ece44faf pam: extend changelog 2025-02-22 18:00:23 +01:00
Peter Bieringer
5302863f53
Merge pull request #1708 from pbiering/merge-pam-auth-from-v1
Merge pam auth from v1
2025-02-22 17:56:58 +01:00
Peter Bieringer
6518f1b63a pam: add to config selector 2025-02-22 17:51:06 +01:00
Peter Bieringer
7f3fedc048 log used python version 2025-02-22 17:50:42 +01:00
Peter Bieringer
0759673e67 pam: config parser 2025-02-22 17:50:24 +01:00
Peter Bieringer
855e3743ca pam: merge+adjust module from v1 2025-02-22 17:50:07 +01:00
Peter Bieringer
c8f650bc2c extend copyright 2025-02-22 17:49:52 +01:00
Peter Bieringer
046d39b1bd pam: add support 2025-02-22 17:49:36 +01:00
Peter Bieringer
954ddea006 pam: config 2025-02-22 17:49:13 +01:00
Peter Bieringer
6683775c81 pam: doc 2025-02-22 17:48:51 +01:00
Peter Bieringer
9791a4db0f pam: doc 2025-02-22 17:48:31 +01:00
Peter Bieringer
970d4ba468 add oauth2 to example 2025-02-22 16:22:18 +01:00
Peter Bieringer
809e35689b
Merge pull request #1707 from pbiering/mtime-check-location-change
Mtime check location change
2025-02-21 07:47:12 +01:00
Peter Bieringer
c3c61c692e update changelog 2025-02-21 07:41:54 +01:00
Peter Bieringer
53251231d4 change mtime test file location to collection-root 2025-02-21 07:41:01 +01:00
Peter Bieringer
63e414850e
Merge pull request #1706 from pbiering/relax-mtime-resolution-check
Relax mtime resolution check
2025-02-20 21:22:00 +01:00
Peter Bieringer
18338b3c6e flake8 fix 2025-02-20 21:17:34 +01:00
Peter Bieringer
d5cb05f817 extend copyright 2025-02-20 21:17:24 +01:00
Peter Bieringer
4ab1cedee3 update changelog 2025-02-20 21:13:43 +01:00
Peter Bieringer
13a78d7365 relax mtime check 2025-02-20 21:12:58 +01:00
Peter Bieringer
93970a1001
Merge pull request #1700 from pbiering/skip-bcrypt-test-if-missing
Skip bcrypt test if missing
2025-02-18 06:20:16 +01:00
Peter Bieringer
c60627141f
Merge pull request #1696 from pbiering/nginx-well-known-rewrite
extend example config for nginx reverse proxy related to well-known
2025-02-18 06:15:36 +01:00
Peter Bieringer
f6b5cb8a1e make flake8 happy 2025-02-18 06:14:59 +01:00
Peter Bieringer
3914735ec0 skip bcrypt related tests if module is missing 2025-02-18 06:13:39 +01:00
Peter Bieringer
48a634af9f check whether bcrypt module is available 2025-02-18 06:13:19 +01:00
Peter Bieringer
3d50ae4a70 update changelog 2025-02-18 06:13:11 +01:00
Peter Bieringer
018978edd8 update from https://github.com/Kozea/Radicale/issues/740 2025-02-11 17:00:05 +01:00
Peter Bieringer
aa35c678ce
Merge pull request #1695 from pbiering/issue-1693-fix-http-return-codes
Issue 1693 fix http return codes
2025-02-11 16:54:41 +01:00
Peter Bieringer
19a47158bd extend changelog 2025-02-11 16:48:48 +01:00
Peter Bieringer
a62da71aa2 fix loglevel 2025-02-11 16:44:30 +01:00
Peter Bieringer
67bbc9a31b catch os error 2025-02-11 16:42:45 +01:00
Peter Bieringer
dc83c6d7d0 extend copyright 2025-02-11 16:42:45 +01:00
Peter Bieringer
484616f363 catch os error 2025-02-11 16:42:45 +01:00
Peter Bieringer
718089e3bf extend copyright 2025-02-11 16:42:45 +01:00
Peter Bieringer
b078a8f002 catch os errors 2025-02-11 16:42:45 +01:00
Peter Bieringer
fde0ecb9b2 change loglevel 2025-02-11 16:42:45 +01:00
Peter Bieringer
803763729a extend copyright 2025-02-11 16:42:45 +01:00
Peter Bieringer
37b18cf5a2 catch error during create_collection 2025-02-11 16:42:45 +01:00
Peter Bieringer
cd51581f38 extend copyright 2025-02-11 16:42:45 +01:00
Peter Bieringer
88accdb672 catch server errors and return proper message 2025-02-11 16:42:45 +01:00
Peter Bieringer
c157dd7d19 extend copyright 2025-02-11 16:42:45 +01:00
Peter Bieringer
605fc65584 improve coding 2025-02-11 16:42:45 +01:00
Peter Bieringer
f0d06cbc7d catch server errors on put 2025-02-11 16:42:45 +01:00
Peter Bieringer
77f69f2b1e add new error code 2025-02-11 16:42:45 +01:00
Peter Bieringer
b011fa4e61 extend copyright year 2025-02-11 16:42:45 +01:00
Peter Bieringer
dcaec20681 extend copyright year 2025-02-11 16:42:45 +01:00
Peter Bieringer
d79abc2b7a
Merge pull request #1692 from raguilar127/master
Fix incorrect method override in authentication plugin example
2025-02-07 06:45:48 +00:00
Rob Aguilar
938f6a97fd
Update DOCUMENTATION.md
Corrected the method override in the authentication plugin example. The original example suggested overriding login(), but BaseAuth expects _login() to be implemented instead. Overriding login() causes a Too many values to unpack error.
2025-02-06 21:56:03 -05:00
Peter Bieringer
c2def71ce6
Merge pull request #1689 from pbiering/auth-oauth2
migrate oauth2 into upstream
2025-02-02 08:17:05 +00:00
Peter Bieringer
6f68a64855 oauth2 doc 2025-02-02 09:14:04 +01:00
Peter Bieringer
f3a7641baa 3.4.2.dev 2025-02-02 09:09:08 +01:00
Peter Bieringer
cfcfbbd231 oauth2 changelog 2025-02-02 09:08:57 +01:00
Peter Bieringer
e0d20edbcd oauth2 do not throw exception in case server is not reachable 2025-02-02 09:04:42 +01:00
Peter Bieringer
d2be086cd1 oauth2 adjustments to radicale changes in the past 2025-02-02 09:04:20 +01:00
Peter Bieringer
7b6146405f make tox happy 2025-02-02 09:04:06 +01:00
Peter Bieringer
04523e5087 oauth2 config option 2025-02-02 09:03:42 +01:00
Peter Bieringer
23a68b2fb1 extend mypy options 2025-02-02 09:03:25 +01:00
Peter Bieringer
87dc5538d2 oauth2 module enabling 2025-02-02 09:01:58 +01:00
Peter Bieringer
e28b719233 oauth2 example config 2025-02-02 09:01:40 +01:00
Peter Bieringer
937acf38f7 oauth2 config check improvement 2025-02-02 08:33:49 +01:00
Peter Bieringer
063883797c add copyright 2025-02-02 08:32:42 +01:00
Peter Bieringer
30389f4525 initial from https://gitlab.mim-libre.fr/alphabet/radicale_oauth/-/blob/dev/oauth2/radicale_auth_oauth2/__init__.py 2025-02-02 08:29:02 +01:00
Peter Bieringer
780aaa7e3e clarify quick installation guides 2025-01-26 12:10:37 +01:00
42 changed files with 1153 additions and 291 deletions

View file

@ -6,7 +6,7 @@ on:
jobs:
generate:
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:

View file

@ -1,5 +1,33 @@
# Changelog
## 3.5.1.dev
* Fix: auth/htpasswd related to detection and use of bcrypt
* Add: option [auth] ldap_ignore_attribute_create_modify_timestamp for support of Authentik LDAP server
* Extend: [storage] hook supports now placeholder for "cwd" and "path" (and catches unsupported placeholders)
* Fix: location of lock file for in case of dedicated cache folder is activated
* Extend: log and create base folders if not existing during startup
## 3.5.0
* Add: option [auth] type oauth2 by code migration from https://gitlab.mim-libre.fr/alphabet/radicale_oauth/-/blob/dev/oauth2/
* Fix: catch OS errors on PUT MKCOL MKCALENDAR MOVE PROPPATCH (insufficient storage, access denied, internal server error)
* Test: skip bcrypt related tests if module is missing
* Improve: relax mtime check on storage filesystem, change test file location to "collection-root" directory
* Add: option [auth] type pam by code migration from v1, add new option pam_serivce
* Cosmetics: extend list of used modules with their version on startup
* Improve: WebUI
* Add: option [server] script_name for reverse proxy base_prefix handling
* Fix: proper base_prefix stripping if running behind reverse proxy
* Review: Apache reverse proxy config example
* Add: on-the-fly link activation and default content adjustment in case of bundled InfCloud (tested with 0.13.1)
* Adjust: [auth] imap: use AUTHENTICATE PLAIN instead of LOGIN towards remote IMAP server
* Improve: log client IP on SSL error and SSL protocol+cipher if successful
* Improve: catch htpasswd hash verification errors
* Improve: add support for more bcrypt algos on autodetection, extend logging for autodetection fallback to PLAIN in case of hash length is not matching
* Add: warning in case of started standalone and not listen on loopback interface but trusting external authentication
* Adjust: Change default [auth] type from "none" to "denyall" for secure-by-default
## 3.4.1
* Add: option [auth] dovecot_connection_type / dovecot_host / dovecot_port
* Add: option [auth] type imap by code migration from https://github.com/Unrud/RadicaleIMAP/

View file

@ -20,25 +20,19 @@ Radicale is a small but powerful CalDAV (calendars, to-do lists) and CardDAV
#### Installation
Radicale is really easy to install (for testing purposes) and works out-of-the-box.
Check
```bash
python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
python3 -m radicale --logging-level info --storage-filesystem-folder=~/.var/lib/radicale/collections
```
* [Tutorials](#tutorials)
* [Documentation](#documentation-1)
* [Wiki on GitHub](https://github.com/Kozea/Radicale/wiki)
* [Disussions on GitHub](https://github.com/Kozea/Radicale/discussions)
* [Open and already Closed Issues on GitHub](https://github.com/Kozea/Radicale/issues?q=is%3Aissue)
When the server is launched, open <http://localhost:5232> in your browser!
You can login with any username and password.
Want more? Check the [tutorials](#tutorials) and the
[documentation](#documentation-1).
Instead of downloading from PyPI look for packages provided by used [distribution](#linux-distribution-packages), they contain also startup scripts to run daemonized.
Hint: instead of downloading from PyPI look for packages provided by used [distribution](#linux-distribution-packages), they contain also startup scripts to run daemonized.
#### What's New?
Read the
[changelog on GitHub.](https://github.com/Kozea/Radicale/blob/master/CHANGELOG.md)
Read the [Changelog on GitHub](https://github.com/Kozea/Radicale/blob/master/CHANGELOG.md).
## Tutorials
@ -48,10 +42,8 @@ You want to try Radicale but only have 5 minutes free in your calendar? Let's
go right now and play a bit with Radicale!
When everything works, you can get a [client](#supported-clients)
and start creating calendars and address books. The server **only** binds to
localhost (is **not** reachable over the network) and you can log in with any
username and password. If Radicale fits your needs, it may be time for
[some basic configuration](#basic-configuration).
and start creating calendars and address books. By default, the server only binds to localhost (is not reachable over the network)
and you can log in with any user name and password. When everything works, you may get a local client and start creating calendars and address books. If Radicale fits your needs, it may be time for some [basic configuration](#basic-configuration) to support remote clients.
Follow one of the chapters below depending on your operating system.
@ -60,17 +52,56 @@ Follow one of the chapters below depending on your operating system.
First, make sure that **python** 3.9 or later and **pip** are installed. On most distributions it should be
enough to install the package ``python3-pip``.
Then open a console and type:
##### as normal user
Recommended only for testing - open a console and type:
```bash
# Run the following command as root or
# add the --user argument to only install for the current user
$ python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
$ python3 -m radicale --storage-filesystem-folder=~/.var/lib/radicale/collections
# Run the following command to only install for the current user
python3 -m pip install --user --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
```
If _install_ is not working and instead `error: externally-managed-environment` is displayed, create and activate a virtual environment in advance
```bash
python3 -m venv ~/venv
source ~/venv/bin/activate
```
and try to install with
```bash
python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
```
Start the service manually, data is stored only for the current user
```bash
# Start, data is stored for the current user only
python3 -m radicale --storage-filesystem-folder=~/.var/lib/radicale/collections
```
##### as system user (or as root)
Alternative one can install and run as system user or as root (not recommended)
```bash
# Run the following command as root (not required)
# or non-root system user (can require --user in case of dependencies are not available system-wide and/or virtual environment)
python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
```
Start the service manually, data is stored in a system folder
```bash
# Start, data is stored in a system folder (requires write permissions to /var/lib/radicale/collections)
python3 -m radicale --storage-filesystem-folder=/var/lib/radicale/collections --auth-type none
```
##### common
Victory! Open <http://localhost:5232> in your browser!
You can log in with any username and password.
You can log in with any username and password (no authentication is required as long as not proper configured - INSECURE).
#### Windows
@ -84,11 +115,11 @@ Launch a command prompt and type:
```powershell
python -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
python -m radicale --storage-filesystem-folder=~/radicale/collections
python -m radicale --storage-filesystem-folder=~/radicale/collections --auth-type none
```
Victory! Open <http://localhost:5232> in your browser!
You can log in with any username and password.
You can log in with any username and password (no authentication is required as long as not proper configured - INSECURE).
### Basic Configuration
@ -122,6 +153,12 @@ It can be stored in the same directory as the configuration file.
The `users` file can be created and managed with
[htpasswd](https://httpd.apache.org/docs/current/programs/htpasswd.html):
Note: some OS contain unpatched `htpasswd` (< 2.4.59) without supporting SHA-256 or SHA-512
(e.g. Ubuntu LTS 22), in this case use '-B' for "bcrypt" hash method or stay with
insecure MD5 (default) or SHA-1 ('-s').
Note that support of SHA-256 or SHA-512 was introduced with 3.1.9
```bash
# Create a new htpasswd file with the user "user1" using SHA-512 as hash method
$ htpasswd -5 -c /path/to/users user1
@ -493,7 +530,9 @@ RequestHeader set X-Remote-User expr=%{REMOTE_USER}
```
> **Security:** Untrusted clients should not be able to access the Radicale
> server directly. Otherwise, they can authenticate as any user.
> server directly. Otherwise, they can authenticate as any user by simply
> setting related HTTP header. This can be prevented by restrict listen to
> loopback interface only or at least a local firewall rule.
#### Secure connection between Radicale and the reverse proxy
@ -748,10 +787,12 @@ to secure TCP traffic between Radicale and a reverse proxy. If you want to
authenticate users with client-side certificates, you also have to write an
authentication plugin that extracts the username from the certificate.
Default:
Default: (unset)
##### protocol
_(>= 3.3.1)_
Accepted SSL protocol (maybe not all supported by underlying OpenSSL version)
Example for secure configuration: ALL -SSLv3 -TLSv1 -TLSv1.1
Format: Apache SSLProtocol list (from "mod_ssl")
@ -760,12 +801,22 @@ Default: (system default)
##### ciphersuite
_(>= 3.3.1)_
Accepted SSL ciphersuite (maybe not all supported by underlying OpenSSL version)
Example for secure configuration: DHE:ECDHE:-NULL:-SHA
Format: OpenSSL cipher list (see also "man openssl-ciphers")
Default: (system-default)
##### script_name
_(>= 3.5.0)_
Strip script name from URI if called by reverse proxy
Default: (taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME)
#### encoding
##### request
@ -791,6 +842,9 @@ Available backends:
`none`
: Just allows all usernames and passwords.
`denyall` _(>= 3.2.2)_
: Just denies all usernames and passwords.
`htpasswd`
: Use an
[Apache htpasswd file](https://httpd.apache.org/docs/current/programs/htpasswd.html)
@ -799,26 +853,38 @@ Available backends:
`remote_user`
: Takes the username from the `REMOTE_USER` environment variable and disables
HTTP authentication. This can be used to provide the username from a WSGI
server.
server which authenticated the client upfront. Required to validate, otherwise
client can supply the header itself which is unconditionally trusted then.
`http_x_remote_user`
: Takes the username from the `X-Remote-User` HTTP header and disables HTTP
authentication. This can be used to provide the username from a reverse
proxy.
proxy which authenticated the client upfront. Required to validate, otherwise
client can supply the header itself which is unconditionally trusted then.
`ldap`
: Use a LDAP or AD server to authenticate users.
`ldap` _(>= 3.3.0)_
: Use a LDAP or AD server to authenticate users by relaying credentials from client and handle result.
`dovecot`
: Use a Dovecot server to authenticate users.
`dovecot` _(>= 3.3.1)_
: Use a Dovecot server to authenticate users by relaying credentials from client and handle result.
`imap`
: Use a IMAP server to authenticate users.
`imap` _(>= 3.4.1)_
: Use an IMAP server to authenticate users by relaying credentials from client and handle result.
Default: `none`
`oauth2` _(>= 3.5.0)_
: Use an OAuth2 server to authenticate users by relaying credentials from client and handle result.
Oauth2 authentication (SSO) directly on client is not supported. Use herefore `http_x_remote_user`
in combination with SSO support in reverse proxy (e.g. Apache+mod_auth_openidc).
`pam` _(>= 3.5.0)_
: Use local PAM to authenticate users by relaying credentials from client and handle result..
Default: `none` _(< 3.5.0)_ `denyall` _(>= 3.5.0)_
##### cache_logins
_(>= 3.4.0)_
Cache successful/failed logins until expiration time. Enable this to avoid
overload of authentication backends.
@ -826,12 +892,16 @@ Default: `false`
##### cache_successful_logins_expiry
_(>= 3.4.0)_
Expiration time of caching successful logins in seconds
Default: `15`
##### cache_failed_logins_expiry
_(>= 3.4.0)_
Expiration time of caching failed logins in seconds
Default: `90`
@ -866,19 +936,21 @@ Available methods:
`md5`
: This uses an iterated MD5 digest of the password with a salt (nowadays insecure).
`sha256`
`sha256` _(>= 3.1.9)_
: This uses an iterated SHA-256 digest of the password with a salt.
`sha512`
`sha512` _(>= 3.1.9)_
: This uses an iterated SHA-512 digest of the password with a salt.
`autodetect`
`autodetect` _(>= 3.1.9)_
: This selects autodetection of method per entry.
Default: `autodetect`
Default: `md5` _(< 3.3.0)_ `autodetect` _(>= 3.3.0)_
##### htpasswd_cache
_(>= 3.4.0)_
Enable caching of htpasswd file based on size and mtime_ns
Default: `False`
@ -897,48 +969,64 @@ Default: `Radicale - Password Required`
##### ldap_uri
_(>= 3.3.0)_
The URI to the ldap server
Default: `ldap://localhost`
##### ldap_base
_(>= 3.3.0)_
LDAP base DN of the ldap server. This parameter must be provided if auth type is ldap.
Default:
##### ldap_reader_dn
_(>= 3.3.0)_
The DN of a ldap user with read access to get the user accounts. This parameter must be provided if auth type is ldap.
Default:
##### ldap_secret
_(>= 3.3.0)_
The password of the ldap_reader_dn. Either this parameter or `ldap_secret_file` must be provided if auth type is ldap.
Default:
##### ldap_secret_file
_(>= 3.3.0)_
Path of the file containing the password of the ldap_reader_dn. Either this parameter or `ldap_secret` must be provided if auth type is ldap.
Default:
##### ldap_filter
_(>= 3.3.0)_
The search filter to find the user DN to authenticate by the username. User '{0}' as placeholder for the user name.
Default: `(cn={0})`
##### ldap_user_attribute
_(>= 3.4.0)_
The LDAP attribute whose value shall be used as the user name after successful authentication
Default: not set, i.e. the login name given is used directly.
##### ldap_groups_attribute
_(>= 3.4.0)_
The LDAP attribute to read the group memberships from in the authenticated user's LDAP entry.
If set, load the LDAP group memberships from the attribute given
@ -950,28 +1038,46 @@ This also gives you access to the group calendars, if they exist.
Use 'memberOf' if you want to load groups on Active Directory and alikes, 'groupMembership' on Novell eDirectory, ...
Default: unset
Default: (unset)
##### ldap_use_ssl
_(>= 3.3.0)_
Use ssl on the ldap connection
Default: False
##### ldap_ssl_verify_mode
_(>= 3.3.0)_
The certificate verification mode. NONE, OPTIONAL or REQUIRED
Default: REQUIRED
##### ldap_ssl_ca_file
_(>= 3.3.0)_
The path to the CA file in pem format which is used to certificate the server certificate
Default:
##### ldap_ignore_attribute_create_modify_timestamp
_(>= 3.5.1)_
Add modifyTimestamp and createTimestamp to the exclusion list of internal ldap3 client
so that these schema attributes are not checked. This is needed at least for Authentik
LDAP server as not providing these both attributes.
Default: false
##### dovecot_connection_type = AF_UNIX
_(>= 3.4.1)_
Connection type for dovecot authentication (AF_UNIX|AF_INET|AF_INET6)
Note: credentials are transmitted in cleartext
@ -980,34 +1086,68 @@ Default: `AF_UNIX`
##### dovecot_socket
_(>= 3.3.1)_
The path to the Dovecot client authentication socket (eg. /run/dovecot/auth-client on Fedora). Radicale must have read / write access to the socket.
Default: `/var/run/dovecot/auth-client`
##### dovecot_host
_(>= 3.4.1)_
Host of via network exposed dovecot socket
Default: `localhost`
##### dovecot_port
_(>= 3.4.1)_
Port of via network exposed dovecot socket
Default: `12345`
##### imap_host
_(>= 3.4.1)_
IMAP server hostname: address | address:port | [address]:port | imap.server.tld
Default: `localhost`
##### imap_security
_(>= 3.4.1)_
Secure the IMAP connection: tls | starttls | none
Default: `tls`
##### oauth2_token_endpoint
_(>= 3.5.0)_
OAuth2 token endpoint URL
Default:
##### pam_service
_(>= 3.5.0)_
PAM service
Default: radicale
##### pam_group_membership
_(>= 3.5.0)_
PAM group user should be member of
Default:
##### lc_username
Сonvert username to lowercase, must be true for case-insensitive auth
@ -1019,6 +1159,8 @@ Note: cannot be enabled together with `uc_username`
##### uc_username
_(>= 3.3.2)_
Сonvert username to uppercase, must be true for case-insensitive auth
providers like ldap, kerberos
@ -1028,6 +1170,8 @@ Note: cannot be enabled together with `lc_username`
##### strip_domain
_(>= 3.2.3)_
Strip domain from username
Default: `False`
@ -1069,7 +1213,7 @@ File for the rights backend `from_file`. See the
##### permit_delete_collection
(New since 3.1.9)
_(>= 3.1.9)_
Global control of permission to delete complete collection (default: True)
@ -1078,7 +1222,7 @@ If True it can be forbidden by permissions per section with: d
##### permit_overwrite_collection
(New since 3.3.0)
_(>= 3.3.0)_
Global control of permission to overwrite complete collection (default: True)
@ -1110,6 +1254,8 @@ Default: `/var/lib/radicale/collections`
##### filesystem_cache_folder
_(>= 3.3.2)_
Folder for storing cache of local collections, created if not present
Default: (filesystem_folder)
@ -1120,6 +1266,8 @@ Note: can be used on multi-instance setup to cache files on local node (see belo
##### use_cache_subfolder_for_item
_(>= 3.3.2)_
Use subfolder `collection-cache` for cache file structure of 'item' instead of inside collection folders, created if not present
Default: `False`
@ -1128,6 +1276,8 @@ Note: can be used on multi-instance setup to cache 'item' on local node
##### use_cache_subfolder_for_history
_(>= 3.3.2)_
Use subfolder `collection-cache` for cache file structure of 'history' instead of inside collection folders, created if not present
Default: `False`
@ -1136,6 +1286,8 @@ Note: use only on single-instance setup, will break consistency with client in m
##### use_cache_subfolder_for_synctoken
_(>= 3.3.2)_
Use subfolder `collection-cache` for cache file structure of 'sync-token' instead of inside collection folders, created if not present
Default: `False`
@ -1144,6 +1296,8 @@ Note: use only on single-instance setup, will break consistency with client in m
##### use_mtime_and_size_for_item_cache
_(>= 3.3.2)_
Use last modifiction time (nanoseconds) and size (bytes) for 'item' cache instead of SHA256 (improves speed)
Default: `False`
@ -1154,6 +1308,8 @@ Note: conversion is done on access, bulk conversion can be done offline using st
##### folder_umask
_(>= 3.3.2)_
Use configured umask for folder creation (not applicable for OS Windows)
Default: (system-default, usual `0022`)
@ -1168,6 +1324,8 @@ Default: `2592000`
##### skip_broken_item
_(>= 3.2.2)_
Skip broken item instead of triggering an exception
Default: `True`
@ -1180,7 +1338,9 @@ Command that is run after changes to storage. Take a look at the
Default:
Supported placeholders:
- `%(user)`: logged-in user
- `%(user)s`: logged-in user
- `%(cwd)s`: current working directory _(>= 3.5.1)_
- `%(path)s`: full path of item _(>= 3.5.1)_
Command will be executed with base directory defined in `filesystem_folder` (see above)
@ -1228,7 +1388,7 @@ Set the logging level.
Available levels: **debug**, **info**, **warning**, **error**, **critical**
Default: `warning`
Default: `warning` _(< 3.2.0)_ `info` _(>= 3.2.0)_
##### mask_passwords
@ -1238,30 +1398,40 @@ Default: `True`
##### bad_put_request_content
_(>= 3.2.1)_
Log bad PUT request content (for further diagnostics)
Default: `False`
##### backtrace_on_debug
_(>= 3.2.2)_
Log backtrace on level=debug
Default: `False`
##### request_header_on_debug
_(>= 3.2.2)_
Log request on level=debug
Default: `False`
##### request_content_on_debug
_(>= 3.2.2)_
Log request on level=debug
Default: `False`
##### response_content_on_debug
_(>= 3.2.2)_
Log response on level=debug
Default: `False`
@ -1274,6 +1444,8 @@ Default: `False`
##### storage_cache_actions_on_debug
_(>= 3.3.2)_
Log storage cache actions on level=debug
Default: `False`
@ -1299,13 +1471,15 @@ Available types:
`none`
: Disabled. Nothing will be notified.
`rabbitmq`
`rabbitmq` _(>= 3.2.0)_
: Push the message to the rabbitmq server.
Default: `none`
##### rabbitmq_endpoint
_(>= 3.2.0)_
End-point address for rabbitmq server.
Ex: amqp://user:password@localhost:5672/
@ -1313,19 +1487,26 @@ Default:
##### rabbitmq_topic
_(>= 3.2.0)_
RabbitMQ topic to publish message.
Default:
##### rabbitmq_queue_type
_(>= 3.2.0)_
RabbitMQ queue type for the topic.
Default: classic
#### reporting
##### max_freebusy_occurrence
_(>= 3.2.3)_
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
@ -1408,16 +1589,13 @@ It will list your existing address books.
#### InfCloud, CalDavZAP and CardDavMATE
You can integrate InfCloud into Radicale's web interface with
[RadicaleInfCloud](https://github.com/Unrud/RadicaleInfCloud). No additional
configuration is required.
You can integrate InfCloud into Radicale's web interface with by simply
download latest package from [InfCloud](https://www.inf-it.com/open-source/clients/infcloud/)
and extract content to new folder `infcloud` in `radicale/web/internal_data/`.
Set the URL of the Radicale server in ``config.js``. If **InfCloud** is not
hosted on the same server and port as Radicale, the browser will deny access to
the Radicale server, because of the
[same-origin policy](https://en.wikipedia.org/wiki/Same-origin_policy).
You have to add additional HTTP header in the `headers` section of Radicale's
configuration. The documentation of **InfCloud** has more details on this.
No further adjustments are required as content is adjusted on the fly (tested with 0.13.1).
See also [Wiki/Client InfCloud](https://github.com/Kozea/Radicale/wiki/Client-InfCloud).
#### Command line
@ -1543,8 +1721,8 @@ The following `permissions` are recognized:
(CalDAV/CardDAV is susceptible to expensive search requests)
* **W:** write collections (excluding address books and calendars)
* **w:** write address book and calendar collections
* **D:** permit delete of collection in case permit_delete_collection=False
* **d:** forbid delete of collection in case permit_delete_collection=True
* **D:** permit delete of collection in case permit_delete_collection=False _(>= 3.3.0)_
* **d:** forbid delete of collection in case permit_delete_collection=True _(>= 3.3.0)_
* **O:** permit overwrite of collection in case permit_overwrite_collection=False
* **o:** forbid overwrite of collection in case permit_overwrite_collection=True
@ -1799,7 +1977,7 @@ class Auth(BaseAuth):
def __init__(self, configuration):
super().__init__(configuration.copy(PLUGIN_CONFIG_SCHEMA))
def login(self, login, password):
def _login(self, login, password):
# Get password from configuration option
static_password = self.configuration.get("auth", "password")
# Check authentication

26
config
View file

@ -46,6 +46,9 @@
# SSL ciphersuite, secure configuration: DHE:ECDHE:-NULL:-SHA (see also "man openssl-ciphers")
#ciphersuite = (default)
# script name to strip from URI if called by reverse proxy
#script_name = (default taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME)
[encoding]
@ -59,8 +62,8 @@
[auth]
# Authentication method
# Value: none | htpasswd | remote_user | http_x_remote_user | dovecot | ldap | denyall
#type = none
# Value: none | htpasswd | remote_user | http_x_remote_user | dovecot | ldap | oauth2 | pam | denyall
#type = denyall
# Cache logins for until expiration time
#cache_logins = false
@ -71,6 +74,9 @@
## Expiration time of caching failed logins in seconds
#cache_failed_logins_expiry = 90
# Ignore modifyTimestamp and createTimestamp attributes. Required e.g. for Authentik LDAP server
#ldap_ignore_attribute_create_modify_timestamp = false
# URI to the LDAP server
#ldap_uri = ldap://localhost
@ -125,6 +131,15 @@
# Value: tls | starttls | none
#imap_security = tls
# OAuth2 token endpoint URL
#oauth2_token_endpoint = <URL>
# PAM service
#pam_serivce = radicale
# PAM group user should be member of
#pam_group_membership =
# Htpasswd filename
#htpasswd_filename = /etc/radicale/users
@ -208,10 +223,13 @@
# Command that is run after changes to storage, default is emtpy
# Supported placeholders:
# %(user): logged-in user
# %(user)s: logged-in user
# %(cwd)s : current working directory
# %(path)s: full path of item
# Command will be executed with base directory defined in filesystem_folder
# For "git" check DOCUMENTATION.md for bootstrap instructions
# Example: git add -A && (git diff --cached --quiet || git commit -m "Changes by \"%(user)s\"")
# Example(test): echo \"user=%(user)s path=%(path)s cwd=%(cwd)s\"
# Example(git): git add -A && (git diff --cached --quiet || git commit -m "Changes by \"%(user)s\"")
#hook =
# Create predefined user collections

View file

@ -4,6 +4,7 @@
## 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
# URI prefix: /radicale
#Define RADICALE_SERVER_REVERSE_PROXY
@ -11,11 +12,12 @@
# 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
# URI prefix: /radicale
#Define RADICALE_SERVER_WSGI
### Extra options
## Apache starting a dedicated VHOST with SSL
## Apache starting a dedicated VHOST with SSL without "/radicale" prefix in URI on port 8443
#Define RADICALE_SERVER_VHOST_SSL
@ -27,8 +29,13 @@
#Define RADICALE_ENFORCE_SSL
### enable authentication by web server (config: [auth] type = http_x_remote_user)
#Define RADICALE_SERVER_USER_AUTHENTICATION
### Particular configuration EXAMPLES, adjust/extend/override to your needs
##########################
### default host
##########################
@ -37,9 +44,14 @@
## RADICALE_SERVER_REVERSE_PROXY
<IfDefine RADICALE_SERVER_REVERSE_PROXY>
RewriteEngine On
RewriteRule ^/radicale$ /radicale/ [R,L]
<Location /radicale>
RewriteCond %{REQUEST_METHOD} GET
RewriteRule ^/radicale/$ /radicale/.web/ [R,L]
<LocationMatch "^/radicale/\.web.*>
# Internal WebUI does not need authentication at all
RequestHeader set X-Script-Name /radicale
RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s"
@ -48,21 +60,40 @@
ProxyPass http://localhost:5232/ retry=0
ProxyPassReverse http://localhost:5232/
## User authentication handled by "radicale"
Require local
<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
Require all granted
</IfDefine>
</LocationMatch>
## You may want to use apache's authentication (config: [auth] type = http_x_remote_user)
## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser
#AuthBasicProvider file
#AuthType Basic
#AuthName "Enter your credentials"
#AuthUserFile /etc/httpd/conf/htpasswd-radicale
#AuthGroupFile /dev/null
#Require valid-user
#RequestHeader set X-Remote-User expr=%{REMOTE_USER}
<LocationMatch "^/radicale(?!/\.web)">
RequestHeader set X-Script-Name /radicale
RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s"
RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME}
ProxyPass http://localhost:5232/ retry=0
ProxyPassReverse http://localhost:5232/
<IfDefine !RADICALE_SERVER_USER_AUTHENTICATION>
## User authentication handled by "radicale"
Require local
<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
Require all granted
</IfDefine>
</IfDefine>
<IfDefine RADICALE_SERVER_USER_AUTHENTICATION>
## You may want to use apache's authentication (config: [auth] type = http_x_remote_user)
## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser
AuthBasicProvider file
AuthType Basic
AuthName "Enter your credentials"
AuthUserFile /etc/httpd/conf/htpasswd-radicale
AuthGroupFile /dev/null
Require valid-user
RequestHeader set X-Remote-User expr=%{REMOTE_USER}
</IfDefine>
<IfDefine RADICALE_ENFORCE_SSL>
<IfModule !ssl_module>
@ -70,7 +101,7 @@
</IfModule>
SSLRequireSSL
</IfDefine>
</Location>
</LocationMatch>
</IfDefine>
@ -96,24 +127,38 @@
WSGIScriptAlias /radicale /usr/share/radicale/radicale.wsgi
<Location /radicale>
# Internal WebUI does not need authentication at all
<LocationMatch "^/radicale/\.web.*>
RequestHeader set X-Script-Name /radicale
## User authentication handled by "radicale"
Require local
<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
Require all granted
</IfDefine>
</LocationMatch>
## You may want to use apache's authentication (config: [auth] type = http_x_remote_user)
## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser
#AuthBasicProvider file
#AuthType Basic
#AuthName "Enter your credentials"
#AuthUserFile /etc/httpd/conf/htpasswd-radicale
#AuthGroupFile /dev/null
#Require valid-user
#RequestHeader set X-Remote-User expr=%{REMOTE_USER}
<LocationMatch "^/radicale(?!/\.web)">
RequestHeader set X-Script-Name /radicale
<IfDefine !RADICALE_SERVER_USER_AUTHENTICATION>
## User authentication handled by "radicale"
Require local
<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
Require all granted
</IfDefine>
</IfDefine>
<IfDefine RADICALE_SERVER_USER_AUTHENTICATION>
## You may want to use apache's authentication (config: [auth] type = http_x_remote_user)
## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser
AuthBasicProvider file
AuthType Basic
AuthName "Enter your credentials"
AuthUserFile /etc/httpd/conf/htpasswd-radicale
AuthGroupFile /dev/null
Require valid-user
RequestHeader set X-Remote-User expr=%{REMOTE_USER}
</IfDefine>
<IfDefine RADICALE_ENFORCE_SSL>
<IfModule !ssl_module>
@ -121,7 +166,7 @@
</IfModule>
SSLRequireSSL
</IfDefine>
</Location>
</LocationMatch>
</IfModule>
<IfModule !wsgi_module>
Error "RADICALE_SERVER_WSGI selected but wsgi module not loaded/enabled"
@ -165,30 +210,51 @@ CustomLog logs/ssl_request_log "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b"
## RADICALE_SERVER_REVERSE_PROXY
<IfDefine RADICALE_SERVER_REVERSE_PROXY>
<Location />
RequestHeader set X-Script-Name /
RewriteEngine On
RewriteCond %{REQUEST_METHOD} GET
RewriteRule ^/$ /.web/ [R,L]
<LocationMatch "^/\.web.*>
RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s"
RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME}
ProxyPass http://localhost:5232/ retry=0
ProxyPassReverse http://localhost:5232/
## User authentication handled by "radicale"
Require local
<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
Require all granted
</IfDefine>
</LocationMatch>
## You may want to use apache's authentication (config: [auth] type = http_x_remote_user)
## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser
#AuthBasicProvider file
#AuthType Basic
#AuthName "Enter your credentials"
#AuthUserFile /etc/httpd/conf/htpasswd-radicale
#AuthGroupFile /dev/null
#Require valid-user
</Location>
<LocationMatch "^(?!/\.web)">
RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s"
RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME}
ProxyPass http://localhost:5232/ retry=0
ProxyPassReverse http://localhost:5232/
<IfDefine !RADICALE_SERVER_USER_AUTHENTICATION>
## User authentication handled by "radicale"
Require local
<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
Require all granted
</IfDefine>
</IfDefine>
<IfDefine RADICALE_SERVER_USER_AUTHENTICATION>
## You may want to use apache's authentication (config: [auth] type = http_x_remote_user)
## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser
AuthBasicProvider file
AuthType Basic
AuthName "Enter your credentials"
AuthUserFile /etc/httpd/conf/htpasswd-radicale
AuthGroupFile /dev/null
Require valid-user
RequestHeader set X-Remote-User expr=%{REMOTE_USER}
</IfDefine>
</LocationMatch>
</IfDefine>
@ -214,24 +280,27 @@ CustomLog logs/ssl_request_log "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b"
WSGIScriptAlias / /usr/share/radicale/radicale.wsgi
<Location />
RequestHeader set X-Script-Name /
## User authentication handled by "radicale"
Require local
<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
Require all granted
<LocationMatch "^/(?!/\.web)">
<IfDefine !RADICALE_SERVER_USER_AUTHENTICATION>
## User authentication handled by "radicale"
Require local
<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
Require all granted
</IfDefine>
</IfDefine>
## You may want to use apache's authentication (config: [auth] type = http_x_remote_user)
## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser
#AuthBasicProvider file
#AuthType Basic
#AuthName "Enter your credentials"
#AuthUserFile /etc/httpd/conf/htpasswd-radicale
#AuthGroupFile /dev/null
#Require valid-user
</Location>
<IfDefine RADICALE_SERVER_USER_AUTHENTICATION>
## You may want to use apache's authentication (config: [auth] type = http_x_remote_user)
## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser
AuthBasicProvider file
AuthType Basic
AuthName "Enter your credentials"
AuthUserFile /etc/httpd/conf/htpasswd-radicale
AuthGroupFile /dev/null
Require valid-user
RequestHeader set X-Remote-User expr=%{REMOTE_USER}
</IfDefine>
</LocationMatch>
</IfModule>
<IfModule !wsgi_module>
Error "RADICALE_SERVER_WSGI selected but wsgi module not loaded/enabled"

View file

@ -2,6 +2,10 @@
###
### Usual configuration file location: /etc/nginx/default.d/
## "well-known" redirect at least for Apple devices
rewrite ^/.well-known/carddav /radicale/ redirect;
rewrite ^/.well-known/caldav /radicale/ redirect;
## Base URI: /radicale/
location /radicale/ {
proxy_pass http://localhost:5232/;

View file

@ -3,7 +3,7 @@ name = "Radicale"
# When the version is updated, a new section in the CHANGELOG.md file must be
# added too.
readme = "README.md"
version = "3.4.1"
version = "3.5.1.dev"
authors = [{name = "Guillaume Ayoub", email = "guillaume.ayoub@kozea.fr"}, {name = "Unrud", email = "unrud@outlook.com"}, {name = "Peter Bieringer", email = "pb@bieringer.de"}]
license = {text = "GNU GPL v3"}
description = "CalDAV and CardDAV Server"
@ -33,6 +33,7 @@ dependencies = [
"passlib",
"vobject>=0.9.6",
"pika>=1.1.0",
"requests",
]
@ -72,7 +73,7 @@ skip_install = true
[tool.tox.env.mypy]
deps = ["mypy==1.11.0"]
commands = [["mypy", "."]]
commands = [["mypy", "--install-types", "--non-interactive", "."]]
skip_install = true

View file

@ -3,4 +3,8 @@ Radicale WSGI file (mod_wsgi and uWSGI compliant).
"""
import os
from radicale import application
# set an environment variable
os.environ.setdefault('SERVER_GATEWAY_INTERFACE', 'Web')

View file

@ -68,6 +68,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
_internal_server: bool
_max_content_length: int
_auth_realm: str
_script_name: str
_extra_headers: Mapping[str, str]
_permit_delete_collection: bool
_permit_overwrite_collection: bool
@ -87,6 +88,19 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
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._script_name = configuration.get("server", "script_name")
if self._script_name:
if self._script_name[0] != "/":
logger.error("server.script_name must start with '/': %r", self._script_name)
raise RuntimeError("server.script_name option has to start with '/'")
else:
if self._script_name.endswith("/"):
logger.error("server.script_name must not end with '/': %r", self._script_name)
raise RuntimeError("server.script_name option must not end with '/'")
else:
logger.info("Provided script name to strip from URI if called by reverse proxy: %r", self._script_name)
else:
logger.info("Default script name to strip from URI if called by reverse proxy is taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME")
self._max_content_length = configuration.get(
"server", "max_content_length")
self._auth_realm = configuration.get("auth", "realm")
@ -136,6 +150,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
time_begin = datetime.datetime.now()
request_method = environ["REQUEST_METHOD"].upper()
unsafe_path = environ.get("PATH_INFO", "")
https = environ.get("HTTPS", "")
"""Manage a request."""
def response(status: int, headers: types.WSGIResponseHeaders,
@ -178,23 +193,31 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
# Return response content
return status_text, list(headers.items()), answers
reverse_proxy = False
remote_host = "unknown"
if environ.get("REMOTE_HOST"):
remote_host = repr(environ["REMOTE_HOST"])
elif environ.get("REMOTE_ADDR"):
remote_host = environ["REMOTE_ADDR"]
if environ.get("HTTP_X_FORWARDED_FOR"):
reverse_proxy = True
remote_host = "%s (forwarded for %r)" % (
remote_host, environ["HTTP_X_FORWARDED_FOR"])
if environ.get("HTTP_X_FORWARDED_HOST") or environ.get("HTTP_X_FORWARDED_PROTO") or environ.get("HTTP_X_FORWARDED_SERVER"):
reverse_proxy = True
remote_useragent = ""
if environ.get("HTTP_USER_AGENT"):
remote_useragent = " using %r" % environ["HTTP_USER_AGENT"]
depthinfo = ""
if environ.get("HTTP_DEPTH"):
depthinfo = " with depth %r" % environ["HTTP_DEPTH"]
logger.info("%s request for %r%s received from %s%s",
if https:
https_info = " " + environ.get("SSL_PROTOCOL", "") + " " + environ.get("SSL_CIPHER", "")
else:
https_info = ""
logger.info("%s request for %r%s received from %s%s%s",
request_method, unsafe_path, depthinfo,
remote_host, remote_useragent)
remote_host, remote_useragent, https_info)
if self._request_header_on_debug:
logger.debug("Request header:\n%s",
pprint.pformat(self._scrub_headers(environ)))
@ -204,24 +227,37 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
# SCRIPT_NAME is already removed from PATH_INFO, according to the
# WSGI specification.
# Reverse proxies can overwrite SCRIPT_NAME with X-SCRIPT-NAME header
base_prefix_src = ("HTTP_X_SCRIPT_NAME" if "HTTP_X_SCRIPT_NAME" in
environ else "SCRIPT_NAME")
base_prefix = environ.get(base_prefix_src, "")
if base_prefix and base_prefix[0] != "/":
logger.error("Base prefix (from %s) must start with '/': %r",
base_prefix_src, base_prefix)
if base_prefix_src == "HTTP_X_SCRIPT_NAME":
return response(*httputils.BAD_REQUEST)
return response(*httputils.INTERNAL_SERVER_ERROR)
if base_prefix.endswith("/"):
logger.warning("Base prefix (from %s) must not end with '/': %r",
base_prefix_src, base_prefix)
base_prefix = base_prefix.rstrip("/")
logger.debug("Base prefix (from %s): %r", base_prefix_src, base_prefix)
if self._script_name and (reverse_proxy is True):
base_prefix_src = "config"
base_prefix = self._script_name
else:
base_prefix_src = ("HTTP_X_SCRIPT_NAME" if "HTTP_X_SCRIPT_NAME" in
environ else "SCRIPT_NAME")
base_prefix = environ.get(base_prefix_src, "")
if base_prefix and base_prefix[0] != "/":
logger.error("Base prefix (from %s) must start with '/': %r",
base_prefix_src, base_prefix)
if base_prefix_src == "HTTP_X_SCRIPT_NAME":
return response(*httputils.BAD_REQUEST)
return response(*httputils.INTERNAL_SERVER_ERROR)
if base_prefix.endswith("/"):
logger.warning("Base prefix (from %s) must not end with '/': %r",
base_prefix_src, base_prefix)
base_prefix = base_prefix.rstrip("/")
if base_prefix:
logger.debug("Base prefix (from %s): %r", base_prefix_src, base_prefix)
# Sanitize request URI (a WSGI server indicates with an empty path,
# that the URL targets the application root without a trailing slash)
path = pathutils.sanitize_path(unsafe_path)
logger.debug("Sanitized path: %r", path)
if (reverse_proxy is True) and (len(base_prefix) > 0):
if path.startswith(base_prefix):
path_new = path.removeprefix(base_prefix)
logger.debug("Called by reverse proxy, remove base prefix %r from path: %r => %r", base_prefix, path, path_new)
path = path_new
else:
logger.warning("Called by reverse proxy, cannot removed base prefix %r from path: %r as not matching", base_prefix, path)
# Get function corresponding to method
function = getattr(self, "do_%s" % request_method, None)

View file

@ -66,6 +66,8 @@ class ApplicationPartGet(ApplicationBase):
if path == "/.web" or path.startswith("/.web/"):
# Redirect to sanitized path for all subpaths of /.web
unsafe_path = environ.get("PATH_INFO", "")
if len(base_prefix) > 0:
unsafe_path = unsafe_path.removeprefix(base_prefix)
if unsafe_path != path:
location = base_prefix + path
logger.info("Redirecting to sanitized path: %r ==> %r",

View file

@ -2,7 +2,8 @@
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -17,7 +18,9 @@
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
import errno
import posixpath
import re
import socket
from http import client
@ -70,7 +73,20 @@ class ApplicationPartMkcalendar(ApplicationBase):
try:
self._storage.create_collection(path, props=props)
except ValueError as e:
logger.warning(
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
# return better matching HTTP result in case errno is provided and catched
errno_match = re.search("\\[Errno ([0-9]+)\\]", str(e))
if errno_match:
logger.error(
"Failed MKCALENDAR request on %r: %s", path, e, exc_info=True)
errno_e = int(errno_match.group(1))
if errno_e == errno.ENOSPC:
return httputils.INSUFFICIENT_STORAGE
elif errno_e in [errno.EPERM, errno.EACCES]:
return httputils.FORBIDDEN
else:
return httputils.INTERNAL_SERVER_ERROR
else:
logger.warning(
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
return client.CREATED, {}, None

View file

@ -2,7 +2,8 @@
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -17,7 +18,9 @@
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
import errno
import posixpath
import re
import socket
from http import client
@ -74,8 +77,21 @@ class ApplicationPartMkcol(ApplicationBase):
try:
self._storage.create_collection(path, props=props)
except ValueError as e:
logger.warning(
"Bad MKCOL request on %r (type:%s): %s", path, collection_type, e, exc_info=True)
return httputils.BAD_REQUEST
# return better matching HTTP result in case errno is provided and catched
errno_match = re.search("\\[Errno ([0-9]+)\\]", str(e))
if errno_match:
logger.error(
"Failed MKCOL request on %r (type:%s): %s", path, collection_type, e, exc_info=True)
errno_e = int(errno_match.group(1))
if errno_e == errno.ENOSPC:
return httputils.INSUFFICIENT_STORAGE
elif errno_e in [errno.EPERM, errno.EACCES]:
return httputils.FORBIDDEN
else:
return httputils.INTERNAL_SERVER_ERROR
else:
logger.warning(
"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

View file

@ -2,7 +2,8 @@
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2017-2023 Unrud <unrud@outlook.com>
# Copyright © 2023-2025 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -17,6 +18,7 @@
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
import errno
import posixpath
import re
from http import client
@ -109,7 +111,20 @@ class ApplicationPartMove(ApplicationBase):
try:
self._storage.move(item, to_collection, to_href)
except ValueError as e:
logger.warning(
"Bad MOVE request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
# return better matching HTTP result in case errno is provided and catched
errno_match = re.search("\\[Errno ([0-9]+)\\]", str(e))
if errno_match:
logger.error(
"Failed MOVE request on %r: %s", path, e, exc_info=True)
errno_e = int(errno_match.group(1))
if errno_e == errno.ENOSPC:
return httputils.INSUFFICIENT_STORAGE
elif errno_e in [errno.EPERM, errno.EACCES]:
return httputils.FORBIDDEN
else:
return httputils.INTERNAL_SERVER_ERROR
else:
logger.warning(
"Bad MOVE request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
return client.NO_CONTENT if to_item else client.CREATED, {}, None

View file

@ -2,7 +2,9 @@
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2017-2020 Unrud <unrud@outlook.com>
# Copyright © 2020-2020 Tuna Celik <tuna@jakpark.com>
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -17,6 +19,8 @@
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
import errno
import re
import socket
import xml.etree.ElementTree as ET
from http import client
@ -107,7 +111,20 @@ class ApplicationPartProppatch(ApplicationBase):
)
self._hook.notify(hook_notification_item)
except ValueError as e:
logger.warning(
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
# return better matching HTTP result in case errno is provided and catched
errno_match = re.search("\\[Errno ([0-9]+)\\]", str(e))
if errno_match:
logger.error(
"Failed PROPPATCH request on %r: %s", path, e, exc_info=True)
errno_e = int(errno_match.group(1))
if errno_e == errno.ENOSPC:
return httputils.INSUFFICIENT_STORAGE
elif errno_e in [errno.EPERM, errno.EACCES]:
return httputils.FORBIDDEN
else:
return httputils.INTERNAL_SERVER_ERROR
else:
logger.warning(
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
return client.MULTI_STATUS, headers, self._xml_response(xml_answer)

View file

@ -4,7 +4,7 @@
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2020 Unrud <unrud@outlook.com>
# Copyright © 2020-2023 Tuna Celik <tuna@jakpark.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -19,8 +19,10 @@
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
import errno
import itertools
import posixpath
import re
import socket
import sys
from http import client
@ -163,7 +165,7 @@ class ApplicationPartPut(ApplicationBase):
bool(rights.intersect(access.permissions, "Ww")),
bool(rights.intersect(access.parent_permissions, "w")))
with self._storage.acquire_lock("w", user):
with self._storage.acquire_lock("w", user, path=path):
item = next(iter(self._storage.discover(path)), None)
parent_item = next(iter(
self._storage.discover(access.parent_path)), None)
@ -264,9 +266,22 @@ class ApplicationPartPut(ApplicationBase):
)
self._hook.notify(hook_notification_item)
except ValueError as e:
logger.warning(
"Bad PUT request on %r (upload): %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
# return better matching HTTP result in case errno is provided and catched
errno_match = re.search("\\[Errno ([0-9]+)\\]", str(e))
if errno_match:
logger.error(
"Failed PUT request on %r (upload): %s", path, e, exc_info=True)
errno_e = int(errno_match.group(1))
if errno_e == errno.ENOSPC:
return httputils.INSUFFICIENT_STORAGE
elif errno_e in [errno.EPERM, errno.EACCES]:
return httputils.FORBIDDEN
else:
return httputils.INTERNAL_SERVER_ERROR
else:
logger.warning(
"Bad PUT request on %r (upload): %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
headers = {"ETag": etag}
return client.CREATED, headers, None

View file

@ -2,7 +2,11 @@
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Pieter Hijma <pieterhijma@users.noreply.github.com>
# Copyright © 2024-2024 Ray <ray@react0r.com>
# Copyright © 2024-2024 Georgiy <metallerok@gmail.com>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -171,7 +175,11 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
xmlutils.make_human_tag(root.tag), path)
return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
props: Union[ET.Element, List] = root.find(xmlutils.make_clark("D:prop")) or []
props: Union[ET.Element, List]
if root.find(xmlutils.make_clark("D:prop")) is not None:
props = root.find(xmlutils.make_clark("D:prop")) # type: ignore[assignment]
else:
props = []
hreferences: Iterable[str]
if root.tag in (

View file

@ -30,9 +30,10 @@ Take a look at the class ``BaseAuth`` if you want to implement your own.
"""
import hashlib
import os
import threading
import time
from typing import Sequence, Set, Tuple, Union, final
from typing import List, Sequence, Set, Tuple, Union, final
from radicale import config, types, utils
from radicale.log import logger
@ -42,6 +43,8 @@ INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user",
"htpasswd",
"ldap",
"imap",
"oauth2",
"pam",
"dovecot")
CACHE_LOGIN_TYPES: Sequence[str] = (
@ -49,6 +52,13 @@ CACHE_LOGIN_TYPES: Sequence[str] = (
"ldap",
"htpasswd",
"imap",
"oauth2",
"pam",
)
INSECURE_IF_NO_LOOPBACK_TYPES: Sequence[str] = (
"remote_user",
"http_x_remote_user",
)
AUTH_SOCKET_FAMILY: Sequence[str] = ("AF_UNIX", "AF_INET", "AF_INET6")
@ -56,10 +66,26 @@ AUTH_SOCKET_FAMILY: Sequence[str] = ("AF_UNIX", "AF_INET", "AF_INET6")
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'")
_type = configuration.get("auth", "type")
if _type == "none":
logger.warning("No user authentication is selected: '[auth] type=none' (INSECURE)")
elif _type == "denyall":
logger.warning("All user authentication is blocked by: '[auth] type=denyall'")
elif _type in INSECURE_IF_NO_LOOPBACK_TYPES:
sgi = os.environ.get('SERVER_GATEWAY_INTERFACE') or None
if not sgi:
hosts: List[Tuple[str, int]] = configuration.get("server", "hosts")
localhost_only = True
address_lo = []
address = []
for address_port in hosts:
if address_port[0] in ["localhost", "localhost6", "127.0.0.1", "::1"]:
address_lo.append(utils.format_address(address_port))
else:
address.append(utils.format_address(address_port))
localhost_only = False
if localhost_only is False:
logger.warning("User authentication '[auth] type=%s' is selected but server is not only listen on loopback address (potentially INSECURE): %s", _type, " ".join(address))
return utils.load_plugin(INTERNAL_TYPES, "auth", "Auth", BaseAuth,
configuration)

View file

@ -51,6 +51,7 @@ When bcrypt is installed:
import functools
import hmac
import os
import re
import threading
import time
from typing import Any, Tuple
@ -73,6 +74,7 @@ class Auth(auth.BaseAuth):
_htpasswd_bcrypt_use: int
_htpasswd_cache: bool
_has_bcrypt: bool
_encryption: str
_lock: threading.Lock
def __init__(self, configuration: config.Configuration) -> None:
@ -83,8 +85,8 @@ class Auth(auth.BaseAuth):
logger.info("auth htpasswd file encoding: %r", self._encoding)
self._htpasswd_cache = configuration.get("auth", "htpasswd_cache")
logger.info("auth htpasswd cache: %s", self._htpasswd_cache)
encryption: str = configuration.get("auth", "htpasswd_encryption")
logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s'", encryption)
self._encryption: str = configuration.get("auth", "htpasswd_encryption")
logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s'", self._encryption)
self._has_bcrypt = False
self._htpasswd_ok = False
@ -92,71 +94,88 @@ class Auth(auth.BaseAuth):
(self._htpasswd_ok, self._htpasswd_bcrypt_use, self._htpasswd, self._htpasswd_size, self._htpasswd_mtime_ns) = self._read_htpasswd(True, False)
self._lock = threading.Lock()
if encryption == "plain":
if self._encryption == "plain":
self._verify = self._plain
elif encryption == "md5":
elif self._encryption == "md5":
self._verify = self._md5apr1
elif encryption == "sha256":
elif self._encryption == "sha256":
self._verify = self._sha256
elif encryption == "sha512":
elif self._encryption == "sha512":
self._verify = self._sha512
elif encryption == "bcrypt" or encryption == "autodetect":
elif self._encryption == "bcrypt" or self._encryption == "autodetect":
try:
import bcrypt
except ImportError as e:
if (encryption == "autodetect") and (self._htpasswd_bcrypt_use == 0):
logger.warning("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' which can require bycrypt module, but currently no entries found", encryption)
if (self._encryption == "autodetect") and (self._htpasswd_bcrypt_use == 0):
logger.warning("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' which can require bycrypt module, but currently no entries found", self._encryption)
else:
raise RuntimeError(
"The htpasswd encryption method 'bcrypt' or 'autodetect' requires "
"the bcrypt module (entries found: %d)." % self._htpasswd_bcrypt_use) from e
else:
if encryption == "autodetect":
self._has_bcrypt = True
if self._encryption == "autodetect":
if self._htpasswd_bcrypt_use == 0:
logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bycrypt module found, but currently not required", encryption)
logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bycrypt module found, but currently not required", self._encryption)
else:
logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bycrypt module found (bcrypt entries found: %d)", encryption, self._htpasswd_bcrypt_use)
if encryption == "bcrypt":
logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bycrypt module found (bcrypt entries found: %d)", self._encryption, self._htpasswd_bcrypt_use)
if self._encryption == "bcrypt":
self._verify = functools.partial(self._bcrypt, bcrypt)
else:
self._verify = self._autodetect
self._verify_bcrypt = functools.partial(self._bcrypt, bcrypt)
self._has_bcrypt = True
if self._htpasswd_bcrypt_use:
self._verify_bcrypt = functools.partial(self._bcrypt, bcrypt)
else:
raise RuntimeError("The htpasswd encryption method %r is not "
"supported." % encryption)
"supported." % self._encryption)
def _plain(self, hash_value: str, password: str) -> tuple[str, bool]:
"""Check if ``hash_value`` and ``password`` match, plain method."""
return ("PLAIN", hmac.compare_digest(hash_value.encode(), password.encode()))
def _plain_fallback(self, method_orig, hash_value: str, password: str) -> tuple[str, bool]:
"""Check if ``hash_value`` and ``password`` match, plain method / fallback in case of hash length is not matching on autodetection."""
info = "PLAIN/fallback as hash length not matching for " + method_orig + ": " + str(len(hash_value))
return (info, hmac.compare_digest(hash_value.encode(), password.encode()))
def _bcrypt(self, bcrypt: Any, hash_value: str, password: str) -> tuple[str, bool]:
return ("BCRYPT", bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hash_value.encode()))
if self._encryption == "autodetect" and len(hash_value) != 60:
return self._plain_fallback("BCRYPT", hash_value, password)
else:
return ("BCRYPT", bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hash_value.encode()))
def _md5apr1(self, hash_value: str, password: str) -> tuple[str, bool]:
return ("MD5-APR1", apr_md5_crypt.verify(password, hash_value.strip()))
if self._encryption == "autodetect" and len(hash_value) != 37:
return self._plain_fallback("MD5-APR1", hash_value, password)
else:
return ("MD5-APR1", apr_md5_crypt.verify(password, hash_value.strip()))
def _sha256(self, hash_value: str, password: str) -> tuple[str, bool]:
return ("SHA-256", sha256_crypt.verify(password, hash_value.strip()))
if self._encryption == "autodetect" and len(hash_value) != 63:
return self._plain_fallback("SHA-256", hash_value, password)
else:
return ("SHA-256", sha256_crypt.verify(password, hash_value.strip()))
def _sha512(self, hash_value: str, password: str) -> tuple[str, bool]:
return ("SHA-512", sha512_crypt.verify(password, hash_value.strip()))
if self._encryption == "autodetect" and len(hash_value) != 106:
return self._plain_fallback("SHA-512", hash_value, password)
else:
return ("SHA-512", sha512_crypt.verify(password, hash_value.strip()))
def _autodetect(self, hash_value: str, password: str) -> tuple[str, bool]:
if hash_value.startswith("$apr1$", 0, 6) and len(hash_value) == 37:
if hash_value.startswith("$apr1$", 0, 6):
# MD5-APR1
return self._md5apr1(hash_value, password)
elif hash_value.startswith("$2y$", 0, 4) and len(hash_value) == 60:
elif re.match(r"^\$2(a|b|x|y)?\$", hash_value):
# BCRYPT
return self._verify_bcrypt(hash_value, password)
elif hash_value.startswith("$5$", 0, 3) and len(hash_value) == 63:
elif hash_value.startswith("$5$", 0, 3):
# SHA-256
return self._sha256(hash_value, password)
elif hash_value.startswith("$6$", 0, 3) and len(hash_value) == 106:
elif hash_value.startswith("$6$", 0, 3):
# SHA-512
return self._sha512(hash_value, password)
else:
# assumed plaintext
return self._plain(hash_value, password)
def _read_htpasswd(self, init: bool, suppress: bool) -> Tuple[bool, int, dict, int, int]:
@ -210,7 +229,7 @@ class Auth(auth.BaseAuth):
htpasswd_ok = False
skip = True
else:
if digest.startswith("$2y$", 0, 4) and len(digest) == 60:
if re.match(r"^\$2(a|b|x|y)?\$", digest) and len(digest) == 60:
if init is True:
bcrypt_use += 1
else:
@ -285,12 +304,16 @@ class Auth(auth.BaseAuth):
login_ok = True
if login_ok is True:
(method, password_ok) = self._verify(digest, password)
logger.debug("Login verification successful for user: '%s' (method '%s')", login, method)
try:
(method, password_ok) = self._verify(digest, password)
except ValueError as e:
logger.error("Login verification failed for user: '%s' (htpasswd/%s) with errror '%s'", login, self._encryption, e)
return ""
if password_ok:
logger.debug("Login verification successful for user: '%s' (htpasswd/%s/%s)", login, self._encryption, method)
return login
else:
logger.debug("Login verification failed for user: '%s' ( method '%s')", login, method)
logger.warning("Login verification failed for user: '%s' (htpasswd/%s/%s)", login, self._encryption, method)
else:
logger.debug("Login verification user not found: '%s'", login)
logger.warning("Login verification user not found (htpasswd): '%s'", login)
return ""

View file

@ -2,7 +2,7 @@
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
#
# 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

View file

@ -59,7 +59,10 @@ class Auth(auth.BaseAuth):
if self._security == "starttls":
connection.starttls(ssl.create_default_context())
try:
connection.login(login, password)
connection.authenticate(
"PLAIN",
lambda _: "{0}\x00{0}\x00{1}".format(login, password).encode(),
)
except imaplib.IMAP4.error as e:
logger.warning("IMAP authentication failed for user %r: %s", login, e, exc_info=False)
return ""

View file

@ -63,6 +63,12 @@ class Auth(auth.BaseAuth):
self.ldap = ldap
except ImportError as e:
raise RuntimeError("LDAP authentication requires the ldap3 module") from e
self._ldap_ignore_attribute_create_modify_timestamp = configuration.get("auth", "ldap_ignore_attribute_create_modify_timestamp")
if self._ldap_ignore_attribute_create_modify_timestamp:
self.ldap3.utils.config._ATTRIBUTES_EXCLUDED_FROM_CHECK.extend(['createTimestamp', 'modifyTimestamp'])
logger.info("auth.ldap_ignore_attribute_create_modify_timestamp applied")
self._ldap_uri = configuration.get("auth", "ldap_uri")
self._ldap_base = configuration.get("auth", "ldap_base")
self._ldap_reader_dn = configuration.get("auth", "ldap_reader_dn")
@ -242,7 +248,7 @@ class Auth(auth.BaseAuth):
logger.debug("_login3 LDAP groups of user: %s", ",".join(self._ldap_groups))
if self._ldap_user_attr:
if user_entry['attributes'][self._ldap_user_attr]:
login = user_entry['attributes'][self._ldap_user_attr][0]
login = user_entry['attributes'][self._ldap_user_attr]
logger.debug(f"_login3 user set to: '{login}'")
conn.unbind()
logger.debug(f"_login3 {login} successfully authenticated")

66
radicale/auth/oauth2.py Normal file
View file

@ -0,0 +1,66 @@
# This file is part of Radicale Server - Calendar Server
#
# Original from https://gitlab.mim-libre.fr/alphabet/radicale_oauth/
# Copyright © 2021-2022 Bruno Boiget
# Copyright © 2022-2022 Daniel Dehennin
#
# Since migration into upstream
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
#
# 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 <http://www.gnu.org/licenses/>.
"""
Authentication backend that checks credentials against an oauth2 server auth endpoint
"""
import requests
from radicale import auth
from radicale.log import logger
class Auth(auth.BaseAuth):
def __init__(self, configuration):
super().__init__(configuration)
self._endpoint = configuration.get("auth", "oauth2_token_endpoint")
if not self._endpoint:
logger.error("auth.oauth2_token_endpoint URL missing")
raise RuntimeError("OAuth2 token endpoint URL is required")
logger.info("auth OAuth2 token endpoint: %s" % (self._endpoint))
def _login(self, login, password):
"""Validate credentials.
Sends login credentials to oauth token endpoint and checks that a token is returned
"""
try:
# authenticate to authentication endpoint and return login if ok, else ""
req_params = {
"username": login,
"password": password,
"grant_type": "password",
"client_id": "radicale",
}
req_headers = {"Content-Type": "application/x-www-form-urlencoded"}
response = requests.post(
self._endpoint, data=req_params, headers=req_headers
)
if (
response.status_code == requests.codes.ok
and "access_token" in response.json()
):
return login
except OSError as e:
logger.critical("Failed to authenticate against OAuth2 server %s: %s" % (self._endpoint, e))
logger.warning("User failed to authenticate using OAuth2: %r" % login)
return ""

105
radicale/auth/pam.py Normal file
View file

@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
#
# This file is part of Radicale Server - Calendar Server
# Copyright © 2011 Henry-Nicolas Tourneur
# Copyright © 2021-2021 Unrud <unrud@outlook.com>
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
#
# 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 <http://www.gnu.org/licenses/>.
"""
PAM authentication.
Authentication using the ``pam-python`` module.
Important: radicale user need access to /etc/shadow by e.g.
chgrp radicale /etc/shadow
chmod g+r
"""
import grp
import pwd
from radicale import auth
from radicale.log import logger
class Auth(auth.BaseAuth):
def __init__(self, configuration) -> None:
super().__init__(configuration)
try:
import pam
self.pam = pam
except ImportError as e:
raise RuntimeError("PAM authentication requires the Python pam module") from e
self._service = configuration.get("auth", "pam_service")
logger.info("auth.pam_service: %s" % self._service)
self._group_membership = configuration.get("auth", "pam_group_membership")
if (self._group_membership):
logger.info("auth.pam_group_membership: %s" % self._group_membership)
else:
logger.warning("auth.pam_group_membership: (empty, nothing to check / INSECURE)")
def pam_authenticate(self, *args, **kwargs):
return self.pam.authenticate(*args, **kwargs)
def _login(self, login: str, password: str) -> str:
"""Check if ``user``/``password`` couple is valid."""
if login is None or password is None:
return ""
# Check whether the user exists in the PAM system
try:
pwd.getpwnam(login).pw_uid
except KeyError:
logger.debug("PAM user not found: %r" % login)
return ""
else:
logger.debug("PAM user found: %r" % login)
# Check whether the user has a primary group (mandatory)
try:
# Get user primary group
primary_group = grp.getgrgid(pwd.getpwnam(login).pw_gid).gr_name
logger.debug("PAM user %r has primary group: %r" % (login, primary_group))
except KeyError:
logger.debug("PAM user has no primary group: %r" % login)
return ""
# Obtain supplementary groups
members = []
if (self._group_membership):
try:
members = grp.getgrnam(self._group_membership).gr_mem
except KeyError:
logger.debug(
"PAM membership required group doesn't exist: %r" %
self._group_membership)
return ""
# Check whether the user belongs to the required group
# (primary or supplementary)
if (self._group_membership):
if (primary_group != self._group_membership) and (login not in members):
logger.warning("PAM user %r belongs not to the required group: %r" % (login, self._group_membership))
return ""
else:
logger.debug("PAM user %r belongs to the required group: %r" % (login, self._group_membership))
# Check the password
if self.pam_authenticate(login, password, service=self._service):
return login
else:
logger.debug("PAM authentication not successful for user: %r (service %r)" % (login, self._service))
return ""

View file

@ -2,7 +2,7 @@
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
#
# 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

View file

@ -187,6 +187,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
"help": "set CA certificate for validating clients",
"aliases": ("--certificate-authority",),
"type": filepath}),
("script_name", {
"value": "",
"help": "script name to strip from URI if called by reverse proxy (default taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME)",
"type": str}),
("_internal_server", {
"value": "False",
"help": "the internal server is used",
@ -202,8 +206,8 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
"type": str})])),
("auth", OrderedDict([
("type", {
"value": "none",
"help": "authentication method",
"value": "denyall",
"help": "authentication method (" + "|".join(auth.INTERNAL_TYPES) + ")",
"type": str_or_callable,
"internal": auth.INTERNAL_TYPES}),
("cache_logins", {
@ -255,6 +259,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
"value": "1",
"help": "incorrect authentication delay",
"type": positive_float}),
("ldap_ignore_attribute_create_modify_timestamp", {
"value": "false",
"help": "Ignore modifyTimestamp and createTimestamp attributes. Need if Authentik LDAP server is used.",
"type": bool}),
("ldap_uri", {
"value": "ldap://localhost",
"help": "URI to the ldap server",
@ -307,6 +315,18 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
"value": "tls",
"help": "Secure the IMAP connection: *tls*|starttls|none",
"type": imap_security}),
("oauth2_token_endpoint", {
"value": "",
"help": "OAuth2 token endpoint URL",
"type": str}),
("pam_group_membership", {
"value": "",
"help": "PAM group user should be member of",
"type": str}),
("pam_service", {
"value": "radicale",
"help": "PAM service",
"type": str}),
("strip_domain", {
"value": "False",
"help": "strip domain from username",

View file

@ -3,7 +3,7 @@
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -79,6 +79,9 @@ REMOTE_DESTINATION: types.WSGIResponse = (
DIRECTORY_LISTING: types.WSGIResponse = (
client.FORBIDDEN, (("Content-Type", "text/plain"),),
"Directory listings are not supported.")
INSUFFICIENT_STORAGE: types.WSGIResponse = (
client.INSUFFICIENT_STORAGE, (("Content-Type", "text/plain"),),
"Insufficient Storage. Please contact the administrator.")
INTERNAL_SERVER_ERROR: types.WSGIResponse = (
client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),),
"A server error occurred. Please contact the administrator.")
@ -193,6 +196,24 @@ def _serve_traversable(
"%a, %d %b %Y %H:%M:%S GMT",
time.gmtime(traversable.stat().st_mtime))
answer = traversable.read_bytes()
if path == "/.web/index.html" or path == "/.web/":
# enable link on the fly in index.html if InfCloud index.html is existing
# class="infcloudlink-hidden" -> class="infcloudlink"
path_posix = str(traversable)
path_posix_infcloud = path_posix.replace("/internal_data/index.html", "/internal_data/infcloud/index.html")
if os.path.isfile(path_posix_infcloud):
# logger.debug("Enable InfCloud link in served page: %r", path)
answer = answer.replace(b"infcloudlink-hidden", b"infcloud")
elif path == "/.web/infcloud/config.js":
# adjust on the fly default config.js of InfCloud installation
# logger.debug("Adjust on-the-fly default InfCloud config.js in served page: %r", path)
answer = answer.replace(b"location.pathname.replace(RegExp('/+[^/]+/*(index\\.html)?$'),'')+", b"location.pathname.replace(RegExp('/\\.web\\.infcloud/(index\\.html)?$'),'')+")
answer = answer.replace(b"'/caldav.php/',", b"'/',")
answer = answer.replace(b"settingsAccount: true,", b"settingsAccount: false,")
elif path == "/.web/infcloud/main.js":
# adjust on the fly default main.js of InfCloud installation
logger.debug("Adjust on-the-fly default InfCloud main.js in served page: %r", path)
answer = answer.replace(b"'InfCloud - the open source CalDAV/CardDAV web client'", b"'InfCloud - the open source CalDAV/CardDAV web client - served through Radicale CalDAV/CardDAV server'")
return client.OK, headers, answer

View file

@ -3,7 +3,7 @@
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2023 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -58,19 +58,7 @@ elif sys.platform == "win32":
# IPv4 (host, port) and IPv6 (host, port, flowinfo, scopeid)
ADDRESS_TYPE = Union[Tuple[Union[str, bytes, bytearray], int],
Tuple[str, int, int, int]]
def format_address(address: ADDRESS_TYPE) -> str:
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)
ADDRESS_TYPE = utils.ADDRESS_TYPE
class ParallelHTTPServer(socketserver.ThreadingMixIn,
@ -226,7 +214,7 @@ class ParallelHTTPSServer(ParallelHTTPServer):
except socket.timeout:
raise
except Exception as e:
raise RuntimeError("SSL handshake failed: %s" % e) from e
raise RuntimeError("SSL handshake failed: %s client %s" % (e, str(client_address[0]))) from e
except Exception:
try:
self.handle_error(request, client_address)
@ -262,6 +250,9 @@ class RequestHandler(wsgiref.simple_server.WSGIRequestHandler):
def get_environ(self) -> Dict[str, Any]:
env = super().get_environ()
if isinstance(self.connection, ssl.SSLSocket):
env["HTTPS"] = "on"
env["SSL_CIPHER"] = self.request.cipher()[0]
env["SSL_PROTOCOL"] = self.request.version()
# The certificate can be evaluated by the auth module
env["REMOTE_CERTIFICATE"] = self.connection.getpeercert()
# Parent class only tries latin1 encoding
@ -318,20 +309,20 @@ def serve(configuration: config.Configuration,
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))
logger.warning("cannot retrieve IPv4 or IPv6 address of '%s': %s" % (utils.format_address(address_port), e))
continue
logger.debug("getaddrinfo of '%s': %s" % (format_address(address_port), getaddrinfo))
logger.debug("getaddrinfo of '%s': %s" % (utils.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)))
logger.debug("try to create server socket on '%s'" % (utils.format_address(socket_address)))
try:
server = server_class(configuration, address_family, (socket_address[0], socket_address[1]), RequestHandler)
except OSError as e:
logger.warning("cannot create server socket on '%s': %s" % (format_address(socket_address), e))
logger.warning("cannot create server socket on '%s': %s" % (utils.format_address(socket_address), e))
continue
servers[server.socket] = server
server.set_app(application)
logger.info("Listening on %r%s",
format_address(server.server_address),
utils.format_address(server.server_address),
" with SSL" if use_ssl else "")
if not servers:
raise RuntimeError("No servers started")

View file

@ -2,7 +2,7 @@
# Copyright © 2014 Jean-Marc Martins
# Copyright © 2012-2017 Guillaume Ayoub
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -94,16 +94,22 @@ class Storage(
def _analyse_mtime(self):
# calculate and display mtime resolution
path = os.path.join(self._filesystem_folder, ".Radicale.mtime_test")
path = os.path.join(self._get_collection_root_folder(), ".Radicale.mtime_test")
logger.debug("Storage item mtime resolution test with file: %r", path)
try:
with open(path, "w") as f:
f.write("mtime_test")
f.close
except Exception as e:
logger.error("Storage item mtime resolution test not possible, cannot write file: %r (%s)", path, e)
logger.warning("Storage item mtime resolution test not possible, cannot write file: %r (%s)", path, e)
raise
# set mtime_ns for tests
os.utime(path, times=None, ns=(MTIME_NS_TEST, MTIME_NS_TEST))
try:
os.utime(path, times=None, ns=(MTIME_NS_TEST, MTIME_NS_TEST))
except Exception as e:
logger.warning("Storage item mtime resolution test not possible, cannot set utime on file: %r (%s)", path, e)
os.remove(path)
raise
logger.debug("Storage item mtime resoultion test set: %d" % MTIME_NS_TEST)
mtime_ns = os.stat(path).st_mtime_ns
logger.debug("Storage item mtime resoultion test get: %d" % mtime_ns)
@ -141,27 +147,37 @@ class Storage(
def __init__(self, configuration: config.Configuration) -> None:
super().__init__(configuration)
logger.info("Storage location: %r", self._filesystem_folder)
self._makedirs_synced(self._filesystem_folder)
if not os.path.exists(self._filesystem_folder):
logger.warning("Storage location: %r not existing, create now", self._filesystem_folder)
self._makedirs_synced(self._filesystem_folder)
logger.info("Storage location subfolder: %r", self._get_collection_root_folder())
if not os.path.exists(self._get_collection_root_folder()):
logger.warning("Storage location subfolder: %r not existing, create now", self._get_collection_root_folder())
self._makedirs_synced(self._get_collection_root_folder())
logger.info("Storage cache subfolder usage for 'item': %s", self._use_cache_subfolder_for_item)
logger.info("Storage cache subfolder usage for 'history': %s", self._use_cache_subfolder_for_history)
logger.info("Storage cache subfolder usage for 'sync-token': %s", self._use_cache_subfolder_for_synctoken)
logger.info("Storage cache use mtime and size for 'item': %s", self._use_mtime_and_size_for_item_cache)
(precision, precision_unit, unit) = self._analyse_mtime()
if precision >= 100000000:
# >= 100 ms
logger.warning("Storage item mtime resolution test result: %d %s (VERY RISKY ON PRODUCTION SYSTEMS)" % (precision_unit, unit))
elif precision >= 10000000:
# >= 10 ms
logger.warning("Storage item mtime resolution test result: %d %s (RISKY ON PRODUCTION SYSTEMS)" % (precision_unit, unit))
else:
logger.info("Storage item mtime resolution test result: %d %s" % (precision_unit, unit))
if self._use_mtime_and_size_for_item_cache is False:
logger.info("Storage cache using mtime and size for 'item' may be an option in case of performance issues")
try:
(precision, precision_unit, unit) = self._analyse_mtime()
if precision >= 100000000:
# >= 100 ms
logger.warning("Storage item mtime resolution test result: %d %s (VERY RISKY ON PRODUCTION SYSTEMS)" % (precision_unit, unit))
elif precision >= 10000000:
# >= 10 ms
logger.warning("Storage item mtime resolution test result: %d %s (RISKY ON PRODUCTION SYSTEMS)" % (precision_unit, unit))
else:
logger.info("Storage item mtime resolution test result: %d %s" % (precision_unit, unit))
if self._use_mtime_and_size_for_item_cache is False:
logger.info("Storage cache using mtime and size for 'item' may be an option in case of performance issues")
except Exception:
logger.warning("Storage item mtime resolution test result not successful")
logger.debug("Storage cache action logging: %s", self._debug_cache_actions)
if self._use_cache_subfolder_for_item is True or self._use_cache_subfolder_for_history is True or self._use_cache_subfolder_for_synctoken is True:
logger.info("Storage cache subfolder: %r", self._get_collection_cache_folder())
self._makedirs_synced(self._get_collection_cache_folder())
if not os.path.exists(self._get_collection_cache_folder()):
logger.warning("Storage cache subfolder: %r not existing, create now", self._get_collection_cache_folder())
self._makedirs_synced(self._get_collection_cache_folder())
if sys.platform != "win32":
if not self._folder_umask:
# retrieve current umask by setting a dummy umask

View file

@ -2,7 +2,7 @@
# Copyright © 2014 Jean-Marc Martins
# Copyright © 2012-2017 Guillaume Ayoub
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -50,27 +50,31 @@ class StoragePartCreateCollection(StorageBase):
self._makedirs_synced(parent_dir)
# Create a temporary directory with an unsafe name
with TemporaryDirectory(prefix=".Radicale.tmp-", dir=parent_dir
) as tmp_dir:
# The temporary directory itself can't be renamed
tmp_filesystem_path = os.path.join(tmp_dir, "collection")
os.makedirs(tmp_filesystem_path)
col = self._collection_class(
cast(multifilesystem.Storage, self),
pathutils.unstrip_path(sane_path, True),
filesystem_path=tmp_filesystem_path)
col.set_meta(props)
if items is not None:
if props.get("tag") == "VCALENDAR":
col._upload_all_nonatomic(items, suffix=".ics")
elif props.get("tag") == "VADDRESSBOOK":
col._upload_all_nonatomic(items, suffix=".vcf")
try:
with TemporaryDirectory(prefix=".Radicale.tmp-", dir=parent_dir
) as tmp_dir:
# The temporary directory itself can't be renamed
tmp_filesystem_path = os.path.join(tmp_dir, "collection")
os.makedirs(tmp_filesystem_path)
col = self._collection_class(
cast(multifilesystem.Storage, self),
pathutils.unstrip_path(sane_path, True),
filesystem_path=tmp_filesystem_path)
col.set_meta(props)
if items is not None:
if props.get("tag") == "VCALENDAR":
col._upload_all_nonatomic(items, suffix=".ics")
elif props.get("tag") == "VADDRESSBOOK":
col._upload_all_nonatomic(items, suffix=".vcf")
if os.path.lexists(filesystem_path):
pathutils.rename_exchange(tmp_filesystem_path, filesystem_path)
else:
os.rename(tmp_filesystem_path, filesystem_path)
self._sync_directory(parent_dir)
if os.path.lexists(filesystem_path):
pathutils.rename_exchange(tmp_filesystem_path, filesystem_path)
else:
os.rename(tmp_filesystem_path, filesystem_path)
self._sync_directory(parent_dir)
except Exception as e:
raise ValueError("Failed to create collection %r as %r %s" %
(href, filesystem_path, e)) from e
return self._collection_class(
cast(multifilesystem.Storage, self),

View file

@ -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 <unrud@outlook.com>
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2023-2025 Peter Bieringer <pb@bieringer.de>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -37,10 +38,11 @@ class CollectionPartLock(CollectionBase):
if self._storage._lock.locked == "w":
yield
return
cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache")
cache_folder = self._storage._get_collection_cache_subfolder(self._filesystem_path, ".Radicale.cache", ns)
self._storage._makedirs_synced(cache_folder)
lock_path = os.path.join(cache_folder,
".Radicale.lock" + (".%s" % ns if ns else ""))
logger.debug("Lock file (CollectionPartLock): %r" % lock_path)
lock = pathutils.RwLock(lock_path)
with lock.acquire("w"):
yield
@ -54,11 +56,12 @@ class StoragePartLock(StorageBase):
def __init__(self, configuration: config.Configuration) -> None:
super().__init__(configuration)
lock_path = os.path.join(self._filesystem_folder, ".Radicale.lock")
logger.debug("Lock file (StoragePartLock): %r" % lock_path)
self._lock = pathutils.RwLock(lock_path)
self._hook = configuration.get("storage", "hook")
@types.contextmanager
def acquire_lock(self, mode: str, user: str = "") -> Iterator[None]:
def acquire_lock(self, mode: str, user: str = "", *args, **kwargs) -> Iterator[None]:
with self._lock.acquire(mode):
yield
# execute hook
@ -73,8 +76,17 @@ class StoragePartLock(StorageBase):
else:
# Process group is also used to identify child processes
preexec_fn = os.setpgrp
command = self._hook % {
"user": shlex.quote(user or "Anonymous")}
# optional argument
path = kwargs.get('path', "")
try:
command = self._hook % {
"path": shlex.quote(self._get_collection_root_folder() + path),
"cwd": shlex.quote(self._filesystem_folder),
"user": shlex.quote(user or "Anonymous")}
except KeyError as e:
logger.error("Storage hook contains not supported placeholder %s (skip execution of: %r)" % (e, self._hook))
return
logger.debug("Executing storage hook: '%s'" % command)
try:
p = subprocess.Popen(

View file

@ -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 <unrud@outlook.com>
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -62,6 +63,9 @@ class CollectionPartMeta(CollectionBase):
def set_meta(self, props: Mapping[str, str]) -> None:
# 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)
try:
with self._atomic_write(self._props_path, "w") as fo: # type: ignore
f = cast(TextIO, fo)
json.dump(props, f, sort_keys=True)
except OSError as e:
raise ValueError("Failed to write meta data %r %s" % (self._props_path, e)) from e

View file

@ -2,7 +2,7 @@
# Copyright © 2014 Jean-Marc Martins
# Copyright © 2012-2017 Guillaume Ayoub
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
#
# 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,6 +21,7 @@ import os
from radicale import item as radicale_item
from radicale import pathutils, storage
from radicale.log import logger
from radicale.storage import multifilesystem
from radicale.storage.multifilesystem.base import StorageBase
@ -34,10 +35,12 @@ class StoragePartMove(StorageBase):
assert isinstance(to_collection, multifilesystem.Collection)
assert isinstance(item.collection, multifilesystem.Collection)
assert item.href
os.replace(pathutils.path_to_filesystem(
item.collection._filesystem_path, item.href),
pathutils.path_to_filesystem(
to_collection._filesystem_path, to_href))
move_from = pathutils.path_to_filesystem(item.collection._filesystem_path, item.href)
move_to = pathutils.path_to_filesystem(to_collection._filesystem_path, to_href)
try:
os.replace(move_from, move_to)
except OSError as e:
raise ValueError("Failed to move file %r => %r %s" % (move_from, move_to, e)) from e
self._sync_directory(to_collection._filesystem_path)
if item.collection._filesystem_path != to_collection._filesystem_path:
self._sync_directory(item.collection._filesystem_path)
@ -45,11 +48,15 @@ class StoragePartMove(StorageBase):
cache_folder = self._get_collection_cache_subfolder(item.collection._filesystem_path, ".Radicale.cache", "item")
to_cache_folder = self._get_collection_cache_subfolder(to_collection._filesystem_path, ".Radicale.cache", "item")
self._makedirs_synced(to_cache_folder)
move_from = os.path.join(cache_folder, item.href)
move_to = os.path.join(to_cache_folder, to_href)
try:
os.replace(os.path.join(cache_folder, item.href),
os.path.join(to_cache_folder, to_href))
os.replace(move_from, move_to)
except FileNotFoundError:
pass
except OSError as e:
logger.error("Failed to move cache file %r => %r %s" % (move_from, move_to, e))
pass
else:
self._makedirs_synced(to_cache_folder)
if cache_folder != to_cache_folder:

View file

@ -41,6 +41,14 @@ class TestBaseAuthRequests(BaseTest):
"""
# test for available bcrypt module
try:
import bcrypt
except ImportError:
has_bcrypt = 0
else:
has_bcrypt = 1
def _test_htpasswd(self, htpasswd_encryption: str, htpasswd_content: str,
test_matrix: Union[str, Iterable[Tuple[str, str, bool]]]
= "ascii") -> None:
@ -71,6 +79,9 @@ class TestBaseAuthRequests(BaseTest):
def test_htpasswd_plain(self) -> None:
self._test_htpasswd("plain", "tmp:bepo")
def test_htpasswd_plain_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:bepo")
def test_htpasswd_plain_password_split(self) -> None:
self._test_htpasswd("plain", "tmp:be:po", (
("tmp", "be:po", True), ("tmp", "bepo", False)))
@ -81,6 +92,9 @@ class TestBaseAuthRequests(BaseTest):
def test_htpasswd_md5(self) -> None:
self._test_htpasswd("md5", "tmp:$apr1$BI7VKCZh$GKW4vq2hqDINMr8uv7lDY/")
def test_htpasswd_md5_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:$apr1$BI7VKCZh$GKW4vq2hqDINMr8uv7lDY/")
def test_htpasswd_md5_unicode(self):
self._test_htpasswd(
"md5", "😀:$apr1$w4ev89r1$29xO8EvJmS2HEAadQ5qy11", "unicode")
@ -88,16 +102,50 @@ class TestBaseAuthRequests(BaseTest):
def test_htpasswd_sha256(self) -> None:
self._test_htpasswd("sha256", "tmp:$5$i4Ni4TQq6L5FKss5$ilpTjkmnxkwZeV35GB9cYSsDXTALBn6KtWRJAzNlCL/")
def test_htpasswd_sha256_autodetect(self) -> None:
self._test_htpasswd("autodetect", "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")
def test_htpasswd_sha512_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:$6$3Qhl8r6FLagYdHYa$UCH9yXCed4A.J9FQsFPYAOXImzZUMfvLa0lwcWOxWYLOF5sE/lF99auQ4jKvHY2vijxmefl7G6kMqZ8JPdhIJ/")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
def test_htpasswd_bcrypt_2a(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2a$10$Mj4A9vMecAp/K7.0fMKoVOk1SjgR.RBhl06a52nvzXhxlT3HB7Reu")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
def test_htpasswd_bcrypt_2a_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:$2a$10$Mj4A9vMecAp/K7.0fMKoVOk1SjgR.RBhl06a52nvzXhxlT3HB7Reu")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
def test_htpasswd_bcrypt_2b(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2b$12$7a4z/fdmXlBIfkz0smvzW.1Nds8wpgC/bo2DVOb4OSQKWCDL1A1wu")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
def test_htpasswd_bcrypt_2b_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:$2b$12$7a4z/fdmXlBIfkz0smvzW.1Nds8wpgC/bo2DVOb4OSQKWCDL1A1wu")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
def test_htpasswd_bcrypt_2y(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
def test_htpasswd_bcrypt_2y_autodetect(self) -> None:
self._test_htpasswd("autodetect", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
def test_htpasswd_bcrypt_C10(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
def test_htpasswd_bcrypt_C10_autodetect(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a")
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
def test_htpasswd_bcrypt_unicode(self) -> None:
self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK"
"6U9Sqlzr.W1mMVCS8wJUftnW", "unicode")
self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK6U9Sqlzr.W1mMVCS8wJUftnW", "unicode")
def test_htpasswd_multi(self) -> None:
self._test_htpasswd("plain", "ign:ign\ntmp:bepo")

View file

@ -1714,6 +1714,7 @@ permissions: RrWw""")
assert status == 200 and prop.text == "text/vcard;charset=utf-8"
def test_authorization(self) -> None:
self.configure({"auth": {"type": "none"}})
_, responses = self.propfind("/", """\
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
@ -1740,6 +1741,7 @@ permissions: RrWw""")
def test_principal_collection_creation(self) -> None:
"""Verify existence of the principal collection."""
self.configure({"auth": {"type": "none"}})
self.propfind("/user/", login="user:")
def test_authentication_current_user_principal_hack(self) -> None:

View file

@ -143,6 +143,7 @@ collection: public/[^/]*
permissions: i""")
self.configure({"rights": {"type": "from_file",
"file": rights_file_path}})
self.configure({"auth": {"type": "none"}})
self.mkcalendar("/tmp/calendar", login="tmp:bepo")
self.mkcol("/public", login="tmp:bepo")
self.mkcalendar("/public/calendar", login="tmp:bepo")
@ -165,6 +166,7 @@ permissions: i""")
Items are allowed at "/.../.../...".
"""
self.configure({"auth": {"type": "none"}})
self.mkcalendar("/", check=401)
self.mkcalendar("/user/", check=401)
self.mkcol("/user/")
@ -175,6 +177,7 @@ permissions: i""")
def test_put_collections_and_items(self) -> None:
"""Test rights for creation of calendars and items with PUT."""
self.configure({"auth": {"type": "none"}})
self.put("/user/", "BEGIN:VCALENDAR\r\nEND:VCALENDAR", check=401)
self.mkcol("/user/")
self.put("/user/calendar/", "BEGIN:VCALENDAR\r\nEND:VCALENDAR")

View file

@ -77,6 +77,7 @@ class TestMultiFileSystem(BaseTest):
"""Verify that the hooks runs when a new user is created."""
self.configure({"storage": {"hook": "mkdir %s" % os.path.join(
"collection-root", "created_by_hook")}})
self.configure({"auth": {"type": "none"}})
self.propfind("/", login="user:")
self.propfind("/created_by_hook/")

View file

@ -2,7 +2,7 @@
# Copyright © 2014 Jean-Marc Martins
# Copyright © 2012-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -18,15 +18,27 @@
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
import ssl
import sys
from importlib import import_module, metadata
from typing import Callable, Sequence, Type, TypeVar, Union
from typing import Callable, Sequence, Tuple, Type, TypeVar, Union
from radicale import config
from radicale.log import logger
_T_co = TypeVar("_T_co", covariant=True)
RADICALE_MODULES: Sequence[str] = ("radicale", "vobject", "passlib", "defusedxml")
RADICALE_MODULES: Sequence[str] = ("radicale", "vobject", "passlib", "defusedxml",
"dateutil",
"bcrypt",
"pika",
"ldap",
"ldap3",
"pam")
# IPv4 (host, port) and IPv6 (host, port, flowinfo, scopeid)
ADDRESS_TYPE = Union[Tuple[Union[str, bytes, bytearray], int],
Tuple[str, int, int, int]]
def load_plugin(internal_types: Sequence[str], module_name: str,
@ -55,11 +67,29 @@ def package_version(name):
def packages_version():
versions = []
versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2]))
for pkg in RADICALE_MODULES:
versions.append("%s=%s" % (pkg, package_version(pkg)))
try:
versions.append("%s=%s" % (pkg, package_version(pkg)))
except Exception:
try:
versions.append("%s=%s" % (pkg, package_version("python-" + pkg)))
except Exception:
versions.append("%s=%s" % (pkg, "n/a"))
return " ".join(versions)
def format_address(address: ADDRESS_TYPE) -> str:
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)
def ssl_context_options_by_protocol(protocol: str, ssl_context_options):
logger.debug("SSL protocol string: '%s' and current SSL context options: '0x%x'", protocol, ssl_context_options)
# disable any protocol by default

View file

@ -39,6 +39,17 @@ main{
color: #484848;
}
#loginscene .infcloudlink{
margin: 0;
width: 100%;
text-align: center;
color: #484848;
}
#loginscene .infcloudlink-hidden{
visibility: hidden;
}
#loginscene input{
}

View file

@ -2,6 +2,7 @@
* This file is part of Radicale Server - Calendar Server
* Copyright © 2017-2024 Unrud <unrud@outlook.com>
* Copyright © 2023-2024 Matthew Hana <matthew.hana@gmail.com>
* Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
*
* 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
@ -874,8 +875,7 @@ function UploadCollectionScene(user, password, collection) {
upload_btn.onclick = upload_start;
uploadfile_form.onchange = onfileschange;
let href = random_uuid();
href_form.value = href;
href_form.value = "";
/** @type {?number} */ let scene_index = null;
/** @type {?XMLHttpRequest} */ let upload_req = null;
@ -927,7 +927,7 @@ function UploadCollectionScene(user, password, collection) {
if(files.length > 1 || href.length == 0){
href = random_uuid();
}
let upload_href = collection.href + "/" + href + "/";
let upload_href = collection.href + href + "/";
upload_req = upload_collection(user, password, upload_href, file, function(result) {
upload_req = null;
results.push(result);
@ -993,10 +993,12 @@ function UploadCollectionScene(user, password, collection) {
hreflimitmsg_html.classList.remove("hidden");
href_form.classList.add("hidden");
href_label.classList.add("hidden");
href_form.value = random_uuid(); // dummy, will be replaced on upload
}else{
hreflimitmsg_html.classList.add("hidden");
href_form.classList.remove("hidden");
href_label.classList.remove("hidden");
href_form.value = files[0].name.replace(/\.(ics|vcf)$/, '');
}
return false;
}
@ -1005,6 +1007,12 @@ function UploadCollectionScene(user, password, collection) {
scene_index = scene_stack.length - 1;
html_scene.classList.remove("hidden");
close_btn.onclick = onclose;
if(error){
error_form.textContent = "Error: " + error;
error_form.classList.remove("hidden");
}else{
error_form.classList.add("hidden");
}
};
this.hide = function() {
@ -1213,7 +1221,7 @@ function CreateEditCollectionScene(user, password, collection) {
alert("You must enter a valid HREF");
return false;
}
href = collection.href + "/" + newhreftxtvalue + "/";
href = collection.href + newhreftxtvalue + "/";
}
displayname = displayname_form.value;
description = description_form.value;
@ -1317,6 +1325,12 @@ function CreateEditCollectionScene(user, password, collection) {
fill_form();
submit_btn.onclick = onsubmit;
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() {
read_form();
@ -1348,8 +1362,10 @@ function cleanHREFinput(a) {
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, '');
//Clean the HREF to remove not permitted chars
currentTxtVal = currentTxtVal.replace(/(?![0-9a-z\-\_\.])./g, '');
//Clean the HREF to remove leading . (would result in hidden directory)
currentTxtVal = currentTxtVal.replace(/^\./, '');
href_form.value = currentTxtVal;
}

View file

@ -1,4 +1,10 @@
<!DOCTYPE html>
<!--
* Copyright © 2018-2020 Unrud <unrud@outlook.com>
* Copyright © 2023-2023 Henning <github@henning-ullrich.de>
* Copyright © 2023-2024 Matthew Hana <matthew.hana@gmail.com>
* Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
-->
<html lang="en">
<head>
<meta charset="utf-8">
@ -27,8 +33,15 @@
</section>
<section id="loginscene" class="container hidden">
<div class="infcloudlink-hidden">
<form action="infcloud/" method="get" target="_blank">
<button class="blue" type="submit">Collection content<br>(InfCloud web client)</button>
</form>
</div>
<div class="logocontainer">
<img src="css/logo.svg" alt="Radicale">
<br>
Collection management
</div>
<h1>Sign in</h1>
<br>
@ -116,6 +129,8 @@
<button type="submit" class="green" data-name="submit">Save</button>
<button type="button" class="red" data-name="cancel">Cancel</button>
</form>
<span class="error hidden" data-name="error"></span>
<br>
</section>
<section id="createcollectionscene" class="container hidden">
@ -149,6 +164,8 @@
<button type="submit" class="green" data-name="submit">Create</button>
<button type="button" class="red" data-name="cancel">Cancel</button>
</form>
<span class="error hidden" data-name="error"></span>
<br>
</section>
<section id="uploadcollectionscene" class="container hidden">
@ -172,6 +189,8 @@
<button type="submit" class="green" data-name="submit">Upload</button>
<button type="button" class="red" data-name="close">Close</button>
</form>
<span class="error hidden" data-name="error"></span>
<br>
</section>
<section id="deletecollectionscene" class="container hidden">

View file

@ -24,7 +24,7 @@ skip_install = True
[testenv:mypy]
deps = mypy==1.11.0
commands = mypy .
commands = mypy --install-types --non-interactive .
skip_install = True
[tool:isort]

View file

@ -20,7 +20,7 @@ from setuptools import find_packages, setup
# When the version is updated, a new section in the CHANGELOG.md file must be
# added too.
VERSION = "3.4.1"
VERSION = "3.5.1.dev"
with open("README.md", encoding="utf-8") as f:
long_description = f.read()
@ -38,6 +38,7 @@ web_files = ["web/internal_data/css/icon.png",
install_requires = ["defusedxml", "passlib", "vobject>=0.9.6",
"pika>=1.1.0",
"requests",
]
bcrypt_requires = ["bcrypt"]
ldap_requires = ["ldap3"]