Radicale 3.2.2__tar.gz → 3.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {radicale-3.2.2 → radicale-3.2.3}/CHANGELOG.md +11 -1
- {radicale-3.2.2 → radicale-3.2.3}/DOCUMENTATION.md +45 -9
- {radicale-3.2.2 → radicale-3.2.3}/PKG-INFO +2 -3
- {radicale-3.2.2 → radicale-3.2.3}/Radicale.egg-info/PKG-INFO +2 -3
- {radicale-3.2.2 → radicale-3.2.3}/Radicale.egg-info/requires.txt +0 -4
- {radicale-3.2.2 → radicale-3.2.3}/config +10 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/__init__.py +1 -1
- {radicale-3.2.2 → radicale-3.2.3}/radicale/__main__.py +2 -2
- {radicale-3.2.2 → radicale-3.2.3}/radicale/app/__init__.py +3 -3
- {radicale-3.2.2 → radicale-3.2.3}/radicale/app/base.py +8 -4
- {radicale-3.2.2 → radicale-3.2.3}/radicale/app/propfind.py +3 -3
- {radicale-3.2.2 → radicale-3.2.3}/radicale/app/put.py +1 -1
- {radicale-3.2.2 → radicale-3.2.3}/radicale/app/report.py +127 -12
- {radicale-3.2.2 → radicale-3.2.3}/radicale/auth/__init__.py +7 -1
- {radicale-3.2.2 → radicale-3.2.3}/radicale/auth/htpasswd.py +1 -1
- {radicale-3.2.2 → radicale-3.2.3}/radicale/config.py +15 -1
- {radicale-3.2.2 → radicale-3.2.3}/radicale/hook/__init__.py +11 -2
- {radicale-3.2.2 → radicale-3.2.3}/radicale/httputils.py +1 -1
- {radicale-3.2.2 → radicale-3.2.3}/radicale/item/__init__.py +7 -1
- {radicale-3.2.2 → radicale-3.2.3}/radicale/item/filter.py +58 -29
- {radicale-3.2.2 → radicale-3.2.3}/radicale/rights/from_file.py +8 -4
- {radicale-3.2.2 → radicale-3.2.3}/radicale/server.py +2 -2
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/get.py +3 -3
- {radicale-3.2.2 → radicale-3.2.3}/setup.cfg +21 -14
- {radicale-3.2.2 → radicale-3.2.3}/setup.py +4 -3
- {radicale-3.2.2 → radicale-3.2.3}/COPYING.md +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/MANIFEST.in +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/README.md +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/Radicale.egg-info/SOURCES.txt +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/Radicale.egg-info/dependency_links.txt +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/Radicale.egg-info/entry_points.txt +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/Radicale.egg-info/top_level.txt +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/app/delete.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/app/get.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/app/head.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/app/mkcalendar.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/app/mkcol.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/app/move.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/app/options.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/app/post.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/app/proppatch.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/auth/denyall.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/auth/http_x_remote_user.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/auth/none.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/auth/remote_user.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/hook/none.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/hook/rabbitmq/__init__.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/log.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/pathutils.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/py.typed +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/rights/__init__.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/rights/authenticated.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/rights/owner_only.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/rights/owner_write.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/__init__.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/__init__.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/base.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/cache.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/create_collection.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/delete.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/discover.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/history.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/lock.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/meta.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/move.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/sync.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/upload.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem/verify.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/storage/multifilesystem_nolock.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/types.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/utils.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/web/__init__.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/icon.png +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/icons/delete.svg +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/icons/download.svg +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/icons/edit.svg +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/icons/new.svg +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/icons/upload.svg +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/loading.svg +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/logo.svg +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/css/main.css +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/fn.js +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/web/internal_data/index.html +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/web/none.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale/xmlutils.py +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/radicale.wsgi +0 -0
- {radicale-3.2.2 → radicale-3.2.3}/rights +0 -0
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 3.
|
|
3
|
+
## 3.2.3
|
|
4
|
+
* Add: support for Python 3.13
|
|
5
|
+
* Fix: Using icalendar's tzinfo on created datetime to fix issue with icalendar
|
|
6
|
+
* Fix: typos in code
|
|
7
|
+
* Enhancement: Added free-busy report
|
|
8
|
+
* Enhancement: Added 'max_freebusy_occurrences` setting to avoid potential DOS on reports
|
|
9
|
+
* Enhancement: remove unexpected control codes from uploaded items
|
|
10
|
+
* Enhancement: add 'strip_domain' setting for username handling
|
|
11
|
+
* Enhancement: add option to toggle debug log of rights rule with doesn't match
|
|
12
|
+
* Drop: remove unused requirement "typeguard"
|
|
13
|
+
* Improve: Refactored some date parsing code
|
|
4
14
|
|
|
5
15
|
## 3.2.2
|
|
6
16
|
* Enhancement: add support for auth.type=denyall (will be default for security reasons in upcoming releases)
|
|
@@ -350,16 +350,13 @@ location /radicale/ { # The trailing / is important!
|
|
|
350
350
|
}
|
|
351
351
|
```
|
|
352
352
|
|
|
353
|
-
Example **Caddy** configuration
|
|
353
|
+
Example **Caddy** configuration:
|
|
354
354
|
|
|
355
|
-
```
|
|
356
|
-
handle_path /radicale
|
|
357
|
-
|
|
358
|
-
user hash
|
|
359
|
-
}
|
|
355
|
+
```
|
|
356
|
+
handle_path /radicale/* {
|
|
357
|
+
uri strip_prefix /radicale
|
|
360
358
|
reverse_proxy localhost:5232 {
|
|
361
|
-
header_up
|
|
362
|
-
header_up +X-remote-user "{http.auth.user.id}"
|
|
359
|
+
header_up X-Script-Name /radicale
|
|
363
360
|
}
|
|
364
361
|
}
|
|
365
362
|
```
|
|
@@ -440,6 +437,21 @@ location /radicale/ {
|
|
|
440
437
|
}
|
|
441
438
|
```
|
|
442
439
|
|
|
440
|
+
Example **Caddy** configuration:
|
|
441
|
+
|
|
442
|
+
```
|
|
443
|
+
handle_path /radicale/* {
|
|
444
|
+
uri strip_prefix /radicale
|
|
445
|
+
basicauth {
|
|
446
|
+
USER HASH
|
|
447
|
+
}
|
|
448
|
+
reverse_proxy localhost:5232 {
|
|
449
|
+
header_up X-Script-Name /radicale
|
|
450
|
+
header_up X-remote-user {http.auth.user.id}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
443
455
|
Example **Apache** configuration:
|
|
444
456
|
|
|
445
457
|
```apache
|
|
@@ -795,6 +807,12 @@ providers like ldap, kerberos
|
|
|
795
807
|
|
|
796
808
|
Default: `False`
|
|
797
809
|
|
|
810
|
+
##### strip_domain
|
|
811
|
+
|
|
812
|
+
Strip domain from username
|
|
813
|
+
|
|
814
|
+
Default: `False`
|
|
815
|
+
|
|
798
816
|
#### rights
|
|
799
817
|
|
|
800
818
|
##### type
|
|
@@ -865,7 +883,7 @@ Delete sync-token that are older than the specified time. (seconds)
|
|
|
865
883
|
|
|
866
884
|
Default: `2592000`
|
|
867
885
|
|
|
868
|
-
|
|
886
|
+
##### skip_broken_item
|
|
869
887
|
|
|
870
888
|
Skip broken item instead of triggering an exception
|
|
871
889
|
|
|
@@ -960,6 +978,12 @@ Log response on level=debug
|
|
|
960
978
|
|
|
961
979
|
Default: `False`
|
|
962
980
|
|
|
981
|
+
##### rights_rule_doesnt_match_on_debug = True
|
|
982
|
+
|
|
983
|
+
Log rights rule which doesn't match on level=debug
|
|
984
|
+
|
|
985
|
+
Default: `False`
|
|
986
|
+
|
|
963
987
|
#### headers
|
|
964
988
|
|
|
965
989
|
In this section additional HTTP headers that are sent to clients can be
|
|
@@ -1005,6 +1029,18 @@ RabbitMQ queue type for the topic.
|
|
|
1005
1029
|
|
|
1006
1030
|
Default: classic
|
|
1007
1031
|
|
|
1032
|
+
#### reporting
|
|
1033
|
+
##### max_freebusy_occurrence
|
|
1034
|
+
|
|
1035
|
+
When returning a free-busy report, a list of busy time occurrences are
|
|
1036
|
+
generated based on a given time frame. Large time frames could
|
|
1037
|
+
generate a lot of occurrences based on the time frame supplied. This
|
|
1038
|
+
setting limits the lookup to prevent potential denial of service
|
|
1039
|
+
attacks on large time frames. If the limit is reached, an HTTP error
|
|
1040
|
+
is thrown instead of returning the results.
|
|
1041
|
+
|
|
1042
|
+
Default: 10000
|
|
1043
|
+
|
|
1008
1044
|
## Supported Clients
|
|
1009
1045
|
|
|
1010
1046
|
Radicale has been tested with:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: Radicale
|
|
3
|
-
Version: 3.2.
|
|
3
|
+
Version: 3.2.3
|
|
4
4
|
Summary: CalDAV and CardDAV Server
|
|
5
5
|
Home-page: https://radicale.org/
|
|
6
6
|
Author: Guillaume Ayoub
|
|
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.10
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.11
|
|
23
23
|
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
25
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
25
26
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
26
27
|
Classifier: Topic :: Office/Business :: Groupware
|
|
@@ -32,10 +33,8 @@ Requires-Dist: passlib
|
|
|
32
33
|
Requires-Dist: vobject>=0.9.6
|
|
33
34
|
Requires-Dist: python-dateutil>=2.7.3
|
|
34
35
|
Requires-Dist: pika>=1.1.0
|
|
35
|
-
Requires-Dist: setuptools; python_version < "3.9"
|
|
36
36
|
Provides-Extra: test
|
|
37
37
|
Requires-Dist: pytest>=7; extra == "test"
|
|
38
|
-
Requires-Dist: typeguard<4.3; extra == "test"
|
|
39
38
|
Requires-Dist: waitress; extra == "test"
|
|
40
39
|
Requires-Dist: bcrypt; extra == "test"
|
|
41
40
|
Provides-Extra: bcrypt
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: Radicale
|
|
3
|
-
Version: 3.2.
|
|
3
|
+
Version: 3.2.3
|
|
4
4
|
Summary: CalDAV and CardDAV Server
|
|
5
5
|
Home-page: https://radicale.org/
|
|
6
6
|
Author: Guillaume Ayoub
|
|
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.10
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.11
|
|
23
23
|
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
25
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
25
26
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
26
27
|
Classifier: Topic :: Office/Business :: Groupware
|
|
@@ -32,10 +33,8 @@ Requires-Dist: passlib
|
|
|
32
33
|
Requires-Dist: vobject>=0.9.6
|
|
33
34
|
Requires-Dist: python-dateutil>=2.7.3
|
|
34
35
|
Requires-Dist: pika>=1.1.0
|
|
35
|
-
Requires-Dist: setuptools; python_version < "3.9"
|
|
36
36
|
Provides-Extra: test
|
|
37
37
|
Requires-Dist: pytest>=7; extra == "test"
|
|
38
|
-
Requires-Dist: typeguard<4.3; extra == "test"
|
|
39
38
|
Requires-Dist: waitress; extra == "test"
|
|
40
39
|
Requires-Dist: bcrypt; extra == "test"
|
|
41
40
|
Provides-Extra: bcrypt
|
|
@@ -73,6 +73,8 @@
|
|
|
73
73
|
# Convert username to lowercase, must be true for case-insensitive auth providers
|
|
74
74
|
#lc_username = False
|
|
75
75
|
|
|
76
|
+
# Strip domain name from username
|
|
77
|
+
#strip_domain = False
|
|
76
78
|
|
|
77
79
|
[rights]
|
|
78
80
|
|
|
@@ -156,6 +158,8 @@
|
|
|
156
158
|
# Log response content on level=debug
|
|
157
159
|
#response_content_on_debug = False
|
|
158
160
|
|
|
161
|
+
# Log rights rule which doesn't match on level=debug
|
|
162
|
+
#rights_rule_doesnt_match_on_debug = False
|
|
159
163
|
|
|
160
164
|
[headers]
|
|
161
165
|
|
|
@@ -170,3 +174,9 @@
|
|
|
170
174
|
#rabbitmq_endpoint =
|
|
171
175
|
#rabbitmq_topic =
|
|
172
176
|
#rabbitmq_queue_type = classic
|
|
177
|
+
|
|
178
|
+
[reporting]
|
|
179
|
+
|
|
180
|
+
# When returning a free-busy report, limit the number of returned
|
|
181
|
+
# occurences per event to prevent DOS attacks.
|
|
182
|
+
#max_freebusy_occurrence = 10000
|
|
@@ -61,7 +61,7 @@ def _get_application_instance(config_path: str, wsgi_errors: types.ErrorStream
|
|
|
61
61
|
if not miss and source != "default config":
|
|
62
62
|
default_config_active = False
|
|
63
63
|
if default_config_active:
|
|
64
|
-
logger.
|
|
64
|
+
logger.warning("%s", "No config file found/readable - only default config is active")
|
|
65
65
|
_application_instance = Application(configuration)
|
|
66
66
|
if _application_config_path != config_path:
|
|
67
67
|
raise ValueError("RADICALE_CONFIG must not change: %r != %r" %
|
|
@@ -175,7 +175,7 @@ def run() -> None:
|
|
|
175
175
|
default_config_active = False
|
|
176
176
|
|
|
177
177
|
if default_config_active:
|
|
178
|
-
logger.
|
|
178
|
+
logger.warning("%s", "No config file found/readable - only default config is active")
|
|
179
179
|
|
|
180
180
|
if args_ns.verify_storage:
|
|
181
181
|
logger.info("Verifying storage")
|
|
@@ -183,7 +183,7 @@ def run() -> None:
|
|
|
183
183
|
storage_ = storage.load(configuration)
|
|
184
184
|
with storage_.acquire_lock("r"):
|
|
185
185
|
if not storage_.verify():
|
|
186
|
-
logger.critical("Storage
|
|
186
|
+
logger.critical("Storage verification failed")
|
|
187
187
|
sys.exit(1)
|
|
188
188
|
except Exception as e:
|
|
189
189
|
logger.critical("An exception occurred during storage "
|
|
@@ -146,7 +146,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|
|
146
146
|
if self._response_content_on_debug:
|
|
147
147
|
logger.debug("Response content:\n%s", answer)
|
|
148
148
|
else:
|
|
149
|
-
logger.debug("Response content: suppressed by config/option [
|
|
149
|
+
logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug")
|
|
150
150
|
headers["Content-Type"] += "; charset=%s" % self._encoding
|
|
151
151
|
answer = answer.encode(self._encoding)
|
|
152
152
|
accept_encoding = [
|
|
@@ -196,7 +196,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|
|
196
196
|
logger.debug("Request header:\n%s",
|
|
197
197
|
pprint.pformat(self._scrub_headers(environ)))
|
|
198
198
|
else:
|
|
199
|
-
logger.debug("Request header: suppressed by config/option [
|
|
199
|
+
logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug")
|
|
200
200
|
|
|
201
201
|
# SCRIPT_NAME is already removed from PATH_INFO, according to the
|
|
202
202
|
# WSGI specification.
|
|
@@ -232,7 +232,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|
|
232
232
|
path.rstrip("/").endswith("/.well-known/carddav")):
|
|
233
233
|
return response(*httputils.redirect(
|
|
234
234
|
base_prefix + "/", client.MOVED_PERMANENTLY))
|
|
235
|
-
# Return NOT FOUND for all other paths containing ".well-
|
|
235
|
+
# Return NOT FOUND for all other paths containing ".well-known"
|
|
236
236
|
if path.endswith("/.well-known") or "/.well-known/" in path:
|
|
237
237
|
return response(*httputils.NOT_FOUND)
|
|
238
238
|
|
|
@@ -51,6 +51,7 @@ class ApplicationBase:
|
|
|
51
51
|
self._encoding = configuration.get("encoding", "request")
|
|
52
52
|
self._log_bad_put_request_content = configuration.get("logging", "bad_put_request_content")
|
|
53
53
|
self._response_content_on_debug = configuration.get("logging", "response_content_on_debug")
|
|
54
|
+
self._request_content_on_debug = configuration.get("logging", "request_content_on_debug")
|
|
54
55
|
self._hook = hook.load(configuration)
|
|
55
56
|
|
|
56
57
|
def _read_xml_request_body(self, environ: types.WSGIEnviron
|
|
@@ -66,17 +67,20 @@ class ApplicationBase:
|
|
|
66
67
|
logger.debug("Request content (Invalid XML):\n%s", content)
|
|
67
68
|
raise RuntimeError("Failed to parse XML: %s" % e) from e
|
|
68
69
|
if logger.isEnabledFor(logging.DEBUG):
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
if self._request_content_on_debug:
|
|
71
|
+
logger.debug("Request content (XML):\n%s",
|
|
72
|
+
xmlutils.pretty_xml(xml_content))
|
|
73
|
+
else:
|
|
74
|
+
logger.debug("Request content (XML): suppressed by config/option [logging] request_content_on_debug")
|
|
71
75
|
return xml_content
|
|
72
76
|
|
|
73
77
|
def _xml_response(self, xml_content: ET.Element) -> bytes:
|
|
74
78
|
if logger.isEnabledFor(logging.DEBUG):
|
|
75
79
|
if self._response_content_on_debug:
|
|
76
|
-
logger.debug("Response content:\n%s",
|
|
80
|
+
logger.debug("Response content (XML):\n%s",
|
|
77
81
|
xmlutils.pretty_xml(xml_content))
|
|
78
82
|
else:
|
|
79
|
-
logger.debug("Response content: suppressed by config/option [
|
|
83
|
+
logger.debug("Response content (XML): suppressed by config/option [logging] response_content_on_debug")
|
|
80
84
|
f = io.BytesIO()
|
|
81
85
|
ET.ElementTree(xml_content).write(f, encoding=self._encoding,
|
|
82
86
|
xml_declaration=True)
|
|
@@ -322,13 +322,13 @@ def xml_propfind_response(
|
|
|
322
322
|
|
|
323
323
|
responses[404 if is404 else 200].append(element)
|
|
324
324
|
|
|
325
|
-
for status_code,
|
|
326
|
-
if not
|
|
325
|
+
for status_code, children in responses.items():
|
|
326
|
+
if not children:
|
|
327
327
|
continue
|
|
328
328
|
propstat = ET.Element(xmlutils.make_clark("D:propstat"))
|
|
329
329
|
response.append(propstat)
|
|
330
330
|
prop = ET.Element(xmlutils.make_clark("D:prop"))
|
|
331
|
-
prop.extend(
|
|
331
|
+
prop.extend(children)
|
|
332
332
|
propstat.append(prop)
|
|
333
333
|
status = ET.Element(xmlutils.make_clark("D:status"))
|
|
334
334
|
status.text = xmlutils.make_response(status_code)
|
|
@@ -150,7 +150,7 @@ class ApplicationPartPut(ApplicationBase):
|
|
|
150
150
|
if self._log_bad_put_request_content:
|
|
151
151
|
logger.warning("Bad PUT request content of %r:\n%s", path, content)
|
|
152
152
|
else:
|
|
153
|
-
logger.debug("Bad PUT request content: suppressed by config/option [
|
|
153
|
+
logger.debug("Bad PUT request content: suppressed by config/option [logging] bad_put_request_content")
|
|
154
154
|
return httputils.BAD_REQUEST
|
|
155
155
|
(prepared_items, prepared_tag, prepared_write_whole_collection,
|
|
156
156
|
prepared_props, prepared_exc_info) = prepare(
|
|
@@ -28,6 +28,7 @@ from typing import (Any, Callable, Iterable, Iterator, List, Optional,
|
|
|
28
28
|
Sequence, Tuple, Union)
|
|
29
29
|
from urllib.parse import unquote, urlparse
|
|
30
30
|
|
|
31
|
+
import vobject
|
|
31
32
|
import vobject.base
|
|
32
33
|
from vobject.base import ContentLine
|
|
33
34
|
|
|
@@ -38,11 +39,110 @@ from radicale.item import filter as radicale_filter
|
|
|
38
39
|
from radicale.log import logger
|
|
39
40
|
|
|
40
41
|
|
|
42
|
+
def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
|
43
|
+
collection: storage.BaseCollection, encoding: str,
|
|
44
|
+
unlock_storage_fn: Callable[[], None],
|
|
45
|
+
max_occurrence: int
|
|
46
|
+
) -> Tuple[int, Union[ET.Element, str]]:
|
|
47
|
+
# NOTE: this function returns both an Element and a string because
|
|
48
|
+
# free-busy reports are an edge-case on the return type according
|
|
49
|
+
# to the spec.
|
|
50
|
+
|
|
51
|
+
multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
|
|
52
|
+
if xml_request is None:
|
|
53
|
+
return client.MULTI_STATUS, multistatus
|
|
54
|
+
root = xml_request
|
|
55
|
+
if (root.tag == xmlutils.make_clark("C:free-busy-query") and
|
|
56
|
+
collection.tag != "VCALENDAR"):
|
|
57
|
+
logger.warning("Invalid REPORT method %r on %r requested",
|
|
58
|
+
xmlutils.make_human_tag(root.tag), path)
|
|
59
|
+
return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
|
|
60
|
+
|
|
61
|
+
time_range_element = root.find(xmlutils.make_clark("C:time-range"))
|
|
62
|
+
assert isinstance(time_range_element, ET.Element)
|
|
63
|
+
|
|
64
|
+
# Build a single filter from the free busy query for retrieval
|
|
65
|
+
# TODO: filter for VFREEBUSY in additional to VEVENT but
|
|
66
|
+
# test_filter doesn't support that yet.
|
|
67
|
+
vevent_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"),
|
|
68
|
+
attrib={'name': 'VEVENT'})
|
|
69
|
+
vevent_cf_element.append(time_range_element)
|
|
70
|
+
vcalendar_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"),
|
|
71
|
+
attrib={'name': 'VCALENDAR'})
|
|
72
|
+
vcalendar_cf_element.append(vevent_cf_element)
|
|
73
|
+
filter_element = ET.Element(xmlutils.make_clark("C:filter"))
|
|
74
|
+
filter_element.append(vcalendar_cf_element)
|
|
75
|
+
filters = (filter_element,)
|
|
76
|
+
|
|
77
|
+
# First pull from storage
|
|
78
|
+
retrieved_items = list(collection.get_filtered(filters))
|
|
79
|
+
# !!! Don't access storage after this !!!
|
|
80
|
+
unlock_storage_fn()
|
|
81
|
+
|
|
82
|
+
cal = vobject.iCalendar()
|
|
83
|
+
collection_tag = collection.tag
|
|
84
|
+
while retrieved_items:
|
|
85
|
+
# Second filtering before evaluating occurrences.
|
|
86
|
+
# ``item.vobject_item`` might be accessed during filtering.
|
|
87
|
+
# Don't keep reference to ``item``, because VObject requires a lot of
|
|
88
|
+
# memory.
|
|
89
|
+
item, filter_matched = retrieved_items.pop(0)
|
|
90
|
+
if not filter_matched:
|
|
91
|
+
try:
|
|
92
|
+
if not test_filter(collection_tag, item, filter_element):
|
|
93
|
+
continue
|
|
94
|
+
except ValueError as e:
|
|
95
|
+
raise ValueError("Failed to free-busy filter item %r from %r: %s" %
|
|
96
|
+
(item.href, collection.path, e)) from e
|
|
97
|
+
except Exception as e:
|
|
98
|
+
raise RuntimeError("Failed to free-busy filter item %r from %r: %s" %
|
|
99
|
+
(item.href, collection.path, e)) from e
|
|
100
|
+
|
|
101
|
+
fbtype = None
|
|
102
|
+
if item.component_name == 'VEVENT':
|
|
103
|
+
transp = getattr(item.vobject_item.vevent, 'transp', None)
|
|
104
|
+
if transp and transp.value != 'OPAQUE':
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
status = getattr(item.vobject_item.vevent, 'status', None)
|
|
108
|
+
if not status or status.value == 'CONFIRMED':
|
|
109
|
+
fbtype = 'BUSY'
|
|
110
|
+
elif status.value == 'CANCELLED':
|
|
111
|
+
fbtype = 'FREE'
|
|
112
|
+
elif status.value == 'TENTATIVE':
|
|
113
|
+
fbtype = 'BUSY-TENTATIVE'
|
|
114
|
+
else:
|
|
115
|
+
# Could do fbtype = status.value for x-name, I prefer this
|
|
116
|
+
fbtype = 'BUSY'
|
|
117
|
+
|
|
118
|
+
# TODO: coalesce overlapping periods
|
|
119
|
+
|
|
120
|
+
if max_occurrence > 0:
|
|
121
|
+
n_occurrences = max_occurrence+1
|
|
122
|
+
else:
|
|
123
|
+
n_occurrences = 0
|
|
124
|
+
occurrences = radicale_filter.time_range_fill(item.vobject_item,
|
|
125
|
+
time_range_element,
|
|
126
|
+
"VEVENT",
|
|
127
|
+
n=n_occurrences)
|
|
128
|
+
if len(occurrences) >= max_occurrence:
|
|
129
|
+
raise ValueError("FREEBUSY occurrences limit of {} hit"
|
|
130
|
+
.format(max_occurrence))
|
|
131
|
+
|
|
132
|
+
for occurrence in occurrences:
|
|
133
|
+
vfb = cal.add('vfreebusy')
|
|
134
|
+
vfb.add('dtstamp').value = item.vobject_item.vevent.dtstamp.value
|
|
135
|
+
vfb.add('dtstart').value, vfb.add('dtend').value = occurrence
|
|
136
|
+
if fbtype:
|
|
137
|
+
vfb.add('fbtype').value = fbtype
|
|
138
|
+
return (client.OK, cal.serialize())
|
|
139
|
+
|
|
140
|
+
|
|
41
141
|
def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
|
42
142
|
collection: storage.BaseCollection, encoding: str,
|
|
43
143
|
unlock_storage_fn: Callable[[], None]
|
|
44
144
|
) -> Tuple[int, ET.Element]:
|
|
45
|
-
"""Read and answer REPORT requests.
|
|
145
|
+
"""Read and answer REPORT requests that return XML.
|
|
46
146
|
|
|
47
147
|
Read rfc3253-3.6 for info.
|
|
48
148
|
|
|
@@ -271,7 +371,7 @@ def _make_vobject_expanded_item(
|
|
|
271
371
|
if hasattr(item.vobject_item.vevent, 'rrule'):
|
|
272
372
|
rruleset = vevent.getrruleset()
|
|
273
373
|
|
|
274
|
-
# There is something
|
|
374
|
+
# There is something strange behaviour during serialization native datetime, so converting manually
|
|
275
375
|
vevent.dtstart.value = vevent.dtstart.value.strftime(dt_format)
|
|
276
376
|
if dt_end is not None:
|
|
277
377
|
vevent.dtend.value = vevent.dtend.value.strftime(dt_format)
|
|
@@ -426,13 +526,28 @@ class ApplicationPartReport(ApplicationBase):
|
|
|
426
526
|
else:
|
|
427
527
|
assert item.collection is not None
|
|
428
528
|
collection = item.collection
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
529
|
+
|
|
530
|
+
if xml_content is not None and \
|
|
531
|
+
xml_content.tag == xmlutils.make_clark("C:free-busy-query"):
|
|
532
|
+
max_occurrence = self.configuration.get("reporting", "max_freebusy_occurrence")
|
|
533
|
+
try:
|
|
534
|
+
status, body = free_busy_report(
|
|
535
|
+
base_prefix, path, xml_content, collection, self._encoding,
|
|
536
|
+
lock_stack.close, max_occurrence)
|
|
537
|
+
except ValueError as e:
|
|
538
|
+
logger.warning(
|
|
539
|
+
"Bad REPORT request on %r: %s", path, e, exc_info=True)
|
|
540
|
+
return httputils.BAD_REQUEST
|
|
541
|
+
headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding}
|
|
542
|
+
return status, headers, str(body)
|
|
543
|
+
else:
|
|
544
|
+
try:
|
|
545
|
+
status, xml_answer = xml_report(
|
|
546
|
+
base_prefix, path, xml_content, collection, self._encoding,
|
|
547
|
+
lock_stack.close)
|
|
548
|
+
except ValueError as e:
|
|
549
|
+
logger.warning(
|
|
550
|
+
"Bad REPORT request on %r: %s", path, e, exc_info=True)
|
|
551
|
+
return httputils.BAD_REQUEST
|
|
552
|
+
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
|
553
|
+
return status, headers, self._xml_response(xml_answer)
|
|
@@ -52,6 +52,7 @@ def load(configuration: "config.Configuration") -> "BaseAuth":
|
|
|
52
52
|
class BaseAuth:
|
|
53
53
|
|
|
54
54
|
_lc_username: bool
|
|
55
|
+
_strip_domain: bool
|
|
55
56
|
|
|
56
57
|
def __init__(self, configuration: "config.Configuration") -> None:
|
|
57
58
|
"""Initialize BaseAuth.
|
|
@@ -63,6 +64,7 @@ class BaseAuth:
|
|
|
63
64
|
"""
|
|
64
65
|
self.configuration = configuration
|
|
65
66
|
self._lc_username = configuration.get("auth", "lc_username")
|
|
67
|
+
self._strip_domain = configuration.get("auth", "strip_domain")
|
|
66
68
|
|
|
67
69
|
def get_external_login(self, environ: types.WSGIEnviron) -> Union[
|
|
68
70
|
Tuple[()], Tuple[str, str]]:
|
|
@@ -91,4 +93,8 @@ class BaseAuth:
|
|
|
91
93
|
raise NotImplementedError
|
|
92
94
|
|
|
93
95
|
def login(self, login: str, password: str) -> str:
|
|
94
|
-
|
|
96
|
+
if self._lc_username:
|
|
97
|
+
login = login.lower()
|
|
98
|
+
if self._strip_domain:
|
|
99
|
+
login = login.split('@')[0]
|
|
100
|
+
return self._login(login, password)
|
|
@@ -36,7 +36,7 @@ pointed to by the ``htpasswd_filename`` configuration value while assuming
|
|
|
36
36
|
the password encryption method specified via the ``htpasswd_encryption``
|
|
37
37
|
configuration value.
|
|
38
38
|
|
|
39
|
-
The following htpasswd password
|
|
39
|
+
The following htpasswd password encryption methods are supported by Radicale
|
|
40
40
|
out-of-the-box:
|
|
41
41
|
- plain-text (created by htpasswd -p ...) -- INSECURE
|
|
42
42
|
- MD5-APR1 (htpasswd -m ...) -- htpasswd's default method, INSECURE
|
|
@@ -191,6 +191,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
|
|
191
191
|
"value": "1",
|
|
192
192
|
"help": "incorrect authentication delay",
|
|
193
193
|
"type": positive_float}),
|
|
194
|
+
("strip_domain", {
|
|
195
|
+
"value": "False",
|
|
196
|
+
"help": "strip domain from username",
|
|
197
|
+
"type": bool}),
|
|
194
198
|
("lc_username", {
|
|
195
199
|
"value": "False",
|
|
196
200
|
"help": "convert username to lowercase, must be true for case-insensitive auth providers",
|
|
@@ -288,12 +292,22 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
|
|
288
292
|
"value": "False",
|
|
289
293
|
"help": "log response content on level=debug",
|
|
290
294
|
"type": bool}),
|
|
295
|
+
("rights_rule_doesnt_match_on_debug", {
|
|
296
|
+
"value": "False",
|
|
297
|
+
"help": "log rights rules which doesn't match on level=debug",
|
|
298
|
+
"type": bool}),
|
|
291
299
|
("mask_passwords", {
|
|
292
300
|
"value": "True",
|
|
293
301
|
"help": "mask passwords in logs",
|
|
294
302
|
"type": bool})])),
|
|
295
303
|
("headers", OrderedDict([
|
|
296
|
-
("_allow_extra", str)]))
|
|
304
|
+
("_allow_extra", str)])),
|
|
305
|
+
("reporting", OrderedDict([
|
|
306
|
+
("max_freebusy_occurrence", {
|
|
307
|
+
"value": "10000",
|
|
308
|
+
"help": "number of occurrences per event when reporting",
|
|
309
|
+
"type": positive_int})]))
|
|
310
|
+
])
|
|
297
311
|
|
|
298
312
|
|
|
299
313
|
def parse_compound_paths(*compound_paths: Optional[str]
|
|
@@ -3,14 +3,23 @@ from enum import Enum
|
|
|
3
3
|
from typing import Sequence
|
|
4
4
|
|
|
5
5
|
from radicale import pathutils, utils
|
|
6
|
+
from radicale.log import logger
|
|
6
7
|
|
|
7
8
|
INTERNAL_TYPES: Sequence[str] = ("none", "rabbitmq")
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def load(configuration):
|
|
11
12
|
"""Load the storage module chosen in configuration."""
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
try:
|
|
14
|
+
return utils.load_plugin(
|
|
15
|
+
INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration)
|
|
16
|
+
except Exception as e:
|
|
17
|
+
logger.warning(e)
|
|
18
|
+
logger.warning("Hook \"%s\" failed to load, falling back to \"none\"." % configuration.get("hook", "type"))
|
|
19
|
+
configuration = configuration.copy()
|
|
20
|
+
configuration.update({"hook": {"type": "none"}}, "hook", privileged=True)
|
|
21
|
+
return utils.load_plugin(
|
|
22
|
+
INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration)
|
|
14
23
|
|
|
15
24
|
|
|
16
25
|
class BaseHook:
|
|
@@ -146,7 +146,7 @@ def read_request_body(configuration: "config.Configuration",
|
|
|
146
146
|
if configuration.get("logging", "request_content_on_debug"):
|
|
147
147
|
logger.debug("Request content:\n%s", content)
|
|
148
148
|
else:
|
|
149
|
-
logger.debug("Request content: suppressed by config/option [
|
|
149
|
+
logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug")
|
|
150
150
|
return content
|
|
151
151
|
|
|
152
152
|
|
|
@@ -49,6 +49,12 @@ def read_components(s: str) -> List[vobject.base.Component]:
|
|
|
49
49
|
s = re.sub(r"^(PHOTO(?:;[^:\r\n]*)?;ENCODING=b(?:;[^:\r\n]*)?:)"
|
|
50
50
|
r"data:[^;,\r\n]*;base64,", r"\1", s,
|
|
51
51
|
flags=re.MULTILINE | re.IGNORECASE)
|
|
52
|
+
# Workaround for bug with malformed ICS files containing control codes
|
|
53
|
+
# Filter out all control codes except those we expect to find:
|
|
54
|
+
# * 0x09 Horizontal Tab
|
|
55
|
+
# * 0x0A Line Feed
|
|
56
|
+
# * 0x0D Carriage Return
|
|
57
|
+
s = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F]', '', s)
|
|
52
58
|
return list(vobject.readComponents(s, allowQP=True))
|
|
53
59
|
|
|
54
60
|
|
|
@@ -298,7 +304,7 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str
|
|
|
298
304
|
Returns a tuple (``start``, ``end``) where ``start`` and ``end`` are
|
|
299
305
|
POSIX timestamps.
|
|
300
306
|
|
|
301
|
-
This is
|
|
307
|
+
This is intended to be used for matching against simplified prefilters.
|
|
302
308
|
|
|
303
309
|
"""
|
|
304
310
|
if not tag:
|
|
@@ -48,10 +48,34 @@ def date_to_datetime(d: date) -> datetime:
|
|
|
48
48
|
if not isinstance(d, datetime):
|
|
49
49
|
d = datetime.combine(d, datetime.min.time())
|
|
50
50
|
if not d.tzinfo:
|
|
51
|
-
|
|
51
|
+
# NOTE: using vobject's UTC as it wasn't playing well with datetime's.
|
|
52
|
+
d = d.replace(tzinfo=vobject.icalendar.utc)
|
|
52
53
|
return d
|
|
53
54
|
|
|
54
55
|
|
|
56
|
+
def parse_time_range(time_filter: ET.Element) -> Tuple[datetime, datetime]:
|
|
57
|
+
start_text = time_filter.get("start")
|
|
58
|
+
end_text = time_filter.get("end")
|
|
59
|
+
if start_text:
|
|
60
|
+
start = datetime.strptime(
|
|
61
|
+
start_text, "%Y%m%dT%H%M%SZ").replace(
|
|
62
|
+
tzinfo=timezone.utc)
|
|
63
|
+
else:
|
|
64
|
+
start = DATETIME_MIN
|
|
65
|
+
if end_text:
|
|
66
|
+
end = datetime.strptime(
|
|
67
|
+
end_text, "%Y%m%dT%H%M%SZ").replace(
|
|
68
|
+
tzinfo=timezone.utc)
|
|
69
|
+
else:
|
|
70
|
+
end = DATETIME_MAX
|
|
71
|
+
return start, end
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def time_range_timestamps(time_filter: ET.Element) -> Tuple[int, int]:
|
|
75
|
+
start, end = parse_time_range(time_filter)
|
|
76
|
+
return (math.floor(start.timestamp()), math.ceil(end.timestamp()))
|
|
77
|
+
|
|
78
|
+
|
|
55
79
|
def comp_match(item: "item.Item", filter_: ET.Element, level: int = 0) -> bool:
|
|
56
80
|
"""Check whether the ``item`` matches the comp ``filter_``.
|
|
57
81
|
|
|
@@ -147,21 +171,10 @@ def time_range_match(vobject_item: vobject.base.Component,
|
|
|
147
171
|
"""Check whether the component/property ``child_name`` of
|
|
148
172
|
``vobject_item`` matches the time-range ``filter_``."""
|
|
149
173
|
|
|
150
|
-
|
|
151
|
-
end_text = filter_.get("end")
|
|
152
|
-
if not start_text and not end_text:
|
|
174
|
+
if not filter_.get("start") and not filter_.get("end"):
|
|
153
175
|
return False
|
|
154
|
-
if start_text:
|
|
155
|
-
start = datetime.strptime(start_text, "%Y%m%dT%H%M%SZ")
|
|
156
|
-
else:
|
|
157
|
-
start = datetime.min
|
|
158
|
-
if end_text:
|
|
159
|
-
end = datetime.strptime(end_text, "%Y%m%dT%H%M%SZ")
|
|
160
|
-
else:
|
|
161
|
-
end = datetime.max
|
|
162
|
-
start = start.replace(tzinfo=timezone.utc)
|
|
163
|
-
end = end.replace(tzinfo=timezone.utc)
|
|
164
176
|
|
|
177
|
+
start, end = parse_time_range(filter_)
|
|
165
178
|
matched = False
|
|
166
179
|
|
|
167
180
|
def range_fn(range_start: datetime, range_end: datetime,
|
|
@@ -181,6 +194,35 @@ def time_range_match(vobject_item: vobject.base.Component,
|
|
|
181
194
|
return matched
|
|
182
195
|
|
|
183
196
|
|
|
197
|
+
def time_range_fill(vobject_item: vobject.base.Component,
|
|
198
|
+
filter_: ET.Element, child_name: str, n: int = 1
|
|
199
|
+
) -> List[Tuple[datetime, datetime]]:
|
|
200
|
+
"""Create a list of ``n`` occurances from the component/property ``child_name``
|
|
201
|
+
of ``vobject_item``."""
|
|
202
|
+
if not filter_.get("start") and not filter_.get("end"):
|
|
203
|
+
return []
|
|
204
|
+
|
|
205
|
+
start, end = parse_time_range(filter_)
|
|
206
|
+
ranges: List[Tuple[datetime, datetime]] = []
|
|
207
|
+
|
|
208
|
+
def range_fn(range_start: datetime, range_end: datetime,
|
|
209
|
+
is_recurrence: bool) -> bool:
|
|
210
|
+
nonlocal ranges
|
|
211
|
+
if start < range_end and range_start < end:
|
|
212
|
+
ranges.append((range_start, range_end))
|
|
213
|
+
if n > 0 and len(ranges) >= n:
|
|
214
|
+
return True
|
|
215
|
+
if end < range_start and not is_recurrence:
|
|
216
|
+
return True
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
def infinity_fn(range_start: datetime) -> bool:
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn)
|
|
223
|
+
return ranges
|
|
224
|
+
|
|
225
|
+
|
|
184
226
|
def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
|
|
185
227
|
range_fn: Callable[[datetime, datetime, bool], bool],
|
|
186
228
|
infinity_fn: Callable[[datetime], bool]) -> None:
|
|
@@ -199,7 +241,7 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
|
|
|
199
241
|
|
|
200
242
|
"""
|
|
201
243
|
|
|
202
|
-
# HACK: According to rfc5545-3.8.4.4
|
|
244
|
+
# HACK: According to rfc5545-3.8.4.4 a recurrence that is rescheduled
|
|
203
245
|
# with Recurrence ID affects the recurrence itself and all following
|
|
204
246
|
# recurrences too. This is not respected and client don't seem to bother
|
|
205
247
|
# either.
|
|
@@ -543,20 +585,7 @@ def simplify_prefilters(filters: Iterable[ET.Element], collection_tag: str
|
|
|
543
585
|
if time_filter.tag != xmlutils.make_clark("C:time-range"):
|
|
544
586
|
simple = False
|
|
545
587
|
continue
|
|
546
|
-
|
|
547
|
-
end_text = time_filter.get("end")
|
|
548
|
-
if start_text:
|
|
549
|
-
start = math.floor(datetime.strptime(
|
|
550
|
-
start_text, "%Y%m%dT%H%M%SZ").replace(
|
|
551
|
-
tzinfo=timezone.utc).timestamp())
|
|
552
|
-
else:
|
|
553
|
-
start = TIMESTAMP_MIN
|
|
554
|
-
if end_text:
|
|
555
|
-
end = math.ceil(datetime.strptime(
|
|
556
|
-
end_text, "%Y%m%dT%H%M%SZ").replace(
|
|
557
|
-
tzinfo=timezone.utc).timestamp())
|
|
558
|
-
else:
|
|
559
|
-
end = TIMESTAMP_MAX
|
|
588
|
+
start, end = time_range_timestamps(time_filter)
|
|
560
589
|
return tag, start, end, simple
|
|
561
590
|
return tag, TIMESTAMP_MIN, TIMESTAMP_MAX, simple
|
|
562
591
|
return None, TIMESTAMP_MIN, TIMESTAMP_MAX, simple
|
|
@@ -22,7 +22,7 @@ config (section "rights", key "file").
|
|
|
22
22
|
The login is matched against the "user" key, and the collection path
|
|
23
23
|
is matched against the "collection" key. In the "collection" regex you can use
|
|
24
24
|
`{user}` and get groups from the "user" regex with `{0}`, `{1}`, etc.
|
|
25
|
-
In consequence of the parameter
|
|
25
|
+
In consequence of the parameter substitution you have to write `{{` and `}}`
|
|
26
26
|
if you want to use regular curly braces in the "user" and "collection" regexes.
|
|
27
27
|
|
|
28
28
|
For example, for the "user" key, ".+" means "authenticated user" and ".*"
|
|
@@ -48,6 +48,7 @@ class Rights(rights.BaseRights):
|
|
|
48
48
|
def __init__(self, configuration: config.Configuration) -> None:
|
|
49
49
|
super().__init__(configuration)
|
|
50
50
|
self._filename = configuration.get("rights", "file")
|
|
51
|
+
self._log_rights_rule_doesnt_match_on_debug = configuration.get("logging", "rights_rule_doesnt_match_on_debug")
|
|
51
52
|
|
|
52
53
|
def authorization(self, user: str, path: str) -> str:
|
|
53
54
|
user = user or ""
|
|
@@ -61,6 +62,8 @@ class Rights(rights.BaseRights):
|
|
|
61
62
|
except Exception as e:
|
|
62
63
|
raise RuntimeError("Failed to load rights file %r: %s" %
|
|
63
64
|
(self._filename, e)) from e
|
|
65
|
+
if not self._log_rights_rule_doesnt_match_on_debug:
|
|
66
|
+
logger.debug("logging of rules which doesn't match suppressed by config/option [logging] rights_rule_doesnt_match_on_debug")
|
|
64
67
|
for section in rights_config.sections():
|
|
65
68
|
try:
|
|
66
69
|
user_pattern = rights_config.get(section, "user")
|
|
@@ -80,8 +83,9 @@ class Rights(rights.BaseRights):
|
|
|
80
83
|
user, sane_path, user_pattern,
|
|
81
84
|
collection_pattern, section, permission)
|
|
82
85
|
return permission
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
if self._log_rights_rule_doesnt_match_on_debug:
|
|
87
|
+
logger.debug("Rule %r:%r doesn't match %r:%r from section %r",
|
|
88
|
+
user, sane_path, user_pattern, collection_pattern,
|
|
89
|
+
section)
|
|
86
90
|
logger.info("Rights: %r:%r doesn't match any section", user, sane_path)
|
|
87
91
|
return ""
|
|
@@ -291,7 +291,7 @@ def serve(configuration: config.Configuration,
|
|
|
291
291
|
try:
|
|
292
292
|
getaddrinfo = socket.getaddrinfo(address_port[0], address_port[1], 0, socket.SOCK_STREAM, socket.IPPROTO_TCP)
|
|
293
293
|
except OSError as e:
|
|
294
|
-
logger.
|
|
294
|
+
logger.warning("cannot retrieve IPv4 or IPv6 address of '%s': %s" % (format_address(address_port), e))
|
|
295
295
|
continue
|
|
296
296
|
logger.debug("getaddrinfo of '%s': %s" % (format_address(address_port), getaddrinfo))
|
|
297
297
|
for (address_family, socket_kind, socket_proto, socket_flags, socket_address) in getaddrinfo:
|
|
@@ -299,7 +299,7 @@ def serve(configuration: config.Configuration,
|
|
|
299
299
|
try:
|
|
300
300
|
server = server_class(configuration, address_family, (socket_address[0], socket_address[1]), RequestHandler)
|
|
301
301
|
except OSError as e:
|
|
302
|
-
logger.
|
|
302
|
+
logger.warning("cannot create server socket on '%s': %s" % (format_address(socket_address), e))
|
|
303
303
|
continue
|
|
304
304
|
servers[server.socket] = server
|
|
305
305
|
server.set_app(application)
|
|
@@ -84,7 +84,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
|
|
|
84
84
|
cache_content = self._load_item_cache(href, cache_hash)
|
|
85
85
|
if cache_content is None:
|
|
86
86
|
with self._acquire_cache_lock("item"):
|
|
87
|
-
# Lock the item cache to prevent
|
|
87
|
+
# Lock the item cache to prevent multiple processes from
|
|
88
88
|
# generating the same data in parallel.
|
|
89
89
|
# This improves the performance for multiple requests.
|
|
90
90
|
if self._storage._lock.locked == "r":
|
|
@@ -127,7 +127,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
|
|
|
127
127
|
|
|
128
128
|
def get_multi(self, hrefs: Iterable[str]
|
|
129
129
|
) -> Iterator[Tuple[str, Optional[radicale_item.Item]]]:
|
|
130
|
-
# It's faster to check for file name
|
|
130
|
+
# It's faster to check for file name collisions here, because
|
|
131
131
|
# we only need to call os.listdir once.
|
|
132
132
|
files = None
|
|
133
133
|
for href in hrefs:
|
|
@@ -146,7 +146,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
|
|
|
146
146
|
|
|
147
147
|
def get_all(self) -> Iterator[radicale_item.Item]:
|
|
148
148
|
for href in self._list():
|
|
149
|
-
# We don't need to check for
|
|
149
|
+
# We don't need to check for collisions, because the file names
|
|
150
150
|
# are from os.listdir.
|
|
151
151
|
item = self._get(href, verify_href=False)
|
|
152
152
|
if item is not None:
|
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
[tool:pytest]
|
|
2
|
-
addopts = --typeguard-packages=radicale
|
|
3
2
|
|
|
4
3
|
[tox:tox]
|
|
4
|
+
min_version = 4.0
|
|
5
|
+
envlist = py, flake8, isort, mypy
|
|
5
6
|
|
|
6
7
|
[testenv]
|
|
7
|
-
extras =
|
|
8
|
+
extras =
|
|
9
|
+
test
|
|
8
10
|
deps =
|
|
9
|
-
|
|
10
|
-
isort
|
|
11
|
-
mypy; implementation_name!='pypy' or python_version>='3.9'
|
|
12
|
-
types-setuptools
|
|
11
|
+
pytest
|
|
13
12
|
pytest-cov
|
|
14
|
-
commands =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
commands = pytest -r s --cov --cov-report=term --cov-report=xml .
|
|
14
|
+
|
|
15
|
+
[testenv:flake8]
|
|
16
|
+
deps = flake8==7.1.0
|
|
17
|
+
commands = flake8 .
|
|
18
|
+
skip_install = True
|
|
19
|
+
|
|
20
|
+
[testenv:isort]
|
|
21
|
+
deps = isort==5.13.2
|
|
22
|
+
commands = isort --check --diff .
|
|
23
|
+
skip_install = True
|
|
24
|
+
|
|
25
|
+
[testenv:mypy]
|
|
26
|
+
deps = mypy==1.11.0
|
|
27
|
+
commands = mypy .
|
|
28
|
+
skip_install = True
|
|
22
29
|
|
|
23
30
|
[tool:isort]
|
|
24
31
|
known_standard_library = _dummy_thread,_thread,abc,aifc,argparse,array,ast,asynchat,asyncio,asyncore,atexit,audioop,base64,bdb,binascii,binhex,bisect,builtins,bz2,cProfile,calendar,cgi,cgitb,chunk,cmath,cmd,code,codecs,codeop,collections,colorsys,compileall,concurrent,configparser,contextlib,contextvars,copy,copyreg,crypt,csv,ctypes,curses,dataclasses,datetime,dbm,decimal,difflib,dis,distutils,doctest,dummy_threading,email,encodings,ensurepip,enum,errno,faulthandler,fcntl,filecmp,fileinput,fnmatch,formatter,fpectl,fractions,ftplib,functools,gc,getopt,getpass,gettext,glob,grp,gzip,hashlib,heapq,hmac,html,http,imaplib,imghdr,imp,importlib,inspect,io,ipaddress,itertools,json,keyword,lib2to3,linecache,locale,logging,lzma,macpath,mailbox,mailcap,marshal,math,mimetypes,mmap,modulefinder,msilib,msvcrt,multiprocessing,netrc,nis,nntplib,ntpath,numbers,operator,optparse,os,ossaudiodev,parser,pathlib,pdb,pickle,pickletools,pipes,pkgutil,platform,plistlib,poplib,posix,posixpath,pprint,profile,pstats,pty,pwd,py_compile,pyclbr,pydoc,queue,quopri,random,re,readline,reprlib,resource,rlcompleter,runpy,sched,secrets,select,selectors,shelve,shlex,shutil,signal,site,smtpd,smtplib,sndhdr,socket,socketserver,spwd,sqlite3,sre,sre_compile,sre_constants,sre_parse,ssl,stat,statistics,string,stringprep,struct,subprocess,sunau,symbol,symtable,sys,sysconfig,syslog,tabnanny,tarfile,telnetlib,tempfile,termios,test,textwrap,threading,time,timeit,tkinter,token,tokenize,trace,traceback,tracemalloc,tty,turtle,turtledemo,types,typing,unicodedata,unittest,urllib,uu,uuid,venv,warnings,wave,weakref,webbrowser,winreg,winsound,wsgiref,xdrlib,xml,xmlrpc,zipapp,zipfile,zipimport,zlib
|
|
@@ -19,7 +19,7 @@ from setuptools import find_packages, setup
|
|
|
19
19
|
|
|
20
20
|
# When the version is updated, a new section in the CHANGELOG.md file must be
|
|
21
21
|
# added too.
|
|
22
|
-
VERSION = "3.2.
|
|
22
|
+
VERSION = "3.2.3"
|
|
23
23
|
|
|
24
24
|
with open("README.md", encoding="utf-8") as f:
|
|
25
25
|
long_description = f.read()
|
|
@@ -38,9 +38,9 @@ web_files = ["web/internal_data/css/icon.png",
|
|
|
38
38
|
install_requires = ["defusedxml", "passlib", "vobject>=0.9.6",
|
|
39
39
|
"python-dateutil>=2.7.3",
|
|
40
40
|
"pika>=1.1.0",
|
|
41
|
-
|
|
41
|
+
]
|
|
42
42
|
bcrypt_requires = ["bcrypt"]
|
|
43
|
-
test_requires = ["pytest>=7", "
|
|
43
|
+
test_requires = ["pytest>=7", "waitress", *bcrypt_requires]
|
|
44
44
|
|
|
45
45
|
setup(
|
|
46
46
|
name="Radicale",
|
|
@@ -75,6 +75,7 @@ setup(
|
|
|
75
75
|
"Programming Language :: Python :: 3.10",
|
|
76
76
|
"Programming Language :: Python :: 3.11",
|
|
77
77
|
"Programming Language :: Python :: 3.12",
|
|
78
|
+
"Programming Language :: Python :: 3.13",
|
|
78
79
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
79
80
|
"Programming Language :: Python :: Implementation :: PyPy",
|
|
80
81
|
"Topic :: Office/Business :: Groupware"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|