Radicale 3.8.1__tar.gz → 3.8.2__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.8.1 → radicale-3.8.2}/CHANGELOG.md +23 -0
- {radicale-3.8.1 → radicale-3.8.2}/DOCUMENTATION.md +34 -3
- {radicale-3.8.1 → radicale-3.8.2}/PKG-INFO +1 -1
- {radicale-3.8.1 → radicale-3.8.2}/Radicale.egg-info/PKG-INFO +1 -1
- {radicale-3.8.1 → radicale-3.8.2}/Radicale.egg-info/SOURCES.txt +0 -1
- {radicale-3.8.1 → radicale-3.8.2}/config +8 -2
- {radicale-3.8.1 → radicale-3.8.2}/pyproject.toml +24 -2
- {radicale-3.8.1 → radicale-3.8.2}/radicale/app/__init__.py +16 -8
- {radicale-3.8.1 → radicale-3.8.2}/radicale/app/base.py +23 -6
- {radicale-3.8.1 → radicale-3.8.2}/radicale/app/delete.py +11 -2
- {radicale-3.8.1 → radicale-3.8.2}/radicale/app/get.py +139 -7
- {radicale-3.8.1 → radicale-3.8.2}/radicale/app/mkcalendar.py +1 -1
- {radicale-3.8.1 → radicale-3.8.2}/radicale/app/mkcol.py +1 -1
- {radicale-3.8.1 → radicale-3.8.2}/radicale/app/move.py +11 -4
- {radicale-3.8.1 → radicale-3.8.2}/radicale/app/propfind.py +120 -26
- {radicale-3.8.1 → radicale-3.8.2}/radicale/app/proppatch.py +7 -4
- {radicale-3.8.1 → radicale-3.8.2}/radicale/app/put.py +9 -6
- {radicale-3.8.1 → radicale-3.8.2}/radicale/app/report.py +205 -65
- {radicale-3.8.1 → radicale-3.8.2}/radicale/auth/htpasswd.py +9 -3
- {radicale-3.8.1 → radicale-3.8.2}/radicale/auth/imap.py +4 -2
- {radicale-3.8.1 → radicale-3.8.2}/radicale/auth/ldap.py +15 -15
- {radicale-3.8.1 → radicale-3.8.2}/radicale/auth/oauth2.py +5 -3
- {radicale-3.8.1 → radicale-3.8.2}/radicale/auth/pam.py +16 -10
- {radicale-3.8.1 → radicale-3.8.2}/radicale/config.py +8 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/hook/__init__.py +3 -1
- {radicale-3.8.1 → radicale-3.8.2}/radicale/httputils.py +2 -1
- {radicale-3.8.1 → radicale-3.8.2}/radicale/item/__init__.py +40 -9
- {radicale-3.8.1 → radicale-3.8.2}/radicale/item/filter.py +9 -3
- {radicale-3.8.1 → radicale-3.8.2}/radicale/rights/__init__.py +3 -1
- {radicale-3.8.1 → radicale-3.8.2}/radicale/server.py +13 -5
- {radicale-3.8.1 → radicale-3.8.2}/radicale/sharing/__init__.py +11 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/sharing/common.py +6 -4
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem/__init__.py +13 -5
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem/create_collection.py +1 -1
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem/lock.py +31 -16
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem/move.py +2 -1
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem/upload.py +1 -1
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/css/main.css +10 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/index.html +8 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/models/collection.js +4 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/scenes/CollectionsScene.js +31 -12
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/scenes/CreateEditShareScene.js +2 -2
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/scenes/IncomingSharingScene.js +1 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/scenes/ShareCollectionScene.js +4 -4
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/utils/misc.js +37 -1
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/utils/permissions.js +40 -16
- {radicale-3.8.1 → radicale-3.8.2}/rights +7 -0
- radicale-3.8.2/setup.cfg +4 -0
- radicale-3.8.1/setup.cfg +0 -27
- {radicale-3.8.1 → radicale-3.8.2}/COPYING.md +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/MANIFEST.in +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/README.md +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/Radicale.egg-info/dependency_links.txt +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/Radicale.egg-info/entry_points.txt +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/Radicale.egg-info/requires.txt +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/Radicale.egg-info/top_level.txt +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/__init__.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/__main__.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/app/head.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/app/options.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/app/post.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/auth/__init__.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/auth/denyall.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/auth/dovecot.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/auth/http_remote_user.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/auth/http_x_remote_user.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/auth/none.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/auth/remote_user.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/group/__init__.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/group/from_auth.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/group/htgroup.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/group/none.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/hook/email/__init__.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/hook/none.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/hook/rabbitmq/__init__.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/log.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/pathutils.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/py.typed +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/rights/authenticated.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/rights/from_file.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/rights/owner_only.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/rights/owner_write.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/sharing/csv.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/sharing/files.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/sharing/none.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/__init__.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem/base.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem/cache.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem/delete.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem/discover.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem/get.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem/history.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem/meta.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem/sync.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem/verify.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/storage/multifilesystem_nolock.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/tests/custom/__init__.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/tests/custom/auth.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/tests/custom/rights.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/tests/custom/storage_simple_sync.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/tests/custom/web.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/types.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/utils.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/__init__.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/css/icon.png +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/css/icons/delete.svg +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/css/icons/download.svg +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/css/icons/edit.svg +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/css/icons/eye.svg +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/css/icons/key.svg +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/css/icons/new.svg +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/css/icons/repeat.svg +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/css/icons/share.svg +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/css/icons/upload.svg +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/css/loading.svg +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/css/logo.svg +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/api/api.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/api/common.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/api/sharing.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/config.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/constants.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/main.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/scenes/LoadingScene.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/scenes/LoginScene.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/scenes/UploadCollectionScene.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/scenes/scene_manager.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/utils/collection_utils.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/utils/collections_cache.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/utils/error.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/utils/form_validator.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/internal_data/js/utils/url_text.js +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/web/none.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale/xmlutils.py +0 -0
- {radicale-3.8.1 → radicale-3.8.2}/radicale.wsgi +0 -0
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.8.2
|
|
4
|
+
* Add: tests: sharing API plus CalDAV/CardDAV workflow and corrupt CSV handling (covers #2259)
|
|
5
|
+
* Add: tests: mocked LDAP, IMAP, OAuth2 and PAM authentication servers (covers #2259)
|
|
6
|
+
* Improve: storage hook: avoid using preexc_fn, use process_group
|
|
7
|
+
* Add: sharing/token: plain GET and HEAD serve free/busy when Actions config `view` is `freebusy`
|
|
8
|
+
* Add: WebUI read-only free/busy calendar URL under each calendar
|
|
9
|
+
* Add: read-only free/busy view via GET ?view=freebusy
|
|
10
|
+
* Fix: free-busy REPORT returns one VFREEBUSY with FREEBUSY periods and no event details (RFC 4791)
|
|
11
|
+
* Add: rights permission `f` to read free-busy time without event details
|
|
12
|
+
* Fix: free-busy REPORT does not expand transparent recurrences
|
|
13
|
+
* Fix: sharing/bday-conversion: add proper RRULE and RECURRENCE-ID in case of "age" support is triggered
|
|
14
|
+
* Fix: sharing/bday-conversion: do not serve item.vcf in parallel of item.ics
|
|
15
|
+
* Fix: sharing/bday-conversion: adjust etag depending on template (item) or content (collection)
|
|
16
|
+
* Fix: sharing/propfind: remove unexpected additional shares listed in case of request on collection with HTTP_DEPTH=1
|
|
17
|
+
* Add: sharing/bday-conversion: X-RADICALE-NAME to generated bday items
|
|
18
|
+
* Fix: sharing/propfind: suppress (resolved) shares having same URI as native ones
|
|
19
|
+
* Fix: sharing/mkcol+mkcalendar: detect conflict with existing resolved shares
|
|
20
|
+
* Fix: WebUI/sharing: display permissions and share-by-group
|
|
21
|
+
* Performance: guard expensive logging with isEnabledFor (where missed)
|
|
22
|
+
* Performance: use lazy formatting for logger calls (where missed)
|
|
23
|
+
* Fix: delete/sharing: neither allow resolved share nor still mapped collections to be deleted
|
|
24
|
+
* Fix: sharing: resolved share resulting in same path as original requested will not have precedence
|
|
25
|
+
|
|
3
26
|
## 3.8.1
|
|
4
27
|
* Add: tests: optional caldav-server-tester based CalDAV compatibility test suite (caldav_compat_tests)
|
|
5
28
|
* Fix: sharing/bday-conversion: add missing support for comma separated text components in vcard field N (name)
|
|
@@ -2269,6 +2269,35 @@ is thrown instead of returning the results.
|
|
|
2269
2269
|
|
|
2270
2270
|
Default: 10000
|
|
2271
2271
|
|
|
2272
|
+
##### freebusy_view_past_days
|
|
2273
|
+
|
|
2274
|
+
_(>= 3.8.2)_
|
|
2275
|
+
|
|
2276
|
+
Number of days before today included in the default window of the
|
|
2277
|
+
read-only free/busy view (`GET` or `HEAD` with `?view=freebusy`). The
|
|
2278
|
+
window starts at midnight UTC. `0` starts the window at midnight UTC
|
|
2279
|
+
today. A request that provides both `start` and `end`
|
|
2280
|
+
(`YYYYMMDDTHHMMSSZ`) overrides this default and
|
|
2281
|
+
`freebusy_view_future_days`.
|
|
2282
|
+
|
|
2283
|
+
Default: 365
|
|
2284
|
+
|
|
2285
|
+
##### freebusy_view_future_days
|
|
2286
|
+
|
|
2287
|
+
_(>= 3.8.2)_
|
|
2288
|
+
|
|
2289
|
+
Number of days after today included in the default window of the
|
|
2290
|
+
read-only free/busy view. The window ends at midnight UTC that many
|
|
2291
|
+
days ahead. `0` ends the window at midnight UTC today.
|
|
2292
|
+
|
|
2293
|
+
Default: 365
|
|
2294
|
+
|
|
2295
|
+
A share-by-token can omit `?view=freebusy`. Set that token's Actions
|
|
2296
|
+
config `view` to `freebusy`. Plain `GET` and `HEAD` of the token then
|
|
2297
|
+
use this view, including an optional `start` and `end`. Any other
|
|
2298
|
+
`view` value is rejected. Other methods keep the token permissions.
|
|
2299
|
+
See [Collection Sharing](https://github.com/Kozea/Radicale/blob/master/SHARING.md).
|
|
2300
|
+
|
|
2272
2301
|
#### [sharing]
|
|
2273
2302
|
|
|
2274
2303
|
_(>= 3.7.0)_
|
|
@@ -2370,7 +2399,7 @@ Default permissions for create token-based sharing
|
|
|
2370
2399
|
|
|
2371
2400
|
Default: `r`
|
|
2372
2401
|
|
|
2373
|
-
Supported: `
|
|
2402
|
+
Supported: `rfwEePp`
|
|
2374
2403
|
|
|
2375
2404
|
##### default_permissions_create_map
|
|
2376
2405
|
|
|
@@ -2380,7 +2409,7 @@ Default permissions for map-based sharing
|
|
|
2380
2409
|
|
|
2381
2410
|
Default: `r`
|
|
2382
2411
|
|
|
2383
|
-
Supported: `
|
|
2412
|
+
Supported: `rfwEePp`
|
|
2384
2413
|
|
|
2385
2414
|
##### conversion_bday_summary_template
|
|
2386
2415
|
|
|
@@ -2388,7 +2417,7 @@ _(>= 3.7.5)_
|
|
|
2388
2417
|
|
|
2389
2418
|
Global template for summary of conversion "bday"
|
|
2390
2419
|
|
|
2391
|
-
Default: `{
|
|
2420
|
+
Default: `[{n:f} {n:g}|{fn}|{nickname}] ({year}) (BDAY)`
|
|
2392
2421
|
|
|
2393
2422
|
Supported placeholders (data used from VCARD)
|
|
2394
2423
|
* `{year}`: year of birthday (RFC6350#6.2.5)
|
|
@@ -2677,6 +2706,8 @@ The following `permissions` are recognized:
|
|
|
2677
2706
|
* **r:** read address book and calendar collections
|
|
2678
2707
|
* **i:** subset of **r** that only allows direct access via HTTP method GET
|
|
2679
2708
|
(CalDAV/CardDAV is susceptible to expensive search requests)
|
|
2709
|
+
* **f:** read the free-busy time of a calendar, without event details
|
|
2710
|
+
(**r** includes this)
|
|
2680
2711
|
* **W:** write collections (excluding address books and calendars)
|
|
2681
2712
|
* **w:** write address book and calendar collections
|
|
2682
2713
|
* **D:** allow deleting a collection in case `permit_delete_collection=False` _(>= 3.3.0)_
|
|
@@ -372,11 +372,11 @@ type = none
|
|
|
372
372
|
#enforce_properties_overlay = true
|
|
373
373
|
|
|
374
374
|
# Default permissions for token-based sharing
|
|
375
|
-
# Supported:
|
|
375
|
+
# Supported: rfwEePp
|
|
376
376
|
#default_permissions_create_token = rp
|
|
377
377
|
|
|
378
378
|
# Default permissions for map-based sharing
|
|
379
|
-
# Supported:
|
|
379
|
+
# Supported: rfwEePp
|
|
380
380
|
#default_permissions_create_map = r
|
|
381
381
|
|
|
382
382
|
# Global template for summary of conversion "bday"
|
|
@@ -526,3 +526,9 @@ Content-Security-Policy = default-src 'self'; object-src 'none'
|
|
|
526
526
|
# When returning a free-busy report, limit the number of returned
|
|
527
527
|
# occurences per event to prevent DoS attacks.
|
|
528
528
|
#max_freebusy_occurrence = 10000
|
|
529
|
+
|
|
530
|
+
# Days before and after today in the default free-busy view
|
|
531
|
+
# (?view=freebusy). The window is aligned to midnight UTC.
|
|
532
|
+
# A request can override both with start and end.
|
|
533
|
+
#freebusy_view_past_days = 365
|
|
534
|
+
#freebusy_view_future_days = 365
|
|
@@ -3,7 +3,7 @@ name = "Radicale"
|
|
|
3
3
|
# When the version is updated, a new section in the CHANGELOG.md file must be
|
|
4
4
|
# added too.
|
|
5
5
|
readme = "README.md"
|
|
6
|
-
version = "3.8.
|
|
6
|
+
version = "3.8.2"
|
|
7
7
|
authors = [{name = "Guillaume Ayoub", email = "guillaume.ayoub@kozea.fr"}, {name = "Unrud", email = "unrud@outlook.com"}, {name = "Peter Bieringer", email = "pb@bieringer.de"}]
|
|
8
8
|
license = {text = "GNU GPL v3"}
|
|
9
9
|
description = "CalDAV and CardDAV Server"
|
|
@@ -60,6 +60,7 @@ min_version = "4.0"
|
|
|
60
60
|
envlist = ["flake8", "isort", "mypy", "py_loglevel_info", "py_radicale_loglevel_info", "py_radicale_loglevel_trace", "py", "integ_test", "caldav_compat_test", "html5validator"]
|
|
61
61
|
|
|
62
62
|
[tool.tox.env.py]
|
|
63
|
+
description = "Server: run 'pytest' (incl. coverage report)"
|
|
63
64
|
extras = ["test"]
|
|
64
65
|
deps = [
|
|
65
66
|
"pytest",
|
|
@@ -68,6 +69,7 @@ deps = [
|
|
|
68
69
|
commands = [["pytest", "-r", "s", "--cov", "--cov-report=term", "--cov-report=xml", "."]]
|
|
69
70
|
|
|
70
71
|
[tool.tox.env.py_loglevel_info]
|
|
72
|
+
description = "Server: run 'pytest' with log level 'INFO'"
|
|
71
73
|
extras = ["test"]
|
|
72
74
|
deps = [
|
|
73
75
|
"pytest"
|
|
@@ -75,6 +77,7 @@ deps = [
|
|
|
75
77
|
commands = [["pytest", "-r", "s", "--log-level", "INFO", "."]]
|
|
76
78
|
|
|
77
79
|
[tool.tox.env.py_radicale_loglevel_info]
|
|
80
|
+
description = "Server: run 'pytest' with radicale on log level 'INFO'"
|
|
78
81
|
extras = ["test"]
|
|
79
82
|
setenv = { PYTEST_RADICALE_LOGLEVEL = "info" }
|
|
80
83
|
deps = [
|
|
@@ -83,6 +86,7 @@ deps = [
|
|
|
83
86
|
commands = [["pytest", "-r", "s", "--log-level", "INFO", "."]]
|
|
84
87
|
|
|
85
88
|
[tool.tox.env.py_radicale_loglevel_trace]
|
|
89
|
+
description = "Server: run 'pytest' with radicale on log level 'TRACE'"
|
|
86
90
|
extras = ["test"]
|
|
87
91
|
setenv = { PYTEST_RADICALE_LOGLEVEL = "trace" }
|
|
88
92
|
deps = [
|
|
@@ -91,6 +95,7 @@ deps = [
|
|
|
91
95
|
commands = [["pytest", "-r", "s", "--log-level", "5", "."]]
|
|
92
96
|
|
|
93
97
|
[tool.tox.env.py_filesystem_vfat]
|
|
98
|
+
description = "Server: run 'pytest' with collection storage filesystem 'vfat' (on Linux)"
|
|
94
99
|
allowlist_externals = [ "sudo", "dd", "mkfs.vfat", "mkdir", "rm", "rmdir", "chmod" ]
|
|
95
100
|
extras = ["test"]
|
|
96
101
|
deps = [
|
|
@@ -118,6 +123,7 @@ commands_post = [
|
|
|
118
123
|
]
|
|
119
124
|
|
|
120
125
|
[tool.tox.env.py_filesystem_vfat_utf8]
|
|
126
|
+
description = "Server: run 'pytest' with collection storage filesystem 'vfat+utf8' (on Linux)"
|
|
121
127
|
allowlist_externals = [ "sudo", "dd", "mkfs.vfat", "mkdir", "rm", "rmdir", "chmod" ]
|
|
122
128
|
extras = ["test"]
|
|
123
129
|
deps = [
|
|
@@ -145,6 +151,7 @@ commands_post = [
|
|
|
145
151
|
]
|
|
146
152
|
|
|
147
153
|
[tool.tox.env.py_filesystem_hfsplus]
|
|
154
|
+
description = "Server: run 'pytest' with collection storage filesystem 'hfsplus' (on Linux)"
|
|
148
155
|
# Fedora
|
|
149
156
|
allowlist_externals = [ "sudo", "dd", "mkfs.hfsplus", "mkdir", "rm", "rmdir", "chmod" ]
|
|
150
157
|
extras = ["test"]
|
|
@@ -173,6 +180,7 @@ commands_post = [
|
|
|
173
180
|
]
|
|
174
181
|
|
|
175
182
|
[tool.tox.env.py_filesystem_ntfs]
|
|
183
|
+
description = "Server: run 'pytest' with collection storage filesystem 'ntfs' (on Linux)"
|
|
176
184
|
allowlist_externals = [ "sudo", "dd", "mkfs.ntfs", "mkdir", "rm", "rmdir", "chmod" ]
|
|
177
185
|
extras = ["test"]
|
|
178
186
|
deps = [
|
|
@@ -200,21 +208,25 @@ commands_post = [
|
|
|
200
208
|
]
|
|
201
209
|
|
|
202
210
|
[tool.tox.env.flake8]
|
|
211
|
+
description = "Server: test code style"
|
|
203
212
|
deps = ["flake8==7.1.0"]
|
|
204
213
|
commands = [["flake8", "."]]
|
|
205
214
|
skip_install = true
|
|
206
215
|
|
|
207
216
|
[tool.tox.env.isort]
|
|
217
|
+
description = "Server: test import sort order"
|
|
208
218
|
deps = ["isort==5.13.2"]
|
|
209
219
|
commands = [["isort", "--check", "--diff", "."]]
|
|
210
220
|
skip_install = true
|
|
211
221
|
|
|
212
222
|
[tool.tox.env.mypy]
|
|
223
|
+
description = "Server: test static types"
|
|
213
224
|
deps = ["mypy==1.11.0"]
|
|
214
225
|
commands = [["mypy", "--install-types", "--non-interactive", "."]]
|
|
215
226
|
skip_install = true
|
|
216
227
|
|
|
217
228
|
[tool.tox.env.integ_test]
|
|
229
|
+
description = "Server+WebUI: run 'playwright' integration tests (with 'chromium')"
|
|
218
230
|
deps = ["pytest", "pytest-playwright"]
|
|
219
231
|
# Store Playwright browsers inside .tox for better caching
|
|
220
232
|
setenv = { PLAYWRIGHT_BROWSERS_PATH = "{toxworkdir}/.playwright" }
|
|
@@ -222,15 +234,25 @@ commands_pre = [["playwright", "install", "chromium"]]
|
|
|
222
234
|
commands = [["pytest", "integ_tests"]]
|
|
223
235
|
|
|
224
236
|
[tool.tox.env.caldav_compat_test]
|
|
237
|
+
description = "Server: run caldav compatibility tests"
|
|
225
238
|
extras = ["caldav_test"]
|
|
226
239
|
deps = [
|
|
227
240
|
"caldav",
|
|
228
|
-
"caldav_server_tester",
|
|
241
|
+
"caldav_server_tester==1.2.0", # synchronize version with .github/workflows/test.yml#caldav-compat-test
|
|
229
242
|
"pytest"
|
|
230
243
|
]
|
|
231
244
|
commands = [["pytest", "caldav_compat_tests"]]
|
|
232
245
|
|
|
246
|
+
[tool.tox.env.js]
|
|
247
|
+
# for optional local test as of now, copied from .github/workflows/test.yml
|
|
248
|
+
# Fedora (will install nodejs): yum install npx
|
|
249
|
+
description = "WebUI: validate JavaScript"
|
|
250
|
+
allowlist_externals = [ "npx" ]
|
|
251
|
+
commands = [["npx", "-y", "-p", "typescript@7.0.2", "tsc", "-p", "radicale/web/jsconfig.json"]]
|
|
252
|
+
skip_install = true
|
|
253
|
+
|
|
233
254
|
[tool.tox.env.html5validator]
|
|
255
|
+
description = "WebUI: validate HTML"
|
|
234
256
|
deps = ["html5validator-2"]
|
|
235
257
|
commands = [["html5validator", "--root", "radicale/web/internal_data/", "--also-check-css"]]
|
|
236
258
|
|
|
@@ -45,7 +45,7 @@ from radicale import config, httputils, log, pathutils, types, utils
|
|
|
45
45
|
from radicale.app import base as app_base
|
|
46
46
|
from radicale.app.base import ApplicationBase
|
|
47
47
|
from radicale.app.delete import ApplicationPartDelete
|
|
48
|
-
from radicale.app.get import ApplicationPartGet
|
|
48
|
+
from radicale.app.get import ApplicationPartGet, requests_freebusy_view
|
|
49
49
|
from radicale.app.head import ApplicationPartHead
|
|
50
50
|
from radicale.app.mkcalendar import ApplicationPartMkcalendar
|
|
51
51
|
from radicale.app.mkcol import ApplicationPartMkcol
|
|
@@ -305,7 +305,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|
|
305
305
|
condition=self._response_content_on_notice_condition,
|
|
306
306
|
value=request_info,
|
|
307
307
|
):
|
|
308
|
-
logger.
|
|
308
|
+
if logger.isEnabledFor(log.LOG_LEVEL_NOTICE):
|
|
309
|
+
logger.notice("Response content (nonXML, log condition passed):\n%s", utils.textwrap_str(answer, self._limit_content))
|
|
309
310
|
else:
|
|
310
311
|
if logger.isEnabledFor(logging.DEBUG):
|
|
311
312
|
logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug")
|
|
@@ -340,7 +341,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|
|
340
341
|
condition=self._response_header_on_notice_condition,
|
|
341
342
|
value=request_info,
|
|
342
343
|
):
|
|
343
|
-
logger.
|
|
344
|
+
if logger.isEnabledFor(log.LOG_LEVEL_NOTICE):
|
|
345
|
+
logger.notice("Response header (log condition passed):\n%s", utils.textwrap_str(pprint.pformat(headers), self._limit_content))
|
|
344
346
|
else:
|
|
345
347
|
if logger.isEnabledFor(logging.DEBUG):
|
|
346
348
|
logger.debug("Response header: suppressed by config/option [logging] response_header_on_debug")
|
|
@@ -474,8 +476,9 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|
|
474
476
|
request_method, unsafe_path, depthinfo,
|
|
475
477
|
remote_host, remote_useragent_txt, https_info)
|
|
476
478
|
if self._request_header_on_debug:
|
|
477
|
-
logger.
|
|
478
|
-
|
|
479
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
480
|
+
logger.debug("Request header:\n%s",
|
|
481
|
+
utils.textwrap_str(pprint.pformat(self._scrub_headers(environ)), self._limit_content))
|
|
479
482
|
else:
|
|
480
483
|
if not self._request_header_on_notice_condition != {}:
|
|
481
484
|
# conditional request header logging is later
|
|
@@ -570,7 +573,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|
|
570
573
|
condition=self._request_header_on_notice_condition,
|
|
571
574
|
value=request_info,
|
|
572
575
|
):
|
|
573
|
-
logger.
|
|
576
|
+
if logger.isEnabledFor(log.LOG_LEVEL_NOTICE):
|
|
577
|
+
logger.notice("Request header (log condition passed):\n%s", utils.textwrap_str(pprint.pformat(self._scrub_headers(environ)), self._limit_content))
|
|
574
578
|
|
|
575
579
|
if user and login == user:
|
|
576
580
|
logger.info("Successful login: %r (%s)", user, info)
|
|
@@ -668,8 +672,12 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|
|
668
672
|
profiler_active = True
|
|
669
673
|
|
|
670
674
|
try:
|
|
671
|
-
|
|
672
|
-
|
|
675
|
+
if (requests_freebusy_view(environ) and
|
|
676
|
+
request_method not in ("GET", "HEAD")):
|
|
677
|
+
status, headers, answer, xml_request = httputils.METHOD_NOT_ALLOWED
|
|
678
|
+
else:
|
|
679
|
+
status, headers, answer, xml_request = function(
|
|
680
|
+
environ, base_prefix, path, user, request_info)
|
|
673
681
|
except PermissionError as e:
|
|
674
682
|
logger.error("PermissionError: %s", e)
|
|
675
683
|
status, headers, answer, xml_request = httputils.INTERNAL_SERVER_ERROR
|
|
@@ -215,7 +215,8 @@ class ApplicationBase:
|
|
|
215
215
|
condition=self._request_content_on_notice_condition,
|
|
216
216
|
value=request_info,
|
|
217
217
|
):
|
|
218
|
-
logger.
|
|
218
|
+
if logger.isEnabledFor(log.LOG_LEVEL_NOTICE):
|
|
219
|
+
logger.notice("Request content (XML, log condition passed):\n%s", utils.textwrap_str(xmlutils.pretty_xml(xml_content)))
|
|
219
220
|
else:
|
|
220
221
|
if logger.isEnabledFor(logging.DEBUG):
|
|
221
222
|
logger.debug("Request content (XML, log condition skipped): suppressed")
|
|
@@ -236,7 +237,8 @@ class ApplicationBase:
|
|
|
236
237
|
condition=self._response_content_on_notice_condition,
|
|
237
238
|
value=request_info,
|
|
238
239
|
):
|
|
239
|
-
logger.
|
|
240
|
+
if logger.isEnabledFor(log.LOG_LEVEL_NOTICE):
|
|
241
|
+
logger.notice("Response content (XML, log condition passed):\n%s", utils.textwrap_str(xmlutils.pretty_xml(xml_content)))
|
|
240
242
|
else:
|
|
241
243
|
if logger.isEnabledFor(logging.DEBUG):
|
|
242
244
|
logger.debug("Response content (XML, log condition skipped): suppressed")
|
|
@@ -255,6 +257,12 @@ class ApplicationBase:
|
|
|
255
257
|
return status, headers, content, None
|
|
256
258
|
|
|
257
259
|
|
|
260
|
+
def _with_implied_freebusy(permissions: str) -> str:
|
|
261
|
+
if "r" in permissions and "f" not in permissions:
|
|
262
|
+
return permissions + "f"
|
|
263
|
+
return permissions
|
|
264
|
+
|
|
265
|
+
|
|
258
266
|
class Access:
|
|
259
267
|
"""Helper class to check access rights of an item"""
|
|
260
268
|
|
|
@@ -275,8 +283,8 @@ class Access:
|
|
|
275
283
|
self.permissions = self._rights.authorization(self.user, self.path)
|
|
276
284
|
if permissions_filter is not None:
|
|
277
285
|
self._permissions_filter = permissions_filter
|
|
278
|
-
|
|
279
|
-
|
|
286
|
+
self.permissions = intersect(
|
|
287
|
+
_with_implied_freebusy(self.permissions), permissions_filter)
|
|
280
288
|
self._parent_permissions = None
|
|
281
289
|
|
|
282
290
|
@property
|
|
@@ -287,8 +295,9 @@ class Access:
|
|
|
287
295
|
self._parent_permissions = self._rights.authorization(
|
|
288
296
|
self.user, self.parent_path)
|
|
289
297
|
if self._permissions_filter is not None:
|
|
290
|
-
|
|
291
|
-
|
|
298
|
+
self._parent_permissions = intersect(
|
|
299
|
+
_with_implied_freebusy(self._parent_permissions),
|
|
300
|
+
self._permissions_filter)
|
|
292
301
|
return self._parent_permissions
|
|
293
302
|
|
|
294
303
|
def check(self, permission: str,
|
|
@@ -310,3 +319,11 @@ class Access:
|
|
|
310
319
|
return bool(rights.intersect(self.permissions, permissions) or (
|
|
311
320
|
self.path != self.parent_path and
|
|
312
321
|
rights.intersect(self.parent_permissions, parent_permissions)))
|
|
322
|
+
|
|
323
|
+
def allows_freebusy(self, item: Optional[types.CollectionOrItem] = None
|
|
324
|
+
) -> bool:
|
|
325
|
+
if item is not None and (
|
|
326
|
+
not isinstance(item, storage.BaseCollection) or
|
|
327
|
+
item.tag != "VCALENDAR"):
|
|
328
|
+
return False
|
|
329
|
+
return "r" in self.permissions or "f" in self.permissions
|
|
@@ -79,6 +79,9 @@ class ApplicationPartDelete(ApplicationBase):
|
|
|
79
79
|
user_lookup += sharing.SHARING_SEPARATOR_GROUP + ','.join(self._rights._user_groups)
|
|
80
80
|
share = self._sharing.sharing_collection_resolver(path, user_lookup)
|
|
81
81
|
if share:
|
|
82
|
+
if path.endswith('/'):
|
|
83
|
+
logger.notice("delete of a mapped collection is never permitted: %r -> %r", share['PathOrToken'], path)
|
|
84
|
+
return httputils.NOT_ALLOWED
|
|
82
85
|
# overwrite and run through extended permission check
|
|
83
86
|
path = share['PathMapped']
|
|
84
87
|
user = share['Owner']
|
|
@@ -86,6 +89,12 @@ class ApplicationPartDelete(ApplicationBase):
|
|
|
86
89
|
access = Access(self._rights, user, path, permissions_filter)
|
|
87
90
|
if not access.check("w"):
|
|
88
91
|
return httputils.NOT_ALLOWED
|
|
92
|
+
if self._sharing._enabled:
|
|
93
|
+
if not share:
|
|
94
|
+
collections_share_list = self._sharing.database_list_sharing(PathMapped=path)
|
|
95
|
+
if collections_share_list is not None and len(collections_share_list) > 0:
|
|
96
|
+
logger.notice("delete of a mapped collection in use (%d) is not permitted: %r", len(collections_share_list), path)
|
|
97
|
+
return httputils.CONFLICT
|
|
89
98
|
with self._storage.acquire_lock("w", user, path=path, request="DELETE"):
|
|
90
99
|
item = next(iter(self._storage.discover(path)), None)
|
|
91
100
|
if not item:
|
|
@@ -100,11 +109,11 @@ class ApplicationPartDelete(ApplicationBase):
|
|
|
100
109
|
if isinstance(item, storage.BaseCollection):
|
|
101
110
|
if self._permit_delete_collection:
|
|
102
111
|
if access.check("d", item):
|
|
103
|
-
logger.info("delete of collection is permitted by config/option [rights] permit_delete_collection but explicit forbidden by permission 'd': %
|
|
112
|
+
logger.info("delete of collection is permitted by config/option [rights] permit_delete_collection but explicit forbidden by permission 'd': %r", path)
|
|
104
113
|
return httputils.NOT_ALLOWED
|
|
105
114
|
else:
|
|
106
115
|
if not access.check("D", item):
|
|
107
|
-
logger.info("delete of collection is prevented by config/option [rights] permit_delete_collection and not explicit allowed by permission 'D': %
|
|
116
|
+
logger.info("delete of collection is prevented by config/option [rights] permit_delete_collection and not explicit allowed by permission 'D': %r", path)
|
|
108
117
|
return httputils.NOT_ALLOWED
|
|
109
118
|
if self._hook.enabled:
|
|
110
119
|
for i in item.get_all():
|
|
@@ -18,17 +18,22 @@
|
|
|
18
18
|
# You should have received a copy of the GNU General Public License
|
|
19
19
|
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
|
20
20
|
|
|
21
|
+
import contextlib
|
|
22
|
+
import datetime
|
|
21
23
|
import plistlib
|
|
22
24
|
import posixpath
|
|
25
|
+
import re
|
|
23
26
|
import urllib
|
|
24
27
|
import uuid
|
|
25
28
|
from http import client
|
|
26
|
-
from typing import Union
|
|
27
|
-
from urllib.parse import quote
|
|
29
|
+
from typing import Tuple, Union
|
|
30
|
+
from urllib.parse import parse_qs, quote
|
|
28
31
|
|
|
29
32
|
from radicale import (httputils, pathutils, sharing, storage, types, utils,
|
|
30
33
|
xmlutils)
|
|
31
34
|
from radicale.app.base import Access, ApplicationBase
|
|
35
|
+
from radicale.app.report import freebusy_collection_text
|
|
36
|
+
from radicale.item import get_etag
|
|
32
37
|
from radicale.log import logger
|
|
33
38
|
|
|
34
39
|
|
|
@@ -57,6 +62,35 @@ def propose_filename(collection: storage.BaseCollection, share: Union[dict, None
|
|
|
57
62
|
return title
|
|
58
63
|
|
|
59
64
|
|
|
65
|
+
_FREEBUSY_VIEW_TIME = re.compile(r"[0-9]{8}T[0-9]{6}Z")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_freebusy_view_time(value: str) -> datetime.datetime:
|
|
69
|
+
if _FREEBUSY_VIEW_TIME.fullmatch(value) is None:
|
|
70
|
+
raise ValueError("invalid free-busy view time: %r" % value)
|
|
71
|
+
return datetime.datetime.strptime(
|
|
72
|
+
value, "%Y%m%dT%H%M%SZ").replace(tzinfo=datetime.timezone.utc)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def requests_freebusy_view(environ: types.WSGIEnviron) -> bool:
|
|
76
|
+
query = parse_qs(environ.get("QUERY_STRING", ""), keep_blank_values=True)
|
|
77
|
+
view = query.get("view")
|
|
78
|
+
return view is not None and "freebusy" in view
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def share_forces_freebusy_view(share: Union[dict, None]) -> bool:
|
|
82
|
+
"""A token can request the free/busy view without ``?view=freebusy``."""
|
|
83
|
+
if not share or share.get("ShareType") != "token":
|
|
84
|
+
return False
|
|
85
|
+
actions = share.get("Actions")
|
|
86
|
+
if not isinstance(actions, dict):
|
|
87
|
+
return False
|
|
88
|
+
config = actions.get("config")
|
|
89
|
+
if not isinstance(config, dict):
|
|
90
|
+
return False
|
|
91
|
+
return config.get("view") == "freebusy"
|
|
92
|
+
|
|
93
|
+
|
|
60
94
|
class ApplicationPartGet(ApplicationBase):
|
|
61
95
|
|
|
62
96
|
def _content_disposition_attachment(self, filename: str) -> str:
|
|
@@ -71,6 +105,74 @@ class ApplicationPartGet(ApplicationBase):
|
|
|
71
105
|
value += "; filename*=%s''%s" % (self._encoding, encoded_filename)
|
|
72
106
|
return value
|
|
73
107
|
|
|
108
|
+
def _freebusy_view_range(
|
|
109
|
+
self, query: dict) -> Tuple[datetime.datetime, datetime.datetime]:
|
|
110
|
+
starts = query.get("start", [])
|
|
111
|
+
ends = query.get("end", [])
|
|
112
|
+
if not starts and not ends:
|
|
113
|
+
today = datetime.datetime.now(datetime.timezone.utc).replace(
|
|
114
|
+
hour=0, minute=0, second=0, microsecond=0)
|
|
115
|
+
past_days = self.configuration.get(
|
|
116
|
+
"reporting", "freebusy_view_past_days")
|
|
117
|
+
future_days = self.configuration.get(
|
|
118
|
+
"reporting", "freebusy_view_future_days")
|
|
119
|
+
return (today - datetime.timedelta(days=past_days),
|
|
120
|
+
today + datetime.timedelta(days=future_days))
|
|
121
|
+
if len(starts) != 1 or len(ends) != 1:
|
|
122
|
+
raise ValueError("free-busy view requires both start and end")
|
|
123
|
+
start = _parse_freebusy_view_time(starts[0])
|
|
124
|
+
end = _parse_freebusy_view_time(ends[0])
|
|
125
|
+
if end < start:
|
|
126
|
+
raise ValueError("free-busy view end is before start")
|
|
127
|
+
return start, end
|
|
128
|
+
|
|
129
|
+
def _freebusy_view(self, environ: types.WSGIEnviron, path: str, user: str,
|
|
130
|
+
access: Access, share: Union[dict, None]
|
|
131
|
+
) -> types.WSGIResponse:
|
|
132
|
+
if not access.allows_freebusy() and "i" not in access.permissions:
|
|
133
|
+
return httputils.NOT_ALLOWED
|
|
134
|
+
query = parse_qs(environ.get("QUERY_STRING", ""), keep_blank_values=True)
|
|
135
|
+
with contextlib.ExitStack() as lock_stack:
|
|
136
|
+
lock_stack.enter_context(self._storage.acquire_lock("r", user))
|
|
137
|
+
item = next(iter(self._storage.discover(path)), None)
|
|
138
|
+
if not item:
|
|
139
|
+
return httputils.NOT_FOUND
|
|
140
|
+
if (not isinstance(item, storage.BaseCollection) or
|
|
141
|
+
item.tag != "VCALENDAR"):
|
|
142
|
+
return httputils.NOT_ALLOWED
|
|
143
|
+
if (not access.allows_freebusy(item) and
|
|
144
|
+
"i" not in access.permissions):
|
|
145
|
+
return httputils.NOT_ALLOWED
|
|
146
|
+
if query.get("view") != ["freebusy"]:
|
|
147
|
+
return httputils.BAD_REQUEST
|
|
148
|
+
try:
|
|
149
|
+
range_start, range_end = self._freebusy_view_range(query)
|
|
150
|
+
except ValueError as e:
|
|
151
|
+
logger.warning("Bad free-busy view on %r: %s", path, e,
|
|
152
|
+
exc_info=True)
|
|
153
|
+
return httputils.BAD_REQUEST
|
|
154
|
+
filename = propose_filename(item, share)
|
|
155
|
+
if filename.lower().endswith(".ics"):
|
|
156
|
+
filename = filename[:-4] + "-freebusy.ics"
|
|
157
|
+
else:
|
|
158
|
+
filename += "-freebusy.ics"
|
|
159
|
+
content_disposition = self._content_disposition_attachment(filename)
|
|
160
|
+
max_occurrence = self.configuration.get(
|
|
161
|
+
"reporting", "max_freebusy_occurrence")
|
|
162
|
+
try:
|
|
163
|
+
answer = freebusy_collection_text(
|
|
164
|
+
item, range_start, range_end, max_occurrence,
|
|
165
|
+
lock_stack.close)
|
|
166
|
+
except ValueError as e:
|
|
167
|
+
logger.warning("Bad free-busy view on %r: %s", path, e,
|
|
168
|
+
exc_info=True)
|
|
169
|
+
return httputils.BAD_REQUEST
|
|
170
|
+
headers = {
|
|
171
|
+
"Content-Type": xmlutils.MIMETYPES["VCALENDAR"],
|
|
172
|
+
"ETag": get_etag(answer),
|
|
173
|
+
"Content-Disposition": content_disposition}
|
|
174
|
+
return client.OK, headers, answer, None
|
|
175
|
+
|
|
74
176
|
def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
|
|
75
177
|
user: str, request_info: dict) -> types.WSGIResponse:
|
|
76
178
|
"""Manage GET request."""
|
|
@@ -154,11 +256,30 @@ class ApplicationPartGet(ApplicationBase):
|
|
|
154
256
|
user_lookup += sharing.SHARING_SEPARATOR_GROUP + ','.join(self._rights._user_groups)
|
|
155
257
|
share = self._sharing.sharing_collection_resolver(path, user_lookup)
|
|
156
258
|
if share:
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
259
|
+
if path != share['PathMapped']:
|
|
260
|
+
# overwrite and run through extended permission check
|
|
261
|
+
path = share['PathMapped']
|
|
262
|
+
user = share['Owner']
|
|
263
|
+
permissions_filter = share['Permissions']
|
|
264
|
+
else:
|
|
265
|
+
logger.trace("GET/shares: skip overlap mapping: path=%r", path)
|
|
161
266
|
access = Access(self._rights, user, path, permissions_filter)
|
|
267
|
+
if share_forces_freebusy_view(share):
|
|
268
|
+
query = parse_qs(environ.get("QUERY_STRING", ""), keep_blank_values=True)
|
|
269
|
+
view = query.get("view")
|
|
270
|
+
if view is None:
|
|
271
|
+
forced_environ = dict(environ)
|
|
272
|
+
current = str(forced_environ.get("QUERY_STRING") or "")
|
|
273
|
+
if current:
|
|
274
|
+
forced_environ["QUERY_STRING"] = current + "&view=freebusy"
|
|
275
|
+
else:
|
|
276
|
+
forced_environ["QUERY_STRING"] = "view=freebusy"
|
|
277
|
+
return self._freebusy_view(
|
|
278
|
+
forced_environ, path, user, access, share)
|
|
279
|
+
if view != ["freebusy"]:
|
|
280
|
+
return httputils.BAD_REQUEST
|
|
281
|
+
if requests_freebusy_view(environ):
|
|
282
|
+
return self._freebusy_view(environ, path, user, access, share)
|
|
162
283
|
if not access.check("r") and "i" not in access.permissions:
|
|
163
284
|
return httputils.NOT_ALLOWED
|
|
164
285
|
with self._storage.acquire_lock("r", user):
|
|
@@ -198,12 +319,23 @@ class ApplicationPartGet(ApplicationBase):
|
|
|
198
319
|
headers["Content-Disposition"] = content_disposition
|
|
199
320
|
if share and share['Conversion'] == "bday":
|
|
200
321
|
if isinstance(item, storage.BaseCollection):
|
|
201
|
-
# convert VCF to ICS
|
|
322
|
+
# convert collection VCF to ICS
|
|
202
323
|
answer = item.serialize(vcf_to_ics=True, ShareActions=share['Actions'])
|
|
324
|
+
# set etag header (64 <hexdigits>)
|
|
325
|
+
# 4 chars special token: 'bda0'
|
|
326
|
+
# 8 chars date : 00000000
|
|
327
|
+
# 2 chars separator : 00
|
|
328
|
+
# 32 chars : leading content hash
|
|
329
|
+
# 2 chars separator : 00
|
|
330
|
+
# 16 chars : trailing original etag
|
|
331
|
+
headers["ETag"] = '"bda0' + '00000000' + '00' + utils.sha256_str(answer)[:32] + '00' + item.etag[49:]
|
|
203
332
|
else:
|
|
333
|
+
# convert item VCF to ICS
|
|
204
334
|
item_converted = item.convert_vcf_to_ics(ShareActions=share['Actions'])
|
|
205
335
|
if item_converted is not None:
|
|
206
336
|
answer = item_converted.serialize()
|
|
337
|
+
# set etag header
|
|
338
|
+
headers["ETag"] = item_converted.etag
|
|
207
339
|
else:
|
|
208
340
|
return httputils.NOT_FOUND
|
|
209
341
|
else:
|
|
@@ -56,7 +56,7 @@ class ApplicationPartMkcalendar(ApplicationBase):
|
|
|
56
56
|
return httputils.BAD_REQUEST
|
|
57
57
|
if self._sharing._enabled:
|
|
58
58
|
# check for shared collections (all)
|
|
59
|
-
collections_share_list = self._sharing.sharing_collection_list()
|
|
59
|
+
collections_share_list = self._sharing.sharing_collection_list(User=user)
|
|
60
60
|
if collections_share_list:
|
|
61
61
|
for share in collections_share_list:
|
|
62
62
|
if share['PathOrToken'] == path:
|
|
@@ -63,7 +63,7 @@ class ApplicationPartMkcol(ApplicationBase):
|
|
|
63
63
|
return httputils.NOT_ALLOWED
|
|
64
64
|
if self._sharing._enabled:
|
|
65
65
|
# check for shared collections (all)
|
|
66
|
-
collections_share_list = self._sharing.sharing_collection_list()
|
|
66
|
+
collections_share_list = self._sharing.sharing_collection_list(User=user)
|
|
67
67
|
if collections_share_list:
|
|
68
68
|
for share in collections_share_list:
|
|
69
69
|
if share['PathOrToken'] == path:
|