Compare commits

...

445 Commits

Author SHA1 Message Date
Lennart
ea2f841269 ical-rs: Pin version to Git commit 2026-01-19 15:04:54 +01:00
Lennart
15e1509fe3 sqlite_store: Add option to skip broken objects and add validation on start-up 2026-01-19 14:48:21 +01:00
Lennart
0eef4ffabf Add test for uploading invalid calendar object and fix precondition 2026-01-19 13:40:54 +01:00
Lennart
303f9aff68 Remove IcalError from caldav/carddav since it had an ambiguous status code 2026-01-19 12:51:51 +01:00
Lennart
3460a2821e dav: Check Host matching for MV,COPY 2026-01-19 12:37:35 +01:00
Lennart
f73658b32f Re-enable calendar-query test and fix calendar expansion 2026-01-19 12:09:34 +01:00
Lennart K
7e099bcd6e Merge branch 'main' into feat/ical-rewrite 2026-01-16 16:47:17 +01:00
Lennart K
dde05d2f45 Workflow: Publish container images for feature branches too 2026-01-16 16:29:38 +01:00
Lennart K
4adf1818d4 Merge branch 'main' into feat/ical-rewrite 2026-01-16 15:58:17 +01:00
Lennart K
f503bf2bf7 Update quick-xml 2026-01-16 15:47:10 +01:00
Lennart K
7c15976a1a rebase main 2026-01-16 15:41:39 +01:00
Lennart K
669d81aea0 address_object resource: Implement displayname 2026-01-16 15:39:56 +01:00
Lennart K
967d18de95 Fix comp-filter 2026-01-16 15:39:55 +01:00
Lennart K
63373ad525 simplify handling of ical-related errors 2026-01-16 15:39:54 +01:00
Lennart K
2c67890343 Update ical-rs 2026-01-16 15:39:53 +01:00
Lennart K
5ec2787ecf build MVP for birthday calendar 2026-01-16 15:39:53 +01:00
Lennart K
7eecd95757 Remove calendar-query integration test for now 2026-01-16 15:39:52 +01:00
Lennart K
c165e761be update ical-rs 2026-01-16 15:39:51 +01:00
Lennart K
5f68a5ae5c Re-add get_last_occurence for sqlite store 2026-01-16 15:39:50 +01:00
Lennart K
c77b59dcb0 Remove unused code 2026-01-16 15:39:49 +01:00
Lennart K
276fdcacf5 Re-implement calendar imports 2026-01-16 15:39:48 +01:00
Lennart K
43fff63008 Calendar export: Fix PRODID 2026-01-16 15:39:47 +01:00
Lennart K
977fd75500 Re-implement calendar export 2026-01-16 15:39:46 +01:00
Lennart K
5639127782 clean up ical-related stuff 2026-01-16 15:39:44 +01:00
Lennart K
a2255bc7f1 make calendar object id extrinsic 2026-01-16 15:39:34 +01:00
Lennart K
758793a11a Make AddressObject object_id an extrinsic property 2026-01-16 15:39:33 +01:00
Lennart K
a9f3833a32 small fixes 2026-01-16 15:39:30 +01:00
Lennart K
896e934c0a Decrease folder nesting 2026-01-16 15:39:01 +01:00
Lennart K
bb880aa403 incorporate get_first_occurenec 2026-01-16 15:39:00 +01:00
Lennart K
69acde10ba migrate to new ical-rs version 2026-01-16 15:38:57 +01:00
Lennart K
d84158e8ad version 0.11.17 2026-01-16 12:26:43 +01:00
Lennart K
7ef566040a Disable a test that will be fixed in 0.12 2026-01-16 12:16:02 +01:00
Lennart K
1c1f0c6da2 Update ical-rs@dev to fix cargo vendor 2026-01-16 12:10:10 +01:00
Lennart
3fafbd22f4 version 0.11.16 2026-01-15 23:43:00 +01:00
Lennart
e68dc921e6 Now actually fix builds 2026-01-15 23:19:35 +01:00
Lennart
60b45e70ad fix docker builds 2026-01-15 22:31:40 +01:00
Lennart
a0c33c82dd version 0.11.14 2026-01-15 13:32:45 +01:00
Lennart
8ae5e46abf Automatic repair for calendar objects with invalid VERSION:4.0 2026-01-15 13:30:14 +01:00
Lennart
48b2e614a8 Suppress ical invalid version error 2026-01-15 11:15:20 +01:00
Lennart K
f26214abb9 build Docker images for dev branch 2026-01-12 11:15:08 +01:00
Lennart
276e65d41a version 0.11.11 2026-01-10 13:37:24 +01:00
Lennart
7c3e9ecbc1 update ical-rs dev to remove panics 2026-01-10 13:35:14 +01:00
Lennart
53f81a9433 Add a startup test to check whether existing data will be compatible with v0.12 2026-01-10 13:22:49 +01:00
Lennart
55eabfde4a version 0.11.10 2026-01-04 00:06:43 +01:00
Lennart
5d9d6e3fdf update snapshots 2026-01-04 00:06:16 +01:00
Lennart
1567bc64ef addressbook-query: Fix filter matching for empty filters 2026-01-04 00:05:07 +01:00
Lennart
44ae995f29 Some small fixes on recurrence expansion 2025-12-31 19:54:06 +01:00
Lennart
9c1cd24d32 test data fix uid 2025-12-31 19:47:29 +01:00
Lennart
ff0c5697cf caldav expand: accept <prop> 2025-12-31 19:47:13 +01:00
Lennart
6ccb5a67e4 ical: Respect that calendar do not have to contain a main event 2025-12-31 17:55:58 +01:00
Lennart
da718dd290 caldav: Add import test case from RFC Appendix B 2025-12-31 17:55:30 +01:00
Lennart
4112347e24 more integration tests 2025-12-31 17:36:02 +01:00
Lennart
f4fd1cdd21 caldav max-resource-size: Fix namespace 2025-12-31 17:35:51 +01:00
Lennart
5f0ca67e54 docs: Add document to track progress of CardDAV support 2025-12-31 17:00:14 +01:00
Lennart
3aef9abe48 carddav: Add more integration tests 2025-12-31 16:50:55 +01:00
Lennart
9784f2b53f PUT object: Properly check If-None-Match header 2025-12-31 16:50:32 +01:00
Lennart
271fdfd686 PUT object: Return ETag 2025-12-31 16:17:34 +01:00
Lennart
4fabf74333 version 0.11.9 2025-12-31 15:58:49 +01:00
Lennart
7b154adec3 remove stray dbg! 2025-12-31 15:57:56 +01:00
Lennart
951a1e4bdc update mkdocs.yml 2025-12-31 15:57:23 +01:00
Lennart
8c44733d0a carddav: Fix namespace for max-resource-size 2025-12-31 15:57:13 +01:00
Lennart
829f7b727f carddav calendar-query: Add parsing support for limit 2025-12-31 15:15:07 +01:00
Lennart
037e6f5c92 version 0.11.8 2025-12-31 14:57:54 +01:00
Lennart
311ceb6bc5 Update README.md 2025-12-31 14:56:39 +01:00
Lennart
1174af3a4b carddav: Add some tests to make sure the filters work 2025-12-31 14:47:23 +01:00
Lennart
845b3e61e3 carddav: Fix filter test="allof"|"anyof" 2025-12-31 14:46:49 +01:00
Lennart
e5d6541ffb fix prop-filter when multiple properties of a name exist 2025-12-31 13:46:43 +01:00
Lennart
b632ff6fe8 update dependencies 2025-12-31 13:31:17 +01:00
Lennart
1ee873ac93 caldav: Add basic filtering test 2025-12-31 13:31:07 +01:00
Lennart
bf5bdb96bc implement param-filter for caldav 2025-12-31 13:07:43 +01:00
Lennart
47c2a55941 implement text-match and prop-filter for carddav 2025-12-31 13:02:53 +01:00
Lennart
bfcd94f096 update test snapshot 2025-12-31 12:35:57 +01:00
Lennart
8cbb72719d fix text-match test 2025-12-31 12:31:09 +01:00
Lennart
ff0246c4fc clippy appeasement 2025-12-31 12:29:19 +01:00
Lennart
15124a2fd5 clippy appeasement 2025-12-31 12:25:59 +01:00
Lennart
5c6f63a5f3 carddav addressbook: Add supported-collation-set 2025-12-31 12:25:35 +01:00
Lennart
17ba8faef2 text-match: Support match types and unicode-casemap collation 2025-12-31 12:25:04 +01:00
Lennart
578ddde36d ResourceName trait: Use Cow instead of String 2025-12-31 10:57:28 +01:00
Lennart
9c3972e21c fix addressbook import test 2025-12-31 01:59:19 +01:00
Lennart
816c26565a clippy appeasement 2025-12-31 01:56:16 +01:00
Lennart
4de0f9f665 add basic addressbook import test 2025-12-31 01:55:49 +01:00
Lennart
cf31a51965 small changes to regression tests and xml namespaces 2025-12-31 01:43:03 +01:00
Lennart
9fc099f6f4 fix carddav integration tests 2025-12-31 01:30:35 +01:00
Lennart
498be172c9 clippy appeasement 2025-12-31 01:23:48 +01:00
Lennart
b8c395e746 add basic integration test for carddav addressbook 2025-12-31 01:23:21 +01:00
Lennart
4b8a8c61f2 Tests: Fix scope of store 2025-12-31 01:05:56 +01:00
Lennart
f778c470d0 update GitHub workflows 2025-12-30 19:16:59 +01:00
Lennart
d44a172261 bump Rust version to 1.92 2025-12-30 19:10:05 +01:00
Lennart
e0ba34baea clippy appeasement 2025-12-30 19:08:58 +01:00
Lennart
a7893ddbda Replace deprecated axum Host extractor with Host header 2025-12-30 13:53:42 +01:00
Lennart
ed7becffc2 update askama to 0.15 2025-12-28 21:02:44 +01:00
Lennart
c29400a799 version 0.11.7 2025-12-28 19:27:27 +01:00
Lennart
047552a726 update dependencies 2025-12-28 19:24:00 +01:00
Lennart K
1cfc8e7c23 frontend: Update dependencies 2025-12-27 15:07:06 +01:00
Lennart
b0fdca1b64 clippy appeasement 2025-12-27 14:30:36 +01:00
Lennart
b65cca9d17 version 0.11.6 2025-12-27 14:23:56 +01:00
Lennart
55ecbdcd41 carddav: Implement addressbook-query 2025-12-27 14:22:23 +01:00
Lennart
89d3d3b7a4 caldav: Outsource text-match to rustical_dav 2025-12-27 13:45:26 +01:00
Lennart K
a74b74369c version 0.11.5 2025-12-21 16:16:48 +01:00
Lennart K
85c49a0bdf update ical-rs 2025-12-20 14:30:26 +01:00
Lennart K
f2e4e2c1a7 add a database benchmark 2025-12-20 11:48:30 +01:00
Lennart K
28c925301e calendar store: Add method for bulk insert 2025-12-20 11:48:05 +01:00
Lennart K
b50ea478db Content-Type: Add charset=utf-8 2025-12-18 21:43:27 +01:00
Lennart K
2c7748255c update test snapshot 2025-12-18 21:40:39 +01:00
Lennart K
f40a23a1f1 update Cargo.toml and fix calendar export ical version 2025-12-18 21:39:36 +01:00
Lennart K
2a4ba33e45 refactor recurrence expansion 2025-12-18 21:27:40 +01:00
Lennart K
6bc4bd3fa3 Update ical-rs dependency 2025-12-18 14:14:26 +01:00
Lennart
7b32d478b8 version 0.11.4 2025-12-17 17:35:11 +01:00
Lennart
fb0bd67176 Update ical-rs version to fix bug with unicode characters in params
addresses #157
2025-12-17 17:33:46 +01:00
Lennart
ecad0d4490 frontend: update vite 2025-12-15 20:44:50 +01:00
Lennart
e702e77656 version 0.11.3 2025-12-15 12:02:59 +01:00
Lennart Kämmle
141f881382 Merge pull request #154 from quantenzitrone/nonlegacy-ip
src/config.rs: bind to legacy and nonlegacy ip addresses by default
2025-12-15 12:02:16 +01:00
quantenzitrone
b8d2ac0654 src/config.rs: bind to legacy and nonlegacy ip addresses by default
see https://github.com/tokio-rs/axum/discussions/834#discussioncomment-2302918
2025-12-15 07:14:00 +01:00
Lennart
16aae73cd1 integration tests: Fix snapshot naming 2025-12-13 00:20:36 +01:00
Lennart
120d45eb0a caldav: Add proppatch test 2025-12-12 23:56:29 +01:00
Lennart
0bdfb786ff clippy appeasement 2025-12-12 23:47:10 +01:00
Lennart
d9cca5a298 improve caldav integration test 2025-12-12 23:43:14 +01:00
Lennart
50a74e3a25 fix integration tests 2025-12-12 14:46:10 +01:00
Lennart
ed8a9e718a more integration tests 2025-12-12 14:35:47 +01:00
Lennart
38b5a3812e add integration tests 2025-12-12 14:12:09 +01:00
Lennart
d3e7ede93c frontend: Merge components into single bundle 2025-12-12 13:00:44 +01:00
Lennart
4e90f725e0 update dependencies 2025-12-12 11:47:15 +01:00
Lennart
ef40f5ea8c version 0.11.2 2025-12-11 20:34:34 +01:00
Lennart
1230e29243 frontend: tiny improvement to import forms 2025-12-11 20:17:04 +01:00
Lennart
1b2296c00a clippy appeasement 2025-12-11 20:16:44 +01:00
Lennart
ac6ab0ca9a clippy appeasement 2025-12-11 20:01:04 +01:00
Lennart
6312f52b10 update license information 2025-12-11 19:59:20 +01:00
Lennart
ec28cb9d9a frontend: improve calendar creation form and fix data binding bugs 2025-12-11 19:52:48 +01:00
Lennart
4b4210b4d7 Add initial test for app initialisation 2025-12-10 14:43:06 +01:00
Lennart
8fadff1b57 fix insta snapshots 2025-12-10 14:30:18 +01:00
Lennart
61a8c32af4 add some more propfind regression tests 2025-12-10 14:22:04 +01:00
Lennart
a45e0b2efd carddav: Try out some tests with insta 2025-12-10 12:26:31 +01:00
Lennart
eecc03b7b7 caldav: add debug to principal resource 2025-12-10 12:25:59 +01:00
Lennart
e8303b9c82 main: slight refactoring 2025-12-10 12:25:13 +01:00
Lennart
a686286d06 sqlite_store: Refactor notification logic 2025-12-10 10:44:41 +01:00
Lennart
d81074de3b version 0.11.1 2025-12-05 20:32:17 +01:00
Lennart
42386adcfa frontend: remove debug statement from template 2025-12-05 20:31:58 +01:00
Lennart
d2f5f7c89b version 0.11.0 2025-12-05 15:06:01 +01:00
Lennart Kämmle
15e431ce12 Merge pull request #138 from lennart-k/feature/birthday-calendar
Feature/birthday calendar
2025-12-05 15:03:56 +01:00
Lennart
96a16951f4 sqlx prepare 2025-12-05 14:55:30 +01:00
Lennart
a32b766c0c Merge branch 'main' into feature/birthday-calendar 2025-12-05 14:51:51 +01:00
Lennart
7a101b7364 frontend: Fix cursor for anchors 2025-12-05 14:51:34 +01:00
Lennart
57275a10b4 Add birthday calendar creation to frontend 2025-12-05 14:50:02 +01:00
Lennart
af239e34bf birthday calendar store: Support manual birthday calendar creation 2025-12-05 14:49:09 +01:00
Lennart
e99b1d9123 calendar resource: Remove prop write guards 2025-12-05 14:48:35 +01:00
Lennart
e39657eb29 PROPPATCH: Fix privileges 2025-12-05 14:48:11 +01:00
Lennart
607db62859 Merge branch 'main' into feature/birthday-calendar 2025-12-05 11:47:42 +01:00
Lennart
eba377b980 update dependencies 2025-12-05 11:47:11 +01:00
Lennart
d5c1ddc590 caldav: Update test_propfind regression test 2025-11-22 18:49:32 +01:00
Lennart
a79e1901b8 test_propfind: Revert assert_eq order 2025-11-22 18:48:36 +01:00
Lennart
f29c8fa925 Merge branch 'main' into feature/birthday-calendar 2025-11-22 18:46:59 +01:00
Lennart
54f1ee0788 use similar-asserts for regression tests 2025-11-22 18:46:47 +01:00
Lennart
96f221f721 birthday_calendar: Refactor insert_birthday_calendar 2025-11-22 18:35:26 +01:00
Lennart
ba3b64a9c4 Merge branch 'main' into feature/birthday-calendar 2025-11-22 18:30:44 +01:00
Lennart
22a0337375 version 0.10.5 2025-11-17 19:14:17 +01:00
Lennart
21902e108a fix some error messages 2025-11-17 19:13:13 +01:00
Lennart
08f526fa5b Add startup routine to fix orphaned objects
fixes #145, related to #142
2025-11-17 19:11:30 +01:00
Lennart
ac73f3aaff addressbook_store: Commit import addressbooks to changelog 2025-11-17 18:35:10 +01:00
Lennart
9fdc8434db calendar import: log added events 2025-11-17 18:22:33 +01:00
Lennart
85f3d89235 version 0.10.4 2025-11-17 01:21:55 +01:00
Lennart
092604694a multiget: percent-decode hrefs 2025-11-17 01:21:20 +01:00
Lennart
8ef24668ba version 0.10.3 2025-11-14 11:02:27 +01:00
Lennart
416658d069 frontend: Fix missing getTimezones import in create-calendar-form
fixes #141
2025-11-14 11:01:59 +01:00
Lennart
80eae5db9e version 0.10.2 2025-11-09 21:39:09 +01:00
Lennart
66f541f1c7 Drop log level for 404 to info
fixes #139
2025-11-09 21:36:17 +01:00
Lennart
ea7196501e docs: Add verification for Google Search Console (not analytics) 2025-11-06 00:19:33 +01:00
Lennart
33d14a9ba0 sqlite_store: Add some more basic tests 2025-11-05 23:17:59 +01:00
Lennart
d843909084 Update Cargo.toml 2025-11-05 16:16:01 +01:00
Lennart
873b40ad10 stylesheet: Add flex-wrap to actions 2025-11-05 16:05:55 +01:00
Lennart
5588137f73 sqlx prepare 2025-11-04 17:01:54 +01:00
Lennart
7bf00da0e5 implement deleting and restoring birthday calendars 2025-11-04 16:56:17 +01:00
Lennart
be08275cd3 Merge branch 'main' into feature/birthday-calendar 2025-11-04 16:28:08 +01:00
Lennart
3a10a695f5 frontend: Only show logout button when logged in 2025-11-04 15:33:13 +01:00
Lennart
53c6e3b1f4 frontend: Update calendar,addressbook pages 2025-11-04 15:32:00 +01:00
Lennart
6838e8e379 frontend: update stylesheet 2025-11-04 15:31:35 +01:00
Lennart
9f28aaec41 frontend: Update deno dependencies 2025-11-04 15:31:18 +01:00
Lennart
381af1b877 run .sqlx prepare 2025-11-03 15:37:40 +01:00
Lennart
7ec62bc6ab attempt to fix docs build 2025-11-02 22:57:29 +01:00
Lennart
9538b68e77 version 0.10.1 2025-11-02 22:21:25 +01:00
Lennart
ea5175387b update licenses 2025-11-02 22:21:16 +01:00
Lennart
0095491a20 frontend: dumb test for timezones 2025-11-02 22:17:23 +01:00
Lennart
e9392cc00b frontend: Add dropdown for timezone selection 2025-11-02 22:08:28 +01:00
Lennart
425d10cb99 CalendarStore::is_read_only now refers to its content only and not its metadata 2025-11-02 21:07:06 +01:00
Lennart
5cdbb3b9d3 migrate birthday store to sqlite 2025-11-02 21:06:43 +01:00
Lennart
547e477eca make sure a birthday calendar will be created for each addressbook 2025-11-02 21:05:31 +01:00
Lennart
c19c3492c3 frontend: Remove birthday calendar guard 2025-11-02 20:45:58 +01:00
Lennart
5878b93d62 add birthday_calendar table migrations 2025-11-02 20:45:31 +01:00
Lennart
888591c952 add test case for converting filter to calendar query 2025-11-02 19:17:59 +01:00
Lennart
de77223170 Merge pull request #137 from lennart-k/feature/comp-filter
Re-implement comp-filter for calendar-query
2025-11-02 18:56:56 +01:00
Lennart
c42f8e5614 clippy appeasement 2025-11-02 18:42:55 +01:00
Lennart
f72559d027 caldav: Add supported-collation-set property 2025-11-02 18:33:54 +01:00
Lennart
167492318f xml: serialize: Support non-string text fields 2025-11-02 18:33:30 +01:00
Lennart
32f43951ac refactor text-match to support collations 2025-11-02 17:48:35 +01:00
Lennart
cd9993cd97 implement comp-filter matching for VTIMEZONE 2025-11-02 17:21:44 +01:00
Lennart
9f911fe5d7 prop-filter: Add time-range checking 2025-11-02 15:09:31 +01:00
Lennart
6361907152 re-implement comp-filter and add property filtering 2025-11-02 15:00:53 +01:00
Lennart
0c0be859f9 calendar object: Move occurence methods to CalendarObjectComponent and add get_property method 2025-11-02 15:00:13 +01:00
Lennart
d2c786eba6 merge main into feature/comp-filter 2025-11-02 13:10:56 +01:00
Lennart
dabddc6282 version 0.10.0 2025-11-01 21:49:44 +01:00
Lennart
76b4194b94 lift restriction on object_id and UID having to match
addresses #135
2025-11-01 21:48:37 +01:00
Lennart
db144ebcae calendarobject: Rename get_id to get_uid 2025-11-01 21:23:55 +01:00
Lennart
a53c333f1f version 0.9.14 2025-11-01 15:10:06 +01:00
Lennart
a05baea472 sqlite_store: Mark write transactions with BEGIN IMMEDIATE
Hopefully addresses SQLITE_BUSY error, see #131
2025-11-01 15:09:42 +01:00
Lennart
f34f7e420e Dockerfile: Update Rust to 1.91 2025-11-01 15:08:36 +01:00
Lennart
24ab323aa0 clippy appeasement 2025-11-01 14:21:44 +01:00
Lennart
f34f56ca89 update dependencies 2025-11-01 14:17:13 +01:00
Lennart
8c2025b674 version 0.9.13 2025-10-27 21:14:31 +01:00
Lennart
77d8f5dacc add ping endpoint and healthcheck command 2025-10-27 21:12:43 +01:00
Lennart
5d142289b3 tokio: Use multi-threaded runtime 2025-10-27 20:34:20 +01:00
Lennart
255282893a update matchit 2025-10-27 20:15:38 +01:00
Lennart
86cf490fa9 Lots of clippy appeasement 2025-10-27 20:12:21 +01:00
Lennart K
0d071d3b92 run clippy fix 2025-10-27 19:01:04 +01:00
Lennart
8ed4db5824 work on new comp-filter implementation 2025-10-27 18:59:00 +01:00
Lennart K
08041c60be clippy: Enable more warnings 2025-10-27 11:39:24 +01:00
Lennart
43d7aabf28 version 0.9.12 2025-10-21 21:06:32 +02:00
Lennart
2fc51fac66 remove duplicate statement 2025-10-21 21:04:41 +02:00
Lennart
18882b2175 version 0.9.11 2025-10-07 22:15:24 +02:00
Lennart
580922fd6b improve error output 2025-10-07 22:14:40 +02:00
Lennart
69274a9f5d chore: Update opentelemetry 2025-10-05 17:17:56 +02:00
Lennart
ef9642ae81 version 0.9.10 2025-10-02 21:05:32 +02:00
Lennart
1c192a452f oidc: Output error when provider discovery fails 2025-10-02 21:04:59 +02:00
Lennart
8c67c8c0e9 version 0.9.9 2025-09-25 19:51:41 +02:00
Lennart
0990342590 frontend: update and reduce dependencies 2025-09-25 19:50:48 +02:00
Lennart
ffef7608ac update licenses.html 2025-09-25 19:48:05 +02:00
Lennart
a28ff967e5 update Cargo.lock 2025-09-25 19:47:09 +02:00
Lennart
8bec653099 dav root: Add some new tests 2025-09-25 19:47:00 +02:00
Lennart
b0091d66d1 remove ci.yml since testing is included in cicd.yml 2025-09-23 11:47:08 +02:00
Lennart
4919514d09 dav: refactor overwrite header 2025-09-23 11:43:42 +02:00
Lennart
602c511c90 increase test coverage :D 2025-09-21 21:58:11 +02:00
Lennart
b208fbaac6 cicd: Update toolchain 2025-09-21 21:33:41 +02:00
Lennart
eef45ef612 clean up cicd 2025-09-21 21:24:18 +02:00
Lennart
dc860a9768 coverage: Exclude xml_derive 2025-09-21 21:10:56 +02:00
Lennart
dd52fd120c GitHub Workflows: Set permissions 2025-09-21 21:03:55 +02:00
Lennart
bc4c6489ff ci: Make sure whole workspace is tested 2025-09-21 20:59:25 +02:00
Lennart
944462ff5e clippy appeasement 2025-09-21 20:56:14 +02:00
Lennart
d51c44c2e7 Add some automated coverage testing 2025-09-21 20:52:31 +02:00
Lennart
8bbc03601a Enable a test for propfind responses 2025-09-21 20:40:03 +02:00
Lennart
1d2b90f7c3 xml: Sort namespaces
Fixes #104
2025-09-21 20:39:23 +02:00
Lennart
979a863b2d some calendar query refactoring 2025-09-21 20:37:24 +02:00
Lennart
660ac9b121 ical: Refactor calendar object type 2025-09-21 20:31:45 +02:00
Lennart
1e9be6c134 Dockerfile: Update Rust to 1.90 2025-09-21 20:15:07 +02:00
Lennart
b6bfb5a620 ical: Remove abstraction structs around journal and todo 2025-09-19 14:47:44 +02:00
Lennart
53f30fce3f version 0.9.8: revert to Rust 1.89 since 1.90 fully online yet 2025-09-18 21:20:07 +02:00
Lennart
4592afac10 version 0.9.7 2025-09-18 21:11:44 +02:00
Lennart
e7ab7c2987 ical: Fix import UID grouping 2025-09-18 21:08:00 +02:00
Lennart
242f7b9076 calendar export: Fix overrides 2025-09-18 20:38:54 +02:00
Lennart
cb1356acad ical: Fix data model to allow calendar objects with overrides
#125
2025-09-18 20:38:37 +02:00
Lennart
55dadbb06b update Rust to 1.90 2025-09-18 16:45:48 +02:00
Lennart
4dd12bfe52 version 0.9.6 2025-09-17 11:35:20 +02:00
Lennart
5e004a6edc calendar import: Enable import to existing calendars (if no objects are overwritten) 2025-09-17 11:33:49 +02:00
Lennart
03e550c2f8 add some debug logging for invalid data in put_event
#125
2025-09-17 10:18:46 +02:00
Lennart
b2f5d5486c version 0.9.5 2025-09-17 10:06:07 +02:00
Lennart
db674d5895 Allow setting HTTP payload limit and set default to 4MB
#124
2025-09-17 10:06:07 +02:00
Lennart K
bc98d1be42 document thing to watch out for with Kubernetes #122 2025-09-16 15:34:31 +02:00
Lennart
4bb8cae9ea docs: Fix typo for env var configuration 2025-09-14 18:55:33 +02:00
Lennart
3774b358a5 version 0.9.4 2025-09-10 23:23:12 +02:00
Lennart
c6b612e5a0 Update dependencies 2025-09-10 23:20:40 +02:00
Lennart
91586ee797 migrate quick-xml to 0.38
fixes #120
2025-09-05 15:24:34 +02:00
Lennart K
87adf94947 Update Cargo.toml and Dockerfile 2025-09-04 13:05:14 +02:00
Lennart
f850f9b3a3 version 0.9.3 2025-09-02 23:38:41 +02:00
Lennart
0eb8359e26 rewrite combined calendar store in preparation for sharing 2025-09-02 23:30:16 +02:00
Lennart
7d961ea93b frontend: make button descriptions shorter to fit mobile screen 2025-09-02 23:19:15 +02:00
Lennart
375caedec6 update docs 2025-09-02 09:32:28 +02:00
Lennart
2d8d2eb194 Update README.md 2025-09-01 00:29:55 +02:00
Lennart
69e788b363 store: prevent objects from being commited to subscription calendar 2025-08-31 12:40:20 +02:00
Lennart
8ea5321503 Merge branch 'main' into sharing 2025-08-30 13:58:50 +02:00
Lennart
76c03fa4d4 clippy appeasement 2025-08-30 11:56:58 +02:00
Lennart
96b63848f0 version 0.9.2 2025-08-30 00:41:50 +02:00
Lennart
16e5cacefe Docker: Target Rust 1.89
fixes #116
2025-08-30 00:21:41 +02:00
Lennart
3819f623a6 update dependencies 2025-08-30 00:20:51 +02:00
Lennart
c4604d4376 xml: Comprehensive refactoring from byte strings to strings 2025-08-28 18:01:41 +02:00
Lennart K
85787e69bc xml: tiny refactoring 2025-08-28 15:24:19 +02:00
Lennart K
43b4150e28 xml: Change ns_prefix from LitByteStr to LitStr 2025-08-28 15:19:27 +02:00
Lennart K
c38fbe004f clippy appeasement 2025-08-28 15:09:01 +02:00
Lennart
bf5d874481 frontend tweaks 2025-08-28 14:53:17 +02:00
Lennart
c648ed315d version 0.9.1 2025-08-25 19:09:48 +02:00
Lennart
2cf481d4e6 make session cookie samesite=lax by default 2025-08-25 19:09:24 +02:00
Lennart
a4285fb2ac Outsource some Calendar info to CalendarMetadata struct 2025-08-24 12:52:28 +02:00
Lennart
f3a1f27caf version 0.9.0 2025-08-23 20:06:38 +02:00
Lennart
0829093571 frontend: add dialog backdrop 2025-08-23 20:00:42 +02:00
Lennart
bfe17d0b65 caldav import: Add safeguard against empty addressbooks 2025-08-23 19:55:29 +02:00
Lennart
9050484932 Add addressbook import to frontend 2025-08-23 19:50:34 +02:00
Lennart
1e90ff3d6c carddav: Remove enforcement of UID matching filename (Apple Contacts doesn't play well) 2025-08-23 19:42:58 +02:00
Lennart
94ace71745 carddav: Change addressbook PUT to IMPORT 2025-08-23 19:01:19 +02:00
Lennart
f22d5ca04b clippy appeasement 2025-08-23 19:00:15 +02:00
Lennart
68a2e7e2a2 carddav: Require UID in address object 2025-08-23 18:09:03 +02:00
Lennart
4e3c3f3a3b Add calendar import endpoint and frontend form 2025-08-23 12:24:42 +02:00
Lennart
b7cfd3301b Add import_calendar method to CalendarStore 2025-08-23 12:23:05 +02:00
Lennart
9c114dc204 export: Include vtimezones
fixes #112
2025-08-22 21:32:34 +02:00
Lennart
9decef093d dav: add new http IMPORT method 2025-08-20 13:48:50 +02:00
Lennart
de2a8a2a8e bump version to 0.8.6 2025-08-17 15:48:37 +02:00
Lennart
51d2293ff9 frontend: Show unauthorized messages instead of redirecting to the login screen for non-user resources 2025-08-17 15:47:35 +02:00
Lennart
5c77719ce4 Add log warning for failed login attempts 2025-08-17 15:38:29 +02:00
Lennart
91996465f9 ical: Remove unused generic around CalendarObject 2025-08-17 15:38:07 +02:00
Lennart
83f4506578 bump version to 0.8.5 2025-08-12 17:19:36 +02:00
Lennart
a5bbb82712 dav_push: Add TTL header to notifcation requests (thanks @drift8797)
see #108
2025-08-12 17:19:16 +02:00
Lennart
6a26f44dd7 bump version to 0.8.4 2025-08-10 14:01:25 +02:00
Lennart
f8a660c222 rename session cookie to rustical_session
To prevent possible clashes with other services, #105
2025-08-10 14:01:00 +02:00
Lennart
a991baaf7d Update version to 0.8.3 2025-08-10 13:51:09 +02:00
Lennart
61d226dada Update dependencies
Fixes #106
2025-08-10 13:49:51 +02:00
Lennart
ce0ce43418 some preparation for better testing 2025-08-10 13:14:45 +02:00
Lennart
038942ff16 Make order of user privileges deterministic during serialisation 2025-07-29 16:48:03 +02:00
Lennart
90c38e7703 dav: for propfind replace HashSet with Vec to make output deterministic 2025-07-29 15:49:58 +02:00
Lennart
0159a8d9c9 clippy appeasement 2025-07-29 15:07:04 +02:00
Lennart
aa8db47f57 dav: Make response xml serialize to make unit testing easier 2025-07-29 15:05:04 +02:00
Lennart
78f7a7e155 rustical_dav: Move propfind parsing to resource type 2025-07-29 14:53:16 +02:00
Lennart
e1a7a188f5 add comment about timezone 2025-07-29 12:53:44 +02:00
Lennart
a42004501b version 0.8.1 2025-07-26 17:37:44 +02:00
Lennart
89ce14ee86 update ical dependency 2025-07-26 17:37:25 +02:00
Lennart
7fc64d219c outsource some more ical logic to ical-rs fork 2025-07-26 13:32:28 +02:00
Lennart
03294ec106 version 0.8.0 2025-07-25 23:26:57 +02:00
Lennart
a22235d976 sqlite_store: Drop timezone column in favour of timezone_id 2025-07-25 23:01:51 +02:00
Lennart
1ba9a97b3f update .sqlx queries 2025-07-25 22:52:26 +02:00
Lennart
51036ec6d5 Update vtimezone-rs to fix missing timezones 2025-07-25 22:51:35 +02:00
Lennart
e1a10338e0 Calendar data model: Switch to only saving timezone id 2025-07-25 22:32:01 +02:00
Lennart
918f27e8c2 frontend: Fix timezone removal 2025-07-25 22:30:52 +02:00
Lennart
dd34dd23d1 ical: Work on calendar object data structure 2025-07-25 21:44:57 +02:00
Lennart
9910e4ee31 Remove duplicate UTC implementation from CalTimezone 2025-07-25 19:06:23 +02:00
Lennart
c22469dea6 update ical dependency 2025-07-25 18:38:21 +02:00
Lennart
f2899aec6b Move to own ical-rs fork and refactor timezone-related stuff 2025-07-25 18:22:06 +02:00
Lennart K
f9380ca7e4 clippy appeasement 2025-07-24 11:46:28 +02:00
Lennart
e7138b5f8c version 0.7.0 2025-07-23 21:32:12 +02:00
Lennart
84af24a2b7 frontend: fill id with uuid for creation forms 2025-07-23 21:31:10 +02:00
Lennart
4bd6271e33 Update vtimezones-rs 2025-07-23 21:15:15 +02:00
Lennart
d817c1384c frontend: Add error handling to collection forms 2025-07-23 20:48:28 +02:00
Lennart
f8abc22e63 clippy appeasement 2025-07-23 20:41:06 +02:00
Lennart
b7b5ca4f91 Update dependencies 2025-07-23 20:31:16 +02:00
Lennart
caca2d28ed update vtimezones-rs 2025-07-23 20:23:21 +02:00
Lennart
3db2f13c1b rename vzic-rs to vtimezones-rs 2025-07-23 18:19:23 +02:00
Lennart
db01024682 add comment 2025-07-23 18:08:04 +02:00
Lennart
b2f15f2d77 fix: Add timezone-id support to mkcalendar 2025-07-23 18:04:19 +02:00
Lennart
89dd94904b frontend: Add timezone fields to calendar forms 2025-07-23 17:59:54 +02:00
Lennart
5d0263abc1 caldav: Add vtimezone repository to date timezone with timezone-id 2025-07-23 17:55:55 +02:00
Lennart
0ef3e19bd3 caldav: Fix principal collection permissions 2025-07-23 11:28:14 +02:00
Lennart
44912057fc subscription store: Correctly return whether subscription already existed 2025-07-23 11:09:48 +02:00
Lennart
c4f613a803 Add example compose.yml 2025-07-23 11:05:05 +02:00
Lennart
eb8f301e45 update dependencies 2025-07-22 17:57:24 +02:00
Lennart
d59ae25eba v0.6.5 2025-07-22 16:57:08 +02:00
Lennart
d4daa35df6 auth: Make app token validation faster by supplying hint to the app token name 2025-07-22 16:48:04 +02:00
Lennart
ea43876410 auth: User faster app token hash 2025-07-22 16:10:19 +02:00
Lennart
18af1b9aa2 remove calendar-proxy from DAV header 2025-07-22 15:41:24 +02:00
Lennart
e69c75102c version 0.6.4 2025-07-22 10:55:28 +02:00
Lennart
09f1bd20ae close connection if request body might not have been consumed
hopefully fixes #77
2025-07-22 10:53:12 +02:00
Lennart
72f970a857 version 0.6.3 2025-07-20 13:39:25 +02:00
Lennart
08c250657e well-known: add second apple user agent 2025-07-20 13:38:57 +02:00
Lennart
b8ef2f1ba2 version 0.6.2 2025-07-20 13:16:42 +02:00
Lennart
c8adf60f48 version 0.6.1 2025-07-20 13:13:01 +02:00
Lennart
507cb77e85 Add /.well-known/caldav exception for Apple Calendar 2025-07-20 13:10:52 +02:00
Lennart
8881ea2a05 frontend: Fix some HTML syntax errors 2025-07-19 17:50:14 +02:00
Lennart
119e17a8e1 rustical_xml: Add :: prefix to quick_xml imports 2025-07-19 16:23:43 +02:00
Lennart
8b01c5388b version 0.6.0 2025-07-18 21:09:11 +02:00
Lennart
35f423d4ca frontend: Add addressbook editing form 2025-07-18 21:08:11 +02:00
Lennart
a827b40b47 frontend: Add calendar editing form 2025-07-18 21:00:58 +02:00
Lennart
16f9ce6f38 dav: Fix proppatch supporting multiple properties in <set> and <remove> elements 2025-07-18 20:59:37 +02:00
Lennart
34839aa2ed caldav: Allow proppatch for supported-calendar-component-set 2025-07-18 20:42:11 +02:00
Lennart
2724154ed3 ical: Serialize calendar component type 2025-07-18 20:41:44 +02:00
Lennart
c490c413ec frontend: Fix layout of calendar component chips 2025-07-18 19:53:45 +02:00
Lennart
994864c6ef Update README and client documentation 2025-07-18 18:21:10 +02:00
Lennart
92fd28cdbb caldav: calendar-query fix xml 2025-07-18 17:39:57 +02:00
Lennart
d7e871f0e6 version 0.5.1 2025-07-18 15:14:47 +02:00
Lennart
a0fc073bd2 docs: Document that we expect HTTPS
fixes #75
2025-07-18 14:31:22 +02:00
Lennart
c8dffb4f9e version 0.5.0 2025-07-18 14:15:14 +02:00
Lennart
b6d1899636 carddav: Add full addressbook-home-set 2025-07-18 14:13:34 +02:00
Lennart
81f1767efa docs: Update client documentation for CalDAV 2025-07-18 14:13:11 +02:00
Lennart K
54eb9ddfcc docs: Update notes for Apple Calendar 2025-07-18 12:24:28 +02:00
Lennart K
60a0f16557 frontend: Update Apple profile for caldav-compat 2025-07-18 12:18:55 +02:00
Lennart K
e4f188d299 Update documentation for simplified calendar home set 2025-07-18 12:18:40 +02:00
Lennart K
69163404a1 caldav: Add endpoint with simplified calendar-home-set 2025-07-18 12:18:27 +02:00
Lennart K
0b7cfea79c clippy appeasement 2025-07-18 11:29:03 +02:00
Lennart
455b4c405f version 0.4.13 2025-07-10 21:39:28 +02:00
Lennart
2774d092ac propfind: Implement <include/>
Implements #95
2025-07-10 15:45:54 +02:00
Lennart
32b616fd75 xml serialize_to_string: Enable indentation 2025-07-10 15:45:07 +02:00
Lennart K
b02f7c427a minor refactoring 2025-07-10 10:51:59 +02:00
Lennart
eae8e7d768 version 0.4.12 2025-07-07 21:18:46 +02:00
Lennart
105718a4ca frontend: Add xml escaping to collection creation forms 2025-07-07 21:18:16 +02:00
Lennart
0e68f1bdce frontend: refactor collection list to allow for dialogs 2025-07-07 11:22:20 +02:00
Lennart
aa744fcea2 version 0.4.11 2025-07-05 10:41:46 +02:00
Lennart
4a51a669cd frontend: stylesheet 2025-07-05 10:41:20 +02:00
Lennart
07fca05e50 Make hash for app tokens less expensive (they are random anyway) 2025-07-05 10:26:06 +02:00
Lennart
509cc8d7a1 docs: Add documentation to setup some clients (more to follow) 2025-07-05 10:22:32 +02:00
Lennart
4134ab0520 frontend: Add user to global scope and make principal inputs dropdowns for collection creation 2025-07-05 10:04:42 +02:00
Lennart
d8803a38a2 frontend: create-calendar-form put subscription url behind checkbox 2025-07-05 09:10:26 +02:00
Lennart
b5bff08b08 version 0.4.10 2025-07-05 08:50:00 +02:00
Lennart
3ca02d9792 dav: Implement HEAD method 2025-07-05 08:47:22 +02:00
Lennart
ee2cc2174c frontend: Slight stylesheet change 2025-07-05 08:47:09 +02:00
Lennart
caf10912e5 Version 0.4.9 2025-07-04 21:53:07 +02:00
Lennart
ec89cd6fa5 fix header bar on mobile 2025-07-04 21:52:23 +02:00
Lennart K
ae20573670 frontend: Add file sizes to collections 2025-07-04 21:20:49 +02:00
Lennart K
71cee2d20c frontend: Add some iconography 2025-07-04 21:12:28 +02:00
Lennart K
83c6bf247e Add sqlx queries 2025-07-04 20:58:32 +02:00
Lennart K
6bcc03d659 frontend: Add basic information about collections 2025-07-04 20:54:37 +02:00
Lennart K
32f5c01716 frontend: checkbox alignment for create calendar form 2025-07-04 19:57:20 +02:00
Lennart K
40938cba02 Some work on the frontend 2025-07-04 19:44:17 +02:00
Lennart
a5663bf006 Remove unnecessary pwhash command 2025-07-02 23:43:18 +02:00
Lennart
26306fd661 xml: Fix writer type 2025-07-02 23:31:04 +02:00
Lennart
d8e4bd1cc4 xml: Remove generics from XmlSerialize 2025-07-02 19:02:25 +02:00
Lennart K
a18ff2b400 propfind: Add todo comment 2025-07-02 16:51:05 +02:00
Lennart K
bf13d95b97 xml: Make XmlSerialize trait more precise 2025-07-02 12:51:29 +02:00
Lennart K
ee1faa4c20 version 0.4.8 2025-07-01 14:09:58 +02:00
Lennart K
1e999ca0cc feat(frontend): Add bodged field to create group collections 2025-07-01 14:09:32 +02:00
Lennart K
f27245f996 fix(store_sqlite): Principal upsert 2025-07-01 13:49:43 +02:00
Lennart
734455b5ab version 0.4.7 2025-06-30 20:05:01 +02:00
Lennart
8c6a616015 fix sync-collection limit element 2025-06-30 20:03:54 +02:00
Lennart K
828e7399c8 xml: Make serialization more ergonomic and clippy appeasement 2025-06-29 17:00:10 +02:00
Lennart K
891ef6a9f3 write test fixtures for sqlite store 2025-06-29 12:23:23 +02:00
Lennart
7b27ac22a4 Add Thunderbird to tested clients 2025-06-28 11:43:20 +02:00
Lennart
15668bf399 version 0.4.6 2025-06-28 01:19:29 +02:00
Lennart
d2de87072f slight frontend changes 2025-06-28 01:14:55 +02:00
Lennart
ff1e38477b slight frontend changes 2025-06-28 00:53:24 +02:00
Lennart
f4fbb7c964 Add docs commands to Justfile 2025-06-28 00:07:40 +02:00
Lennart
e8e60d4aac Weaken installation warning since I'm becoming more confident. 2025-06-28 00:06:27 +02:00
Lennart
283be0a26c version 0.4.5 2025-06-27 17:40:48 +02:00
Lennart
1060625b9d fix(oidc): Fix login not working for missing groups claim
see #87
2025-06-27 17:38:42 +02:00
Lennart
86ae31e94c tiny steps towards unit testing for each resource 2025-06-27 14:33:25 +02:00
Lennart
e2f5773e3c Dockerfile: Target Rust 1.88 2025-06-27 14:32:08 +02:00
Lennart
b54fbebe7c store: test preparations 2025-06-27 13:58:14 +02:00
Lennart
fe78a82806 clippy appeasement 2025-06-27 13:57:57 +02:00
Lennart
22544b8c2f Justfile: Add commands to build frontend components 2025-06-27 13:57:44 +02:00
Lennart
340b99e491 Dockerfile: Fix llvm dependency for arm64 builds 2025-06-26 22:25:04 +02:00
Lennart
787ea90376 Dockerfile, update Rust to 1.87+ 2025-06-26 22:03:16 +02:00
Lennart
973a86f21a remove some disabled and broken tests 2025-06-26 19:42:06 +02:00
Lennart
39fc2fb55d principal_type refactoring 2025-06-26 12:50:37 +02:00
Lennart
ab4d763304 tiny improvements to documentation 2025-06-26 12:39:23 +02:00
Lennart
9cf74f7198 frontend: Explicitly mark collections from other groups 2025-06-25 16:14:55 +02:00
Lennart
2c2a6006c7 version 0.4.4 2025-06-25 16:03:31 +02:00
Lennart
4600f03b45 frontend: slight improvements to collection lists 2025-06-25 16:01:17 +02:00
Lennart
41fc1e6ea5 frontend: Remove default red for calendars without color 2025-06-25 15:56:46 +02:00
Lennart
b56591c482 frontend: LSP appeasement 2025-06-25 15:54:47 +02:00
Lennart
d639b18005 version 0.4.3 2025-06-23 16:44:21 +02:00
Lennart
6046439fc7 feat(dav): Add show_deleted parameter to get_resource
Fixes #86
2025-06-23 16:43:46 +02:00
Lennart
f9de8a4687 feat: Add show_deleted to get_calendar 2025-06-23 16:35:36 +02:00
Lennart
8dfb47b28f version 0.4.2 2025-06-23 16:13:18 +02:00
Lennart
eb720ded99 ci: Only tag releases as latest container images 2025-06-23 16:12:36 +02:00
Lennart
89ef7b2ced Update vcard date tests 2025-06-23 16:09:22 +02:00
Lennart
6e0129130e Fix birthdays without year in birthday calendar
Fixes #79
2025-06-23 16:03:59 +02:00
Lennart
c646986c56 Version 0.4.1 2025-06-23 14:08:06 +02:00
Lennart
503cbe3699 fix: Add default frontend config 2025-06-23 14:07:38 +02:00
Lennart
79c66a0b46 fix(caldav): Fix permissions to allow for deletion of calendar subscriptions
fixes #84
2025-06-23 14:04:09 +02:00
Lennart
e5687c6e43 fix(frontend): calendar subscription creation 2025-06-23 14:03:10 +02:00
Lennart
79b67a17c3 Implement deletion button to permanently delete collections 2025-06-23 13:48:00 +02:00
Lennart
7d18faff69 version 0.3.6 2025-06-23 11:21:04 +02:00
Lennart
753f8e90d3 fix(frontend): Fix calendar download link 2025-06-23 11:20:44 +02:00
Lennart
701fa9dd9c Version 3.4.5 2025-06-23 08:54:26 +02:00
Lennart
31b17cfe7f Frontend: Fix dumb typo in calendar creation form
Fixes #82
2025-06-23 08:53:50 +02:00
Lennart
d802a0085a Add Home Assistant to tested clients 2025-06-23 00:42:45 +02:00
346 changed files with 17656 additions and 12054 deletions

View File

@@ -2,3 +2,5 @@
indent_style = space indent_style = space
indent_size = 4 indent_size = 4
[docs/**/*.md]
indent_size = 4

View File

@@ -1,20 +0,0 @@
name: Rust CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose --workspace

66
.github/workflows/cicd.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: "CICD"
on: [push, pull_request]
permissions:
contents: read
pull-requests: write
env:
RUST_VERSION: "1.92"
CARGO_TERM_COLOR: always
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
- name: Checkout sources
uses: actions/checkout@v4
- run: cargo check
test:
name: Test Suite
runs-on: ubuntu-latest
steps:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
- name: Checkout sources
uses: actions/checkout@v4
- run: cargo test --all-features --verbose --workspace
coverage:
name: Test Coverage
runs-on: ubuntu-latest
steps:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
- name: Install tarpaulin
run: cargo install cargo-tarpaulin
- name: Checkout sources
uses: actions/checkout@v4
- name: Run tarpaulin
run: cargo tarpaulin --workspace --all-features --exclude xml_derive --coveralls ${{ secrets.COVERALLS_REPO_TOKEN }}
lints:
name: Lints
runs-on: ubuntu-latest
steps:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
components: rustfmt, clippy
- name: Checkout sources
uses: actions/checkout@v4
- name: Run cargo fmt
run: cargo fmt --all -- --check
- name: Run cargo clippy
run: cargo clippy -- -D warnings

View File

@@ -2,7 +2,10 @@ name: Docker
on: on:
push: push:
branches: ["main"] branches:
- main
- dev
- feat/*
release: release:
types: ["published"] types: ["published"]
@@ -26,12 +29,12 @@ jobs:
# https://github.com/docker/setup-buildx-action # https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 uses: docker/setup-buildx-action@v3
# https://github.com/docker/login-action # https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }} - name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -41,13 +44,12 @@ jobs:
# https://github.com/docker/metadata-action # https://github.com/docker/metadata-action
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# As long as we don't have releases everything on the main branch shall be tagged as latest
tags: | tags: |
type=raw,value=latest,enable={{is_default_branch}} ${{ github.ref_name == 'main' && 'type=ref,event=branch' || '' }}
type=ref,event=branch type=ref,event=branch,prefix=br-
type=ref,event=pr type=ref,event=pr
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
@@ -56,7 +58,7 @@ jobs:
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 uses: docker/build-push-action@v5
with: with:
context: . context: .
platforms: linux/arm64,linux/amd64 platforms: linux/arm64,linux/amd64

View File

@@ -17,6 +17,8 @@ jobs:
with: with:
python-version: 3.x python-version: 3.x
- run: rustup update
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- name: Set up build cache - name: Set up build cache

2
.gitignore vendored
View File

@@ -16,3 +16,5 @@ site
# Frontend # Frontend
**/node_modules **/node_modules
**/.vite **/.vite
**/*.snap.new

View File

@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "\n SELECT principal, cal_id, id, (deleted_at IS NOT NULL) AS \"deleted: bool\"\n FROM calendarobjects\n WHERE (principal, cal_id, id) NOT IN (\n SELECT DISTINCT principal, cal_id, object_id FROM calendarobjectchangelog\n )\n ;\n ",
"describe": {
"columns": [
{
"name": "principal",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "cal_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "deleted: bool",
"ordinal": 3,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "053c17f3b54ae3e153137926115486eb19a801bd73a74230bcf72a9a7254824a"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM calendarobjectchangelog WHERE (principal, cal_id, object_id) = (?, ?, ?);",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "146e23ae4e0eaae4d65ac7563c67d4f295ccc2534dcc4b3bd710de773ed137f9"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT *\n FROM calendars\n WHERE principal = ? AND deleted_at IS NOT NULL", "query": "SELECT principal, id, displayname, \"order\", description, color, timezone_id, deleted_at, synctoken, subscription_url, push_topic, comp_event, comp_todo, comp_journal\n FROM calendars\n WHERE principal = ? AND deleted_at IS NOT NULL",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -14,68 +14,63 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "synctoken", "name": "displayname",
"ordinal": 2, "ordinal": 2,
"type_info": "Integer" "type_info": "Text"
}, },
{ {
"name": "displayname", "name": "order",
"ordinal": 3, "ordinal": 3,
"type_info": "Text" "type_info": "Integer"
}, },
{ {
"name": "description", "name": "description",
"ordinal": 4, "ordinal": 4,
"type_info": "Text" "type_info": "Text"
}, },
{
"name": "order",
"ordinal": 5,
"type_info": "Integer"
},
{ {
"name": "color", "name": "color",
"ordinal": 6, "ordinal": 5,
"type_info": "Text"
},
{
"name": "timezone",
"ordinal": 7,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "timezone_id", "name": "timezone_id",
"ordinal": 8, "ordinal": 6,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "deleted_at", "name": "deleted_at",
"ordinal": 9, "ordinal": 7,
"type_info": "Datetime" "type_info": "Datetime"
}, },
{
"name": "synctoken",
"ordinal": 8,
"type_info": "Integer"
},
{ {
"name": "subscription_url", "name": "subscription_url",
"ordinal": 10, "ordinal": 9,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "push_topic", "name": "push_topic",
"ordinal": 11, "ordinal": 10,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "comp_event", "name": "comp_event",
"ordinal": 12, "ordinal": 11,
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"name": "comp_todo", "name": "comp_todo",
"ordinal": 13, "ordinal": 12,
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"name": "comp_journal", "name": "comp_journal",
"ordinal": 14, "ordinal": 13,
"type_info": "Bool" "type_info": "Bool"
} }
], ],
@@ -85,14 +80,13 @@
"nullable": [ "nullable": [
false, false,
false, false,
false,
true,
true, true,
false, false,
true, true,
true, true,
true, true,
true, true,
false,
true, true,
false, false,
false, false,
@@ -100,5 +94,5 @@
false false
] ]
}, },
"hash": "cce62f7829bd688cd8c7928b587bc31f0e50865c214b1df113350bea2c254237" "hash": "27ac68a4eea40c1cac663cad034028cf6c373354b29e3a5290c18f58101913cd"
} }

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n REPLACE INTO principals\n (id, displayname, principal_type, password_hash)\n VALUES (?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "2f043f62a7c0eae1023e319f0bc8f35dfdcf6a8247e03b1de3e2cabb2d3ab8ae"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE calendarobjects SET ics = ? WHERE (principal, cal_id, id) = (?, ?, ?);",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "354decac84758c88280f60fbf0f93dddc6c7ff92ac7b8ba44049d31df3c680e3"
}

View File

@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT id, uid, ics FROM calendarobjects\n WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL\n AND (last_occurence IS NULL OR ? IS NULL OR last_occurence >= date(?))\n AND (first_occurence IS NULL OR ? IS NULL OR first_occurence <= date(?))\n ",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "uid",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "ics",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 6
},
"nullable": [
false,
false,
false
]
},
"hash": "3a29efff3d3f6e1e05595d1a2d095af5fc963572c90bd10a6616af78757f8c39"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "REPLACE INTO calendarobjects (principal, cal_id, id, ics, first_occurence, last_occurence, etag, object_type) VALUES (?, ?, ?, ?, date(?), date(?), ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "3e1cca532372e891ab3e604ecb79311d8cd64108d4f238db4c79e9467a3b6d2e"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE calendars SET principal = ?, id = ?, displayname = ?, description = ?, \"order\" = ?, color = ?, timezone_id = ?, push_topic = ?, comp_event = ?, comp_todo = ?, comp_journal = ?\n WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 13
},
"nullable": []
},
"hash": "46ae176a06e314492f661c28436d6370883052c854da43475d7ced60cf8326e3"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE birthday_calendars SET principal = ?, id = ?, displayname = ?, description = ?, \"order\" = ?, color = ?, timezone_id = ?, push_topic = ?\n WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "4a05eda4e23e8652312548b179a1cc16f43768074ab9e7ab7b7783395384984e"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT id, ics FROM calendarobjects WHERE (principal, cal_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) OR ?)", "query": "SELECT id, uid, ics FROM calendarobjects WHERE (principal, cal_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) OR ?)",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -9,18 +9,24 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "ics", "name": "uid",
"ordinal": 1, "ordinal": 1,
"type_info": "Text" "type_info": "Text"
},
{
"name": "ics",
"ordinal": 2,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
"Right": 4 "Right": 4
}, },
"nullable": [ "nullable": [
false,
false, false,
false false
] ]
}, },
"hash": "543838c030550cb09d1af08adfeade8b7ce3575d92fddbc6e9582d141bc9e49d" "hash": "505ebe8e64ac709b230dce7150240965e45442aca6c5f3b3115738ef508939ed"
} }

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO calendars (principal, id, displayname, description, \"order\", color, subscription_url, timezone, timezone_id, push_topic, comp_event, comp_todo, comp_journal)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 13
},
"nullable": []
},
"hash": "5132ee8198f155242aa332a10019c48ec334884bcf7841c8aa03fd5eb11351d9"
}

View File

@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "SELECT principal, id, displayname, description, \"order\", color, timezone_id, deleted_at, addr_synctoken, push_topic\n FROM birthday_calendars\n INNER JOIN (\n SELECT principal AS addr_principal,\n id AS addr_id,\n synctoken AS addr_synctoken\n FROM addressbooks\n ) ON (principal, id) = (addr_principal, addr_id)\n WHERE (principal, id) = (?, ?)\n AND ((deleted_at IS NULL) OR ?)\n ",
"describe": {
"columns": [
{
"name": "principal",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "displayname",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "order",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "color",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "timezone_id",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "deleted_at",
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "addr_synctoken",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "push_topic",
"ordinal": 9,
"type_info": "Text"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
true,
true,
false,
true,
true,
true,
false,
false
]
},
"hash": "525fc4eab8a0f3eacff7e3c78ce809943f817abf8c8f9ae50073924bccdea2dc"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "REPLACE INTO davpush_subscriptions (id, topic, expiration, push_resource, public_key, public_key_type, auth_secret) VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "583069cbeba5285c63c2b95e989669d3faed66a75fbfc7cd93e5f64b778f45ab"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO principals\n (id, displayname, principal_type, password_hash) VALUES (?, ?, ?, ?)\n ON CONFLICT(id) DO UPDATE SET\n (displayname, principal_type, password_hash)\n = (excluded.displayname, excluded.principal_type, excluded.password_hash)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "5c09c2a3c052188435409d4ff076575394e625dd19f00dea2d4c71a9f34a5952"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO calendars (principal, id, displayname, description, \"order\", color, subscription_url, timezone_id, push_topic, comp_event, comp_todo, comp_journal)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 12
},
"nullable": []
},
"hash": "60b940ff493e7c0fcb2ffe8ae97172c6444525ffeec21b194bd7443d11d06113"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO calendarobjects (principal, cal_id, id, ics, first_occurence, last_occurence, etag, object_type) VALUES (?, ?, ?, ?, date(?), date(?), ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "6327bee90e5df01536a0ddb15adcc37af3027f6902aa3786365c5ab2fbf06bda"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT length(vcf) AS 'length!: u64', deleted_at AS 'deleted!: bool' FROM addressobjects WHERE principal = ? AND addressbook_id = ?",
"describe": {
"columns": [
{
"name": "length!: u64",
"ordinal": 0,
"type_info": "Null"
},
{
"name": "deleted!: bool",
"ordinal": 1,
"type_info": "Datetime"
}
],
"parameters": {
"Right": 2
},
"nullable": [
null,
true
]
},
"hash": "660833e0505d3bbcd6dd736cce06b1bf14263d0e0e87b27d89d376d422e4e474"
}

View File

@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "SELECT principal, id, displayname, description, \"order\", color, timezone_id, deleted_at, addr_synctoken, push_topic\n FROM birthday_calendars\n INNER JOIN (\n SELECT principal AS addr_principal,\n id AS addr_id,\n synctoken AS addr_synctoken\n FROM addressbooks\n ) ON (principal, id) = (addr_principal, addr_id)\n WHERE principal = ?\n AND (\n (deleted_at IS NULL AND NOT ?) -- not deleted, want not deleted\n OR (deleted_at IS NOT NULL AND ?) -- deleted, want deleted\n )\n ",
"describe": {
"columns": [
{
"name": "principal",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "displayname",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "order",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "color",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "timezone_id",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "deleted_at",
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "addr_synctoken",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "push_topic",
"ordinal": 9,
"type_info": "Text"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
true,
true,
false,
true,
true,
true,
false,
false
]
},
"hash": "66d57f2c99ef37b383a478aff99110e1efbc7ce9332f10da4fa69f7594fb7455"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE birthday_calendars SET deleted_at = NULL WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "6c039308ad2ec29570ab492d7a0e85fb79c0a4d3b882b74ff1c2786c12324896"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO davpush_subscriptions (id, topic, expiration, push_resource, public_key, public_key_type, auth_secret) VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "6d08d3a014743da9b445ab012437ec11f81fd86d3b02fc1df07a036c6b47ace2"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO birthday_calendars (principal, id, displayname, description, \"order\", color, push_topic)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "72c7c67f4952ad669ecd54d96bbcb717815081f74575f0a65987163faf9fe30a"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT id, ics FROM calendarobjects WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL", "query": "SELECT id, uid, ics FROM calendarobjects WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -9,18 +9,24 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "ics", "name": "uid",
"ordinal": 1, "ordinal": 1,
"type_info": "Text" "type_info": "Text"
},
{
"name": "ics",
"ordinal": 2,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
"Right": 2 "Right": 2
}, },
"nullable": [ "nullable": [
false,
false, false,
false false
] ]
}, },
"hash": "54c9c0e36a52e6963f11c6aa27f13aafb4204b8aa34b664fd825bd447db80e86" "hash": "804ed2a4a7032e9605d1871297498f5a96de0fc816ce660c705fb28318be0d42"
} }

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE birthday_calendars SET deleted_at = datetime() WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "83f0aaf406785e323ac12019ac24f603c53125a1b2326f324c1e2d7b6c690adc"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "REPLACE INTO calendarobjects (principal, cal_id, id, uid, ics, first_occurence, last_occurence, etag, object_type) VALUES (?, ?, ?, ?, ?, date(?), date(?), ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "a68a1b96189b854a7ba2a3cd866ba583af5ad84bc1cd8b20cb805e9ce3bad820"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT *\n FROM calendars\n WHERE (principal, id) = (?, ?)", "query": "SELECT *\n FROM calendars\n WHERE (principal, id) = (?, ?)\n AND ((deleted_at IS NULL) OR ?) ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -39,48 +39,43 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "timezone", "name": "timezone_id",
"ordinal": 7, "ordinal": 7,
"type_info": "Text" "type_info": "Text"
}, },
{
"name": "timezone_id",
"ordinal": 8,
"type_info": "Text"
},
{ {
"name": "deleted_at", "name": "deleted_at",
"ordinal": 9, "ordinal": 8,
"type_info": "Datetime" "type_info": "Datetime"
}, },
{ {
"name": "subscription_url", "name": "subscription_url",
"ordinal": 10, "ordinal": 9,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "push_topic", "name": "push_topic",
"ordinal": 11, "ordinal": 10,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "comp_event", "name": "comp_event",
"ordinal": 12, "ordinal": 11,
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"name": "comp_todo", "name": "comp_todo",
"ordinal": 13, "ordinal": 12,
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"name": "comp_journal", "name": "comp_journal",
"ordinal": 14, "ordinal": 13,
"type_info": "Bool" "type_info": "Bool"
} }
], ],
"parameters": { "parameters": {
"Right": 2 "Right": 3
}, },
"nullable": [ "nullable": [
false, false,
@@ -93,12 +88,11 @@
true, true,
true, true,
true, true,
true,
false, false,
false, false,
false, false,
false false
] ]
}, },
"hash": "9f930775043a6d4571a8ffd5a981cadf7c51f3f11a189f8461505abec31076e6" "hash": "bb2fa030f2e7c7afdb38c5c54cb31de5293be332d86cf643977d479999542553"
} }

View File

@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT principal, cal_id, id, ics FROM calendarobjects WHERE ics LIKE '%VERSION:4.0%';",
"describe": {
"columns": [
{
"name": "principal",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "cal_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "ics",
"ordinal": 3,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "bdaa4bee8b01d0e3773e34672ed4805d1e71d24888f2227045afd90bf080fc23"
}

View File

@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "\n SELECT principal, addressbook_id, id, (deleted_at IS NOT NULL) AS \"deleted: bool\"\n FROM addressobjects\n WHERE (principal, addressbook_id, id) NOT IN (\n SELECT DISTINCT principal, addressbook_id, object_id FROM addressobjectchangelog\n )\n ;\n ",
"describe": {
"columns": [
{
"name": "principal",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "addressbook_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "deleted: bool",
"ordinal": 3,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "c138b1143ac04af4930266ffae0990e82005911c11a683ad565e92335e085f4d"
}

View File

@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, ics FROM calendarobjects\n WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL\n AND (last_occurence IS NULL OR ? IS NULL OR last_occurence >= date(?))\n AND (first_occurence IS NULL OR ? IS NULL OR first_occurence <= date(?))\n ",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "ics",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 6
},
"nullable": [
false,
false
]
},
"hash": "c550dbf3d5ce7069f28d767ea9045e477ef8d29d6186851760757a06dec42339"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM birthday_calendars WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "cadc4ac16b7ac22b71c91ab36ad9dbb1dec943798d795fcbc811f4c651fea02a"
}

View File

@@ -39,43 +39,38 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "timezone", "name": "timezone_id",
"ordinal": 7, "ordinal": 7,
"type_info": "Text" "type_info": "Text"
}, },
{
"name": "timezone_id",
"ordinal": 8,
"type_info": "Text"
},
{ {
"name": "deleted_at", "name": "deleted_at",
"ordinal": 9, "ordinal": 8,
"type_info": "Datetime" "type_info": "Datetime"
}, },
{ {
"name": "subscription_url", "name": "subscription_url",
"ordinal": 10, "ordinal": 9,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "push_topic", "name": "push_topic",
"ordinal": 11, "ordinal": 10,
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "comp_event", "name": "comp_event",
"ordinal": 12, "ordinal": 11,
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"name": "comp_todo", "name": "comp_todo",
"ordinal": 13, "ordinal": 12,
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"name": "comp_journal", "name": "comp_journal",
"ordinal": 14, "ordinal": 13,
"type_info": "Bool" "type_info": "Bool"
} }
], ],
@@ -93,7 +88,6 @@
true, true,
true, true,
true, true,
true,
false, false,
false, false,
false, false,

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO calendarobjects (principal, cal_id, id, uid, ics, first_occurence, last_occurence, etag, object_type) VALUES (?, ?, ?, ?, ?, date(?), date(?), ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "d498a758ed707408b00b7d2675250ea739a681ce1f009f05e97f2e101bd7e556"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE calendars SET principal = ?, id = ?, displayname = ?, description = ?, \"order\" = ?, color = ?, timezone = ?, timezone_id = ?, push_topic = ?, comp_event = ?, comp_todo = ?, comp_journal = ?\n WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 14
},
"nullable": []
},
"hash": "d65c9c40606e59dd816a51b9b9ac60fd2ff81aaa358fcc038134e9a68ba45ad7"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT length(ics) AS 'length!: u64', deleted_at AS 'deleted!: bool' FROM calendarobjects WHERE principal = ? AND cal_id = ?",
"describe": {
"columns": [
{
"name": "length!: u64",
"ordinal": 0,
"type_info": "Null"
},
{
"name": "deleted!: bool",
"ordinal": 1,
"type_info": "Datetime"
}
],
"parameters": {
"Right": 2
},
"nullable": [
null,
true
]
},
"hash": "d9f14260a46a7ccd137d462c35d350a7fe338a074131776596c5d803fcda1f48"
}

2217
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,21 +2,24 @@
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.3.4" version = "0.11.17"
rust-version = "1.92"
edition = "2024" edition = "2024"
description = "A CalDAV server" description = "A CalDAV server"
documentation = "https://lennart-k.github.io/rustical/"
repository = "https://github.com/lennart-k/rustical" repository = "https://github.com/lennart-k/rustical"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
[package] [package]
name = "rustical" name = "rustical"
version.workspace = true version.workspace = true
rust-version.workspace = true
edition.workspace = true edition.workspace = true
description.workspace = true description.workspace = true
repository.workspace = true repository.workspace = true
license.workspace = true license.workspace = true
resolver = "2" resolver = "2"
publish = false publish = true
[features] [features]
debug = ["opentelemetry"] debug = ["opentelemetry"]
@@ -29,13 +32,23 @@ opentelemetry = [
"dep:tracing-opentelemetry", "dep:tracing-opentelemetry",
] ]
[profile.dev] [profile.dev]
debug = 0 debug = 0
[workspace.dependencies] [workspace.dependencies]
matchit = "0.8" rustical_dav = { path = "./crates/dav/", features = ["ical"] }
uuid = { version = "1.11", features = ["v4", "fast-rng"] } rustical_dav_push = { path = "./crates/dav_push/" }
rustical_store = { path = "./crates/store/" }
rustical_store_sqlite = { path = "./crates/store_sqlite/" }
rustical_caldav = { path = "./crates/caldav/" }
rustical_carddav = { path = "./crates/carddav/" }
rustical_frontend = { path = "./crates/frontend/" }
rustical_xml = { path = "./crates/xml/" }
rustical_oidc = { path = "./crates/oidc/" }
rustical_ical = { path = "./crates/ical/" }
matchit = "0.9"
uuid = { version = "1.19", features = ["v4", "fast-rng"] }
async-trait = "0.1" async-trait = "0.1"
axum = "0.8" axum = "0.8"
tracing = { version = "0.1", features = ["async-await"] } tracing = { version = "0.1", features = ["async-await"] }
@@ -46,12 +59,11 @@ password-auth = { version = "1.0", features = ["argon2", "pbkdf2"] }
pbkdf2 = { version = "0.12", features = ["simple"] } pbkdf2 = { version = "0.12", features = ["simple"] }
rand_core = { version = "0.9", features = ["std"] } rand_core = { version = "0.9", features = ["std"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
regex = "1.10" regex = "1.12"
lazy_static = "1.5" rstest = "0.26"
rstest = "0.25"
rstest_reuse = "0.7" rstest_reuse = "0.7"
sha2 = "0.10" sha2 = "0.10"
tokio = { version = "1", features = [ tokio = { version = "1.48", features = [
"net", "net",
"tracing", "tracing",
"macros", "macros",
@@ -61,15 +73,15 @@ tokio = { version = "1", features = [
url = "2.5" url = "2.5"
base64 = "0.22" base64 = "0.22"
thiserror = "2.0" thiserror = "2.0"
quick-xml = { version = "0.37" } quick-xml = { version = "0.39" }
rust-embed = "8.5" rust-embed = "8.9"
tower-sessions = "0.14" tower-sessions = "0.14"
futures-core = "0.3.31" futures-core = "0.3"
hex = { version = "0.4.3", features = ["serde"] } hex = { version = "0.4", features = ["serde"] }
mime_guess = "2.0" mime_guess = "2.0"
itertools = "0.14" itertools = "0.14"
log = "0.4" log = "0.4"
derive_more = { version = "2.0", features = [ derive_more = { version = "2.1", features = [
"from", "from",
"try_into", "try_into",
"into", "into",
@@ -77,8 +89,8 @@ derive_more = { version = "2.0", features = [
"constructor", "constructor",
"display", "display",
] } ] }
askama = { version = "0.14", features = ["serde_json"] } askama = { version = "0.15", features = ["serde_json"] }
askama_web = { version = "0.14.0", features = ["axum-0.8"] } askama_web = { version = "0.15", features = ["axum-0.8"] }
sqlx = { version = "0.8", default-features = false, features = [ sqlx = { version = "0.8", default-features = false, features = [
"sqlx-sqlite", "sqlx-sqlite",
"uuid", "uuid",
@@ -89,14 +101,16 @@ sqlx = { version = "0.8", default-features = false, features = [
"migrate", "migrate",
"json", "json",
] } ] }
http = "1.3" http = "1.4"
headers = "0.4" headers = "0.4"
strum = "0.27" strum = "0.27"
strum_macros = "0.27" strum_macros = "0.27"
serde_json = { version = "1.0", features = ["raw_value"] } serde_json = { version = "1.0", features = ["raw_value"] }
sqlx-sqlite = { version = "0.8", features = ["bundled"] } sqlx-sqlite = { version = "0.8", features = ["bundled"] }
ical = { version = "0.11", features = ["generator", "serde"] } ical = { git = "https://github.com/lennart-k/ical-rs", rev = "f1ad6456fd6cbd1e6da095297febddd2cfe61422", features = [
toml = "0.8" "chrono-tz",
] }
toml = "0.9"
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = [ tower-http = { version = "0.6", features = [
"trace", "trace",
@@ -104,29 +118,19 @@ tower-http = { version = "0.6", features = [
"catch-panic", "catch-panic",
] } ] }
percent-encoding = "2.3" percent-encoding = "2.3"
rustical_dav = { path = "./crates/dav/" }
rustical_dav_push = { path = "./crates/dav_push/" }
rustical_store = { path = "./crates/store/" }
rustical_store_sqlite = { path = "./crates/store_sqlite/" }
rustical_caldav = { path = "./crates/caldav/" }
rustical_carddav = { path = "./crates/carddav/" }
rustical_frontend = { path = "./crates/frontend/" }
rustical_xml = { path = "./crates/xml/" }
rustical_oidc = { path = "./crates/oidc/" }
rustical_ical = { path = "./crates/ical/" }
chrono-tz = "0.10" chrono-tz = "0.10"
chrono-humanize = "0.2" chrono-humanize = "0.2"
rand = "0.9" rand = "0.9"
axum-extra = { version = "0.10", features = ["typed-header"] } axum-extra = { version = "0.12", features = ["typed-header"] }
rrule = "0.14" rrule = "0.14"
argon2 = "0.5" argon2 = "0.5"
rpassword = "7.3" rpassword = "7.4"
password-hash = { version = "0.5" } password-hash = { version = "0.5" }
syn = { version = "2.0", features = ["full"] } syn = { version = "2.0", features = ["full"] }
quote = "1.0" quote = "1.0"
proc-macro2 = "1.0" proc-macro2 = "1.0"
heck = "0.5" heck = "0.5"
darling = "0.20" darling = "0.23"
reqwest = { version = "0.12", features = [ reqwest = { version = "0.12", features = [
"rustls-tls", "rustls-tls",
"charset", "charset",
@@ -134,38 +138,50 @@ reqwest = { version = "0.12", features = [
], default-features = false } ], default-features = false }
openidconnect = "4.0" openidconnect = "4.0"
clap = { version = "4.5", features = ["derive", "env"] } clap = { version = "4.5", features = ["derive", "env"] }
matchit-serde = { git = "https://github.com/lennart-k/matchit-serde", rev = "f0591d13" } matchit-serde = { git = "https://github.com/lennart-k/matchit-serde", rev = "e18e65d7" }
vtimezones-rs = "0.2"
ece = { version = "2.3", default-features = false, features = [ ece = { version = "2.3", default-features = false, features = [
"backend-openssl", "backend-openssl",
] } ] }
openssl = { version = "0.10", features = ["vendored"] } openssl = { version = "0.10", features = ["vendored"] }
async-std = { version = "1.13", features = ["attributes"] }
similar-asserts = "1.7"
insta = { version = "1.44", features = ["filters"] }
criterion = { version = "0.8", features = ["async_tokio"] }
[dev-dependencies]
rstest.workspace = true
rustical_store_sqlite = { workspace = true, features = ["test"] }
insta.workspace = true
similar-asserts.workspace = true
[dependencies] [dependencies]
rustical_store = { workspace = true } rustical_store.workspace = true
rustical_store_sqlite = { workspace = true } rustical_store_sqlite.workspace = true
rustical_caldav = { workspace = true } rustical_caldav.workspace = true
rustical_carddav.workspace = true rustical_carddav.workspace = true
rustical_frontend = { workspace = true } rustical_frontend.workspace = true
toml = { workspace = true } ical.workspace = true
serde = { workspace = true } toml.workspace = true
tokio = { workspace = true } serde.workspace = true
tracing = { workspace = true } tokio.workspace = true
anyhow = { workspace = true } tracing.workspace = true
anyhow.workspace = true
clap.workspace = true clap.workspace = true
sqlx = { workspace = true } sqlx.workspace = true
async-trait = { workspace = true } async-trait.workspace = true
uuid.workspace = true uuid.workspace = true
axum.workspace = true axum.workspace = true
opentelemetry = { version = "0.30", optional = true } opentelemetry = { version = "0.31", optional = true }
opentelemetry-otlp = { version = "0.30", optional = true, features = [ opentelemetry-otlp = { version = "0.31", optional = true, features = [
"grpc-tonic", "grpc-tonic",
] } ] }
opentelemetry_sdk = { version = "0.30", features = [ opentelemetry_sdk = { version = "0.31", features = [
"rt-tokio", "rt-tokio",
], optional = true } ], optional = true }
opentelemetry-semantic-conventions = { version = "0.30", optional = true } opentelemetry-semantic-conventions = { version = "0.31", optional = true }
tracing-opentelemetry = { version = "0.31", optional = true } tracing-opentelemetry = { version = "0.32", optional = true }
tracing-subscriber = { version = "0.3", features = [ tracing-subscriber = { version = "0.3", features = [
"env-filter", "env-filter",
"fmt", "fmt",

View File

@@ -1,11 +1,11 @@
FROM --platform=$BUILDPLATFORM rust:1.86-alpine AS chef FROM --platform=$BUILDPLATFORM rust:1.92-alpine AS chef
ARG TARGETPLATFORM ARG TARGETPLATFORM
ARG BUILDPLATFORM ARG BUILDPLATFORM
# the compiler will otherwise ask for aarch64-linux-musl-gcc # the compiler will otherwise ask for aarch64-linux-musl-gcc
ENV CC_aarch64_unknown_linux_musl="clang" ENV CC_aarch64_unknown_linux_musl="clang"
ENV AR_aarch64_unknown_linux_musl="llvm-ar" ENV AR_aarch64_unknown_linux_musl="llvm20-ar"
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-Clink-self-contained=yes -Clinker=rust-lld" ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-Clink-self-contained=yes -Clinker=rust-lld"
# Stupid workaound with tempfiles since environment variables # Stupid workaound with tempfiles since environment variables
@@ -16,7 +16,7 @@ RUN case $TARGETPLATFORM in \
*) echo "Unsupported platform ${TARGETPLATFORM}"; exit 1;; \ *) echo "Unsupported platform ${TARGETPLATFORM}"; exit 1;; \
esac esac
RUN apk add --no-cache musl-dev llvm19 clang perl pkgconf make \ RUN apk add --no-cache musl-dev llvm20 clang perl pkgconf make \
&& rustup target add "$(cat /tmp/rust_target)" \ && rustup target add "$(cat /tmp/rust_target)" \
&& cargo install cargo-chef --locked \ && cargo install cargo-chef --locked \
&& rm -rf "$CARGO_HOME/registry" && rm -rf "$CARGO_HOME/registry"
@@ -36,7 +36,7 @@ COPY --from=planner /rustical/recipe.json recipe.json
RUN cargo chef cook --release --target "$(cat /tmp/rust_target)" RUN cargo chef cook --release --target "$(cat /tmp/rust_target)"
COPY . . COPY . .
RUN cargo install --target "$(cat /tmp/rust_target)" --path . RUN cargo install --locked --target "$(cat /tmp/rust_target)" --path .
FROM scratch FROM scratch
COPY --from=builder /usr/local/cargo/bin/rustical /usr/local/bin/rustical COPY --from=builder /usr/local/cargo/bin/rustical /usr/local/bin/rustical
@@ -45,4 +45,7 @@ CMD ["/usr/local/bin/rustical"]
ENV RUSTICAL_DATA_STORE__SQLITE__DB_URL=/var/lib/rustical/db.sqlite3 ENV RUSTICAL_DATA_STORE__SQLITE__DB_URL=/var/lib/rustical/db.sqlite3
LABEL org.opencontainers.image.authors="Lennart K github.com/lennart-k" LABEL org.opencontainers.image.authors="Lennart K github.com/lennart-k"
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
EXPOSE 4000 EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=30s --start-period=3s --retries=3 CMD ["/usr/local/bin/rustical", "health"]

View File

@@ -1,2 +1,17 @@
licenses: licenses:
cargo about generate about.hbs > crates/frontend/public/assets/licenses.html cargo about generate about.hbs > crates/frontend/public/assets/licenses.html
frontend-dev:
cd crates/frontend/js-components && deno task dev
frontend-build:
cd crates/frontend/js-components && deno task build
docs:
mkdocs build
docs-dev:
mkdocs serve
coverage:
cargo tarpaulin --workspace --exclude xml_derive

View File

@@ -1,24 +1,29 @@
[![license](https://img.shields.io/github/license/lennart-k/rustical)](https://raw.githubusercontent.com/lennart-k/rustical/main/LICENSE)
[![Coverage Status](https://coveralls.io/repos/github/lennart-k/rustical/badge.svg?branch=main)](https://coveralls.io/github/lennart-k/rustical?branch=main)
# RustiCal # RustiCal
a CalDAV/CardDAV server a CalDAV/CardDAV server
> [!WARNING] > [!WARNING]
RustiCal is **not production-ready!** RustiCal is under **active development**!
I've been using RustiCal for the last few weeks and I'm slowly becoming more confident, While I've been successfully using RustiCal productively for some months now and there seems to be a growing user base,
however you'd be one of the first testers so expect bugs and rough edges. you'd still be one of the first testers so expect bugs and rough edges.
If you still want to play around with it in its current state, absolutely feel free to do so and to open up an issue if something is not working. :) If you still want to use it in its current state, absolutely feel free to do so and to open up an issue if something is not working. :)
## Features ## Features
- easy to backup, everything saved in one SQLite database - easy to backup, everything saved in one SQLite database
- also export feature in the frontend - also export feature in the frontend
- [WebDAV Push](https://github.com/bitfireAT/webdav-push/) support, so near-instant synchronisation to DAVx5 - Import your existing calendars in the frontend
- **[WebDAV Push](https://github.com/bitfireAT/webdav-push/)** support, so near-instant synchronisation to DAVx5
- lightweight (the container image contains only one binary) - lightweight (the container image contains only one binary)
- adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks) - adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks)
- deleted calendars are recoverable - deleted calendars are recoverable
- Nextcloud login flow (In DAVx5 you can login through the Nextcloud flow and automatically generate an app token) - Nextcloud login flow (In DAVx5 you can login through the Nextcloud flow and automatically generate an app token)
- Apple configuration profiles (skip copy-pasting passwords and instead generate the configuration in the frontend) - Apple configuration profiles (skip copy-pasting passwords and instead generate the configuration in the frontend)
- OpenID Connect support (with option to disable password login) - **OpenID Connect** support (with option to disable password login)
- Group-based **sharing**
## Getting Started ## Getting Started
@@ -30,3 +35,5 @@ a CalDAV/CardDAV server
- GNOME Accounts, GNOME Calendar, GNOME Contacts - GNOME Accounts, GNOME Calendar, GNOME Contacts
- Evolution - Evolution
- Apple Calendar - Apple Calendar
- Home Assistant integration
- Thunderbird

View File

@@ -7,6 +7,9 @@ accepted = [
"CDLA-Permissive-2.0", "CDLA-Permissive-2.0",
"Zlib", "Zlib",
"AGPL-3.0", "AGPL-3.0",
"GPL-3.0",
"MPL-2.0", "MPL-2.0",
"AGPL-3.0-or-later",
"GPL-3.0-or-later",
] ]
workarounds = ["ring", "chrono", "rustls"] workarounds = ["ring", "chrono", "rustls"]

22
compose.oidc.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
rustical:
image: ghcr.io/lennart-k/rustical:latest
restart: unless-stopped
environment:
RUSTICAL_FRONTEND__ALLOW_PASSWORD_LOGIN: "false"
RUSTICAL_OIDC__NAME: "Authelia"
RUSTICAL_OIDC__ISSUER: "https://auth.example.com"
RUSTICAL_OIDC__CLIENT_ID: "{{ rustical_oidc_client_id }}"
RUSTICAL_OIDC__CLIENT_SECRET: "{{ rustical_oidc_client_secret }}"
RUSTICAL_OIDC__CLAIM_USERID: "preferred_username"
RUSTICAL_OIDC__SCOPES: '["openid", "profile", "groups"]'
RUSTICAL_OIDC__REQUIRE_GROUP: "app:rustical" # optional
RUSTICAL_OIDC__ALLOW_SIGN_UP: "true"
volumes:
- data:/var/lib/rustical
# Here you probably want to you expose instead
ports:
- 4000:4000
volumes:
data:

View File

@@ -1,31 +1,39 @@
[package] [package]
name = "rustical_caldav" name = "rustical_caldav"
version.workspace = true version.workspace = true
rust-version.workspace = true
edition.workspace = true edition.workspace = true
description.workspace = true description.workspace = true
repository.workspace = true repository.workspace = true
license.workspace = true license.workspace = true
publish = false publish = false
[dev-dependencies]
rustical_store_sqlite = { workspace = true, features = ["test"] }
rstest.workspace = true
async-std.workspace = true
serde_json.workspace = true
insta.workspace = true
[dependencies] [dependencies]
axum.workspace = true axum.workspace = true
axum-extra.workspace = true axum-extra.workspace = true
tower.workspace = true tower.workspace = true
async-trait = { workspace = true } async-trait.workspace = true
thiserror = { workspace = true } thiserror.workspace = true
quick-xml = { workspace = true } quick-xml.workspace = true
tracing = { workspace = true } tracing.workspace = true
futures-util = { workspace = true } futures-util.workspace = true
derive_more = { workspace = true } derive_more.workspace = true
base64 = { workspace = true } base64.workspace = true
serde = { workspace = true } serde.workspace = true
tokio = { workspace = true } tokio.workspace = true
url = { workspace = true } url.workspace = true
rustical_dav = { workspace = true } rustical_dav = { workspace = true, features = ["ical"] }
rustical_store = { workspace = true } rustical_store.workspace = true
chrono = { workspace = true } chrono.workspace = true
chrono-tz = { workspace = true } chrono-tz.workspace = true
sha2 = { workspace = true } sha2.workspace = true
ical.workspace = true ical.workspace = true
percent-encoding.workspace = true percent-encoding.workspace = true
rustical_xml.workspace = true rustical_xml.workspace = true
@@ -37,3 +45,5 @@ headers.workspace = true
tower-http.workspace = true tower-http.workspace = true
strum.workspace = true strum.workspace = true
strum_macros.workspace = true strum_macros.workspace = true
vtimezones-rs.workspace = true
similar-asserts.workspace = true

View File

@@ -4,13 +4,12 @@ use axum::body::Body;
use axum::extract::State; use axum::extract::State;
use axum::{extract::Path, response::Response}; use axum::{extract::Path, response::Response};
use headers::{ContentType, HeaderMapExt}; use headers::{ContentType, HeaderMapExt};
use http::{HeaderValue, StatusCode, header}; use http::{HeaderValue, Method, StatusCode, header};
use ical::generator::{Emitter, IcalCalendarBuilder}; use ical::component::IcalCalendar;
use ical::property::Property; use ical::generator::Emitter;
use ical::property::ContentLine;
use percent_encoding::{CONTROLS, utf8_percent_encode}; use percent_encoding::{CONTROLS, utf8_percent_encode};
use rustical_ical::{CalendarObjectComponent, EventObject, JournalObject, TodoObject};
use rustical_store::{CalendarStore, SubscriptionStore, auth::Principal}; use rustical_store::{CalendarStore, SubscriptionStore, auth::Principal};
use std::collections::HashMap;
use std::str::FromStr; use std::str::FromStr;
use tracing::instrument; use tracing::instrument;
@@ -19,69 +18,62 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
Path((principal, calendar_id)): Path<(String, String)>, Path((principal, calendar_id)): Path<(String, String)>,
State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>, State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
user: Principal, user: Principal,
method: Method,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(crate::Error::Unauthorized); return Err(crate::Error::Unauthorized);
} }
let calendar = cal_store.get_calendar(&principal, &calendar_id).await?; let calendar = cal_store
.get_calendar(&principal, &calendar_id, true)
.await?;
if !user.is_principal(&calendar.principal) { if !user.is_principal(&calendar.principal) {
return Err(crate::Error::Unauthorized); return Err(crate::Error::Unauthorized);
} }
let calendar = cal_store.get_calendar(&principal, &calendar_id).await?; let objects = cal_store
.get_objects(&principal, &calendar_id)
.await?
.into_iter()
.map(|(_, object)| object.into())
.collect();
let mut timezones = HashMap::new(); let mut props = vec![];
let objects = cal_store.get_objects(&principal, &calendar_id).await?;
let mut ical_calendar_builder = IcalCalendarBuilder::version("4.0") if let Some(displayname) = calendar.meta.displayname {
.gregorian() props.push(ContentLine {
.prodid("RustiCal");
if calendar.displayname.is_some() {
ical_calendar_builder = ical_calendar_builder.set(Property {
name: "X-WR-CALNAME".to_owned(), name: "X-WR-CALNAME".to_owned(),
value: calendar.displayname, value: Some(displayname),
params: None, params: vec![].into(),
}); });
} }
if calendar.description.is_some() { if let Some(description) = calendar.meta.description {
ical_calendar_builder = ical_calendar_builder.set(Property { props.push(ContentLine {
name: "X-WR-CALDESC".to_owned(), name: "X-WR-CALDESC".to_owned(),
value: calendar.description, value: Some(description),
params: None, params: vec![].into(),
}); });
} }
if calendar.timezone_id.is_some() { if let Some(color) = calendar.meta.color {
ical_calendar_builder = ical_calendar_builder.set(Property { props.push(ContentLine {
name: "X-WR-CALCOLOR".to_owned(),
value: Some(color),
params: vec![].into(),
});
}
if let Some(timezone_id) = calendar.timezone_id {
props.push(ContentLine {
name: "X-WR-TIMEZONE".to_owned(), name: "X-WR-TIMEZONE".to_owned(),
value: calendar.timezone_id, value: Some(timezone_id),
params: None, params: vec![].into(),
}); });
} }
let mut ical_calendar = ical_calendar_builder.build();
for object in &objects { let export_calendar = IcalCalendar::from_objects("RustiCal Export".to_owned(), objects, props);
match object.get_data() {
CalendarObjectComponent::Event(EventObject {
event,
timezones: object_timezones,
..
}) => {
timezones.extend(object_timezones);
ical_calendar.events.push(event.clone());
}
CalendarObjectComponent::Todo(TodoObject { todo, .. }) => {
ical_calendar.todos.push(todo.clone());
}
CalendarObjectComponent::Journal(JournalObject { journal, .. }) => {
ical_calendar.journals.push(journal.clone());
}
}
}
let mut resp = Response::builder().status(StatusCode::OK); let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap(); let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ContentType::from_str("text/calendar").unwrap()); hdrs.typed_insert(ContentType::from_str("text/calendar; charset=utf-8").unwrap());
let filename = format!("{}_{}.ics", calendar.principal, calendar.id); let filename = format!("{}_{}.ics", calendar.principal, calendar.id);
let filename = utf8_percent_encode(&filename, CONTROLS); let filename = utf8_percent_encode(&filename, CONTROLS);
@@ -92,5 +84,9 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
)) ))
.unwrap(), .unwrap(),
); );
Ok(resp.body(Body::new(ical_calendar.generate())).unwrap()) if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(export_calendar.generate())).unwrap())
}
} }

View File

@@ -0,0 +1,100 @@
use crate::Error;
use crate::calendar::CalendarResourceService;
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use ical::parser::{Component, ComponentMut};
use rustical_dav::header::Overwrite;
use rustical_ical::CalendarObjectType;
use rustical_store::{
Calendar, CalendarMetadata, CalendarStore, SubscriptionStore, auth::Principal,
};
use tracing::instrument;
#[instrument(skip(resource_service))]
pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
Path((principal, cal_id)): Path<(String, String)>,
user: Principal,
State(resource_service): State<CalendarResourceService<C, S>>,
Overwrite(overwrite): Overwrite,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
}
let parser = ical::IcalParser::from_slice(body.as_bytes());
let mut cal = match parser.expect_one() {
Ok(cal) => cal.mutable(),
Err(err) => return Ok((StatusCode::BAD_REQUEST, err.to_string()).into_response()),
};
// Extract calendar metadata
let displayname = cal
.get_property("X-WR-CALNAME")
.and_then(|prop| prop.value.clone());
let description = cal
.get_property("X-WR-CALDESC")
.and_then(|prop| prop.value.clone());
let color = cal
.get_property("X-WR-CALCOLOR")
.and_then(|prop| prop.value.clone());
let timezone_id = cal
.get_property("X-WR-TIMEZONE")
.and_then(|prop| prop.value.clone());
// These properties should not appear in the expanded calendar objects
cal.remove_property("X-WR-CALNAME");
cal.remove_property("X-WR-CALDESC");
cal.remove_property("X-WR-CALCOLOR");
cal.remove_property("X-WR-TIMEZONE");
let cal = cal.build(None).unwrap();
// Make sure timezone is valid
if let Some(timezone_id) = timezone_id.as_ref() {
assert!(
vtimezones_rs::VTIMEZONES.contains_key(timezone_id),
"Invalid calendar timezone id"
);
}
// // Extract necessary component types
let mut cal_components = vec![];
if !cal.events.is_empty() {
cal_components.push(CalendarObjectType::Event);
}
if !cal.journals.is_empty() {
cal_components.push(CalendarObjectType::Journal);
}
if !cal.todos.is_empty() {
cal_components.push(CalendarObjectType::Todo);
}
let objects = match cal.into_objects() {
Ok(objects) => objects.into_iter().map(Into::into).collect(),
Err(err) => return Ok((StatusCode::BAD_REQUEST, err.to_string()).into_response()),
};
let new_cal = Calendar {
principal,
id: cal_id,
meta: CalendarMetadata {
displayname,
order: 0,
description,
color,
},
timezone_id,
deleted_at: None,
synctoken: 0,
subscription_url: None,
push_topic: uuid::Uuid::new_v4().to_string(),
components: cal_components,
};
let cal_store = resource_service.cal_store;
cal_store
.import_calendar(new_cal, objects, overwrite)
.await?;
Ok(StatusCode::OK.into_response())
}

View File

@@ -4,10 +4,11 @@ use crate::calendar::prop::SupportedCalendarComponentSet;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use http::{Method, StatusCode}; use http::{Method, StatusCode};
use ical::IcalParser;
use rustical_dav::xml::HrefElement; use rustical_dav::xml::HrefElement;
use rustical_ical::CalendarObjectType; use rustical_ical::CalendarObjectType;
use rustical_store::auth::Principal; use rustical_store::auth::Principal;
use rustical_store::{Calendar, CalendarStore, SubscriptionStore}; use rustical_store::{Calendar, CalendarMetadata, CalendarStore, SubscriptionStore};
use rustical_xml::{Unparsed, XmlDeserialize, XmlDocument, XmlRootTag}; use rustical_xml::{Unparsed, XmlDeserialize, XmlDocument, XmlRootTag};
use tracing::instrument; use tracing::instrument;
@@ -45,7 +46,7 @@ pub struct PropElement {
} }
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug)] #[derive(XmlDeserialize, XmlRootTag, Clone, Debug)]
#[xml(root = b"mkcalendar")] #[xml(root = "mkcalendar")]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
struct MkcalendarRequest { struct MkcalendarRequest {
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
@@ -53,7 +54,7 @@ struct MkcalendarRequest {
} }
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug)] #[derive(XmlDeserialize, XmlRootTag, Clone, Debug)]
#[xml(root = b"mkcol")] #[xml(root = "mkcol")]
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
struct MkcolRequest { struct MkcolRequest {
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
@@ -78,31 +79,56 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
_ => unreachable!("We never call with another method"), _ => unreachable!("We never call with another method"),
}; };
if let Some("") = request.displayname.as_deref() { if request.displayname.as_deref() == Some("") {
request.displayname = None request.displayname = None;
} }
let timezone_id = if let Some(tzid) = request.calendar_timezone_id {
Some(tzid)
} else if let Some(tz) = request.calendar_timezone {
// TODO: Proper error (calendar-timezone precondition)
let calendar = IcalParser::from_slice(tz.as_bytes())
.next()
.ok_or_else(|| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?
.map_err(|_| rustical_dav::Error::BadRequest("Error parsing timezone".to_owned()))?;
let timezone = calendar.vtimezones.values().next().ok_or_else(|| {
rustical_dav::Error::BadRequest("No timezone data provided".to_owned())
})?;
let timezone: Option<chrono_tz::Tz> = timezone.into();
let timezone = timezone.ok_or_else(|| {
rustical_dav::Error::BadRequest("Cannot translate VTIMEZONE into IANA TZID".to_owned())
})?;
Some(timezone.name().to_owned())
} else {
None
};
let calendar = Calendar { let calendar = Calendar {
id: cal_id.to_owned(), id: cal_id.clone(),
principal: principal.to_owned(), principal: principal.clone(),
meta: CalendarMetadata {
order: request.calendar_order.unwrap_or(0), order: request.calendar_order.unwrap_or(0),
displayname: request.displayname, displayname: request.displayname,
timezone: request.calendar_timezone,
timezone_id: request.calendar_timezone_id,
color: request.calendar_color, color: request.calendar_color,
description: request.calendar_description, description: request.calendar_description,
},
timezone_id,
deleted_at: None, deleted_at: None,
synctoken: 0, synctoken: 0,
subscription_url: request.source.map(|href| href.href), subscription_url: request.source.map(|href| href.href),
push_topic: uuid::Uuid::new_v4().to_string(), push_topic: uuid::Uuid::new_v4().to_string(),
components: request components: request.supported_calendar_component_set.map_or_else(
.supported_calendar_component_set || {
.map(Into::into) vec![
.unwrap_or(vec![
CalendarObjectType::Event, CalendarObjectType::Event,
CalendarObjectType::Todo, CalendarObjectType::Todo,
CalendarObjectType::Journal, CalendarObjectType::Journal,
]), ]
},
Into::into,
),
}; };
cal_store.insert_calendar(calendar).await?; cal_store.insert_calendar(calendar).await?;

View File

@@ -1,4 +1,5 @@
pub mod get; pub mod get;
pub mod import;
pub mod mkcalendar; pub mod mkcalendar;
pub mod post; pub mod post;
pub mod report; pub mod report;

View File

@@ -25,7 +25,7 @@ pub async fn route_post<C: CalendarStore, S: SubscriptionStore>(
let calendar = resource_service let calendar = resource_service
.cal_store .cal_store
.get_calendar(&principal, &cal_id) .get_calendar(&principal, &cal_id, false)
.await?; .await?;
let calendar_resource = CalendarResource { let calendar_resource = CalendarResource {
cal: calendar, cal: calendar,
@@ -49,12 +49,12 @@ pub async fn route_post<C: CalendarStore, S: SubscriptionStore>(
}; };
let subscription = Subscription { let subscription = Subscription {
id: sub_id.to_owned(), id: sub_id.clone(),
push_resource: request push_resource: request
.subscription .subscription
.web_push_subscription .web_push_subscription
.push_resource .push_resource
.to_owned(), .clone(),
topic: calendar_resource.cal.push_topic, topic: calendar_resource.cal.push_topic,
expiration: expires.naive_local(), expiration: expires.naive_local(),
public_key: request public_key: request

View File

@@ -4,10 +4,10 @@ use rustical_ical::CalendarObject;
use rustical_store::CalendarStore; use rustical_store::CalendarStore;
use rustical_xml::XmlDeserialize; use rustical_xml::XmlDeserialize;
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)] #[allow(dead_code)]
// <!ELEMENT calendar-query ((DAV:allprop | DAV:propname | DAV:prop)?, href+)> // <!ELEMENT calendar-query ((DAV:allprop | DAV:propname | DAV:prop)?, href+)>
pub(crate) struct CalendarMultigetRequest { pub struct CalendarMultigetRequest {
#[xml(ty = "untagged")] #[xml(ty = "untagged")]
pub(crate) prop: PropfindType<CalendarObjectPropWrapperName>, pub(crate) prop: PropfindType<CalendarObjectPropWrapperName>,
#[xml(flatten)] #[xml(flatten)]
@@ -21,26 +21,26 @@ pub async fn get_objects_calendar_multiget<C: CalendarStore>(
principal: &str, principal: &str,
cal_id: &str, cal_id: &str,
store: &C, store: &C,
) -> Result<(Vec<CalendarObject>, Vec<String>), Error> { ) -> Result<(Vec<(String, CalendarObject)>, Vec<String>), Error> {
let mut result = vec![]; let mut result = vec![];
let mut not_found = vec![]; let mut not_found = vec![];
for href in &cal_query.href { for href in &cal_query.href {
if let Some(filename) = href.strip_prefix(path) { if let Ok(href) = percent_encoding::percent_decode_str(href).decode_utf8()
let filename = filename.trim_start_matches("/"); && let Some(filename) = href.strip_prefix(path)
{
let filename = filename.trim_start_matches('/');
if let Some(object_id) = filename.strip_suffix(".ics") { if let Some(object_id) = filename.strip_suffix(".ics") {
match store.get_object(principal, cal_id, object_id, false).await { match store.get_object(principal, cal_id, object_id, false).await {
Ok(object) => result.push(object), Ok(object) => result.push((object_id.to_owned(), object)),
Err(rustical_store::Error::NotFound) => not_found.push(href.to_owned()), Err(rustical_store::Error::NotFound) => not_found.push(href.to_string()),
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
}; }
} else { } else {
not_found.push(href.to_owned()); not_found.push(href.to_string());
continue;
} }
} else { } else {
not_found.push(href.to_owned()); not_found.push(href.to_owned());
continue;
} }
} }

View File

@@ -1,205 +0,0 @@
use crate::{Error, calendar_object::CalendarObjectPropWrapperName};
use rustical_dav::xml::PropfindType;
use rustical_ical::{CalendarObject, UtcDateTime};
use rustical_store::{CalendarStore, calendar_store::CalendarQuery};
use rustical_xml::XmlDeserialize;
use std::ops::Deref;
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
pub(crate) struct TimeRangeElement {
#[xml(ty = "attr")]
pub(crate) start: Option<UtcDateTime>,
#[xml(ty = "attr")]
pub(crate) end: Option<UtcDateTime>,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
struct ParamFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
is_not_defined: Option<()>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
text_match: Option<TextMatchElement>,
#[xml(ty = "attr")]
name: String,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
struct TextMatchElement {
#[xml(ty = "attr")]
collation: String,
#[xml(ty = "attr")]
negate_collation: String,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
pub(crate) struct PropFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
is_not_defined: Option<()>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
time_range: Option<TimeRangeElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
text_match: Option<TextMatchElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
param_filter: Vec<ParamFilterElement>,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
// https://datatracker.ietf.org/doc/html/rfc4791#section-9.7.1
pub(crate) struct CompFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) is_not_defined: Option<()>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) time_range: Option<TimeRangeElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
pub(crate) prop_filter: Vec<PropFilterElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
pub(crate) comp_filter: Vec<CompFilterElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", ty = "attr")]
pub(crate) name: String,
}
impl CompFilterElement {
// match the VCALENDAR part
pub fn matches_root(&self, cal_object: &CalendarObject) -> bool {
let comp_vcal = self.name == "VCALENDAR";
match (self.is_not_defined, comp_vcal) {
// Client wants VCALENDAR to not exist but we are a VCALENDAR
(Some(()), true) => return false,
// Client is asking for something different than a vcalendar
(None, false) => return false,
_ => {}
};
if self.time_range.is_some() {
// <time-range> should be applied on VEVENT/VTODO but not on VCALENDAR
return false;
}
// TODO: Implement prop-filter at some point
// Apply sub-comp-filters on VEVENT/VTODO/VJOURNAL component
if self
.comp_filter
.iter()
.all(|filter| filter.matches(cal_object))
{
return true;
}
false
}
// match the VEVENT/VTODO/VJOURNAL part
pub fn matches(&self, cal_object: &CalendarObject) -> bool {
let comp_name_matches = self.name == cal_object.get_component_name();
match (self.is_not_defined, comp_name_matches) {
// Client wants VCALENDAR to not exist but we are a VCALENDAR
(Some(()), true) => return false,
// Client is asking for something different than a vcalendar
(None, false) => return false,
_ => {}
};
// TODO: Implement prop-filter (and comp-filter?) at some point
if let Some(time_range) = &self.time_range {
if let Some(start) = &time_range.start {
if let Some(last_occurence) = cal_object.get_last_occurence().unwrap_or(None) {
if start.deref() > &last_occurence.utc() {
return false;
}
};
}
if let Some(end) = &time_range.end {
if let Some(first_occurence) = cal_object.get_first_occurence().unwrap_or(None) {
if end.deref() < &first_occurence.utc() {
return false;
}
};
}
}
true
}
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
// https://datatracker.ietf.org/doc/html/rfc4791#section-9.7
pub(crate) struct FilterElement {
// This comp-filter matches on VCALENDAR
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) comp_filter: CompFilterElement,
}
impl FilterElement {
pub fn matches(&self, cal_object: &CalendarObject) -> bool {
self.comp_filter.matches_root(cal_object)
}
}
impl From<&FilterElement> for CalendarQuery {
fn from(value: &FilterElement) -> Self {
let comp_filter_vcalendar = &value.comp_filter;
for comp_filter in comp_filter_vcalendar.comp_filter.iter() {
// A calendar object cannot contain both VEVENT and VTODO, so we only have to handle
// whatever we get first
if matches!(comp_filter.name.as_str(), "VEVENT" | "VTODO") {
if let Some(time_range) = &comp_filter.time_range {
let start = time_range.start.as_ref().map(|start| start.date_naive());
let end = time_range.end.as_ref().map(|end| end.date_naive());
return CalendarQuery {
time_start: start,
time_end: end,
};
}
}
}
Default::default()
}
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
// <!ELEMENT calendar-query ((DAV:allprop | DAV:propname | DAV:prop)?, filter, timezone?)>
pub struct CalendarQueryRequest {
#[xml(ty = "untagged")]
pub prop: PropfindType<CalendarObjectPropWrapperName>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) filter: Option<FilterElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) timezone: Option<String>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) timezone_id: Option<String>,
}
impl From<&CalendarQueryRequest> for CalendarQuery {
fn from(value: &CalendarQueryRequest) -> Self {
value
.filter
.as_ref()
.map(CalendarQuery::from)
.unwrap_or_default()
}
}
pub async fn get_objects_calendar_query<C: CalendarStore>(
cal_query: &CalendarQueryRequest,
principal: &str,
cal_id: &str,
store: &C,
) -> Result<Vec<CalendarObject>, Error> {
let mut objects = store
.calendar_query(principal, cal_id, cal_query.into())
.await?;
if let Some(filter) = &cal_query.filter {
objects.retain(|object| filter.matches(object));
}
Ok(objects)
}

View File

@@ -0,0 +1,430 @@
use crate::calendar::methods::report::calendar_query::{
TimeRangeElement,
prop_filter::{PropFilterElement, PropFilterable},
};
use ical::{
component::{CalendarInnerData, IcalAlarm, IcalCalendarObject, IcalEvent, IcalTodo},
parser::{Component, ical::component::IcalTimeZone},
};
use rustical_xml::XmlDeserialize;
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
// https://datatracker.ietf.org/doc/html/rfc4791#section-9.7.1
pub struct CompFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) is_not_defined: Option<()>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) time_range: Option<TimeRangeElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
pub(crate) prop_filter: Vec<PropFilterElement>,
#[allow(clippy::use_self)]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
pub(crate) comp_filter: Vec<CompFilterElement>,
#[xml(ty = "attr")]
pub(crate) name: String,
}
pub trait CompFilterable: PropFilterable + Sized {
fn get_comp_name(&self) -> &'static str;
fn match_time_range(&self, time_range: &TimeRangeElement) -> bool;
fn match_subcomponents(&self, comp_filter: &CompFilterElement) -> bool;
// https://datatracker.ietf.org/doc/html/rfc4791#section-9.7.1
// The scope of the
// CALDAV:comp-filter XML element is the calendar object when used as
// a child of the CALDAV:filter XML element. The scope of the
// CALDAV:comp-filter XML element is the enclosing calendar component
// when used as a child of another CALDAV:comp-filter XML element
fn matches(&self, comp_filter: &CompFilterElement) -> bool {
let name_matches = self.get_comp_name() == comp_filter.name;
match (comp_filter.is_not_defined.is_some(), name_matches) {
// We are the component that's not supposed to be defined
(true, true)
// We don't match
| (false, false) => return false,
// We shall not be and indeed we aren't
(true, false) => return true,
_ => {}
}
if let Some(time_range) = comp_filter.time_range.as_ref()
&& !self.match_time_range(time_range)
{
return false;
}
for prop_filter in &comp_filter.prop_filter {
if !prop_filter.match_component(self) {
return false;
}
}
comp_filter
.comp_filter
.iter()
.all(|filter| self.match_subcomponents(filter))
}
}
impl CompFilterable for CalendarInnerData {
fn get_comp_name(&self) -> &'static str {
match self {
Self::Event(main, _) => main.get_comp_name(),
Self::Journal(main, _) => main.get_comp_name(),
Self::Todo(main, _) => main.get_comp_name(),
}
}
fn match_time_range(&self, time_range: &TimeRangeElement) -> bool {
if let Some(start) = &time_range.start
&& let Some(last_end) = self.get_last_occurence()
&& start.to_utc() > last_end.utc()
{
return false;
}
if let Some(end) = &time_range.end
&& let Some(first_start) = self.get_first_occurence()
&& end.to_utc() < first_start.utc()
{
return false;
}
true
}
fn match_subcomponents(&self, comp_filter: &CompFilterElement) -> bool {
match self {
Self::Event(main, overrides) => std::iter::once(main)
.chain(overrides.iter())
.flat_map(IcalEvent::get_alarms)
.any(|alarm| alarm.matches(comp_filter)),
Self::Todo(main, overrides) => std::iter::once(main)
.chain(overrides.iter())
.flat_map(IcalTodo::get_alarms)
.any(|alarm| alarm.matches(comp_filter)),
// VJOURNAL has no subcomponents
Self::Journal(_, _) => comp_filter.is_not_defined.is_some(),
}
}
}
impl PropFilterable for IcalAlarm {
fn get_named_properties<'a>(
&'a self,
name: &'a str,
) -> impl Iterator<Item = &'a ical::property::ContentLine> {
Component::get_named_properties(self, name)
}
}
impl CompFilterable for IcalAlarm {
fn get_comp_name(&self) -> &'static str {
Component::get_comp_name(self)
}
fn match_time_range(&self, _time_range: &TimeRangeElement) -> bool {
true
}
fn match_subcomponents(&self, comp_filter: &CompFilterElement) -> bool {
comp_filter.is_not_defined.is_some()
}
}
impl PropFilterable for CalendarInnerData {
#[allow(refining_impl_trait)]
fn get_named_properties<'a>(
&'a self,
name: &'a str,
) -> Box<dyn Iterator<Item = &'a ical::property::ContentLine> + 'a> {
// TODO: If we were pedantic, we would have to do recurrence expansion first
// and take into account the overrides :(
match self {
Self::Event(main, _) => Box::new(main.get_named_properties(name)),
Self::Todo(main, _) => Box::new(main.get_named_properties(name)),
Self::Journal(main, _) => Box::new(main.get_named_properties(name)),
}
}
}
impl PropFilterable for IcalCalendarObject {
fn get_named_properties<'a>(
&'a self,
name: &'a str,
) -> impl Iterator<Item = &'a ical::property::ContentLine> {
Component::get_named_properties(self, name)
}
}
impl CompFilterable for IcalCalendarObject {
fn get_comp_name(&self) -> &'static str {
Component::get_comp_name(self)
}
fn match_time_range(&self, _time_range: &TimeRangeElement) -> bool {
// VCALENDAR has no concept of time range
false
}
fn match_subcomponents(&self, comp_filter: &CompFilterElement) -> bool {
let mut matches = self
.get_vtimezones()
.values()
.map(|tz| tz.matches(comp_filter))
.chain([self.get_inner().matches(comp_filter)]);
if comp_filter.is_not_defined.is_some() {
matches.all(|x| !x)
} else {
matches.any(|x| x)
}
}
}
impl PropFilterable for IcalTimeZone {
fn get_named_properties<'a>(
&'a self,
name: &'a str,
) -> impl Iterator<Item = &'a ical::property::ContentLine> {
Component::get_named_properties(self, name)
}
}
impl CompFilterable for IcalTimeZone {
fn get_comp_name(&self) -> &'static str {
Component::get_comp_name(self)
}
fn match_time_range(&self, _time_range: &TimeRangeElement) -> bool {
false
}
fn match_subcomponents(&self, comp_filter: &CompFilterElement) -> bool {
// VTIMEZONE has no subcomponents
comp_filter.is_not_defined.is_some()
}
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use rustical_dav::xml::{MatchType, NegateCondition, TextCollation, TextMatchElement};
use rustical_ical::{CalendarObject, UtcDateTime};
use crate::calendar::methods::report::calendar_query::{
CompFilterElement, CompFilterable, PropFilterElement, TimeRangeElement,
};
const ICS: &str = r"BEGIN:VCALENDAR
CALSCALE:GREGORIAN
VERSION:2.0
PRODID:me
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
END:VTIMEZONE
BEGIN:VEVENT
UID:318ec6503573d9576818daf93dac07317058d95c
DTSTAMP:20250502T132758Z
DTSTART;TZID=Europe/Berlin:20250506T090000
DTEND;TZID=Europe/Berlin:20250506T092500
SEQUENCE:2
SUMMARY:weekly stuff
TRANSP:OPAQUE
RRULE:FREQ=WEEKLY;COUNT=4;INTERVAL=2;BYDAY=TU,TH,SU
END:VEVENT
END:VCALENDAR";
#[test]
fn test_comp_filter_matching() {
let object = CalendarObject::from_ics(ICS.to_string()).unwrap();
let comp_filter = CompFilterElement {
is_not_defined: Some(()),
name: "VCALENDAR".to_string(),
time_range: None,
prop_filter: vec![],
comp_filter: vec![],
};
assert!(
!object.get_inner().matches(&comp_filter),
"filter: wants no VCALENDAR"
);
let comp_filter = CompFilterElement {
is_not_defined: None,
name: "VCALENDAR".to_string(),
time_range: None,
prop_filter: vec![],
comp_filter: vec![CompFilterElement {
name: "VTODO".to_string(),
is_not_defined: None,
time_range: None,
prop_filter: vec![],
comp_filter: vec![],
}],
};
assert!(
!object.get_inner().matches(&comp_filter),
"filter matches VTODO"
);
let comp_filter = CompFilterElement {
is_not_defined: None,
name: "VCALENDAR".to_string(),
time_range: None,
prop_filter: vec![],
comp_filter: vec![CompFilterElement {
name: "VEVENT".to_string(),
is_not_defined: None,
time_range: None,
prop_filter: vec![],
comp_filter: vec![],
}],
};
assert!(
object.get_inner().matches(&comp_filter),
"filter matches VEVENT"
);
let comp_filter = CompFilterElement {
is_not_defined: None,
name: "VCALENDAR".to_string(),
time_range: None,
prop_filter: vec![
PropFilterElement {
is_not_defined: None,
name: "VERSION".to_string(),
time_range: None,
text_match: Some(TextMatchElement {
match_type: MatchType::Contains,
needle: "2.0".to_string(),
collation: TextCollation::default(),
negate_condition: NegateCondition::default(),
}),
param_filter: vec![],
},
PropFilterElement {
is_not_defined: Some(()),
name: "STUFF".to_string(),
time_range: None,
text_match: None,
param_filter: vec![],
},
],
comp_filter: vec![CompFilterElement {
name: "VEVENT".to_string(),
is_not_defined: None,
time_range: None,
prop_filter: vec![PropFilterElement {
is_not_defined: None,
name: "SUMMARY".to_string(),
time_range: None,
text_match: Some(TextMatchElement {
match_type: MatchType::Contains,
collation: TextCollation::default(),
negate_condition: NegateCondition(false),
needle: "weekly".to_string(),
}),
param_filter: vec![],
}],
comp_filter: vec![],
}],
};
assert!(
object.get_inner().matches(&comp_filter),
"Some prop filters on VCALENDAR and VEVENT"
);
}
#[test]
fn test_comp_filter_time_range() {
let object = CalendarObject::from_ics(ICS.to_string()).unwrap();
let comp_filter = CompFilterElement {
is_not_defined: None,
name: "VCALENDAR".to_string(),
time_range: None,
prop_filter: vec![],
comp_filter: vec![CompFilterElement {
name: "VEVENT".to_string(),
is_not_defined: None,
time_range: Some(TimeRangeElement {
start: Some(UtcDateTime(
Utc.with_ymd_and_hms(2025, 4, 1, 0, 0, 0).unwrap(),
)),
end: Some(UtcDateTime(
Utc.with_ymd_and_hms(2025, 8, 1, 0, 0, 0).unwrap(),
)),
}),
prop_filter: vec![],
comp_filter: vec![],
}],
};
assert!(
object.get_inner().matches(&comp_filter),
"event should lie in time range"
);
let comp_filter = CompFilterElement {
is_not_defined: None,
name: "VCALENDAR".to_string(),
time_range: None,
prop_filter: vec![],
comp_filter: vec![CompFilterElement {
name: "VEVENT".to_string(),
is_not_defined: None,
time_range: Some(TimeRangeElement {
start: Some(UtcDateTime(
Utc.with_ymd_and_hms(2024, 4, 1, 0, 0, 0).unwrap(),
)),
end: Some(UtcDateTime(
Utc.with_ymd_and_hms(2024, 8, 1, 0, 0, 0).unwrap(),
)),
}),
prop_filter: vec![],
comp_filter: vec![],
}],
};
assert!(
!object.get_inner().matches(&comp_filter),
"event should not lie in time range"
);
}
#[test]
fn test_match_timezone() {
let object = CalendarObject::from_ics(ICS.to_string()).unwrap();
let comp_filter = CompFilterElement {
is_not_defined: None,
name: "VCALENDAR".to_string(),
time_range: None,
prop_filter: vec![],
comp_filter: vec![CompFilterElement {
name: "VTIMEZONE".to_string(),
is_not_defined: None,
time_range: None,
prop_filter: vec![PropFilterElement {
is_not_defined: None,
name: "TZID".to_string(),
time_range: None,
text_match: Some(TextMatchElement {
match_type: MatchType::Contains,
collation: TextCollation::AsciiCasemap,
negate_condition: NegateCondition::default(),
needle: "Europe/Berlin".to_string(),
}),
param_filter: vec![],
}],
comp_filter: vec![],
}],
};
assert!(
object.get_inner().matches(&comp_filter),
"Timezone should be Europe/Berlin"
);
}
}

View File

@@ -0,0 +1,146 @@
use super::comp_filter::{CompFilterElement, CompFilterable};
use crate::calendar_object::CalendarObjectPropWrapperName;
use ical::{component::IcalCalendarObject, property::ContentLine};
use rustical_dav::xml::{PropfindType, TextMatchElement};
use rustical_ical::UtcDateTime;
use rustical_store::calendar_store::CalendarQuery;
use rustical_xml::{XmlDeserialize, XmlRootTag};
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
pub struct TimeRangeElement {
#[xml(ty = "attr")]
pub(crate) start: Option<UtcDateTime>,
#[xml(ty = "attr")]
pub(crate) end: Option<UtcDateTime>,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
// https://www.rfc-editor.org/rfc/rfc4791#section-9.7.3
pub struct ParamFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) is_not_defined: Option<()>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) text_match: Option<TextMatchElement>,
#[xml(ty = "attr")]
pub(crate) name: String,
}
impl ParamFilterElement {
#[must_use]
pub fn match_property(&self, prop: &ContentLine) -> bool {
let Some(param) = prop.params.get_param(&self.name) else {
return self.is_not_defined.is_some();
};
if self.is_not_defined.is_some() {
return false;
}
let Some(text_match) = self.text_match.as_ref() else {
return true;
};
text_match.match_text(param)
}
}
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug, PartialEq)]
#[xml(root = "filter", ns = "rustical_dav::namespace::NS_CALDAV")]
#[allow(dead_code)]
// https://datatracker.ietf.org/doc/html/rfc4791#section-9.7
pub struct FilterElement {
// This comp-filter matches on VCALENDAR
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) comp_filter: CompFilterElement,
}
impl FilterElement {
#[must_use]
pub fn matches(&self, cal_object: &IcalCalendarObject) -> bool {
cal_object.matches(&self.comp_filter)
}
}
impl From<&FilterElement> for CalendarQuery {
fn from(value: &FilterElement) -> Self {
let comp_filter_vcalendar = &value.comp_filter;
for comp_filter in &comp_filter_vcalendar.comp_filter {
// A calendar object cannot contain both VEVENT and VTODO, so we only have to handle
// whatever we get first
if matches!(comp_filter.name.as_str(), "VEVENT" | "VTODO")
&& let Some(time_range) = &comp_filter.time_range
{
let start = time_range.start.as_ref().map(|start| start.date_naive());
let end = time_range.end.as_ref().map(|end| end.date_naive());
return Self {
time_start: start,
time_end: end,
};
}
}
Self::default()
}
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
// <!ELEMENT calendar-query ((DAV:allprop | DAV:propname | DAV:prop)?, filter, timezone?)>
pub struct CalendarQueryRequest {
#[xml(ty = "untagged")]
pub prop: PropfindType<CalendarObjectPropWrapperName>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) filter: Option<FilterElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) timezone: Option<String>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) timezone_id: Option<String>,
}
impl From<&CalendarQueryRequest> for CalendarQuery {
fn from(value: &CalendarQueryRequest) -> Self {
value.filter.as_ref().map(Self::from).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use crate::calendar::methods::report::calendar_query::{
CompFilterElement, FilterElement, TimeRangeElement,
};
use chrono::{NaiveDate, TimeZone, Utc};
use rustical_ical::UtcDateTime;
use rustical_store::calendar_store::CalendarQuery;
#[test]
fn test_filter_element_calendar_query() {
let filter = FilterElement {
comp_filter: CompFilterElement {
name: "VCALENDAR".to_string(),
is_not_defined: None,
time_range: None,
prop_filter: vec![],
comp_filter: vec![CompFilterElement {
name: "VEVENT".to_string(),
is_not_defined: None,
time_range: Some(TimeRangeElement {
start: Some(UtcDateTime(
Utc.with_ymd_and_hms(2024, 4, 1, 0, 0, 0).unwrap(),
)),
end: Some(UtcDateTime(
Utc.with_ymd_and_hms(2024, 8, 1, 0, 0, 0).unwrap(),
)),
}),
prop_filter: vec![],
comp_filter: vec![],
}],
},
};
let derived_query: CalendarQuery = (&filter).into();
let query = CalendarQuery {
time_start: Some(NaiveDate::from_ymd_opt(2024, 4, 1).unwrap()),
time_end: Some(NaiveDate::from_ymd_opt(2024, 8, 1).unwrap()),
};
assert_eq!(derived_query, query);
}
}

View File

@@ -0,0 +1,131 @@
use crate::Error;
use rustical_ical::CalendarObject;
use rustical_store::CalendarStore;
mod comp_filter;
mod elements;
mod prop_filter;
#[cfg(test)]
mod tests;
#[allow(unused_imports)]
pub use comp_filter::{CompFilterElement, CompFilterable};
pub use elements::*;
#[allow(unused_imports)]
pub use prop_filter::PropFilterElement;
pub async fn get_objects_calendar_query<C: CalendarStore>(
cal_query: &CalendarQueryRequest,
principal: &str,
cal_id: &str,
store: &C,
) -> Result<Vec<(String, CalendarObject)>, Error> {
let mut objects = store
.calendar_query(principal, cal_id, cal_query.into())
.await?;
if let Some(filter) = &cal_query.filter {
objects.retain(|(_id, object)| filter.matches(object.get_inner()));
}
Ok(objects)
}
#[cfg(test)]
mod xml_tests {
use super::{
CalendarQueryRequest, FilterElement, ParamFilterElement, comp_filter::CompFilterElement,
prop_filter::PropFilterElement,
};
use crate::{
calendar::methods::report::ReportRequest,
calendar_object::{CalendarData, CalendarObjectPropName, CalendarObjectPropWrapperName},
};
use rustical_dav::xml::{
MatchType, NegateCondition, PropElement, TextCollation, TextMatchElement,
};
use rustical_xml::XmlDocument;
#[test]
fn calendar_query_7_8_7() {
const INPUT: &str = r#"
<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop xmlns:D="DAV:">
<D:getetag/>
<C:calendar-data/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:prop-filter name="ATTENDEE">
<C:text-match collation="i;ascii-casemap">mailto:lisa@example.com</C:text-match>
<C:param-filter name="PARTSTAT">
<C:text-match collation="i;ascii-casemap">NEEDS-ACTION</C:text-match>
</C:param-filter>
</C:prop-filter>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"#;
let report = ReportRequest::parse_str(INPUT).unwrap();
let calendar_query: CalendarQueryRequest =
if let ReportRequest::CalendarQuery(query) = report {
query
} else {
panic!()
};
assert_eq!(
calendar_query,
CalendarQueryRequest {
prop: rustical_dav::xml::PropfindType::Prop(PropElement(
vec![
CalendarObjectPropWrapperName::CalendarObject(
CalendarObjectPropName::Getetag,
),
CalendarObjectPropWrapperName::CalendarObject(
CalendarObjectPropName::CalendarData(CalendarData::default())
),
],
vec![]
)),
filter: Some(FilterElement {
comp_filter: CompFilterElement {
is_not_defined: None,
time_range: None,
prop_filter: vec![],
comp_filter: vec![CompFilterElement {
prop_filter: vec![PropFilterElement {
name: "ATTENDEE".to_owned(),
text_match: Some(TextMatchElement {
match_type: MatchType::Contains,
collation: TextCollation::AsciiCasemap,
negate_condition: NegateCondition(false),
needle: "mailto:lisa@example.com".to_string()
}),
is_not_defined: None,
param_filter: vec![ParamFilterElement {
is_not_defined: None,
name: "PARTSTAT".to_owned(),
text_match: Some(TextMatchElement {
match_type: MatchType::Contains,
collation: TextCollation::AsciiCasemap,
negate_condition: NegateCondition(false),
needle: "NEEDS-ACTION".to_string()
}),
}],
time_range: None
}],
comp_filter: vec![],
is_not_defined: None,
name: "VEVENT".to_owned(),
time_range: None
}],
name: "VCALENDAR".to_owned()
}
}),
timezone: None,
timezone_id: None
}
);
}
}

View File

@@ -0,0 +1,77 @@
use super::{ParamFilterElement, TimeRangeElement};
use ical::{property::ContentLine, types::CalDateTime};
use rustical_dav::xml::TextMatchElement;
use rustical_ical::UtcDateTime;
use rustical_xml::XmlDeserialize;
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
// https://www.rfc-editor.org/rfc/rfc4791#section-9.7.2
pub struct PropFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) is_not_defined: Option<()>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) time_range: Option<TimeRangeElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) text_match: Option<TextMatchElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
pub(crate) param_filter: Vec<ParamFilterElement>,
#[xml(ty = "attr")]
pub(crate) name: String,
}
pub trait PropFilterable {
fn get_named_properties<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a ContentLine>;
}
impl PropFilterElement {
#[must_use]
pub fn match_property(&self, property: &ContentLine) -> bool {
if let Some(TimeRangeElement { start, end }) = &self.time_range {
// TODO: Respect timezones
let Ok(timestamp) = CalDateTime::parse_prop(property, None) else {
return false;
};
let timestamp = timestamp.utc();
if let Some(UtcDateTime(start)) = start
&& start > &timestamp
{
return false;
}
if let Some(UtcDateTime(end)) = end
&& end < &timestamp
{
return false;
}
return true;
}
if let Some(text_match) = &self.text_match
&& !text_match.match_property(property)
{
return false;
}
if !self
.param_filter
.iter()
.all(|param_filter| param_filter.match_property(property))
{
return false;
}
true
}
pub fn match_component(&self, comp: &impl PropFilterable) -> bool {
let mut properties = comp.get_named_properties(&self.name);
if self.is_not_defined.is_some() {
return properties.next().is_none();
}
// The filter matches when one property instance matches
// Example where this matters: We have multiple attendees and want to match one
properties.any(|prop| self.match_property(prop))
}
}

View File

@@ -0,0 +1,83 @@
use super::FilterElement;
use rstest::rstest;
use rustical_ical::CalendarObject;
use rustical_xml::XmlDocument;
const ICS_1: &str = r"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com
DTSTAMP:20060206T001220Z
DTSTART;TZID=US/Eastern:20060104T100000
DURATION:PT1H
LAST-MODIFIED:20060206T001330Z
ORGANIZER:mailto:cyrus@example.com
SEQUENCE:1
STATUS:TENTATIVE
SUMMARY:Event #3
UID:DC6C50A017428C5216A2F1CD@example.com
X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com
END:VEVENT
END:VCALENDAR
";
const FILTER_1: &str = r#"
<?xml version="1.0" encoding="utf-8" ?>
<C:filter xmlns:C="urn:ietf:params:xml:ns:caldav">
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:prop-filter name="ATTENDEE">
<C:text-match collation="i;ascii-casemap">mailto:lisa@example.com</C:text-match>
<C:param-filter name="PARTSTAT">
<C:text-match collation="i;ascii-casemap">NEEDS-ACTION</C:text-match>
</C:param-filter>
</C:prop-filter>
</C:comp-filter>
</C:comp-filter>
</C:filter>
"#;
const FILTER_2: &str = r#"
<?xml version="1.0" encoding="utf-8" ?>
<C:filter xmlns:C="urn:ietf:params:xml:ns:caldav">
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:prop-filter name="ATTENDEE">
<C:text-match collation="i;ascii-casemap">mailto:lisa@example.com</C:text-match>
<C:param-filter name="PARTSTAT">
<C:text-match collation="i;ascii-casemap">ACCEPTED</C:text-match>
</C:param-filter>
</C:prop-filter>
</C:comp-filter>
</C:comp-filter>
</C:filter>
"#;
#[rstest]
#[case(ICS_1, FILTER_1, true)]
#[case(ICS_1, FILTER_2, false)]
fn yeet(#[case] ics: &str, #[case] filter: &str, #[case] matches: bool) {
let obj = CalendarObject::from_ics(ics.to_owned()).unwrap();
let filter = FilterElement::parse_str(filter).unwrap();
assert_eq!(matches, filter.matches(obj.get_inner()));
}

View File

@@ -27,7 +27,7 @@ use sync_collection::handle_sync_collection;
use tracing::instrument; use tracing::instrument;
mod calendar_multiget; mod calendar_multiget;
mod calendar_query; pub mod calendar_query;
mod sync_collection; mod sync_collection;
#[derive(XmlDeserialize, XmlDocument, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, XmlDocument, Clone, Debug, PartialEq)]
@@ -41,17 +41,17 @@ pub(crate) enum ReportRequest {
} }
impl ReportRequest { impl ReportRequest {
fn props(&self) -> &PropfindType<CalendarObjectPropWrapperName> { const fn props(&self) -> &PropfindType<CalendarObjectPropWrapperName> {
match &self { match &self {
ReportRequest::CalendarMultiget(CalendarMultigetRequest { prop, .. }) => prop, Self::CalendarMultiget(CalendarMultigetRequest { prop, .. })
ReportRequest::CalendarQuery(CalendarQueryRequest { prop, .. }) => prop, | Self::CalendarQuery(CalendarQueryRequest { prop, .. })
ReportRequest::SyncCollection(SyncCollectionRequest { prop, .. }) => prop, | Self::SyncCollection(SyncCollectionRequest { prop, .. }) => prop,
} }
} }
} }
fn objects_response( fn objects_response(
objects: Vec<CalendarObject>, objects: Vec<(String, CalendarObject)>,
not_found: Vec<String>, not_found: Vec<String>,
path: &str, path: &str,
principal: &str, principal: &str,
@@ -60,14 +60,15 @@ fn objects_response(
prop: &PropfindType<CalendarObjectPropWrapperName>, prop: &PropfindType<CalendarObjectPropWrapperName>,
) -> Result<MultistatusElement<CalendarObjectPropWrapper, String>, Error> { ) -> Result<MultistatusElement<CalendarObjectPropWrapper, String>, Error> {
let mut responses = Vec::new(); let mut responses = Vec::new();
for object in objects { for (object_id, object) in objects {
let path = format!("{}/{}.ics", path, object.get_id()); let path = format!("{path}/{object_id}.ics");
responses.push( responses.push(
CalendarObjectResource { CalendarObjectResource {
object, object,
object_id,
principal: principal.to_owned(), principal: principal.to_owned(),
} }
.propfind(&path, prop, puri, user)?, .propfind(&path, prop, None, puri, user)?,
); );
} }
@@ -174,7 +175,7 @@ mod tests {
prop: rustical_dav::xml::PropfindType::Prop(PropElement(vec![ prop: rustical_dav::xml::PropfindType::Prop(PropElement(vec![
CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::Getetag), CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::Getetag),
CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::CalendarData( CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::CalendarData(
CalendarData { comp: None, expand: Some(ExpandElement { CalendarData { comp: None, prop: None, expand: Some(ExpandElement {
start: <UtcDateTime as ValueDeserialize>::deserialize("20250426T220000Z").unwrap(), start: <UtcDateTime as ValueDeserialize>::deserialize("20250426T220000Z").unwrap(),
end: <UtcDateTime as ValueDeserialize>::deserialize("20250503T220000Z").unwrap(), end: <UtcDateTime as ValueDeserialize>::deserialize("20250503T220000Z").unwrap(),
}), limit_recurrence_set: None, limit_freebusy_set: None } }), limit_recurrence_set: None, limit_freebusy_set: None }
@@ -184,7 +185,7 @@ mod tests {
"/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b".to_owned() "/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b".to_owned()
] ]
}) })
) );
} }
#[test] #[test]
@@ -241,7 +242,7 @@ mod tests {
timezone: None, timezone: None,
timezone_id: None, timezone_id: None,
}) })
) );
} }
#[test] #[test]
@@ -269,6 +270,6 @@ mod tests {
"/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b".to_owned() "/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b".to_owned()
] ]
}) })
) );
} }
} }

View File

@@ -32,14 +32,15 @@ pub async fn handle_sync_collection<C: CalendarStore>(
.await?; .await?;
let mut responses = Vec::new(); let mut responses = Vec::new();
for object in new_objects { for (object_id, object) in new_objects {
let path = format!("{}/{}.ics", path, object.get_id()); let path = format!("{}/{}.ics", path, &object_id);
responses.push( responses.push(
CalendarObjectResource { CalendarObjectResource {
object, object,
object_id,
principal: principal.to_owned(), principal: principal.to_owned(),
} }
.propfind(&path, &sync_collection.prop, puri, user)?, .propfind(&path, &sync_collection.prop, None, puri, user)?,
); );
} }

View File

@@ -4,3 +4,6 @@ pub mod resource;
mod service; mod service;
pub use service::CalendarResourceService; pub use service::CalendarResourceService;
#[cfg(test)]
pub mod tests;

View File

@@ -1,15 +1,16 @@
use derive_more::derive::{From, Into}; use derive_more::derive::{From, Into};
use rustical_dav::xml::TextCollation;
use rustical_ical::CalendarObjectType; use rustical_ical::CalendarObjectType;
use rustical_xml::{XmlDeserialize, XmlSerialize}; use rustical_xml::{XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray; use strum_macros::VariantArray;
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, From, Into)] #[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, Eq, From, Into)]
pub struct SupportedCalendarComponent { pub struct SupportedCalendarComponent {
#[xml(ty = "attr")] #[xml(ty = "attr")]
pub name: CalendarObjectType, pub name: CalendarObjectType,
} }
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq)] #[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, Eq)]
pub struct SupportedCalendarComponentSet { pub struct SupportedCalendarComponentSet {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)] #[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
pub comp: Vec<SupportedCalendarComponent>, pub comp: Vec<SupportedCalendarComponent>,
@@ -36,7 +37,30 @@ impl From<SupportedCalendarComponentSet> for Vec<CalendarObjectType> {
} }
} }
#[derive(Debug, Clone, XmlSerialize, PartialEq)] #[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, Eq, From, Into)]
pub struct SupportedCollation(#[xml(ty = "text")] pub TextCollation);
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, Eq)]
pub struct SupportedCollationSet(
#[xml(
ns = "rustical_dav::namespace::NS_CALDAV",
flatten,
rename = "supported-collation"
)]
pub Vec<SupportedCollation>,
);
impl Default for SupportedCollationSet {
fn default() -> Self {
Self(vec![
SupportedCollation(TextCollation::AsciiCasemap),
SupportedCollation(TextCollation::UnicodeCasemap),
SupportedCollation(TextCollation::Octet),
])
}
}
#[derive(Debug, Clone, XmlSerialize, PartialEq, Eq)]
pub struct CalendarData { pub struct CalendarData {
#[xml(ty = "attr")] #[xml(ty = "attr")]
content_type: String, content_type: String,
@@ -53,13 +77,13 @@ impl Default for CalendarData {
} }
} }
#[derive(Debug, Clone, XmlSerialize, Default, PartialEq)] #[derive(Debug, Clone, XmlSerialize, Default, PartialEq, Eq)]
pub struct SupportedCalendarData { pub struct SupportedCalendarData {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
calendar_data: CalendarData, calendar_data: CalendarData,
} }
#[derive(Debug, Clone, XmlSerialize, PartialEq, VariantArray)] #[derive(Debug, Clone, XmlSerialize, PartialEq, Eq, VariantArray)]
pub enum ReportMethod { pub enum ReportMethod {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarQuery, CalendarQuery,

View File

@@ -1,8 +1,10 @@
use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData}; use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData};
use crate::Error; use crate::Error;
use crate::calendar::prop::ReportMethod; use crate::calendar::prop::{ReportMethod, SupportedCollationSet};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use derive_more::derive::{From, Into}; use derive_more::derive::{From, Into};
use ical::IcalParser;
use ical::types::CalDateTime;
use rustical_dav::extensions::{ use rustical_dav::extensions::{
CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp, CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp,
}; };
@@ -10,14 +12,14 @@ use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName}; use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner, SupportedReportSet}; use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner, SupportedReportSet};
use rustical_dav_push::{DavPushExtension, DavPushExtensionProp}; use rustical_dav_push::{DavPushExtension, DavPushExtensionProp};
use rustical_ical::CalDateTime;
use rustical_store::Calendar; use rustical_store::Calendar;
use rustical_store::auth::Principal; use rustical_store::auth::Principal;
use rustical_xml::{EnumVariants, PropName}; use rustical_xml::{EnumVariants, PropName};
use rustical_xml::{XmlDeserialize, XmlSerialize}; use rustical_xml::{XmlDeserialize, XmlSerialize};
use std::str::FromStr; use serde::Deserialize;
use std::borrow::Cow;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "CalendarPropName")] #[xml(unit_variants_ident = "CalendarPropName")]
pub enum CalendarProp { pub enum CalendarProp {
// CalDAV (RFC 4791) // CalDAV (RFC 4791)
@@ -34,11 +36,13 @@ pub enum CalendarProp {
CalendarTimezoneId(Option<String>), CalendarTimezoneId(Option<String>),
#[xml(ns = "rustical_dav::namespace::NS_ICAL")] #[xml(ns = "rustical_dav::namespace::NS_ICAL")]
CalendarOrder(Option<i64>), CalendarOrder(Option<i64>),
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
SupportedCalendarComponentSet(SupportedCalendarComponentSet), SupportedCalendarComponentSet(SupportedCalendarComponentSet),
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)] #[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)]
SupportedCalendarData(SupportedCalendarData), SupportedCalendarData(SupportedCalendarData),
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)]
SupportedCollationSet(SupportedCollationSet),
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
MaxResourceSize(i64), MaxResourceSize(i64),
#[xml(skip_deserializing)] #[xml(skip_deserializing)]
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
@@ -53,7 +57,7 @@ pub enum CalendarProp {
MaxDateTime(String), MaxDateTime(String),
} }
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "CalendarPropWrapperName", untagged)] #[xml(unit_variants_ident = "CalendarPropWrapperName", untagged)]
pub enum CalendarPropWrapper { pub enum CalendarPropWrapper {
Calendar(CalendarProp), Calendar(CalendarProp),
@@ -62,15 +66,15 @@ pub enum CalendarPropWrapper {
Common(CommonPropertiesProp), Common(CommonPropertiesProp),
} }
#[derive(Clone, Debug, From, Into)] #[derive(Clone, Debug, From, Into, Deserialize)]
pub struct CalendarResource { pub struct CalendarResource {
pub cal: Calendar, pub cal: Calendar,
pub read_only: bool, pub read_only: bool,
} }
impl ResourceName for CalendarResource { impl ResourceName for CalendarResource {
fn get_name(&self) -> String { fn get_name(&self) -> Cow<'_, str> {
self.cal.id.to_owned() Cow::from(&self.cal.id)
} }
} }
@@ -88,7 +92,7 @@ impl SyncTokenExtension for CalendarResource {
impl DavPushExtension for CalendarResource { impl DavPushExtension for CalendarResource {
fn get_topic(&self) -> String { fn get_topic(&self) -> String {
self.cal.push_topic.to_owned() self.cal.push_topic.clone()
} }
} }
@@ -127,13 +131,17 @@ impl Resource for CalendarResource {
Ok(match prop { Ok(match prop {
CalendarPropWrapperName::Calendar(prop) => CalendarPropWrapper::Calendar(match prop { CalendarPropWrapperName::Calendar(prop) => CalendarPropWrapper::Calendar(match prop {
CalendarPropName::CalendarColor => { CalendarPropName::CalendarColor => {
CalendarProp::CalendarColor(self.cal.color.clone()) CalendarProp::CalendarColor(self.cal.meta.color.clone())
} }
CalendarPropName::CalendarDescription => { CalendarPropName::CalendarDescription => {
CalendarProp::CalendarDescription(self.cal.description.clone()) CalendarProp::CalendarDescription(self.cal.meta.description.clone())
} }
CalendarPropName::CalendarTimezone => { CalendarPropName::CalendarTimezone => {
CalendarProp::CalendarTimezone(self.cal.timezone.clone()) CalendarProp::CalendarTimezone(self.cal.timezone_id.as_ref().and_then(|tzid| {
vtimezones_rs::VTIMEZONES
.get(tzid)
.map(|tz| (*tz).to_string())
}))
} }
// chrono_tz uses the IANA database // chrono_tz uses the IANA database
CalendarPropName::TimezoneServiceSet => CalendarProp::TimezoneServiceSet( CalendarPropName::TimezoneServiceSet => CalendarProp::TimezoneServiceSet(
@@ -143,7 +151,7 @@ impl Resource for CalendarResource {
CalendarProp::CalendarTimezoneId(self.cal.timezone_id.clone()) CalendarProp::CalendarTimezoneId(self.cal.timezone_id.clone())
} }
CalendarPropName::CalendarOrder => { CalendarPropName::CalendarOrder => {
CalendarProp::CalendarOrder(Some(self.cal.order)) CalendarProp::CalendarOrder(Some(self.cal.meta.order))
} }
CalendarPropName::SupportedCalendarComponentSet => { CalendarPropName::SupportedCalendarComponentSet => {
CalendarProp::SupportedCalendarComponentSet(self.cal.components.clone().into()) CalendarProp::SupportedCalendarComponentSet(self.cal.components.clone().into())
@@ -151,13 +159,16 @@ impl Resource for CalendarResource {
CalendarPropName::SupportedCalendarData => { CalendarPropName::SupportedCalendarData => {
CalendarProp::SupportedCalendarData(SupportedCalendarData::default()) CalendarProp::SupportedCalendarData(SupportedCalendarData::default())
} }
CalendarPropName::MaxResourceSize => CalendarProp::MaxResourceSize(10000000), CalendarPropName::SupportedCollationSet => {
CalendarProp::SupportedCollationSet(SupportedCollationSet::default())
}
CalendarPropName::MaxResourceSize => CalendarProp::MaxResourceSize(10_000_000),
CalendarPropName::SupportedReportSet => { CalendarPropName::SupportedReportSet => {
CalendarProp::SupportedReportSet(SupportedReportSet::all()) CalendarProp::SupportedReportSet(SupportedReportSet::all())
} }
CalendarPropName::Source => CalendarProp::Source( CalendarPropName::Source => {
self.cal.subscription_url.to_owned().map(HrefElement::from), CalendarProp::Source(self.cal.subscription_url.clone().map(HrefElement::from))
), }
CalendarPropName::MinDateTime => { CalendarPropName::MinDateTime => {
CalendarProp::MinDateTime(CalDateTime::from(DateTime::<Utc>::MIN_UTC).format()) CalendarProp::MinDateTime(CalDateTime::from(DateTime::<Utc>::MIN_UTC).format())
} }
@@ -178,54 +189,70 @@ impl Resource for CalendarResource {
} }
fn set_prop(&mut self, prop: Self::Prop) -> Result<(), rustical_dav::Error> { fn set_prop(&mut self, prop: Self::Prop) -> Result<(), rustical_dav::Error> {
if self.read_only {
return Err(rustical_dav::Error::PropReadOnly);
}
match prop { match prop {
CalendarPropWrapper::Calendar(prop) => match prop { CalendarPropWrapper::Calendar(prop) => match prop {
CalendarProp::CalendarColor(color) => { CalendarProp::CalendarColor(color) => {
self.cal.color = color; self.cal.meta.color = color;
Ok(()) Ok(())
} }
CalendarProp::CalendarDescription(description) => { CalendarProp::CalendarDescription(description) => {
self.cal.description = description; self.cal.meta.description = description;
Ok(()) Ok(())
} }
CalendarProp::CalendarTimezone(timezone) => { CalendarProp::CalendarTimezone(timezone) => {
// TODO: Ensure that timezone-id is also updated if let Some(tz) = timezone {
self.cal.timezone = timezone; // TODO: Proper error (calendar-timezone precondition)
let calendar = IcalParser::from_slice(tz.as_bytes())
.next()
.ok_or_else(|| {
rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
)
})?
.map_err(|_| {
rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
)
})?;
let timezone = calendar.vtimezones.values().next().ok_or_else(|| {
rustical_dav::Error::BadRequest("No timezone data provided".to_owned())
})?;
let timezone: Option<chrono_tz::Tz> = timezone.into();
let timezone = timezone.ok_or_else(|| {
rustical_dav::Error::BadRequest("No timezone data provided".to_owned())
})?;
self.cal.timezone_id = Some(timezone.name().to_owned());
}
Ok(()) Ok(())
} }
CalendarProp::TimezoneServiceSet(_) => Err(rustical_dav::Error::PropReadOnly),
CalendarProp::CalendarTimezoneId(timezone_id) => { CalendarProp::CalendarTimezoneId(timezone_id) => {
if let Some(tzid) = &timezone_id { if let Some(tzid) = &timezone_id
// Validate timezone id && !vtimezones_rs::VTIMEZONES.contains_key(tzid)
chrono_tz::Tz::from_str(tzid).map_err(|_| { {
rustical_dav::Error::BadRequest(format!( return Err(rustical_dav::Error::BadRequest(format!(
"Invalid timezone-id: {}", "Invalid timezone-id: {tzid}"
tzid )));
))
})?;
// TODO: Ensure that timezone is also updated (For now hope that clients play nice)
} }
self.cal.timezone_id = timezone_id; self.cal.timezone_id = timezone_id;
Ok(()) Ok(())
} }
CalendarProp::CalendarOrder(order) => { CalendarProp::CalendarOrder(order) => {
self.cal.order = order.unwrap_or_default(); self.cal.meta.order = order.unwrap_or_default();
Ok(()) Ok(())
} }
CalendarProp::SupportedCalendarComponentSet(comp_set) => { CalendarProp::SupportedCalendarComponentSet(comp_set) => {
self.cal.components = comp_set.into(); self.cal.components = comp_set.into();
Ok(()) Ok(())
} }
CalendarProp::SupportedCalendarData(_) => Err(rustical_dav::Error::PropReadOnly), CalendarProp::TimezoneServiceSet(_)
CalendarProp::MaxResourceSize(_) => Err(rustical_dav::Error::PropReadOnly), | CalendarProp::SupportedCalendarData(_)
CalendarProp::SupportedReportSet(_) => Err(rustical_dav::Error::PropReadOnly), | CalendarProp::SupportedCollationSet(_)
// Converting between a calendar subscription calendar and a normal one would be weird | CalendarProp::MaxResourceSize(_)
CalendarProp::Source(_) => Err(rustical_dav::Error::PropReadOnly), | CalendarProp::SupportedReportSet(_)
CalendarProp::MinDateTime(_) => Err(rustical_dav::Error::PropReadOnly), | CalendarProp::Source(_)
CalendarProp::MaxDateTime(_) => Err(rustical_dav::Error::PropReadOnly), | CalendarProp::MinDateTime(_)
| CalendarProp::MaxDateTime(_) => Err(rustical_dav::Error::PropReadOnly),
}, },
CalendarPropWrapper::SyncToken(prop) => SyncTokenExtension::set_prop(self, prop), CalendarPropWrapper::SyncToken(prop) => SyncTokenExtension::set_prop(self, prop),
CalendarPropWrapper::DavPush(prop) => DavPushExtension::set_prop(self, prop), CalendarPropWrapper::DavPush(prop) => DavPushExtension::set_prop(self, prop),
@@ -234,42 +261,35 @@ impl Resource for CalendarResource {
} }
fn remove_prop(&mut self, prop: &CalendarPropWrapperName) -> Result<(), rustical_dav::Error> { fn remove_prop(&mut self, prop: &CalendarPropWrapperName) -> Result<(), rustical_dav::Error> {
if self.read_only {
return Err(rustical_dav::Error::PropReadOnly);
}
match prop { match prop {
CalendarPropWrapperName::Calendar(prop) => match prop { CalendarPropWrapperName::Calendar(prop) => match prop {
CalendarPropName::CalendarColor => { CalendarPropName::CalendarColor => {
self.cal.color = None; self.cal.meta.color = None;
Ok(()) Ok(())
} }
CalendarPropName::CalendarDescription => { CalendarPropName::CalendarDescription => {
self.cal.description = None; self.cal.meta.description = None;
Ok(()) Ok(())
} }
CalendarPropName::CalendarTimezone => { CalendarPropName::CalendarTimezone | CalendarPropName::CalendarTimezoneId => {
self.cal.timezone = None;
Ok(())
}
CalendarPropName::TimezoneServiceSet => Err(rustical_dav::Error::PropReadOnly),
CalendarPropName::CalendarTimezoneId => {
self.cal.timezone_id = None; self.cal.timezone_id = None;
Ok(()) Ok(())
} }
CalendarPropName::CalendarOrder => { CalendarPropName::CalendarOrder => {
self.cal.order = 0; self.cal.meta.order = 0;
Ok(()) Ok(())
} }
CalendarPropName::SupportedCalendarComponentSet => { CalendarPropName::SupportedCalendarComponentSet => {
Err(rustical_dav::Error::PropReadOnly) Err(rustical_dav::Error::PropReadOnly)
} }
CalendarPropName::SupportedCalendarData => Err(rustical_dav::Error::PropReadOnly), CalendarPropName::TimezoneServiceSet
CalendarPropName::MaxResourceSize => Err(rustical_dav::Error::PropReadOnly), | CalendarPropName::SupportedCalendarData
CalendarPropName::SupportedReportSet => Err(rustical_dav::Error::PropReadOnly), | CalendarPropName::SupportedCollationSet
// Converting a calendar subscription calendar into a normal one would be weird | CalendarPropName::MaxResourceSize
CalendarPropName::Source => Err(rustical_dav::Error::PropReadOnly), | CalendarPropName::SupportedReportSet
CalendarPropName::MinDateTime => Err(rustical_dav::Error::PropReadOnly), | CalendarPropName::Source
CalendarPropName::MaxDateTime => Err(rustical_dav::Error::PropReadOnly), | CalendarPropName::MinDateTime
| CalendarPropName::MaxDateTime => Err(rustical_dav::Error::PropReadOnly),
}, },
CalendarPropWrapperName::SyncToken(prop) => SyncTokenExtension::remove_prop(self, prop), CalendarPropWrapperName::SyncToken(prop) => SyncTokenExtension::remove_prop(self, prop),
CalendarPropWrapperName::DavPush(prop) => DavPushExtension::remove_prop(self, prop), CalendarPropWrapperName::DavPush(prop) => DavPushExtension::remove_prop(self, prop),
@@ -280,10 +300,10 @@ impl Resource for CalendarResource {
} }
fn get_displayname(&self) -> Option<&str> { fn get_displayname(&self) -> Option<&str> {
self.cal.displayname.as_deref() self.cal.meta.displayname.as_deref()
} }
fn set_displayname(&mut self, name: Option<String>) -> Result<(), rustical_dav::Error> { fn set_displayname(&mut self, name: Option<String>) -> Result<(), rustical_dav::Error> {
self.cal.displayname = name; self.cal.meta.displayname = name;
Ok(()) Ok(())
} }
@@ -293,7 +313,7 @@ impl Resource for CalendarResource {
fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> { fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
if self.cal.subscription_url.is_some() || self.read_only { if self.cal.subscription_url.is_some() || self.read_only {
return Ok(UserPrivilegeSet::owner_read( return Ok(UserPrivilegeSet::owner_write_properties(
user.is_principal(&self.cal.principal), user.is_principal(&self.cal.principal),
)); ));
} }
@@ -303,3 +323,15 @@ impl Resource for CalendarResource {
)) ))
} }
} }
#[cfg(test)]
mod tests {
#[test]
fn test_tzdb_version() {
// Ensure that both chrono_tz and vzic_rs use the same tzdb version
assert_eq!(
chrono_tz::IANA_TZDB_VERSION,
vtimezones_rs::IANA_TZDB_VERSION
);
}
}

View File

@@ -1,4 +1,5 @@
use crate::calendar::methods::get::route_get; use crate::calendar::methods::get::route_get;
use crate::calendar::methods::import::route_import;
use crate::calendar::methods::mkcalendar::route_mkcalendar; use crate::calendar::methods::mkcalendar::route_mkcalendar;
use crate::calendar::methods::post::route_post; use crate::calendar::methods::post::route_post;
use crate::calendar::methods::report::route_report_calendar; use crate::calendar::methods::report::route_report_calendar;
@@ -34,7 +35,7 @@ impl<C: CalendarStore, S: SubscriptionStore> Clone for CalendarResourceService<C
} }
impl<C: CalendarStore, S: SubscriptionStore> CalendarResourceService<C, S> { impl<C: CalendarStore, S: SubscriptionStore> CalendarResourceService<C, S> {
pub fn new(cal_store: Arc<C>, sub_store: Arc<S>) -> Self { pub const fn new(cal_store: Arc<C>, sub_store: Arc<S>) -> Self {
Self { Self {
cal_store, cal_store,
sub_store, sub_store,
@@ -51,13 +52,17 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
type Principal = Principal; type Principal = Principal;
type PrincipalUri = CalDavPrincipalUri; type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy, webdav-push"; const DAV_HEADER: &str = "1, 3, access-control, calendar-access, webdav-push";
async fn get_resource( async fn get_resource(
&self, &self,
(principal, cal_id): &Self::PathComponents, (principal, cal_id): &Self::PathComponents,
show_deleted: bool,
) -> Result<Self::Resource, Error> { ) -> Result<Self::Resource, Error> {
let calendar = self.cal_store.get_calendar(principal, cal_id).await?; let calendar = self
.cal_store
.get_calendar(principal, cal_id, show_deleted)
.await?;
Ok(CalendarResource { Ok(CalendarResource {
cal: calendar, cal: calendar,
read_only: self.cal_store.is_read_only(cal_id), read_only: self.cal_store.is_read_only(cal_id),
@@ -73,8 +78,9 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
.get_objects(principal, cal_id) .get_objects(principal, cal_id)
.await? .await?
.into_iter() .into_iter()
.map(|object| CalendarObjectResource { .map(|(object_id, object)| CalendarObjectResource {
object, object,
object_id,
principal: principal.to_owned(), principal: principal.to_owned(),
}) })
.collect()) .collect())
@@ -86,7 +92,7 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
file: Self::Resource, file: Self::Resource,
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
self.cal_store self.cal_store
.update_calendar(principal.to_owned(), cal_id.to_owned(), file.into()) .update_calendar(principal, cal_id, file.into())
.await?; .await?;
Ok(()) Ok(())
} }
@@ -134,6 +140,13 @@ impl<C: CalendarStore, S: SubscriptionStore> AxumMethods for CalendarResourceSer
}) })
} }
fn import() -> Option<rustical_dav::resource::MethodFunction<Self>> {
Some(|state, req| {
let mut service = Handler::with_state(route_import::<C, S>, state);
Box::pin(Service::call(&mut service, req))
})
}
fn mkcalendar() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> fn mkcalendar() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>>
{ {
Some(|state, req| { Some(|state, req| {

View File

@@ -0,0 +1,200 @@
---
source: crates/caldav/src/calendar/tests.rs
expression: output
---
<?xml version="1.0" encoding="utf-8"?>
<response xmlns="DAV:">
<href>/caldav/principal/user/calendar/</href>
<propstat>
<prop xmlns="DAV:">
<calendar-timezone xmlns="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
PRODID:-//github.com/lennart-k/vzic-rs//RustiCal Calendar server//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Berlin
LAST-MODIFIED:20250723T190331Z
X-LIC-LOCATION:Europe/Berlin
X-PROLEPTIC-TZNAME:LMT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+005328
TZOFFSETTO:+0100
DTSTART:18930401T000000
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19160430T230000
RDATE:19400401T020000
RDATE:19430329T020000
RDATE:19460414T020000
RDATE:19470406T030000
RDATE:19480418T020000
RDATE:19490410T020000
RDATE:19800406T020000
END:DAYLIGHT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19161001T010000
RDATE:19421102T030000
RDATE:19431004T030000
RDATE:19441002T030000
RDATE:19451118T030000
RDATE:19461007T030000
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19170416T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3MO;UNTIL=19180415T010000Z
END:DAYLIGHT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19170917T030000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=3MO;UNTIL=19180916T010000Z
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19440403T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1MO;UNTIL=19450402T010000Z
END:DAYLIGHT
BEGIN:DAYLIGHT
TZNAME:CEMT
TZOFFSETFROM:+0200
TZOFFSETTO:+0300
DTSTART:19450524T020000
RDATE:19470511T030000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0300
TZOFFSETTO:+0200
DTSTART:19450924T030000
RDATE:19470629T030000
END:DAYLIGHT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0100
TZOFFSETTO:+0100
DTSTART:19460101T000000
RDATE:19800101T000000
END:STANDARD
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19471005T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19491002T010000Z
END:STANDARD
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19800928T030000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19950924T010000Z
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19810329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19961027T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
END:VCALENDAR
</calendar-timezone>
<timezone-service-set xmlns="urn:ietf:params:xml:ns:caldav">
<href xmlns="DAV:">https://www.iana.org/time-zones</href>
</timezone-service-set>
<calendar-timezone-id xmlns="urn:ietf:params:xml:ns:caldav">Europe/Berlin</calendar-timezone-id>
<calendar-order xmlns="http://apple.com/ns/ical/">0</calendar-order>
<supported-calendar-component-set xmlns="urn:ietf:params:xml:ns:caldav">
<comp xmlns="urn:ietf:params:xml:ns:caldav" name="VEVENT"/>
<comp xmlns="urn:ietf:params:xml:ns:caldav" name="VTODO"/>
</supported-calendar-component-set>
<supported-calendar-data xmlns="urn:ietf:params:xml:ns:caldav">
<calendar-data xmlns="urn:ietf:params:xml:ns:caldav" content-type="text/calendar" version="2.0"/>
</supported-calendar-data>
<supported-collation-set xmlns="urn:ietf:params:xml:ns:caldav">
<supported-collation xmlns="urn:ietf:params:xml:ns:caldav">i;ascii-casemap</supported-collation>
<supported-collation xmlns="urn:ietf:params:xml:ns:caldav">i;unicode-casemap</supported-collation>
<supported-collation xmlns="urn:ietf:params:xml:ns:caldav">i;octet</supported-collation>
</supported-collation-set>
<max-resource-size xmlns="urn:ietf:params:xml:ns:caldav">10000000</max-resource-size>
<supported-report-set xmlns="DAV:">
<supported-report xmlns="DAV:">
<report xmlns="DAV:">
<calendar-query xmlns="urn:ietf:params:xml:ns:caldav"/>
</report>
</supported-report>
<supported-report xmlns="DAV:">
<report xmlns="DAV:">
<calendar-multiget xmlns="urn:ietf:params:xml:ns:caldav"/>
</report>
</supported-report>
<supported-report xmlns="DAV:">
<report xmlns="DAV:">
<sync-collection xmlns="DAV:"/>
</report>
</supported-report>
</supported-report-set>
<min-date-time xmlns="urn:ietf:params:xml:ns:caldav">-2621430101T000000Z</min-date-time>
<max-date-time xmlns="urn:ietf:params:xml:ns:caldav">+2621421231T235959Z</max-date-time>
<sync-token xmlns="DAV:">github.com/lennart-k/rustical/ns/12</sync-token>
<getctag xmlns="http://calendarserver.org/ns/">github.com/lennart-k/rustical/ns/12</getctag>
<transports xmlns="https://bitfire.at/webdav-push">
<web-push xmlns="https://bitfire.at/webdav-push"/>
</transports>
<topic xmlns="https://bitfire.at/webdav-push">b28b41e9-8801-4fc5-ae29-8efb5fadeb36</topic>
<supported-triggers xmlns="https://bitfire.at/webdav-push">
<content-update xmlns="https://bitfire.at/webdav-push">
<depth xmlns="DAV:">1</depth>
</content-update>
<property-update xmlns="https://bitfire.at/webdav-push">
<depth xmlns="DAV:">1</depth>
</property-update>
</supported-triggers>
<resourcetype xmlns="DAV:">
<collection xmlns="DAV:"/>
<calendar xmlns="urn:ietf:params:xml:ns:caldav"/>
</resourcetype>
<displayname xmlns="DAV:">Calendar</displayname>
<current-user-principal xmlns="DAV:">
<href xmlns="DAV:">/caldav/principal/user/</href>
</current-user-principal>
<current-user-privilege-set xmlns="DAV:">
<privilege>
<read/>
</privilege>
<privilege>
<write-properties/>
</privilege>
<privilege>
<read-acl/>
</privilege>
<privilege>
<read-current-user-privilege-set/>
</privilege>
</current-user-privilege-set>
<owner xmlns="DAV:">
<href xmlns="DAV:">/caldav/principal/user/</href>
</owner>
</prop>
<status xmlns="DAV:">HTTP/1.1 200 OK</status>
</propstat>
</response>

View File

@@ -0,0 +1,37 @@
---
source: crates/caldav/src/calendar/tests.rs
expression: output
---
<?xml version="1.0" encoding="utf-8"?>
<response xmlns="DAV:">
<href>/caldav/principal/user/calendar/</href>
<propstat>
<prop xmlns="DAV:">
<calendar-color xmlns="http://apple.com/ns/ical/"/>
<calendar-description xmlns="urn:ietf:params:xml:ns:caldav"/>
<calendar-timezone xmlns="urn:ietf:params:xml:ns:caldav"/>
<timezone-service-set xmlns="urn:ietf:params:xml:ns:caldav"/>
<calendar-timezone-id xmlns="urn:ietf:params:xml:ns:caldav"/>
<calendar-order xmlns="http://apple.com/ns/ical/"/>
<supported-calendar-component-set xmlns="urn:ietf:params:xml:ns:caldav"/>
<supported-calendar-data xmlns="urn:ietf:params:xml:ns:caldav"/>
<supported-collation-set xmlns="urn:ietf:params:xml:ns:caldav"/>
<max-resource-size xmlns="urn:ietf:params:xml:ns:caldav"/>
<supported-report-set xmlns="DAV:"/>
<source xmlns="http://calendarserver.org/ns/"/>
<min-date-time xmlns="urn:ietf:params:xml:ns:caldav"/>
<max-date-time xmlns="urn:ietf:params:xml:ns:caldav"/>
<sync-token xmlns="DAV:"/>
<getctag xmlns="http://calendarserver.org/ns/"/>
<transports xmlns="https://bitfire.at/webdav-push"/>
<topic xmlns="https://bitfire.at/webdav-push"/>
<supported-triggers xmlns="https://bitfire.at/webdav-push"/>
<resourcetype xmlns="DAV:"/>
<displayname xmlns="DAV:"/>
<current-user-principal xmlns="DAV:"/>
<current-user-privilege-set xmlns="DAV:"/>
<owner xmlns="DAV:"/>
</prop>
<status xmlns="DAV:">HTTP/1.1 200 OK</status>
</propstat>
</response>

View File

@@ -0,0 +1,11 @@
[
{
"id": "user",
"displayname": null,
"principal_type": "individual",
"password": null,
"memberships": [
"group"
]
}
]

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><propname/></propfind>
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><allprop/></propfind>

View File

@@ -0,0 +1,42 @@
[
{
"cal": {
"principal": "user",
"id": "calendar",
"displayname": "Calendar",
"order": 0,
"description": null,
"color": null,
"timezone_id": "Europe/Berlin",
"deleted_at": null,
"synctoken": 12,
"subscription_url": null,
"push_topic": "b28b41e9-8801-4fc5-ae29-8efb5fadeb36",
"components": [
"VEVENT",
"VTODO"
]
},
"read_only": true
},
{
"cal": {
"principal": "user",
"id": "calendar",
"displayname": "Calendar",
"order": 0,
"description": null,
"color": null,
"timezone_id": "Europe/Berlin",
"deleted_at": null,
"synctoken": 12,
"subscription_url": null,
"push_topic": "b28b41e9-8801-4fc5-ae29-8efb5fadeb36",
"components": [
"VEVENT",
"VTODO"
]
},
"read_only": true
}
]

View File

@@ -0,0 +1,39 @@
use crate::{CalDavPrincipalUri, calendar::resource::CalendarResource};
use rustical_dav::resource::Resource;
use rustical_store::auth::Principal;
use rustical_xml::XmlSerializeRoot;
use serde_json::from_str;
#[tokio::test]
async fn test_propfind() {
let requests: Vec<_> = include_str!("./test_files/propfind.requests")
.trim()
.split("\n\n")
.collect();
let principals: Vec<Principal> =
from_str(include_str!("./test_files/propfind.principals.json")).unwrap();
let resources: Vec<CalendarResource> =
from_str(include_str!("./test_files/propfind.resources.json")).unwrap();
for principal in principals {
for (request, resource) in requests.iter().zip(&resources) {
let propfind = CalendarResource::parse_propfind(request).unwrap();
let response = resource
.propfind(
&format!("/caldav/principal/{}/{}", principal.id, resource.cal.id),
&propfind.prop,
propfind.include.as_ref(),
&CalDavPrincipalUri("/caldav"),
&principal,
)
.unwrap();
let output = response
.serialize_to_string()
.unwrap()
.trim()
.replace("\r\n", "\n");
insta::assert_snapshot!(output);
}
}
}

View File

@@ -6,12 +6,12 @@ use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
use headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch}; use headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch};
use http::{HeaderMap, StatusCode}; use http::{HeaderMap, HeaderValue, Method, StatusCode};
use rustical_ical::CalendarObject; use rustical_ical::CalendarObject;
use rustical_store::CalendarStore; use rustical_store::CalendarStore;
use rustical_store::auth::Principal; use rustical_store::auth::Principal;
use std::str::FromStr; use std::str::FromStr;
use tracing::instrument; use tracing::{instrument, warn};
#[instrument(skip(cal_store))] #[instrument(skip(cal_store))]
pub async fn get_event<C: CalendarStore>( pub async fn get_event<C: CalendarStore>(
@@ -22,12 +22,15 @@ pub async fn get_event<C: CalendarStore>(
}): Path<CalendarObjectPathComponents>, }): Path<CalendarObjectPathComponents>,
State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>, State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>,
user: Principal, user: Principal,
method: Method,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(crate::Error::Unauthorized); return Err(crate::Error::Unauthorized);
} }
let calendar = cal_store.get_calendar(&principal, &calendar_id).await?; let calendar = cal_store
.get_calendar(&principal, &calendar_id, false)
.await?;
if !user.is_principal(&calendar.principal) { if !user.is_principal(&calendar.principal) {
return Err(crate::Error::Unauthorized); return Err(crate::Error::Unauthorized);
} }
@@ -39,8 +42,12 @@ pub async fn get_event<C: CalendarStore>(
let mut resp = Response::builder().status(StatusCode::OK); let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap(); let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ETag::from_str(&event.get_etag()).unwrap()); hdrs.typed_insert(ETag::from_str(&event.get_etag()).unwrap());
hdrs.typed_insert(ContentType::from_str("text/calendar").unwrap()); hdrs.typed_insert(ContentType::from_str("text/calendar; charset=utf-8").unwrap());
if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(event.get_ics().to_owned())).unwrap()) Ok(resp.body(Body::new(event.get_ics().to_owned())).unwrap())
}
} }
#[instrument(skip(cal_store))] #[instrument(skip(cal_store))]
@@ -66,20 +73,44 @@ pub async fn put_event<C: CalendarStore>(
} }
let overwrite = if let Some(TypedHeader(if_none_match)) = if_none_match { let overwrite = if let Some(TypedHeader(if_none_match)) = if_none_match {
if_none_match == IfNoneMatch::any() // TODO: Put into transaction?
let existing = match cal_store
.get_object(&principal, &calendar_id, &object_id, false)
.await
{
Ok(existing) => Some(existing),
Err(rustical_store::Error::NotFound) => None,
Err(err) => Err(err)?,
};
existing.is_none_or(|existing| {
if_none_match.precondition_passes(
&existing
.get_etag()
.parse()
.expect("We only generate valid ETags"),
)
})
} else { } else {
true true
}; };
let object = match CalendarObject::from_ics(object_id, body) { let object = match CalendarObject::from_ics(body.clone()) {
Ok(obj) => obj, Ok(object) => object,
Err(_) => { Err(err) => {
warn!("invalid calendar data:\n{body}");
warn!("{err}");
return Err(Error::PreconditionFailed(Precondition::ValidCalendarData)); return Err(Error::PreconditionFailed(Precondition::ValidCalendarData));
} }
}; };
let etag = object.get_etag();
cal_store cal_store
.put_object(principal, calendar_id, object, overwrite) .put_object(&principal, &calendar_id, &object_id, object, overwrite)
.await?; .await?;
Ok(StatusCode::CREATED.into_response()) let mut headers = HeaderMap::new();
headers.insert(
"ETag",
HeaderValue::from_str(&etag).expect("Contains no invalid characters"),
);
Ok((StatusCode::CREATED, headers).into_response())
} }

View File

@@ -1,8 +1,8 @@
use rustical_dav::extensions::CommonPropertiesProp; use rustical_dav::extensions::CommonPropertiesProp;
use rustical_ical::UtcDateTime; use rustical_ical::UtcDateTime;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{EnumVariants, PropName, Unparsed, XmlDeserialize, XmlSerialize};
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "CalendarObjectPropName")] #[xml(unit_variants_ident = "CalendarObjectPropName")]
pub enum CalendarObjectProp { pub enum CalendarObjectProp {
// WebDAV (RFC 2518) // WebDAV (RFC 2518)
@@ -17,7 +17,7 @@ pub enum CalendarObjectProp {
CalendarData(String), CalendarData(String),
} }
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "CalendarObjectPropWrapperName", untagged)] #[xml(unit_variants_ident = "CalendarObjectPropWrapperName", untagged)]
pub enum CalendarObjectPropWrapper { pub enum CalendarObjectPropWrapper {
CalendarObject(CalendarObjectProp), CalendarObject(CalendarObjectProp),
@@ -25,7 +25,7 @@ pub enum CalendarObjectPropWrapper {
} }
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq, Hash)] #[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct ExpandElement { pub struct ExpandElement {
#[xml(ty = "attr")] #[xml(ty = "attr")]
pub(crate) start: UtcDateTime, pub(crate) start: UtcDateTime,
#[xml(ty = "attr")] #[xml(ty = "attr")]
@@ -35,7 +35,9 @@ pub(crate) struct ExpandElement {
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Default, Eq, Hash)] #[derive(XmlDeserialize, Clone, Debug, PartialEq, Default, Eq, Hash)]
pub struct CalendarData { pub struct CalendarData {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) comp: Option<()>, pub(crate) comp: Option<Unparsed>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) prop: Option<Unparsed>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) expand: Option<ExpandElement>, pub(crate) expand: Option<ExpandElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]

View File

@@ -1,6 +1,10 @@
use super::prop::*; use super::prop::{
CalendarData, CalendarObjectProp, CalendarObjectPropName, CalendarObjectPropWrapper,
CalendarObjectPropWrapperName,
};
use crate::Error; use crate::Error;
use derive_more::derive::{From, Into}; use derive_more::derive::{From, Into};
use ical::generator::Emitter;
use rustical_dav::{ use rustical_dav::{
extensions::CommonPropertiesExtension, extensions::CommonPropertiesExtension,
privileges::UserPrivilegeSet, privileges::UserPrivilegeSet,
@@ -9,16 +13,18 @@ use rustical_dav::{
}; };
use rustical_ical::CalendarObject; use rustical_ical::CalendarObject;
use rustical_store::auth::Principal; use rustical_store::auth::Principal;
use std::borrow::Cow;
#[derive(Clone, From, Into)] #[derive(Clone, From, Into)]
pub struct CalendarObjectResource { pub struct CalendarObjectResource {
pub object: CalendarObject, pub object: CalendarObject,
pub object_id: String,
pub principal: String, pub principal: String,
} }
impl ResourceName for CalendarObjectResource { impl ResourceName for CalendarObjectResource {
fn get_name(&self) -> String { fn get_name(&self) -> Cow<'_, str> {
format!("{}.ics", self.object.get_id()) Cow::from(format!("{}.ics", self.object_id))
} }
} }
@@ -48,14 +54,18 @@ impl Resource for CalendarObjectResource {
CalendarObjectProp::Getetag(self.object.get_etag()) CalendarObjectProp::Getetag(self.object.get_etag())
} }
CalendarObjectPropName::CalendarData(CalendarData { expand, .. }) => { CalendarObjectPropName::CalendarData(CalendarData { expand, .. }) => {
CalendarObjectProp::CalendarData(if let Some(expand) = expand.as_ref() { CalendarObjectProp::CalendarData(expand.as_ref().map_or_else(
self.object.expand_recurrence( || self.object.get_ics().to_owned(),
|expand| {
self.object
.get_inner()
.expand_recurrence(
Some(expand.start.to_utc()), Some(expand.start.to_utc()),
Some(expand.end.to_utc()), Some(expand.end.to_utc()),
)? )
} else { .generate()
self.object.get_ics().to_owned() },
}) ))
} }
CalendarObjectPropName::Getcontenttype => { CalendarObjectPropName::Getcontenttype => {
CalendarObjectProp::Getcontenttype("text/calendar;charset=utf-8") CalendarObjectProp::Getcontenttype("text/calendar;charset=utf-8")
@@ -69,7 +79,6 @@ impl Resource for CalendarObjectResource {
} }
fn get_displayname(&self) -> Option<&str> { fn get_displayname(&self) -> Option<&str> {
// TODO: Extract summary from object
None None
} }

View File

@@ -35,7 +35,7 @@ impl<C: CalendarStore> Clone for CalendarObjectResourceService<C> {
} }
impl<C: CalendarStore> CalendarObjectResourceService<C> { impl<C: CalendarStore> CalendarObjectResourceService<C> {
pub fn new(cal_store: Arc<C>) -> Self { pub const fn new(cal_store: Arc<C>) -> Self {
Self { cal_store } Self { cal_store }
} }
} }
@@ -58,13 +58,15 @@ impl<C: CalendarStore> ResourceService for CalendarObjectResourceService<C> {
calendar_id, calendar_id,
object_id, object_id,
}: &Self::PathComponents, }: &Self::PathComponents,
show_deleted: bool,
) -> Result<Self::Resource, Self::Error> { ) -> Result<Self::Resource, Self::Error> {
let object = self let object = self
.cal_store .cal_store
.get_object(principal, calendar_id, object_id, false) .get_object(principal, calendar_id, object_id, show_deleted)
.await?; .await?;
Ok(CalendarObjectResource { Ok(CalendarObjectResource {
object, object,
object_id: object_id.to_owned(),
principal: principal.to_owned(), principal: principal.to_owned(),
}) })
} }
@@ -105,9 +107,8 @@ where
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
let name: String = Deserialize::deserialize(deserializer)?; let name: String = Deserialize::deserialize(deserializer)?;
if let Some(object_id) = name.strip_suffix(".ics") { name.strip_suffix(".ics").map_or_else(
Ok(object_id.to_owned()) || Err(serde::de::Error::custom("Missing .ics extension")),
} else { |object_id| Ok(object_id.to_owned()),
Err(serde::de::Error::custom("Missing .ics extension")) )
}
} }

View File

@@ -52,37 +52,42 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
XmlDecodeError(#[from] rustical_xml::XmlError), XmlDecodeError(#[from] rustical_xml::XmlError),
#[error(transparent)]
IcalError(#[from] rustical_ical::Error),
#[error(transparent)] #[error(transparent)]
PreconditionFailed(Precondition), PreconditionFailed(Precondition),
} }
impl Error { impl Error {
#[must_use]
pub fn status_code(&self) -> StatusCode { pub fn status_code(&self) -> StatusCode {
match self { match self {
Error::StoreError(err) => match err { Self::StoreError(err) => match err {
rustical_store::Error::NotFound => StatusCode::NOT_FOUND, rustical_store::Error::NotFound => StatusCode::NOT_FOUND,
rustical_store::Error::AlreadyExists => StatusCode::CONFLICT, rustical_store::Error::AlreadyExists => StatusCode::CONFLICT,
rustical_store::Error::ReadOnly => StatusCode::FORBIDDEN, rustical_store::Error::ReadOnly => StatusCode::FORBIDDEN,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}, },
Error::ChronoParseError(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::DavError(err) => StatusCode::try_from(err.status_code().as_u16())
Error::DavError(err) => StatusCode::try_from(err.status_code().as_u16())
.expect("Just converting between versions"), .expect("Just converting between versions"),
Error::Unauthorized => StatusCode::UNAUTHORIZED, Self::Unauthorized => StatusCode::UNAUTHORIZED,
Error::XmlDecodeError(_) => StatusCode::BAD_REQUEST, Self::XmlDecodeError(_) => StatusCode::BAD_REQUEST,
Error::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR, Self::ChronoParseError(_) | Self::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR,
Error::NotFound => StatusCode::NOT_FOUND, Self::NotFound => StatusCode::NOT_FOUND,
Error::IcalError(err) => err.status_code(), Self::PreconditionFailed(_err) => StatusCode::PRECONDITION_FAILED,
Error::PreconditionFailed(_err) => StatusCode::PRECONDITION_FAILED,
} }
} }
} }
impl IntoResponse for Error { impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
if let Self::PreconditionFailed(precondition) = self {
return precondition.into_response();
}
if matches!(
self.status_code(),
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::PRECONDITION_FAILED
) {
error!("{self}");
}
(self.status_code(), self.to_string()).into_response() (self.status_code(), self.to_string()).into_response()
} }
} }

View File

@@ -1,5 +1,5 @@
use axum::response::Redirect; #![warn(clippy::all, clippy::pedantic, clippy::nursery)]
use axum::routing::any; #![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
use axum::{Extension, Router}; use axum::{Extension, Router};
use derive_more::Constructor; use derive_more::Constructor;
use principal::PrincipalResourceService; use principal::PrincipalResourceService;
@@ -14,7 +14,6 @@ pub mod calendar;
pub mod calendar_object; pub mod calendar_object;
pub mod error; pub mod error;
pub mod principal; pub mod principal;
pub use error::Error; pub use error::Error;
#[derive(Debug, Clone, Constructor)] #[derive(Debug, Clone, Constructor)]
@@ -34,23 +33,18 @@ pub fn caldav_router<AP: AuthenticationProvider, C: CalendarStore, S: Subscripti
auth_provider: Arc<AP>, auth_provider: Arc<AP>,
store: Arc<C>, store: Arc<C>,
subscription_store: Arc<S>, subscription_store: Arc<S>,
simplified_home_set: bool,
) -> Router { ) -> Router {
let principal_service = PrincipalResourceService { Router::new().nest(
auth_provider: auth_provider.clone(),
sub_store: subscription_store.clone(),
cal_store: store.clone(),
};
Router::new()
.nest(
prefix, prefix,
RootResourceService::<_, Principal, CalDavPrincipalUri>::new(principal_service.clone()) RootResourceService::<_, Principal, CalDavPrincipalUri>::new(PrincipalResourceService {
auth_provider: auth_provider.clone(),
sub_store: subscription_store,
cal_store: store,
simplified_home_set,
})
.axum_router() .axum_router()
.layer(AuthenticationLayer::new(auth_provider)) .layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CalDavPrincipalUri(prefix))), .layer(Extension(CalDavPrincipalUri(prefix))),
) )
.route(
"/.well-known/caldav",
any(async || Redirect::permanent(prefix)),
)
} }

View File

@@ -6,21 +6,26 @@ use rustical_dav::xml::{
GroupMemberSet, GroupMembership, Resourcetype, ResourcetypeInner, SupportedReportSet, GroupMemberSet, GroupMembership, Resourcetype, ResourcetypeInner, SupportedReportSet,
}; };
use rustical_store::auth::Principal; use rustical_store::auth::Principal;
use std::borrow::Cow;
mod service; mod service;
pub use service::*; pub use service::*;
mod prop; mod prop;
pub use prop::*; pub use prop::*;
#[cfg(test)]
pub mod tests;
#[derive(Clone)] #[derive(Debug, Clone)]
pub struct PrincipalResource { pub struct PrincipalResource {
principal: Principal, principal: Principal,
members: Vec<String>, members: Vec<String>,
// If true only return the principal as the calendar home set, otherwise also groups
simplified_home_set: bool,
} }
impl ResourceName for PrincipalResource { impl ResourceName for PrincipalResource {
fn get_name(&self) -> String { fn get_name(&self) -> Cow<'_, str> {
self.principal.id.to_owned() Cow::from(&self.principal.id)
} }
} }
@@ -37,11 +42,6 @@ impl Resource for PrincipalResource {
Resourcetype(&[ Resourcetype(&[
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "collection"), ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "collection"),
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "principal"), ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "principal"),
// https://github.com/apple/ccs-calendarserver/blob/13c706b985fb728b9aab42dc0fef85aae21921c3/doc/Extensions/caldav-proxy.txt
// ResourcetypeInner(
// Some(rustical_dav::namespace::NS_CALENDARSERVER),
// "calendar-proxy-write",
// ),
]) ])
} }
@@ -57,14 +57,22 @@ impl Resource for PrincipalResource {
PrincipalPropWrapperName::Principal(prop) => { PrincipalPropWrapperName::Principal(prop) => {
PrincipalPropWrapper::Principal(match prop { PrincipalPropWrapper::Principal(match prop {
PrincipalPropName::CalendarUserType => { PrincipalPropName::CalendarUserType => {
PrincipalProp::CalendarUserType(self.principal.principal_type.to_owned()) PrincipalProp::CalendarUserType(self.principal.principal_type.clone())
} }
PrincipalPropName::PrincipalUrl => { PrincipalPropName::PrincipalUrl => {
PrincipalProp::PrincipalUrl(principal_url.into()) PrincipalProp::PrincipalUrl(principal_url.into())
} }
PrincipalPropName::CalendarHomeSet => { PrincipalPropName::CalendarHomeSet => PrincipalProp::CalendarHomeSet(
PrincipalProp::CalendarHomeSet(principal_url.into()) CalendarHomeSet(if self.simplified_home_set {
} vec![principal_url.into()]
} else {
self.principal
.memberships()
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect()
}),
),
PrincipalPropName::CalendarUserAddressSet => { PrincipalPropName::CalendarUserAddressSet => {
PrincipalProp::CalendarUserAddressSet(principal_url.into()) PrincipalProp::CalendarUserAddressSet(principal_url.into())
} }
@@ -114,7 +122,7 @@ impl Resource for PrincipalResource {
} }
fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> { fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_read( Ok(UserPrivilegeSet::owner_only(
user.is_principal(&self.principal.id), user.is_principal(&self.principal.id),
)) ))
} }

View File

@@ -6,7 +6,7 @@ use rustical_store::auth::PrincipalType;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray; use strum_macros::VariantArray;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName, Debug)]
#[xml(unit_variants_ident = "PrincipalPropName")] #[xml(unit_variants_ident = "PrincipalPropName")]
pub enum PrincipalProp { pub enum PrincipalProp {
// Scheduling Extensions to CalDAV (RFC 6638) // Scheduling Extensions to CalDAV (RFC 6638)
@@ -16,13 +16,13 @@ pub enum PrincipalProp {
CalendarUserAddressSet(HrefElement), CalendarUserAddressSet(HrefElement),
// WebDAV Access Control (RFC 3744) // WebDAV Access Control (RFC 3744)
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = b"principal-URL")] #[xml(ns = "rustical_dav::namespace::NS_DAV", rename = "principal-URL")]
PrincipalUrl(HrefElement), PrincipalUrl(HrefElement),
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
GroupMembership(GroupMembership), GroupMembership(GroupMembership),
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
GroupMemberSet(GroupMemberSet), GroupMemberSet(GroupMemberSet),
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = b"alternate-URI-set")] #[xml(ns = "rustical_dav::namespace::NS_DAV", rename = "alternate-URI-set")]
AlternateUriSet, AlternateUriSet,
// #[xml(ns = "rustical_dav::namespace::NS_DAV")] // #[xml(ns = "rustical_dav::namespace::NS_DAV")]
// PrincipalCollectionSet(HrefElement), // PrincipalCollectionSet(HrefElement),
@@ -31,17 +31,20 @@ pub enum PrincipalProp {
// CalDAV (RFC 4791) // CalDAV (RFC 4791)
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarHomeSet(HrefElement), CalendarHomeSet(CalendarHomeSet),
} }
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, Debug)]
pub struct CalendarHomeSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName, Debug)]
#[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)] #[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)]
pub enum PrincipalPropWrapper { pub enum PrincipalPropWrapper {
Principal(PrincipalProp), Principal(PrincipalProp),
Common(CommonPropertiesProp), Common(CommonPropertiesProp),
} }
#[derive(XmlSerialize, PartialEq, Clone, VariantArray)] #[derive(XmlSerialize, PartialEq, Eq, Debug, Clone, VariantArray)]
pub enum ReportMethod { pub enum ReportMethod {
// We don't actually support principal-match // We don't actually support principal-match
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]

View File

@@ -18,6 +18,8 @@ pub struct PrincipalResourceService<
pub(crate) auth_provider: Arc<AP>, pub(crate) auth_provider: Arc<AP>,
pub(crate) sub_store: Arc<S>, pub(crate) sub_store: Arc<S>,
pub(crate) cal_store: Arc<CS>, pub(crate) cal_store: Arc<CS>,
// If true only return the principal as the calendar home set, otherwise also groups
pub(crate) simplified_home_set: bool,
} }
impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Clone impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Clone
@@ -28,6 +30,7 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Clone
auth_provider: self.auth_provider.clone(), auth_provider: self.auth_provider.clone(),
sub_store: self.sub_store.clone(), sub_store: self.sub_store.clone(),
cal_store: self.cal_store.clone(), cal_store: self.cal_store.clone(),
simplified_home_set: self.simplified_home_set,
} }
} }
} }
@@ -43,11 +46,12 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
type Principal = Principal; type Principal = Principal;
type PrincipalUri = CalDavPrincipalUri; type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy"; const DAV_HEADER: &str = "1, 3, access-control, calendar-access";
async fn get_resource( async fn get_resource(
&self, &self,
(principal,): &Self::PathComponents, (principal,): &Self::PathComponents,
_show_deleted: bool,
) -> Result<Self::Resource, Self::Error> { ) -> Result<Self::Resource, Self::Error> {
let user = self let user = self
.auth_provider .auth_provider
@@ -57,6 +61,7 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
Ok(PrincipalResource { Ok(PrincipalResource {
members: self.auth_provider.list_members(&user.id).await?, members: self.auth_provider.list_members(&user.id).await?,
principal: user, principal: user,
simplified_home_set: self.simplified_home_set,
}) })
} }

View File

@@ -0,0 +1,136 @@
---
source: crates/caldav/src/principal/tests.rs
expression: response
---
ResponseElement {
href: "/caldav/principal/user/",
status: None,
propstat: [
Normal(
PropstatElement {
prop: PropTagWrapper(
[
Principal(
CalendarUserType(
Individual,
),
),
Principal(
CalendarUserAddressSet(
HrefElement {
href: "/caldav/principal/user/",
},
),
),
Principal(
PrincipalUrl(
HrefElement {
href: "/caldav/principal/user/",
},
),
),
Principal(
GroupMembership(
GroupMembership(
[
HrefElement {
href: "/caldav/principal/group/",
},
],
),
),
),
Principal(
GroupMemberSet(
GroupMemberSet(
[],
),
),
),
Principal(
AlternateUriSet,
),
Principal(
SupportedReportSet(
SupportedReportSet {
supported_report: [
ReportWrapper {
report: PrincipalMatch,
},
],
},
),
),
Principal(
CalendarHomeSet(
CalendarHomeSet(
[
HrefElement {
href: "/caldav/principal/group/",
},
HrefElement {
href: "/caldav/principal/user/",
},
],
),
),
),
Common(
Resourcetype(
Resourcetype(
[
ResourcetypeInner(
Some(
Namespace("DAV:"),
),
"collection",
),
ResourcetypeInner(
Some(
Namespace("DAV:"),
),
"principal",
),
],
),
),
),
Common(
Displayname(
Some(
"user",
),
),
),
Common(
CurrentUserPrincipal(
HrefElement {
href: "/caldav/principal/user/",
},
),
),
Common(
CurrentUserPrivilegeSet(
UserPrivilegeSet {
privileges: {
All,
},
},
),
),
Common(
Owner(
Some(
HrefElement {
href: "/caldav/principal/user/",
},
),
),
),
],
),
status: 200,
},
),
],
}

View File

@@ -0,0 +1,53 @@
---
source: crates/caldav/src/principal/tests.rs
expression: response.serialize_to_string().unwrap()
---
<?xml version="1.0" encoding="utf-8"?>
<response xmlns="DAV:">
<href>/caldav/principal/user/</href>
<propstat>
<prop xmlns="DAV:">
<calendar-user-type xmlns="urn:ietf:params:xml:ns:caldav">INDIVIDUAL</calendar-user-type>
<calendar-user-address-set xmlns="urn:ietf:params:xml:ns:caldav">
<href xmlns="DAV:">/caldav/principal/user/</href>
</calendar-user-address-set>
<principal-URL xmlns="DAV:">
<href xmlns="DAV:">/caldav/principal/user/</href>
</principal-URL>
<group-membership xmlns="DAV:">
<href xmlns="DAV:">/caldav/principal/group/</href>
</group-membership>
<group-member-set xmlns="DAV:">
</group-member-set>
<alternate-URI-set xmlns="DAV:"/>
<supported-report-set xmlns="DAV:">
<supported-report xmlns="DAV:">
<report xmlns="DAV:">
<principal-match xmlns="DAV:"/>
</report>
</supported-report>
</supported-report-set>
<calendar-home-set xmlns="urn:ietf:params:xml:ns:caldav">
<href xmlns="DAV:">/caldav/principal/group/</href>
<href xmlns="DAV:">/caldav/principal/user/</href>
</calendar-home-set>
<resourcetype xmlns="DAV:">
<collection xmlns="DAV:"/>
<principal xmlns="DAV:"/>
</resourcetype>
<displayname xmlns="DAV:">user</displayname>
<current-user-principal xmlns="DAV:">
<href xmlns="DAV:">/caldav/principal/user/</href>
</current-user-principal>
<current-user-privilege-set xmlns="DAV:">
<privilege>
<all/>
</privilege>
</current-user-privilege-set>
<owner xmlns="DAV:">
<href xmlns="DAV:">/caldav/principal/user/</href>
</owner>
</prop>
<status xmlns="DAV:">HTTP/1.1 200 OK</status>
</propstat>
</response>

View File

@@ -0,0 +1,8 @@
---
source: crates/caldav/src/principal/tests.rs
expression: propfind
---
PropfindElement {
prop: Allprop,
include: None,
}

View File

@@ -0,0 +1,89 @@
use crate::{
CalDavPrincipalUri,
principal::{PrincipalResource, PrincipalResourceService},
};
use rstest::rstest;
use rustical_dav::resource::{Resource, ResourceService};
use rustical_store::auth::{Principal, PrincipalType::Individual};
use rustical_store_sqlite::tests::{TestStoreContext, test_store_context};
use rustical_xml::XmlSerializeRoot;
use std::sync::Arc;
#[rstest]
#[tokio::test]
async fn test_principal_resource(
#[future]
#[from(test_store_context)]
context: TestStoreContext,
) {
let TestStoreContext {
cal_store,
sub_store,
principal_store: auth_provider,
..
} = context.await;
let service = PrincipalResourceService {
cal_store: Arc::new(cal_store),
sub_store: Arc::new(sub_store),
auth_provider: Arc::new(auth_provider),
simplified_home_set: false,
};
// We don't have any calendars here
assert!(
service
.get_members(&("user".to_owned(),))
.await
.unwrap()
.is_empty()
);
assert!(matches!(
service
.get_resource(&("invalid-user".to_owned(),), true)
.await,
Err(crate::Error::NotFound)
));
let _principal_resource = service
.get_resource(&("user".to_owned(),), true)
.await
.unwrap();
}
#[tokio::test]
async fn test_propfind() {
let propfind = PrincipalResource::parse_propfind(
r#"<?xml version="1.0" encoding="UTF-8"?><propfind xmlns="DAV:"><allprop/></propfind>"#,
)
.unwrap();
insta::assert_debug_snapshot!(propfind);
let principal = Principal {
id: "user".to_string(),
displayname: None,
principal_type: Individual,
password: None,
memberships: vec!["group".to_string()],
};
let resource = PrincipalResource {
principal: principal.clone(),
members: vec![],
simplified_home_set: false,
};
let response = resource
.propfind(
&format!("/caldav/principal/{}", principal.id),
&propfind.prop,
propfind.include.as_ref(),
&CalDavPrincipalUri("/caldav"),
&principal,
)
.unwrap();
insta::assert_debug_snapshot!(response);
insta::assert_snapshot!(response.serialize_to_string().unwrap());
}

View File

@@ -1,6 +1,7 @@
[package] [package]
name = "rustical_carddav" name = "rustical_carddav"
version.workspace = true version.workspace = true
rust-version.workspace = true
edition.workspace = true edition.workspace = true
description.workspace = true description.workspace = true
repository.workspace = true repository.workspace = true
@@ -11,19 +12,19 @@ publish = false
axum.workspace = true axum.workspace = true
axum-extra.workspace = true axum-extra.workspace = true
tower.workspace = true tower.workspace = true
async-trait = { workspace = true } async-trait.workspace = true
thiserror = { workspace = true } thiserror.workspace = true
quick-xml = { workspace = true } quick-xml.workspace = true
tracing = { workspace = true } tracing.workspace = true
futures-util = { workspace = true } futures-util.workspace = true
derive_more = { workspace = true } derive_more.workspace = true
base64 = { workspace = true } base64.workspace = true
serde = { workspace = true } serde.workspace = true
tokio = { workspace = true } tokio.workspace = true
url = { workspace = true } url.workspace = true
rustical_dav = { workspace = true } rustical_dav = { workspace = true, features = ["ical"] }
rustical_store = { workspace = true } rustical_store.workspace = true
chrono = { workspace = true } chrono.workspace = true
rustical_xml.workspace = true rustical_xml.workspace = true
uuid.workspace = true uuid.workspace = true
rustical_dav_push.workspace = true rustical_dav_push.workspace = true
@@ -34,3 +35,7 @@ percent-encoding.workspace = true
ical.workspace = true ical.workspace = true
strum.workspace = true strum.workspace = true
strum_macros.workspace = true strum_macros.workspace = true
rstest.workspace = true
[dev-dependencies]
insta.workspace = true

View File

@@ -7,6 +7,8 @@ use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
use axum_extra::headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch}; use axum_extra::headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch};
use http::HeaderValue;
use http::Method;
use http::{HeaderMap, StatusCode}; use http::{HeaderMap, StatusCode};
use rustical_dav::privileges::UserPrivilege; use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource; use rustical_dav::resource::Resource;
@@ -25,6 +27,7 @@ pub async fn get_object<AS: AddressbookStore>(
}): Path<AddressObjectPathComponents>, }): Path<AddressObjectPathComponents>,
State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>, State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>,
user: Principal, user: Principal,
method: Method,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
@@ -48,8 +51,12 @@ pub async fn get_object<AS: AddressbookStore>(
let mut resp = Response::builder().status(StatusCode::OK); let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap(); let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ETag::from_str(&object.get_etag()).unwrap()); hdrs.typed_insert(ETag::from_str(&object.get_etag()).unwrap());
hdrs.typed_insert(ContentType::from_str("text/vcard").unwrap()); hdrs.typed_insert(ContentType::from_str("text/vcard; charset=utf-8").unwrap());
if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(object.get_vcf().to_owned())).unwrap()) Ok(resp.body(Body::new(object.get_vcf().to_owned())).unwrap())
}
} }
#[instrument(skip(addr_store, body))] #[instrument(skip(addr_store, body))]
@@ -75,15 +82,40 @@ pub async fn put_object<AS: AddressbookStore>(
} }
let overwrite = if let Some(TypedHeader(if_none_match)) = if_none_match { let overwrite = if let Some(TypedHeader(if_none_match)) = if_none_match {
if_none_match == IfNoneMatch::any() // TODO: Put into transaction?
let existing = match addr_store
.get_object(&principal, &addressbook_id, &object_id, false)
.await
{
Ok(existing) => Some(existing),
Err(rustical_store::Error::NotFound) => None,
Err(err) => Err(err)?,
};
existing.is_none_or(|existing| {
if_none_match.precondition_passes(
&existing
.get_etag()
.parse()
.expect("We only generate valid ETags"),
)
})
} else { } else {
true true
}; };
let object = AddressObject::from_vcf(object_id, body)?; let object = match AddressObject::from_vcf(body) {
Ok(object) => object,
Err(err) => return Ok((StatusCode::BAD_REQUEST, err.to_string()).into_response()),
};
let etag = object.get_etag();
addr_store addr_store
.put_object(principal, addressbook_id, object, overwrite) .put_object(&principal, &addressbook_id, &object_id, object, overwrite)
.await?; .await?;
Ok(StatusCode::CREATED.into_response()) let mut headers = HeaderMap::new();
headers.insert(
"ETag",
HeaderValue::from_str(&etag).expect("Contains no invalid characters"),
);
Ok((StatusCode::CREATED, headers).into_response())
} }

View File

@@ -1,7 +1,7 @@
use rustical_dav::extensions::CommonPropertiesProp; use rustical_dav::extensions::CommonPropertiesProp;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "AddressObjectPropName")] #[xml(unit_variants_ident = "AddressObjectPropName")]
pub enum AddressObjectProp { pub enum AddressObjectProp {
// WebDAV (RFC 2518) // WebDAV (RFC 2518)
@@ -15,7 +15,7 @@ pub enum AddressObjectProp {
AddressData(String), AddressData(String),
} }
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Eq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "AddressObjectPropWrapperName", untagged)] #[xml(unit_variants_ident = "AddressObjectPropWrapperName", untagged)]
pub enum AddressObjectPropWrapper { pub enum AddressObjectPropWrapper {
AddressObject(AddressObjectProp), AddressObject(AddressObjectProp),

View File

@@ -1,3 +1,5 @@
use std::borrow::Cow;
use crate::{ use crate::{
Error, Error,
address_object::{ address_object::{
@@ -6,6 +8,7 @@ use crate::{
}, },
}; };
use derive_more::derive::{From, Into}; use derive_more::derive::{From, Into};
use ical::parser::VcardFNProperty;
use rustical_dav::{ use rustical_dav::{
extensions::CommonPropertiesExtension, extensions::CommonPropertiesExtension,
privileges::UserPrivilegeSet, privileges::UserPrivilegeSet,
@@ -19,11 +22,12 @@ use rustical_store::auth::Principal;
pub struct AddressObjectResource { pub struct AddressObjectResource {
pub object: AddressObject, pub object: AddressObject,
pub principal: String, pub principal: String,
pub object_id: String,
} }
impl ResourceName for AddressObjectResource { impl ResourceName for AddressObjectResource {
fn get_name(&self) -> String { fn get_name(&self) -> Cow<'_, str> {
format!("{}.vcf", self.object.get_id()) Cow::from(format!("{}.vcf", self.object_id))
} }
} }
@@ -67,7 +71,11 @@ impl Resource for AddressObjectResource {
} }
fn get_displayname(&self) -> Option<&str> { fn get_displayname(&self) -> Option<&str> {
self.object.get_full_name() self.object
.get_vcard()
.full_name
.first()
.map(|VcardFNProperty(name, _)| name.as_str())
} }
fn get_owner(&self) -> Option<&str> { fn get_owner(&self) -> Option<&str> {

View File

@@ -49,13 +49,15 @@ impl<AS: AddressbookStore> ResourceService for AddressObjectResourceService<AS>
addressbook_id, addressbook_id,
object_id, object_id,
}: &Self::PathComponents, }: &Self::PathComponents,
show_deleted: bool,
) -> Result<Self::Resource, Self::Error> { ) -> Result<Self::Resource, Self::Error> {
let object = self let object = self
.addr_store .addr_store
.get_object(principal, addressbook_id, object_id, false) .get_object(principal, addressbook_id, object_id, show_deleted)
.await?; .await?;
Ok(AddressObjectResource { Ok(AddressObjectResource {
object, object,
object_id: object_id.to_owned(),
principal: principal.to_owned(), principal: principal.to_owned(),
}) })
} }
@@ -97,9 +99,8 @@ where
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
let name: String = Deserialize::deserialize(deserializer)?; let name: String = Deserialize::deserialize(deserializer)?;
if let Some(object_id) = name.strip_suffix(".vcf") { name.strip_suffix(".vcf").map_or_else(
Ok(object_id.to_owned()) || Err(serde::de::Error::custom("Missing .vcf extension")),
} else { |object_id| Ok(object_id.to_owned()),
Err(serde::de::Error::custom("Missing .vcf extension")) )
}
} }

View File

@@ -5,11 +5,10 @@ use axum::body::Body;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::response::Response; use axum::response::Response;
use axum_extra::headers::{ContentType, HeaderMapExt}; use axum_extra::headers::{ContentType, HeaderMapExt};
use http::{HeaderValue, StatusCode, header}; use http::{HeaderValue, Method, StatusCode, header};
use percent_encoding::{CONTROLS, utf8_percent_encode}; use percent_encoding::{CONTROLS, utf8_percent_encode};
use rustical_dav::privileges::UserPrivilege; use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource; use rustical_dav::resource::Resource;
use rustical_ical::AddressObject;
use rustical_store::auth::Principal; use rustical_store::auth::Principal;
use rustical_store::{AddressbookStore, SubscriptionStore}; use rustical_store::{AddressbookStore, SubscriptionStore};
use std::str::FromStr; use std::str::FromStr;
@@ -20,6 +19,7 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>, Path((principal, addressbook_id)): Path<(String, String)>,
State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>, State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,
user: Principal, user: Principal,
method: Method,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
@@ -39,14 +39,14 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
let objects = addr_store.get_objects(&principal, &addressbook_id).await?; let objects = addr_store.get_objects(&principal, &addressbook_id).await?;
let vcf = objects let vcf = objects
.iter() .iter()
.map(AddressObject::get_vcf) .map(|(_id, obj)| obj.get_vcf())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\r\n"); .join("\r\n");
let mut resp = Response::builder().status(StatusCode::OK); let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap(); let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ContentType::from_str("text/vcard").unwrap()); hdrs.typed_insert(ContentType::from_str("text/vcard; charset=utf-8").unwrap());
let filename = format!("{}_{}.vcf", principal, addressbook_id); let filename = format!("{principal}_{addressbook_id}.vcf");
let filename = utf8_percent_encode(&filename, CONTROLS); let filename = utf8_percent_encode(&filename, CONTROLS);
hdrs.insert( hdrs.insert(
header::CONTENT_DISPOSITION, header::CONTENT_DISPOSITION,
@@ -55,5 +55,9 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
)) ))
.unwrap(), .unwrap(),
); );
if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(vcf)).unwrap()) Ok(resp.body(Body::new(vcf)).unwrap())
}
} }

View File

@@ -0,0 +1,66 @@
use crate::Error;
use crate::addressbook::AddressbookResourceService;
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use ical::{
parser::{Component, ComponentMut, vcard},
property::ContentLine,
};
use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::Principal};
use tracing::instrument;
#[instrument(skip(resource_service))]
pub async fn route_import<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>,
user: Principal,
State(resource_service): State<AddressbookResourceService<AS, S>>,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
}
let parser = vcard::VcardParser::from_slice(body.as_bytes());
let mut objects = vec![];
for res in parser {
let mut card = res.unwrap();
let uid = card.get_uid();
if uid.is_none() {
let mut card_mut = card.mutable();
card_mut.add_content_line(ContentLine {
name: "UID".to_owned(),
value: Some(uuid::Uuid::new_v4().to_string()),
params: vec![].into(),
});
card = card_mut.build(None).unwrap();
}
// TODO: Make nicer
let uid = card.get_uid().unwrap();
objects.push((uid.to_owned(), card.into()));
}
if objects.is_empty() {
return Ok((StatusCode::BAD_REQUEST, "empty addressbook data").into_response());
}
let addressbook = Addressbook {
principal,
id: addressbook_id,
displayname: None,
description: None,
deleted_at: None,
synctoken: 0,
push_topic: uuid::Uuid::new_v4().to_string(),
};
let addr_store = resource_service.addr_store;
addr_store
.import_addressbook(addressbook, objects, false)
.await?;
Ok(StatusCode::OK.into_response())
}

View File

@@ -8,7 +8,7 @@ use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::Pri
use rustical_xml::{XmlDeserialize, XmlDocument, XmlRootTag}; use rustical_xml::{XmlDeserialize, XmlDocument, XmlRootTag};
use tracing::instrument; use tracing::instrument;
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
pub struct Resourcetype { pub struct Resourcetype {
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
addressbook: Option<()>, addressbook: Option<()>,
@@ -16,25 +16,25 @@ pub struct Resourcetype {
collection: Option<()>, collection: Option<()>,
} }
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
pub struct MkcolAddressbookProp { pub struct MkcolAddressbookProp {
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
resourcetype: Option<Resourcetype>, resourcetype: Option<Resourcetype>,
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
displayname: Option<String>, displayname: Option<String>,
#[xml(rename = b"addressbook-description")] #[xml(rename = "addressbook-description")]
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
description: Option<String>, description: Option<String>,
} }
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
pub struct PropElement<T: XmlDeserialize> { pub struct PropElement<T: XmlDeserialize> {
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
prop: T, prop: T,
} }
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, XmlRootTag, Clone, Debug, PartialEq)]
#[xml(root = b"mkcol")] #[xml(root = "mkcol")]
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
struct MkcolRequest { struct MkcolRequest {
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
@@ -53,13 +53,13 @@ pub async fn route_mkcol<AS: AddressbookStore, S: SubscriptionStore>(
} }
let mut request = MkcolRequest::parse_str(&body)?.set.prop; let mut request = MkcolRequest::parse_str(&body)?.set.prop;
if let Some("") = request.displayname.as_deref() { if request.displayname.as_deref() == Some("") {
request.displayname = None request.displayname = None;
} }
let addressbook = Addressbook { let addressbook = Addressbook {
id: addressbook_id.to_owned(), id: addressbook_id.clone(),
principal: principal.to_owned(), principal: principal.clone(),
displayname: request.displayname, displayname: request.displayname,
description: request.description, description: request.description,
deleted_at: None, deleted_at: None,
@@ -88,15 +88,8 @@ pub async fn route_mkcol<AS: AddressbookStore, S: SubscriptionStore>(
} }
} }
match addr_store.insert_addressbook(addressbook).await { addr_store.insert_addressbook(addressbook).await?;
// TODO: The spec says we should return a mkcol-response. Ok(StatusCode::CREATED.into_response())
// However, it works without one but breaks on iPadOS when using an empty one :)
Ok(()) => Ok(StatusCode::CREATED.into_response()),
Err(err) => {
dbg!(err.to_string());
Err(err.into())
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -134,6 +127,6 @@ mod tests {
} }
} }
} }
) );
} }
} }

View File

@@ -1,5 +1,5 @@
pub mod get; pub mod get;
pub mod import;
pub mod mkcol; pub mod mkcol;
pub mod post; pub mod post;
pub mod put;
pub mod report; pub mod report;

View File

@@ -45,12 +45,12 @@ pub async fn route_post<AS: AddressbookStore, S: SubscriptionStore>(
}; };
let subscription = Subscription { let subscription = Subscription {
id: sub_id.to_owned(), id: sub_id.clone(),
push_resource: request push_resource: request
.subscription .subscription
.web_push_subscription .web_push_subscription
.push_resource .push_resource
.to_owned(), .clone(),
topic: addressbook_resource.0.push_topic, topic: addressbook_resource.0.push_topic,
expiration: expires.naive_local(), expiration: expires.naive_local(),
public_key: request public_key: request

View File

@@ -1,47 +0,0 @@
use crate::Error;
use crate::addressbook::AddressbookResourceService;
use axum::response::IntoResponse;
use axum::{
extract::{Path, State},
response::Response,
};
use http::StatusCode;
use ical::VcardParser;
use rustical_ical::AddressObject;
use rustical_store::Addressbook;
use rustical_store::{AddressbookStore, SubscriptionStore, auth::Principal};
use tracing::instrument;
#[instrument(skip(addr_store))]
pub async fn route_put<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>,
State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,
user: Principal,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
}
let mut objects = vec![];
for object in VcardParser::new(body.as_bytes()) {
let object = object.map_err(rustical_ical::Error::from)?;
objects.push(AddressObject::try_from(object)?);
}
let addressbook = Addressbook {
id: addressbook_id.clone(),
principal: principal.clone(),
displayname: None,
description: None,
deleted_at: None,
synctoken: Default::default(),
push_topic: uuid::Uuid::new_v4().to_string(),
};
addr_store
.import_addressbook(principal.clone(), addressbook, objects)
.await?;
Ok(StatusCode::CREATED.into_response())
}

View File

@@ -13,7 +13,7 @@ use rustical_ical::AddressObject;
use rustical_store::{AddressbookStore, auth::Principal}; use rustical_store::{AddressbookStore, auth::Principal};
use rustical_xml::XmlDeserialize; use rustical_xml::XmlDeserialize;
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)] #[allow(dead_code)]
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
pub struct AddressbookMultigetRequest { pub struct AddressbookMultigetRequest {
@@ -29,35 +29,36 @@ pub async fn get_objects_addressbook_multiget<AS: AddressbookStore>(
principal: &str, principal: &str,
addressbook_id: &str, addressbook_id: &str,
store: &AS, store: &AS,
) -> Result<(Vec<AddressObject>, Vec<String>), Error> { ) -> Result<(Vec<(String, AddressObject)>, Vec<String>), Error> {
let mut result = vec![]; let mut result = vec![];
let mut not_found = vec![]; let mut not_found = vec![];
for href in &addressbook_multiget.href { for href in &addressbook_multiget.href {
if let Some(filename) = href.strip_prefix(path) { if let Ok(href) = percent_encoding::percent_decode_str(href).decode_utf8()
let filename = filename.trim_start_matches("/"); && let Some(filename) = href.strip_prefix(path)
{
let filename = filename.trim_start_matches('/');
if let Some(object_id) = filename.strip_suffix(".vcf") { if let Some(object_id) = filename.strip_suffix(".vcf") {
match store match store
.get_object(principal, addressbook_id, object_id, false) .get_object(principal, addressbook_id, object_id, false)
.await .await
{ {
Ok(object) => result.push(object), Ok(object) => result.push((object_id.to_owned(), object)),
Err(rustical_store::Error::NotFound) => not_found.push(href.to_owned()), Err(rustical_store::Error::NotFound) => not_found.push(href.to_string()),
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
}; }
} else { } else {
not_found.push(href.to_owned()); not_found.push(href.to_string());
continue;
} }
} else { } else {
not_found.push(href.to_owned()); not_found.push(href.to_owned());
continue;
} }
} }
Ok((result, not_found)) Ok((result, not_found))
} }
#[allow(clippy::too_many_arguments)]
pub async fn handle_addressbook_multiget<AS: AddressbookStore>( pub async fn handle_addressbook_multiget<AS: AddressbookStore>(
addr_multiget: &AddressbookMultigetRequest, addr_multiget: &AddressbookMultigetRequest,
prop: &PropfindType<AddressObjectPropWrapperName>, prop: &PropfindType<AddressObjectPropWrapperName>,
@@ -73,14 +74,15 @@ pub async fn handle_addressbook_multiget<AS: AddressbookStore>(
.await?; .await?;
let mut responses = Vec::new(); let mut responses = Vec::new();
for object in objects { for (object_id, object) in objects {
let path = format!("{}/{}.vcf", path, object.get_id()); let path = format!("{path}/{object_id}.vcf");
responses.push( responses.push(
AddressObjectResource { AddressObjectResource {
object, object,
object_id,
principal: principal.to_owned(), principal: principal.to_owned(),
} }
.propfind(&path, prop, puri, user)?, .propfind(&path, prop, None, puri, user)?,
); );
} }

View File

@@ -0,0 +1,139 @@
use crate::{
address_object::AddressObjectPropWrapperName,
addressbook::methods::report::addressbook_query::PropFilterElement,
};
use derive_more::{From, Into};
use ical::property::ContentLine;
use rustical_dav::xml::{PropfindType, TextMatchElement};
use rustical_ical::{AddressObject, UtcDateTime};
use rustical_xml::{ValueDeserialize, XmlDeserialize, XmlRootTag};
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
pub struct TimeRangeElement {
#[xml(ty = "attr")]
pub(crate) start: Option<UtcDateTime>,
#[xml(ty = "attr")]
pub(crate) end: Option<UtcDateTime>,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
// https://www.rfc-editor.org/rfc/rfc4791#section-9.7.3
pub struct ParamFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
pub(crate) is_not_defined: Option<()>,
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
pub(crate) text_match: Option<TextMatchElement>,
#[xml(ty = "attr")]
pub(crate) name: String,
}
impl ParamFilterElement {
#[must_use]
pub fn match_property(&self, prop: &ContentLine) -> bool {
let Some(param) = prop.params.get_param(&self.name) else {
return self.is_not_defined.is_some();
};
if self.is_not_defined.is_some() {
return false;
}
let Some(text_match) = self.text_match.as_ref() else {
return true;
};
text_match.match_text(param)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Default, From, Into)]
pub struct Allof(pub bool);
impl ValueDeserialize for Allof {
fn deserialize(val: &str) -> Result<Self, rustical_xml::XmlError> {
Ok(Self(match val {
"allof" => true,
"anyof" => false,
_ => {
return Err(rustical_xml::XmlError::InvalidVariant(format!(
"Invalid test parameter: {val}"
)));
}
}))
}
}
// <!ELEMENT filter (prop-filter*)>
// <!ATTLIST filter test (anyof | allof) "anyof">
// <!-- test value:
// anyof logical OR for prop-filter matches
// allof logical AND for prop-filter matches -->
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug, PartialEq, Eq)]
#[xml(root = "filter", ns = "rustical_dav::namespace::NS_CARDDAV")]
#[allow(dead_code)]
pub struct FilterElement {
#[xml(ty = "attr", default = "Default::default")]
pub test: Allof,
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", flatten)]
pub(crate) prop_filter: Vec<PropFilterElement>,
}
impl FilterElement {
#[must_use]
pub fn matches(&self, addr_object: &AddressObject) -> bool {
if self.prop_filter.is_empty() {
// Filter empty
return true;
}
let Allof(allof) = self.test;
let mut results = self
.prop_filter
.iter()
.map(|prop_filter| prop_filter.match_component(addr_object));
if allof {
results.all(|x| x)
} else {
results.any(|x| x)
}
}
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
// <!ELEMENT addressbook-query ((DAV:allprop |
// DAV:propname |
// DAV:prop)?, filter, limit?)>
pub struct AddressbookQueryRequest {
#[xml(ty = "untagged")]
pub prop: PropfindType<AddressObjectPropWrapperName>,
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
pub(crate) filter: FilterElement,
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
pub(crate) limit: Option<LimitElement>,
}
// https://datatracker.ietf.org/doc/html/rfc5323#section-5.17
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
pub struct LimitElement {
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
pub nresults: NresultsElement,
}
impl From<u64> for LimitElement {
fn from(value: u64) -> Self {
Self {
nresults: NresultsElement(value),
}
}
}
impl From<LimitElement> for u64 {
fn from(value: LimitElement) -> Self {
value.nresults.0
}
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
pub struct NresultsElement(#[xml(ty = "text")] pub u64);

View File

@@ -0,0 +1,22 @@
use crate::Error;
mod elements;
mod prop_filter;
pub use elements::*;
#[allow(unused_imports)]
pub use prop_filter::{PropFilterElement, PropFilterable};
use rustical_ical::AddressObject;
use rustical_store::AddressbookStore;
#[cfg(test)]
mod tests;
pub async fn get_objects_addressbook_query<AS: AddressbookStore>(
addr_query: &AddressbookQueryRequest,
principal: &str,
addressbook_id: &str,
store: &AS,
) -> Result<Vec<(String, AddressObject)>, Error> {
let mut objects = store.get_objects(principal, addressbook_id).await?;
objects.retain(|(_id, object)| addr_query.filter.matches(object));
Ok(objects)
}

View File

@@ -0,0 +1,77 @@
use super::{Allof, ParamFilterElement};
use ical::{parser::Component, property::ContentLine};
use rustical_dav::xml::TextMatchElement;
use rustical_ical::AddressObject;
use rustical_xml::XmlDeserialize;
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
// <!ELEMENT prop-filter (is-not-defined |
// (text-match*, param-filter*))>
//
// <!ATTLIST prop-filter name CDATA #REQUIRED
// test (anyof | allof) "anyof">
// <!-- name value: a vCard property name (e.g., "NICKNAME")
// test value:
// anyof logical OR for text-match/param-filter matches
// allof logical AND for text-match/param-filter matches -->
pub struct PropFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
pub(crate) is_not_defined: Option<()>,
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", flatten)]
pub(crate) text_match: Vec<TextMatchElement>,
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", flatten)]
pub(crate) param_filter: Vec<ParamFilterElement>,
#[xml(ty = "attr", default = "Default::default")]
pub test: Allof,
#[xml(ty = "attr")]
pub(crate) name: String,
}
impl PropFilterElement {
#[must_use]
pub fn match_property(&self, property: &ContentLine) -> bool {
if self.param_filter.is_empty() && self.text_match.is_empty() {
// Filter empty
return true;
}
let Allof(allof) = self.test;
let text_matches = self
.text_match
.iter()
.map(|text_match| text_match.match_property(property));
let param_matches = self
.param_filter
.iter()
.map(|param_filter| param_filter.match_property(property));
let mut matches = text_matches.chain(param_matches);
if allof {
matches.all(|a| a)
} else {
matches.any(|a| a)
}
}
pub fn match_component(&self, comp: &impl PropFilterable) -> bool {
let mut properties = comp.get_named_properties(&self.name);
if self.is_not_defined.is_some() {
return properties.next().is_none();
}
// The filter matches when one property instance matches
properties.any(|prop| self.match_property(prop))
}
}
pub trait PropFilterable {
fn get_named_properties<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a ContentLine>;
}
impl PropFilterable for AddressObject {
fn get_named_properties<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a ContentLine> {
self.get_vcard().get_named_properties(name)
}
}

View File

@@ -0,0 +1,70 @@
use super::FilterElement;
use rstest::rstest;
use rustical_ical::AddressObject;
use rustical_xml::XmlDocument;
const VCF_1: &str = r"BEGIN:VCARD
VERSION:4.0
FN:Simon Perreault
N:Perreault;Simon;;;ing. jr,M.Sc.
BDAY:--0203
GENDER:M
EMAIL;TYPE=work:simon.perreault@viagenie.ca
END:VCARD";
const VCF_2: &str = r"BEGIN:VCARD
VERSION:4.0
N:Gump;Forrest;;Mr.;
FN:Forrest Gump
ORG:Bubba Gump Shrimp Co.
TITLE:Shrimp Man
PHOTO;MEDIATYPE=image/gif:http://www.example.com/dir_photos/my_photo.gif
TEL;TYPE=work,voice;VALUE=uri:tel:+1-111-555-1212
TEL;TYPE=home,voice;VALUE=uri:tel:+1-404-555-1212
ADR;TYPE=WORK;PREF=1;LABEL=100 Waters Edge\\nBaytown\\, LA 30314\\nUnited S
tates of America:;;100 Waters Edge;Baytown;LA;30314;United States of Ameri
ca
ADR;TYPE=HOME;LABEL=42 Plantation St.\\nBaytown\\, LA 30314\\nUnited States
of America:;;42 Plantation St.;Baytown;LA;30314;United States of America
EMAIL:forrestgump@example.com
REV:20080424T195243Z
x-qq:21588891
UID:890a9da4-bb6d-4afb-9f32-b5eff6494a53
END:VCARD
";
const FILTER_1: &str = r#"
<?xml version="1.0" encoding="utf-8" ?>
<C:filter xmlns:C="urn:ietf:params:xml:ns:carddav">
<C:prop-filter name="EMAIL" test="allof">
<C:text-match collation="i;ascii-casemap">simon.perreault@viagenie.ca</C:text-match>
<C:param-filter name="TYPE">
<C:text-match match-type="equals" collation="i;unicode-casemap">WORK</C:text-match>
</C:param-filter>
</C:prop-filter>
</C:filter>
"#;
const FILTER_2: &str = r#"
<?xml version="1.0" encoding="utf-8" ?>
<C:filter xmlns:C="urn:ietf:params:xml:ns:carddav">
<C:prop-filter name="EMAIL" test="anyof">
<C:text-match collation="i;ascii-casemap">forrestgump@example.com</C:text-match>
<C:param-filter name="TYPE">
<C:text-match match-type="equals" collation="i;ascii-casemap">WORK</C:text-match>
</C:param-filter>
</C:prop-filter>
</C:filter>
"#;
#[rstest]
#[case(VCF_1, FILTER_1, true)]
#[case(VCF_2, FILTER_1, false)]
#[case(VCF_1, FILTER_2, true)]
#[case(VCF_2, FILTER_2, true)]
fn test_filter(#[case] vcf: &str, #[case] filter: &str, #[case] matches: bool) {
dbg!(vcf);
let obj = AddressObject::from_vcf(vcf.to_owned()).unwrap();
let filter = FilterElement::parse_str(filter).unwrap();
assert_eq!(matches, filter.matches(&obj));
}

Some files were not shown because too many files have changed in this diff Show More