Compare commits

...

240 Commits

Author SHA1 Message Date
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
Lennart
786b15f5b9 version 0.3.4 2025-06-22 23:58:49 +02:00
Lennart
f5d097ac55 oidc: Fix for OIDC servers not supporting RFC 9207
see #81
2025-06-22 23:55:57 +02:00
Lennart
668fa86e3c Update version to 0.3.3 2025-06-22 21:46:37 +02:00
Lennart
23d2024644 Update note on production-readiness 2025-06-22 19:43:46 +02:00
Lennart
15aadcf1be Rename User struct to Principal 2025-06-19 20:59:59 +02:00
Lennart
4a3b7d7ce6 Update typescript config 2025-06-19 20:52:17 +02:00
Lennart
1a2f3b8f8a frontend: Move collection creation to dialog 2025-06-18 18:09:19 +02:00
Lennart
9e8c218308 Remove unused p256 dependency 2025-06-18 17:49:00 +02:00
Lennart
f2adce739b Update version to v0.3.2 2025-06-15 17:12:34 +02:00
Lennart
0415664ff3 calendar_store: Fix deleted objects being returned 2025-06-15 16:31:07 +02:00
Lennart
677e0082fa multistatus response: Set No-Cache 2025-06-15 13:16:37 +02:00
Lennart
a387885b0a Remove calendar-proxy-write from caldav principal 2025-06-15 11:44:44 +02:00
Lennart
990b953055 Fix typo on store preventing us from deleting calendar objects 2025-06-15 10:37:51 +02:00
Lennart
36b47a645d Fix missing ece backend, finally managed to statically link openssl 2025-06-14 22:26:01 +02:00
223 changed files with 8622 additions and 7750 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

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

@@ -0,0 +1,57 @@
name: "CICD"
on: [push, pull_request]
permissions:
contents: read
pull-requests: write
env:
CARGO_TERM_COLOR: always
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- run: rustup update
- name: Checkout sources
uses: actions/checkout@v4
- run: cargo check
test:
name: Test Suite
runs-on: ubuntu-latest
steps:
- run: rustup update
- name: Checkout sources
uses: actions/checkout@v4
- run: cargo test --all-features --verbose --workspace
coverage:
name: Test Coverage
runs-on: ubuntu-latest
steps:
- run: rustup update
- 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:
- run: rustup update
- run: rustup component add 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

@@ -41,12 +41,10 @@ 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}}
type=ref,event=branch type=ref,event=branch
type=ref,event=pr type=ref,event=pr
type=semver,pattern={{version}} type=semver,pattern={{version}}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT id, vcf FROM addressobjects WHERE (principal, addressbook_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) or ?)", "query": "SELECT id, vcf FROM addressobjects WHERE (principal, addressbook_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) OR ?)",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -22,5 +22,5 @@
false false
] ]
}, },
"hash": "395e40a7b3333b79bc2ad50a123d99f74bc2712a16257ee2119dd211fdb61f7e" "hash": "246ec675667992c1297c29348d46496a884c59adb8b64b569d36f4ce10f88f47"
} }

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 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

@@ -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

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT id, ics FROM calendarobjects WHERE (principal, cal_id, id) = (?, ?, ?)", "query": "SELECT id, ics FROM calendarobjects WHERE (principal, cal_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) OR ?)",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -15,12 +15,12 @@
} }
], ],
"parameters": { "parameters": {
"Right": 3 "Right": 4
}, },
"nullable": [ "nullable": [
false, false,
false false
] ]
}, },
"hash": "d2f7423e2e8f97607f6664200990dcadb927445880ec6edffba3b5aedf4e199b" "hash": "543838c030550cb09d1af08adfeade8b7ce3575d92fddbc6e9582d141bc9e49d"
} }

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

@@ -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

@@ -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

@@ -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

@@ -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"
}

1481
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,10 @@
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.3.0" version = "0.9.10"
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"
@@ -16,7 +17,7 @@ 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"]
@@ -48,7 +49,7 @@ rand_core = { version = "0.9", features = ["std"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
regex = "1.10" regex = "1.10"
lazy_static = "1.5" lazy_static = "1.5"
rstest = "0.25" rstest = "0.26"
rstest_reuse = "0.7" rstest_reuse = "0.7"
sha2 = "0.10" sha2 = "0.10"
tokio = { version = "1", features = [ tokio = { version = "1", features = [
@@ -61,7 +62,7 @@ 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.38" }
rust-embed = "8.5" rust-embed = "8.5"
tower-sessions = "0.14" tower-sessions = "0.14"
futures-core = "0.3.31" futures-core = "0.3.31"
@@ -95,8 +96,12 @@ 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", features = [
toml = "0.8" "generator",
"serde",
"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",
@@ -126,7 +131,7 @@ 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.21"
reqwest = { version = "0.12", features = [ reqwest = { version = "0.12", features = [
"rustls-tls", "rustls-tls",
"charset", "charset",
@@ -135,8 +140,12 @@ reqwest = { version = "0.12", features = [
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 = "f0591d13" }
ece = { version = "2.3", default-features = false } vtimezones-rs = "0.2"
p256 = { version = "0.13", features = ["ecdh"] } ece = { version = "2.3", default-features = false, features = [
"backend-openssl",
] }
openssl = { version = "0.10", features = ["vendored"] }
async-std = { version = "1.13", features = ["attributes"] }
[dependencies] [dependencies]
rustical_store = { workspace = true } rustical_store = { workspace = true }

View File

@@ -1,11 +1,11 @@
FROM --platform=$BUILDPLATFORM rust:1.86-alpine AS chef FROM --platform=$BUILDPLATFORM rust:1.90-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 \ 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"
@@ -45,4 +45,5 @@ 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

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

@@ -3,22 +3,24 @@
a CalDAV/CardDAV server a CalDAV/CardDAV server
> [!WARNING] > [!WARNING]
RustiCal is **not production-ready!** RustiCal is under **active development**!
While I've started migrating to RustiCal and becoming more confident, While I've been successfully using RustiCal productively for some months now and there seems to be a growing user base,
please know that bugs and rough edges will still occur. 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 +32,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,7 @@ 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",
] ]
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

@@ -7,6 +7,12 @@ 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
[dependencies] [dependencies]
axum.workspace = true axum.workspace = true
axum-extra.workspace = true axum-extra.workspace = true
@@ -37,3 +43,4 @@ 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

View File

@@ -4,12 +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::generator::{Emitter, IcalCalendarBuilder};
use ical::property::Property; use ical::property::Property;
use percent_encoding::{CONTROLS, utf8_percent_encode}; use percent_encoding::{CONTROLS, utf8_percent_encode};
use rustical_ical::{CalendarObjectComponent, EventObject, JournalObject, TodoObject}; use rustical_ical::{CalendarObjectComponent, EventObject};
use rustical_store::{CalendarStore, SubscriptionStore, auth::User}; use rustical_store::{CalendarStore, SubscriptionStore, auth::Principal};
use std::collections::HashMap; use std::collections::HashMap;
use std::str::FromStr; use std::str::FromStr;
use tracing::instrument; use tracing::instrument;
@@ -18,66 +18,93 @@ use tracing::instrument;
pub async fn route_get<C: CalendarStore, S: SubscriptionStore>( 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: User, 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 calendar = cal_store
.get_calendar(&principal, &calendar_id, true)
.await?;
let mut timezones = HashMap::new(); let mut timezones = HashMap::new();
let mut vtimezones = HashMap::new();
let objects = cal_store.get_objects(&principal, &calendar_id).await?; let objects = cal_store.get_objects(&principal, &calendar_id).await?;
let mut ical_calendar_builder = IcalCalendarBuilder::version("4.0") let mut ical_calendar_builder = IcalCalendarBuilder::version("4.0")
.gregorian() .gregorian()
.prodid("RustiCal"); .prodid("RustiCal");
if calendar.displayname.is_some() { if let Some(displayname) = calendar.meta.displayname {
ical_calendar_builder = ical_calendar_builder.set(Property { 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: None,
}); });
} }
if calendar.description.is_some() { if let Some(description) = calendar.meta.description {
ical_calendar_builder = ical_calendar_builder.set(Property { ical_calendar_builder = ical_calendar_builder.set(Property {
name: "X-WR-CALDESC".to_owned(), name: "X-WR-CALDESC".to_owned(),
value: calendar.description, value: Some(description),
params: None, params: None,
}); });
} }
if calendar.timezone_id.is_some() { if let Some(timezone_id) = calendar.timezone_id {
ical_calendar_builder = ical_calendar_builder.set(Property { ical_calendar_builder = ical_calendar_builder.set(Property {
name: "X-WR-TIMEZONE".to_owned(), name: "X-WR-TIMEZONE".to_owned(),
value: calendar.timezone_id, value: Some(timezone_id),
params: None, params: None,
}); });
} }
let mut ical_calendar = ical_calendar_builder.build();
for object in &objects { for object in &objects {
vtimezones.extend(object.get_vtimezones());
match object.get_data() { match object.get_data() {
CalendarObjectComponent::Event(EventObject { CalendarObjectComponent::Event(
EventObject {
event, event,
timezones: object_timezones, timezones: object_timezones,
.. ..
}) => { },
overrides,
) => {
timezones.extend(object_timezones); timezones.extend(object_timezones);
ical_calendar.events.push(event.clone()); ical_calendar_builder = ical_calendar_builder.add_event(event.clone());
for _override in overrides {
ical_calendar_builder =
ical_calendar_builder.add_event(_override.event.clone());
} }
CalendarObjectComponent::Todo(TodoObject { todo, .. }) => {
ical_calendar.todos.push(todo.clone());
} }
CalendarObjectComponent::Journal(JournalObject { journal, .. }) => { CalendarObjectComponent::Todo(todo, overrides) => {
ical_calendar.journals.push(journal.clone()); ical_calendar_builder = ical_calendar_builder.add_todo(todo.clone());
for _override in overrides {
ical_calendar_builder = ical_calendar_builder.add_todo(_override.clone());
}
}
CalendarObjectComponent::Journal(journal, overrides) => {
ical_calendar_builder = ical_calendar_builder.add_journal(journal.clone());
for _override in overrides {
ical_calendar_builder = ical_calendar_builder.add_journal(_override.clone());
} }
} }
} }
}
for vtimezone in vtimezones.into_values() {
ical_calendar_builder = ical_calendar_builder.add_tz(vtimezone.to_owned());
}
let ical_calendar = ical_calendar_builder
.build()
.map_err(|parser_error| Error::IcalError(parser_error.into()))?;
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();
@@ -92,5 +119,9 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
)) ))
.unwrap(), .unwrap(),
); );
if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(ical_calendar.generate())).unwrap()) Ok(resp.body(Body::new(ical_calendar.generate())).unwrap())
}
} }

View File

@@ -0,0 +1,110 @@
use crate::Error;
use crate::calendar::CalendarResourceService;
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use ical::{
generator::Emitter,
parser::{Component, ComponentMut},
};
use rustical_dav::header::Overwrite;
use rustical_ical::{CalendarObject, CalendarObjectType};
use rustical_store::{
Calendar, CalendarMetadata, CalendarStore, SubscriptionStore, auth::Principal,
};
use std::io::BufReader;
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 mut parser = ical::IcalParser::new(BufReader::new(body.as_bytes()));
let mut cal = parser
.next()
.expect("input must contain calendar")
.unwrap()
.mutable();
if parser.next().is_some() {
return Err(rustical_ical::Error::InvalidData(
"multiple calendars, only one allowed".to_owned(),
)
.into());
}
// Extract calendar metadata
let displayname = cal
.get_property("X-WR-CALNAME")
.and_then(|prop| prop.value.to_owned());
let description = cal
.get_property("X-WR-CALDESC")
.and_then(|prop| prop.value.to_owned());
let timezone_id = cal
.get_property("X-WR-TIMEZONE")
.and_then(|prop| prop.value.to_owned());
// 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-TIMEZONE");
let cal = cal.verify().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 expanded_cals = cal.expand_calendar();
// Janky way to convert between IcalCalendar and CalendarObject
let objects = expanded_cals
.into_iter()
.map(|cal| cal.generate())
.map(CalendarObject::from_ics)
.collect::<Result<Vec<_>, _>>()?;
let new_cal = Calendar {
principal,
id: cal_id,
meta: CalendarMetadata {
displayname,
order: 0,
description,
color: None,
},
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::User; 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")]
@@ -63,7 +64,7 @@ struct MkcolRequest {
#[instrument(skip(cal_store))] #[instrument(skip(cal_store))]
pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>( pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
Path((principal, cal_id)): Path<(String, String)>, Path((principal, cal_id)): Path<(String, String)>,
user: User, user: Principal,
State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>, State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
method: Method, method: Method,
body: String, body: String,
@@ -82,15 +83,42 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
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::new(tz.as_bytes())
.next()
.ok_or(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
.timezones
.first()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?;
let timezone: chrono_tz::Tz = timezone
.try_into()
.map_err(|_| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?;
Some(timezone.name().to_owned())
} else {
None
};
let calendar = Calendar { let calendar = Calendar {
id: cal_id.to_owned(), id: cal_id.to_owned(),
principal: principal.to_owned(), principal: principal.to_owned(),
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),

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

@@ -7,7 +7,7 @@ use http::{HeaderMap, HeaderValue, StatusCode, header};
use rustical_dav::privileges::UserPrivilege; use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource; use rustical_dav::resource::Resource;
use rustical_dav_push::register::PushRegister; use rustical_dav_push::register::PushRegister;
use rustical_store::auth::User; use rustical_store::auth::Principal;
use rustical_store::{CalendarStore, Subscription, SubscriptionStore}; use rustical_store::{CalendarStore, Subscription, SubscriptionStore};
use rustical_xml::XmlDocument; use rustical_xml::XmlDocument;
use tracing::instrument; use tracing::instrument;
@@ -15,7 +15,7 @@ use tracing::instrument;
#[instrument(skip(resource_service))] #[instrument(skip(resource_service))]
pub async fn route_post<C: CalendarStore, S: SubscriptionStore>( pub async fn route_post<C: CalendarStore, S: SubscriptionStore>(
Path((principal, cal_id)): Path<(String, String)>, Path((principal, cal_id)): Path<(String, String)>,
user: User, user: Principal,
State(resource_service): State<CalendarResourceService<C, S>>, State(resource_service): State<CalendarResourceService<C, S>>,
body: String, body: String,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
@@ -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,

View File

@@ -29,7 +29,7 @@ pub async fn get_objects_calendar_multiget<C: CalendarStore>(
if let Some(filename) = href.strip_prefix(path) { if let Some(filename) = href.strip_prefix(path) {
let filename = filename.trim_start_matches("/"); 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).await { match store.get_object(principal, cal_id, object_id, false).await {
Ok(object) => result.push(object), Ok(object) => result.push(object),
Err(rustical_store::Error::NotFound) => not_found.push(href.to_owned()), Err(rustical_store::Error::NotFound) => not_found.push(href.to_owned()),
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),

View File

@@ -1,7 +1,7 @@
use crate::{Error, calendar_object::CalendarObjectPropWrapperName}; use crate::calendar_object::CalendarObjectPropWrapperName;
use rustical_dav::xml::PropfindType; use rustical_dav::xml::PropfindType;
use rustical_ical::{CalendarObject, UtcDateTime}; use rustical_ical::{CalendarObject, UtcDateTime};
use rustical_store::{CalendarStore, calendar_store::CalendarQuery}; use rustical_store::calendar_store::CalendarQuery;
use rustical_xml::XmlDeserialize; use rustical_xml::XmlDeserialize;
use std::ops::Deref; use std::ops::Deref;
@@ -16,36 +16,42 @@ pub(crate) struct TimeRangeElement {
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)] #[allow(dead_code)]
struct ParamFilterElement { // https://www.rfc-editor.org/rfc/rfc4791#section-9.7.3
pub struct ParamFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
is_not_defined: Option<()>, pub(crate) is_not_defined: Option<()>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
text_match: Option<TextMatchElement>, pub(crate) text_match: Option<TextMatchElement>,
#[xml(ty = "attr")] #[xml(ty = "attr")]
name: String, pub(crate) name: String,
} }
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)] #[allow(dead_code)]
struct TextMatchElement { pub struct TextMatchElement {
#[xml(ty = "attr")] #[xml(ty = "attr")]
collation: String, pub(crate) collation: String,
#[xml(ty = "attr")] #[xml(ty = "attr")]
negate_collation: String, // "yes" or "no", default: "no"
pub(crate) negate_condition: Option<String>,
} }
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)] #[allow(dead_code)]
// https://www.rfc-editor.org/rfc/rfc4791#section-9.7.2
pub(crate) struct PropFilterElement { pub(crate) struct PropFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
is_not_defined: Option<()>, pub(crate) is_not_defined: Option<()>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
time_range: Option<TimeRangeElement>, pub(crate) time_range: Option<TimeRangeElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
text_match: Option<TextMatchElement>, pub(crate) text_match: Option<TextMatchElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)] #[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
param_filter: Vec<ParamFilterElement>, pub(crate) param_filter: Vec<ParamFilterElement>,
#[xml(ty = "attr")]
pub(crate) name: String,
} }
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq)]
@@ -61,7 +67,7 @@ pub(crate) struct CompFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)] #[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
pub(crate) comp_filter: Vec<CompFilterElement>, pub(crate) comp_filter: Vec<CompFilterElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", ty = "attr")] #[xml(ty = "attr")]
pub(crate) name: String, pub(crate) name: String,
} }
@@ -110,20 +116,18 @@ impl CompFilterElement {
// TODO: Implement prop-filter (and comp-filter?) at some point // TODO: Implement prop-filter (and comp-filter?) at some point
if let Some(time_range) = &self.time_range { if let Some(time_range) = &self.time_range {
if let Some(start) = &time_range.start { if let Some(start) = &time_range.start
if let Some(last_occurence) = cal_object.get_last_occurence().unwrap_or(None) { && let Some(last_occurence) = cal_object.get_last_occurence().unwrap_or(None)
if start.deref() > &last_occurence.utc() { && start.deref() > &last_occurence.utc()
{
return false; return false;
} }
}; if let Some(end) = &time_range.end
} && let Some(first_occurence) = cal_object.get_first_occurence().unwrap_or(None)
if let Some(end) = &time_range.end { && end.deref() < &first_occurence.utc()
if let Some(first_occurence) = cal_object.get_first_occurence().unwrap_or(None) { {
if end.deref() < &first_occurence.utc() {
return false; return false;
} }
};
}
} }
true true
} }
@@ -150,8 +154,9 @@ impl From<&FilterElement> for CalendarQuery {
for comp_filter in comp_filter_vcalendar.comp_filter.iter() { 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 // A calendar object cannot contain both VEVENT and VTODO, so we only have to handle
// whatever we get first // whatever we get first
if matches!(comp_filter.name.as_str(), "VEVENT" | "VTODO") { if matches!(comp_filter.name.as_str(), "VEVENT" | "VTODO")
if let Some(time_range) = &comp_filter.time_range { && let Some(time_range) = &comp_filter.time_range
{
let start = time_range.start.as_ref().map(|start| start.date_naive()); let start = time_range.start.as_ref().map(|start| start.date_naive());
let end = time_range.end.as_ref().map(|end| end.date_naive()); let end = time_range.end.as_ref().map(|end| end.date_naive());
return CalendarQuery { return CalendarQuery {
@@ -160,7 +165,6 @@ impl From<&FilterElement> for CalendarQuery {
}; };
} }
} }
}
Default::default() Default::default()
} }
} }
@@ -188,18 +192,3 @@ impl From<&CalendarQueryRequest> for CalendarQuery {
.unwrap_or_default() .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,120 @@
use crate::Error;
use rustical_ical::CalendarObject;
use rustical_store::CalendarStore;
mod elements;
pub(crate) use elements::*;
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)
}
#[cfg(test)]
mod tests {
use rustical_dav::xml::PropElement;
use rustical_xml::XmlDocument;
use crate::{
calendar::methods::report::{
ReportRequest,
calendar_query::{
CalendarQueryRequest, CompFilterElement, FilterElement, ParamFilterElement,
PropFilterElement, TextMatchElement,
},
},
calendar_object::{CalendarObjectPropName, CalendarObjectPropWrapperName},
};
#[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(Default::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 {
collation: "i;ascii-casemap".to_owned(),
negate_condition: None
}),
is_not_defined: None,
param_filter: vec![ParamFilterElement {
is_not_defined: None,
name: "PARTSTAT".to_owned(),
text_match: Some(TextMatchElement {
collation: "i;ascii-casemap".to_owned(),
negate_condition: None
}),
}],
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

@@ -21,7 +21,7 @@ use rustical_dav::{
}, },
}; };
use rustical_ical::CalendarObject; use rustical_ical::CalendarObject;
use rustical_store::{CalendarStore, SubscriptionStore, auth::User}; use rustical_store::{CalendarStore, SubscriptionStore, auth::Principal};
use rustical_xml::{XmlDeserialize, XmlDocument}; use rustical_xml::{XmlDeserialize, XmlDocument};
use sync_collection::handle_sync_collection; use sync_collection::handle_sync_collection;
use tracing::instrument; use tracing::instrument;
@@ -56,7 +56,7 @@ fn objects_response(
path: &str, path: &str,
principal: &str, principal: &str,
puri: &impl PrincipalUri, puri: &impl PrincipalUri,
user: &User, user: &Principal,
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();
@@ -67,7 +67,7 @@ fn objects_response(
object, object,
principal: principal.to_owned(), principal: principal.to_owned(),
} }
.propfind(&path, prop, puri, user)?, .propfind(&path, prop, None, puri, user)?,
); );
} }
@@ -90,7 +90,7 @@ fn objects_response(
#[instrument(skip(cal_store))] #[instrument(skip(cal_store))]
pub async fn route_report_calendar<C: CalendarStore, S: SubscriptionStore>( pub async fn route_report_calendar<C: CalendarStore, S: SubscriptionStore>(
Path((principal, cal_id)): Path<(String, String)>, Path((principal, cal_id)): Path<(String, String)>,
user: User, user: Principal,
Extension(puri): Extension<CalDavPrincipalUri>, Extension(puri): Extension<CalDavPrincipalUri>,
State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>, State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
OriginalUri(uri): OriginalUri, OriginalUri(uri): OriginalUri,

View File

@@ -13,7 +13,7 @@ use rustical_dav::{
}; };
use rustical_store::{ use rustical_store::{
CalendarStore, CalendarStore,
auth::User, auth::Principal,
synctoken::{format_synctoken, parse_synctoken}, synctoken::{format_synctoken, parse_synctoken},
}; };
@@ -21,7 +21,7 @@ pub async fn handle_sync_collection<C: CalendarStore>(
sync_collection: &SyncCollectionRequest<CalendarObjectPropWrapperName>, sync_collection: &SyncCollectionRequest<CalendarObjectPropWrapperName>,
path: &str, path: &str,
puri: &impl PrincipalUri, puri: &impl PrincipalUri,
user: &User, user: &Principal,
principal: &str, principal: &str,
cal_id: &str, cal_id: &str,
cal_store: &C, cal_store: &C,
@@ -39,7 +39,7 @@ pub async fn handle_sync_collection<C: CalendarStore>(
object, object,
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

@@ -3,6 +3,7 @@ use crate::Error;
use crate::calendar::prop::ReportMethod; use crate::calendar::prop::ReportMethod;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use derive_more::derive::{From, Into}; use derive_more::derive::{From, Into};
use ical::IcalParser;
use rustical_dav::extensions::{ use rustical_dav::extensions::{
CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp, CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp,
}; };
@@ -12,10 +13,10 @@ use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner, SupportedR
use rustical_dav_push::{DavPushExtension, DavPushExtensionProp}; use rustical_dav_push::{DavPushExtension, DavPushExtensionProp};
use rustical_ical::CalDateTime; use rustical_ical::CalDateTime;
use rustical_store::Calendar; use rustical_store::Calendar;
use rustical_store::auth::User; 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;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "CalendarPropName")] #[xml(unit_variants_ident = "CalendarPropName")]
@@ -34,7 +35,7 @@ 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),
@@ -62,7 +63,7 @@ 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,
@@ -95,7 +96,7 @@ impl DavPushExtension for CalendarResource {
impl Resource for CalendarResource { impl Resource for CalendarResource {
type Prop = CalendarPropWrapper; type Prop = CalendarPropWrapper;
type Error = Error; type Error = Error;
type Principal = User; type Principal = Principal;
fn is_collection(&self) -> bool { fn is_collection(&self) -> bool {
true true
@@ -121,19 +122,21 @@ impl Resource for CalendarResource {
fn get_prop( fn get_prop(
&self, &self,
puri: &impl PrincipalUri, puri: &impl PrincipalUri,
user: &User, user: &Principal,
prop: &CalendarPropWrapperName, prop: &CalendarPropWrapperName,
) -> Result<Self::Prop, Self::Error> { ) -> Result<Self::Prop, Self::Error> {
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 +146,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())
@@ -184,35 +187,56 @@ impl Resource for CalendarResource {
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::new(tz.as_bytes())
.next()
.ok_or(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
.timezones
.first()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?;
let timezone: chrono_tz::Tz = timezone.try_into().map_err(|_| {
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::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) => {
@@ -240,24 +264,20 @@ impl Resource for CalendarResource {
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::TimezoneServiceSet => Err(rustical_dav::Error::PropReadOnly),
CalendarPropName::CalendarOrder => { CalendarPropName::CalendarOrder => {
self.cal.order = 0; self.cal.meta.order = 0;
Ok(()) Ok(())
} }
CalendarPropName::SupportedCalendarComponentSet => { CalendarPropName::SupportedCalendarComponentSet => {
@@ -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(())
} }
@@ -291,8 +311,13 @@ impl Resource for CalendarResource {
Some(&self.cal.principal) Some(&self.cal.principal)
} }
fn get_user_privileges(&self, user: &User) -> 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() {
return Ok(UserPrivilegeSet::owner_write_properties(
user.is_principal(&self.cal.principal),
));
}
if self.read_only {
return Ok(UserPrivilegeSet::owner_read( return Ok(UserPrivilegeSet::owner_read(
user.is_principal(&self.cal.principal), user.is_principal(&self.cal.principal),
)); ));
@@ -303,3 +328,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;
@@ -13,7 +14,7 @@ use axum::handler::Handler;
use axum::response::Response; use axum::response::Response;
use futures_util::future::BoxFuture; use futures_util::future::BoxFuture;
use rustical_dav::resource::{AxumMethods, ResourceService}; use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::auth::User; use rustical_store::auth::Principal;
use rustical_store::{CalendarStore, SubscriptionStore}; use rustical_store::{CalendarStore, SubscriptionStore};
use std::convert::Infallible; use std::convert::Infallible;
use std::sync::Arc; use std::sync::Arc;
@@ -48,16 +49,20 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
type PathComponents = (String, String); // principal, calendar_id type PathComponents = (String, String); // principal, calendar_id
type Resource = CalendarResource; type Resource = CalendarResource;
type Error = Error; type Error = Error;
type Principal = User; 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),
@@ -134,6 +139,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,222 @@
<?xml version="1.0" encoding="utf-8"?>
<response xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
<href>/caldav/principal/user/calendar/</href>
<propstat>
<prop>
<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"/>
<max-resource-size xmlns="DAV:"/>
<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>HTTP/1.1 200 OK</status>
</propstat>
</response>
<?xml version="1.0" encoding="utf-8"?>
<response xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
<href>/caldav/principal/user/calendar/</href>
<propstat>
<prop>
<CAL:calendar-timezone>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
</CAL:calendar-timezone>
<CAL:timezone-service-set>
<href>https://www.iana.org/time-zones</href>
</CAL:timezone-service-set>
<CAL:calendar-timezone-id>Europe/Berlin</CAL:calendar-timezone-id>
<calendar-order xmlns="http://apple.com/ns/ical/">0</calendar-order>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
<CAL:comp name="VTODO"/>
</CAL:supported-calendar-component-set>
<CAL:supported-calendar-data>
<CAL:calendar-data content-type="text/calendar" version="2.0"/>
</CAL:supported-calendar-data>
<max-resource-size>10000000</max-resource-size>
<supported-report-set>
<supported-report>
<report>
<CAL:calendar-query/>
</report>
</supported-report>
<supported-report>
<report>
<CAL:calendar-multiget/>
</report>
</supported-report>
<supported-report>
<report>
<sync-collection/>
</report>
</supported-report>
</supported-report-set>
<CAL:min-date-time>-2621430101T000000Z</CAL:min-date-time>
<CAL:max-date-time>+2621421231T235959Z</CAL:max-date-time>
<sync-token>github.com/lennart-k/rustical/ns/12</sync-token>
<CS:getctag>github.com/lennart-k/rustical/ns/12</CS:getctag>
<PUSH:transports>
<PUSH:web-push/>
</PUSH:transports>
<PUSH:topic>b28b41e9-8801-4fc5-ae29-8efb5fadeb36</PUSH:topic>
<PUSH:supported-triggers>
<PUSH:content-update>
<depth>1</depth>
</PUSH:content-update>
<PUSH:property-update>
<depth>1</depth>
</PUSH:property-update>
</PUSH:supported-triggers>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Calendar</displayname>
<current-user-principal>
<href>/caldav/principal/user/</href>
</current-user-principal>
<current-user-privilege-set>
<privilege>
<read/>
</privilege>
<privilege>
<read-acl/>
</privilege>
<privilege>
<read-current-user-privilege-set/>
</privilege>
</current-user-privilege-set>
<owner>
<href>/caldav/principal/user/</href>
</owner>
</prop>
<status>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,47 @@
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();
let outputs: Vec<_> = include_str!("./test_files/propfind.outputs")
.trim()
.split("\n\n")
.collect();
for principal in principals {
for ((request, resource), &expected_output) in requests.iter().zip(&resources).zip(&outputs)
{
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 expected_output = expected_output.trim();
let output = response
.serialize_to_string()
.unwrap()
.trim()
.replace("\r\n", "\n");
println!("{output}");
println!("{}, {} \n\n\n", output.len(), expected_output.len());
assert_eq!(output, expected_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, Method, StatusCode};
use rustical_ical::CalendarObject; use rustical_ical::CalendarObject;
use rustical_store::CalendarStore; use rustical_store::CalendarStore;
use rustical_store::auth::User; use rustical_store::auth::Principal;
use std::str::FromStr; use std::str::FromStr;
use tracing::instrument; use tracing::{debug, instrument};
#[instrument(skip(cal_store))] #[instrument(skip(cal_store))]
pub async fn get_event<C: CalendarStore>( pub async fn get_event<C: CalendarStore>(
@@ -21,26 +21,33 @@ pub async fn get_event<C: CalendarStore>(
object_id, object_id,
}): Path<CalendarObjectPathComponents>, }): Path<CalendarObjectPathComponents>,
State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>, State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>,
user: User, 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);
} }
let event = cal_store let event = cal_store
.get_object(&principal, &calendar_id, &object_id) .get_object(&principal, &calendar_id, &object_id, false)
.await?; .await?;
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").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))]
@@ -51,7 +58,7 @@ pub async fn put_event<C: CalendarStore>(
object_id, object_id,
}): Path<CalendarObjectPathComponents>, }): Path<CalendarObjectPathComponents>,
State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>, State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>,
user: User, user: Principal,
mut if_none_match: Option<TypedHeader<IfNoneMatch>>, mut if_none_match: Option<TypedHeader<IfNoneMatch>>,
header_map: HeaderMap, header_map: HeaderMap,
body: String, body: String,
@@ -71,12 +78,14 @@ pub async fn put_event<C: CalendarStore>(
true true
}; };
let object = match CalendarObject::from_ics(object_id, body) { let object = match CalendarObject::from_ics(body.clone()) {
Ok(obj) => obj, Ok(obj) => obj,
Err(_) => { Err(_) => {
debug!("invalid calendar data:\n{body}");
return Err(Error::PreconditionFailed(Precondition::ValidCalendarData)); return Err(Error::PreconditionFailed(Precondition::ValidCalendarData));
} }
}; };
assert_eq!(object.get_id(), object_id);
cal_store cal_store
.put_object(principal, calendar_id, object, overwrite) .put_object(principal, calendar_id, object, overwrite)
.await?; .await?;

View File

@@ -8,7 +8,7 @@ use rustical_dav::{
xml::Resourcetype, xml::Resourcetype,
}; };
use rustical_ical::CalendarObject; use rustical_ical::CalendarObject;
use rustical_store::auth::User; use rustical_store::auth::Principal;
#[derive(Clone, From, Into)] #[derive(Clone, From, Into)]
pub struct CalendarObjectResource { pub struct CalendarObjectResource {
@@ -25,7 +25,7 @@ impl ResourceName for CalendarObjectResource {
impl Resource for CalendarObjectResource { impl Resource for CalendarObjectResource {
type Prop = CalendarObjectPropWrapper; type Prop = CalendarObjectPropWrapper;
type Error = Error; type Error = Error;
type Principal = User; type Principal = Principal;
fn is_collection(&self) -> bool { fn is_collection(&self) -> bool {
false false
@@ -38,7 +38,7 @@ impl Resource for CalendarObjectResource {
fn get_prop( fn get_prop(
&self, &self,
puri: &impl PrincipalUri, puri: &impl PrincipalUri,
user: &User, user: &Principal,
prop: &CalendarObjectPropWrapperName, prop: &CalendarObjectPropWrapperName,
) -> Result<Self::Prop, Self::Error> { ) -> Result<Self::Prop, Self::Error> {
Ok(match prop { Ok(match prop {
@@ -69,7 +69,6 @@ impl Resource for CalendarObjectResource {
} }
fn get_displayname(&self) -> Option<&str> { fn get_displayname(&self) -> Option<&str> {
// TODO: Extract summary from object
None None
} }
@@ -81,7 +80,7 @@ impl Resource for CalendarObjectResource {
Some(self.object.get_etag()) Some(self.object.get_etag())
} }
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> { fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_only( Ok(UserPrivilegeSet::owner_only(
user.is_principal(&self.principal), user.is_principal(&self.principal),
)) ))

View File

@@ -9,7 +9,7 @@ use async_trait::async_trait;
use axum::{extract::Request, handler::Handler, response::Response}; use axum::{extract::Request, handler::Handler, response::Response};
use futures_util::future::BoxFuture; use futures_util::future::BoxFuture;
use rustical_dav::resource::{AxumMethods, ResourceService}; use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::{CalendarStore, auth::User}; use rustical_store::{CalendarStore, auth::Principal};
use serde::{Deserialize, Deserializer}; use serde::{Deserialize, Deserializer};
use std::{convert::Infallible, sync::Arc}; use std::{convert::Infallible, sync::Arc};
use tower::Service; use tower::Service;
@@ -46,7 +46,7 @@ impl<C: CalendarStore> ResourceService for CalendarObjectResourceService<C> {
type Resource = CalendarObjectResource; type Resource = CalendarObjectResource;
type MemberType = CalendarObjectResource; type MemberType = CalendarObjectResource;
type Error = Error; type Error = Error;
type Principal = User; type Principal = Principal;
type PrincipalUri = CalDavPrincipalUri; type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access"; const DAV_HEADER: &str = "1, 3, access-control, calendar-access";
@@ -58,10 +58,11 @@ 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) .get_object(principal, calendar_id, object_id, show_deleted)
.await?; .await?;
Ok(CalendarObjectResource { Ok(CalendarObjectResource {
object, object,

View File

@@ -1,12 +1,10 @@
use axum::response::Redirect;
use axum::routing::any;
use axum::{Extension, Router}; use axum::{Extension, Router};
use derive_more::Constructor; use derive_more::Constructor;
use principal::PrincipalResourceService; use principal::PrincipalResourceService;
use rustical_dav::resource::{PrincipalUri, ResourceService}; use rustical_dav::resource::{PrincipalUri, ResourceService};
use rustical_dav::resources::RootResourceService; use rustical_dav::resources::RootResourceService;
use rustical_store::auth::middleware::AuthenticationLayer; use rustical_store::auth::middleware::AuthenticationLayer;
use rustical_store::auth::{AuthenticationProvider, User}; use rustical_store::auth::{AuthenticationProvider, Principal};
use rustical_store::{CalendarStore, SubscriptionStore}; use rustical_store::{CalendarStore, SubscriptionStore};
use std::sync::Arc; use std::sync::Arc;
@@ -14,7 +12,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 +31,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(
prefix,
RootResourceService::<_, Principal, CalDavPrincipalUri>::new(PrincipalResourceService {
auth_provider: auth_provider.clone(), auth_provider: auth_provider.clone(),
sub_store: subscription_store.clone(), sub_store: subscription_store.clone(),
cal_store: store.clone(), cal_store: store.clone(),
}; simplified_home_set,
})
Router::new()
.nest(
prefix,
RootResourceService::<_, User, CalDavPrincipalUri>::new(principal_service.clone())
.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

@@ -5,17 +5,21 @@ use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{ use rustical_dav::xml::{
GroupMemberSet, GroupMembership, Resourcetype, ResourcetypeInner, SupportedReportSet, GroupMemberSet, GroupMembership, Resourcetype, ResourcetypeInner, SupportedReportSet,
}; };
use rustical_store::auth::User; use rustical_store::auth::Principal;
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: User, 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 {
@@ -27,7 +31,7 @@ impl ResourceName for PrincipalResource {
impl Resource for PrincipalResource { impl Resource for PrincipalResource {
type Prop = PrincipalPropWrapper; type Prop = PrincipalPropWrapper;
type Error = Error; type Error = Error;
type Principal = User; type Principal = Principal;
fn is_collection(&self) -> bool { fn is_collection(&self) -> bool {
true true
@@ -37,18 +41,13 @@ 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",
),
]) ])
} }
fn get_prop( fn get_prop(
&self, &self,
puri: &impl PrincipalUri, puri: &impl PrincipalUri,
user: &User, user: &Principal,
prop: &PrincipalPropWrapperName, prop: &PrincipalPropWrapperName,
) -> Result<Self::Prop, Self::Error> { ) -> Result<Self::Prop, Self::Error> {
let principal_url = puri.principal_uri(&self.principal.id); let principal_url = puri.principal_uri(&self.principal.id);
@@ -62,9 +61,17 @@ impl Resource for PrincipalResource {
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())
} }
@@ -113,8 +120,8 @@ impl Resource for PrincipalResource {
Some(&self.principal.id) Some(&self.principal.id)
} }
fn get_user_privileges(&self, user: &User) -> 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

@@ -2,7 +2,7 @@ use rustical_dav::{
extensions::CommonPropertiesProp, extensions::CommonPropertiesProp,
xml::{GroupMemberSet, GroupMembership, HrefElement, SupportedReportSet}, xml::{GroupMemberSet, GroupMembership, HrefElement, SupportedReportSet},
}; };
use rustical_store::auth::user::PrincipalType; 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;
@@ -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,9 +31,12 @@ 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)]
pub struct CalendarHomeSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)] #[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)]
pub enum PrincipalPropWrapper { pub enum PrincipalPropWrapper {

View File

@@ -5,7 +5,7 @@ use crate::{CalDavPrincipalUri, Error};
use async_trait::async_trait; use async_trait::async_trait;
use axum::Router; use axum::Router;
use rustical_dav::resource::{AxumMethods, ResourceService}; use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::auth::{AuthenticationProvider, User}; use rustical_store::auth::{AuthenticationProvider, Principal};
use rustical_store::{CalendarStore, SubscriptionStore}; use rustical_store::{CalendarStore, SubscriptionStore};
use std::sync::Arc; use std::sync::Arc;
@@ -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,
} }
} }
} }
@@ -40,14 +43,15 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
type MemberType = CalendarResource; type MemberType = CalendarResource;
type Resource = PrincipalResource; type Resource = PrincipalResource;
type Error = Error; type Error = Error;
type Principal = User; 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,92 @@
use std::sync::Arc;
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::{
SqliteStore,
calendar_store::SqliteCalendarStore,
principal_store::SqlitePrincipalStore,
tests::{get_test_calendar_store, get_test_principal_store, get_test_subscription_store},
};
use rustical_xml::XmlSerializeRoot;
#[rstest]
#[tokio::test]
async fn test_principal_resource(
#[from(get_test_calendar_store)]
#[future]
cal_store: SqliteCalendarStore,
#[from(get_test_principal_store)]
#[future]
auth_provider: SqlitePrincipalStore,
#[from(get_test_subscription_store)]
#[future]
sub_store: SqliteStore,
) {
let service = PrincipalResourceService {
cal_store: Arc::new(cal_store.await),
sub_store: Arc::new(sub_store.await),
auth_provider: Arc::new(auth_provider.await),
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();
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();
let _output = response.serialize_to_string().unwrap();
}

View File

@@ -7,12 +7,13 @@ 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::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;
use rustical_ical::AddressObject; use rustical_ical::AddressObject;
use rustical_store::AddressbookStore; use rustical_store::AddressbookStore;
use rustical_store::auth::User; use rustical_store::auth::Principal;
use std::str::FromStr; use std::str::FromStr;
use tracing::instrument; use tracing::instrument;
@@ -24,7 +25,8 @@ pub async fn get_object<AS: AddressbookStore>(
object_id, object_id,
}): Path<AddressObjectPathComponents>, }): Path<AddressObjectPathComponents>,
State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>, State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>,
user: User, 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);
@@ -49,7 +51,11 @@ pub async fn get_object<AS: AddressbookStore>(
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").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))]
@@ -60,7 +66,7 @@ pub async fn put_object<AS: AddressbookStore>(
object_id, object_id,
}): Path<AddressObjectPathComponents>, }): Path<AddressObjectPathComponents>,
State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>, State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>,
user: User, user: Principal,
mut if_none_match: Option<TypedHeader<IfNoneMatch>>, mut if_none_match: Option<TypedHeader<IfNoneMatch>>,
header_map: HeaderMap, header_map: HeaderMap,
body: String, body: String,

View File

@@ -13,7 +13,7 @@ use rustical_dav::{
xml::Resourcetype, xml::Resourcetype,
}; };
use rustical_ical::AddressObject; use rustical_ical::AddressObject;
use rustical_store::auth::User; use rustical_store::auth::Principal;
#[derive(Clone, From, Into)] #[derive(Clone, From, Into)]
pub struct AddressObjectResource { pub struct AddressObjectResource {
@@ -30,7 +30,7 @@ impl ResourceName for AddressObjectResource {
impl Resource for AddressObjectResource { impl Resource for AddressObjectResource {
type Prop = AddressObjectPropWrapper; type Prop = AddressObjectPropWrapper;
type Error = Error; type Error = Error;
type Principal = User; type Principal = Principal;
fn is_collection(&self) -> bool { fn is_collection(&self) -> bool {
false false
@@ -43,7 +43,7 @@ impl Resource for AddressObjectResource {
fn get_prop( fn get_prop(
&self, &self,
puri: &impl PrincipalUri, puri: &impl PrincipalUri,
user: &User, user: &Principal,
prop: &AddressObjectPropWrapperName, prop: &AddressObjectPropWrapperName,
) -> Result<Self::Prop, Self::Error> { ) -> Result<Self::Prop, Self::Error> {
Ok(match prop { Ok(match prop {
@@ -78,7 +78,7 @@ impl Resource for AddressObjectResource {
Some(self.object.get_etag()) Some(self.object.get_etag())
} }
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> { fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_only( Ok(UserPrivilegeSet::owner_only(
user.is_principal(&self.principal), user.is_principal(&self.principal),
)) ))

View File

@@ -5,7 +5,7 @@ use axum::{extract::Request, handler::Handler, response::Response};
use derive_more::derive::Constructor; use derive_more::derive::Constructor;
use futures_util::future::BoxFuture; use futures_util::future::BoxFuture;
use rustical_dav::resource::{AxumMethods, ResourceService}; use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::{AddressbookStore, auth::User}; use rustical_store::{AddressbookStore, auth::Principal};
use serde::{Deserialize, Deserializer}; use serde::{Deserialize, Deserializer};
use std::{convert::Infallible, sync::Arc}; use std::{convert::Infallible, sync::Arc};
use tower::Service; use tower::Service;
@@ -37,7 +37,7 @@ impl<AS: AddressbookStore> ResourceService for AddressObjectResourceService<AS>
type Resource = AddressObjectResource; type Resource = AddressObjectResource;
type MemberType = AddressObjectResource; type MemberType = AddressObjectResource;
type Error = Error; type Error = Error;
type Principal = User; type Principal = Principal;
type PrincipalUri = CardDavPrincipalUri; type PrincipalUri = CardDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, addressbook"; const DAV_HEADER: &str = "1, 3, access-control, addressbook";
@@ -49,10 +49,11 @@ 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,

View File

@@ -5,12 +5,12 @@ 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_ical::AddressObject;
use rustical_store::auth::User; use rustical_store::auth::Principal;
use rustical_store::{AddressbookStore, SubscriptionStore}; use rustical_store::{AddressbookStore, SubscriptionStore};
use std::str::FromStr; use std::str::FromStr;
use tracing::instrument; use tracing::instrument;
@@ -19,7 +19,8 @@ use tracing::instrument;
pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>( 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: User, 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);
@@ -46,7 +47,7 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
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").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 +56,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,67 @@
use std::io::BufReader;
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::Property,
};
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::new(BufReader::new(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.set_property(Property {
name: "UID".to_owned(),
value: Some(uuid::Uuid::new_v4().to_string()),
params: None,
});
card = card_mut.verify().unwrap();
}
objects.push(card.try_into().unwrap());
}
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

@@ -4,7 +4,7 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use http::StatusCode; use http::StatusCode;
use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::User}; use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::Principal};
use rustical_xml::{XmlDeserialize, XmlDocument, XmlRootTag}; use rustical_xml::{XmlDeserialize, XmlDocument, XmlRootTag};
use tracing::instrument; use tracing::instrument;
@@ -22,7 +22,7 @@ pub struct MkcolAddressbookProp {
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>,
} }
@@ -34,7 +34,7 @@ pub struct PropElement<T: XmlDeserialize> {
} }
#[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")]
@@ -44,7 +44,7 @@ struct MkcolRequest {
#[instrument(skip(addr_store))] #[instrument(skip(addr_store))]
pub async fn route_mkcol<AS: AddressbookStore, S: SubscriptionStore>( pub async fn route_mkcol<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>, Path((principal, addressbook_id)): Path<(String, String)>,
user: User, user: Principal,
State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>, State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,
body: String, body: String,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
@@ -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)]

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

@@ -7,7 +7,7 @@ use http::{HeaderMap, HeaderValue, StatusCode, header};
use rustical_dav::privileges::UserPrivilege; use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource; use rustical_dav::resource::Resource;
use rustical_dav_push::register::PushRegister; use rustical_dav_push::register::PushRegister;
use rustical_store::auth::User; use rustical_store::auth::Principal;
use rustical_store::{AddressbookStore, Subscription, SubscriptionStore}; use rustical_store::{AddressbookStore, Subscription, SubscriptionStore};
use rustical_xml::XmlDocument; use rustical_xml::XmlDocument;
use tracing::instrument; use tracing::instrument;
@@ -15,7 +15,7 @@ use tracing::instrument;
#[instrument(skip(resource_service))] #[instrument(skip(resource_service))]
pub async fn route_post<AS: AddressbookStore, S: SubscriptionStore>( pub async fn route_post<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addr_id)): Path<(String, String)>, Path((principal, addr_id)): Path<(String, String)>,
user: User, user: Principal,
State(resource_service): State<AddressbookResourceService<AS, S>>, State(resource_service): State<AddressbookResourceService<AS, S>>,
body: String, body: String,
) -> Result<Response, Error> { ) -> Result<Response, Error> {

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::User};
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: User,
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

@@ -10,7 +10,7 @@ use rustical_dav::{
xml::{MultistatusElement, PropfindType, multistatus::ResponseElement}, xml::{MultistatusElement, PropfindType, multistatus::ResponseElement},
}; };
use rustical_ical::AddressObject; use rustical_ical::AddressObject;
use rustical_store::{AddressbookStore, auth::User}; use rustical_store::{AddressbookStore, auth::Principal};
use rustical_xml::XmlDeserialize; use rustical_xml::XmlDeserialize;
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq)]
@@ -58,12 +58,13 @@ pub async fn get_objects_addressbook_multiget<AS: AddressbookStore>(
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>,
path: &str, path: &str,
puri: &impl PrincipalUri, puri: &impl PrincipalUri,
user: &User, user: &Principal,
principal: &str, principal: &str,
cal_id: &str, cal_id: &str,
addr_store: &AS, addr_store: &AS,
@@ -80,7 +81,7 @@ pub async fn handle_addressbook_multiget<AS: AddressbookStore>(
object, object,
principal: principal.to_owned(), principal: principal.to_owned(),
} }
.propfind(&path, prop, puri, user)?, .propfind(&path, prop, None, puri, user)?,
); );
} }

View File

@@ -9,7 +9,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
}; };
use rustical_dav::xml::{PropfindType, sync_collection::SyncCollectionRequest}; use rustical_dav::xml::{PropfindType, sync_collection::SyncCollectionRequest};
use rustical_store::{AddressbookStore, SubscriptionStore, auth::User}; use rustical_store::{AddressbookStore, SubscriptionStore, auth::Principal};
use rustical_xml::{XmlDeserialize, XmlDocument}; use rustical_xml::{XmlDeserialize, XmlDocument};
use sync_collection::handle_sync_collection; use sync_collection::handle_sync_collection;
use tracing::instrument; use tracing::instrument;
@@ -37,7 +37,7 @@ impl ReportRequest {
#[instrument(skip(addr_store))] #[instrument(skip(addr_store))]
pub async fn route_report_addressbook<AS: AddressbookStore, S: SubscriptionStore>( pub async fn route_report_addressbook<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>, Path((principal, addressbook_id)): Path<(String, String)>,
user: User, user: Principal,
OriginalUri(uri): OriginalUri, OriginalUri(uri): OriginalUri,
Extension(puri): Extension<CardDavPrincipalUri>, Extension(puri): Extension<CardDavPrincipalUri>,
State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>, State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,

View File

@@ -13,7 +13,7 @@ use rustical_dav::{
}; };
use rustical_store::{ use rustical_store::{
AddressbookStore, AddressbookStore,
auth::User, auth::Principal,
synctoken::{format_synctoken, parse_synctoken}, synctoken::{format_synctoken, parse_synctoken},
}; };
@@ -21,7 +21,7 @@ pub async fn handle_sync_collection<AS: AddressbookStore>(
sync_collection: &SyncCollectionRequest<AddressObjectPropWrapperName>, sync_collection: &SyncCollectionRequest<AddressObjectPropWrapperName>,
path: &str, path: &str,
puri: &impl PrincipalUri, puri: &impl PrincipalUri,
user: &User, user: &Principal,
principal: &str, principal: &str,
addressbook_id: &str, addressbook_id: &str,
addr_store: &AS, addr_store: &AS,
@@ -39,7 +39,7 @@ pub async fn handle_sync_collection<AS: AddressbookStore>(
object, object,
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

@@ -10,7 +10,7 @@ use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{Resourcetype, ResourcetypeInner, SupportedReportSet}; use rustical_dav::xml::{Resourcetype, ResourcetypeInner, SupportedReportSet};
use rustical_dav_push::DavPushExtension; use rustical_dav_push::DavPushExtension;
use rustical_store::Addressbook; use rustical_store::Addressbook;
use rustical_store::auth::User; use rustical_store::auth::Principal;
#[derive(Clone, Debug, From, Into)] #[derive(Clone, Debug, From, Into)]
pub struct AddressbookResource(pub(crate) Addressbook); pub struct AddressbookResource(pub(crate) Addressbook);
@@ -36,7 +36,7 @@ impl DavPushExtension for AddressbookResource {
impl Resource for AddressbookResource { impl Resource for AddressbookResource {
type Prop = AddressbookPropWrapper; type Prop = AddressbookPropWrapper;
type Error = Error; type Error = Error;
type Principal = User; type Principal = Principal;
fn is_collection(&self) -> bool { fn is_collection(&self) -> bool {
true true
@@ -52,7 +52,7 @@ impl Resource for AddressbookResource {
fn get_prop( fn get_prop(
&self, &self,
puri: &impl PrincipalUri, puri: &impl PrincipalUri,
user: &User, user: &Principal,
prop: &AddressbookPropWrapperName, prop: &AddressbookPropWrapperName,
) -> Result<Self::Prop, Self::Error> { ) -> Result<Self::Prop, Self::Error> {
Ok(match prop { Ok(match prop {
@@ -138,7 +138,7 @@ impl Resource for AddressbookResource {
Some(&self.0.principal) Some(&self.0.principal)
} }
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> { fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_only( Ok(UserPrivilegeSet::owner_only(
user.is_principal(&self.0.principal), user.is_principal(&self.0.principal),
)) ))

View File

@@ -3,8 +3,8 @@ use super::methods::report::route_report_addressbook;
use crate::address_object::AddressObjectResourceService; use crate::address_object::AddressObjectResourceService;
use crate::address_object::resource::AddressObjectResource; use crate::address_object::resource::AddressObjectResource;
use crate::addressbook::methods::get::route_get; use crate::addressbook::methods::get::route_get;
use crate::addressbook::methods::import::route_import;
use crate::addressbook::methods::post::route_post; use crate::addressbook::methods::post::route_post;
use crate::addressbook::methods::put::route_put;
use crate::addressbook::resource::AddressbookResource; use crate::addressbook::resource::AddressbookResource;
use crate::{CardDavPrincipalUri, Error}; use crate::{CardDavPrincipalUri, Error};
use async_trait::async_trait; use async_trait::async_trait;
@@ -14,7 +14,7 @@ use axum::handler::Handler;
use axum::response::Response; use axum::response::Response;
use futures_util::future::BoxFuture; use futures_util::future::BoxFuture;
use rustical_dav::resource::{AxumMethods, ResourceService}; use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::auth::User; use rustical_store::auth::Principal;
use rustical_store::{AddressbookStore, SubscriptionStore}; use rustical_store::{AddressbookStore, SubscriptionStore};
use std::convert::Infallible; use std::convert::Infallible;
use std::sync::Arc; use std::sync::Arc;
@@ -51,7 +51,7 @@ impl<AS: AddressbookStore, S: SubscriptionStore> ResourceService
type PathComponents = (String, String); // principal, addressbook_id type PathComponents = (String, String); // principal, addressbook_id
type Resource = AddressbookResource; type Resource = AddressbookResource;
type Error = Error; type Error = Error;
type Principal = User; type Principal = Principal;
type PrincipalUri = CardDavPrincipalUri; type PrincipalUri = CardDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, addressbook, webdav-push"; const DAV_HEADER: &str = "1, 3, access-control, addressbook, webdav-push";
@@ -59,10 +59,11 @@ impl<AS: AddressbookStore, S: SubscriptionStore> ResourceService
async fn get_resource( async fn get_resource(
&self, &self,
(principal, addressbook_id): &Self::PathComponents, (principal, addressbook_id): &Self::PathComponents,
show_deleted: bool,
) -> Result<Self::Resource, Error> { ) -> Result<Self::Resource, Error> {
let addressbook = self let addressbook = self
.addr_store .addr_store
.get_addressbook(principal, addressbook_id, false) .get_addressbook(principal, addressbook_id, show_deleted)
.await .await
.map_err(|_e| Error::NotFound)?; .map_err(|_e| Error::NotFound)?;
Ok(addressbook.into()) Ok(addressbook.into())
@@ -138,9 +139,9 @@ impl<AS: AddressbookStore, S: SubscriptionStore> AxumMethods for AddressbookReso
}) })
} }
fn put() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> { fn import() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| { Some(|state, req| {
let mut service = Handler::with_state(route_put::<AS, S>, state); let mut service = Handler::with_state(route_import::<AS, S>, state);
Box::pin(Service::call(&mut service, req)) Box::pin(Service::call(&mut service, req))
}) })
} }

View File

@@ -9,7 +9,7 @@ use rustical_dav::resources::RootResourceService;
use rustical_store::auth::middleware::AuthenticationLayer; use rustical_store::auth::middleware::AuthenticationLayer;
use rustical_store::{ use rustical_store::{
AddressbookStore, SubscriptionStore, AddressbookStore, SubscriptionStore,
auth::{AuthenticationProvider, User}, auth::{AuthenticationProvider, Principal},
}; };
use std::sync::Arc; use std::sync::Arc;
@@ -44,7 +44,9 @@ pub fn carddav_router<AP: AuthenticationProvider, A: AddressbookStore, S: Subscr
Router::new() Router::new()
.nest( .nest(
prefix, prefix,
RootResourceService::<_, User, CardDavPrincipalUri>::new(principal_service.clone()) RootResourceService::<_, Principal, CardDavPrincipalUri>::new(
principal_service.clone(),
)
.axum_router() .axum_router()
.layer(AuthenticationLayer::new(auth_provider)) .layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CardDavPrincipalUri(prefix))), .layer(Extension(CardDavPrincipalUri(prefix))),

View File

@@ -5,7 +5,7 @@ use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{ use rustical_dav::xml::{
GroupMemberSet, GroupMembership, HrefElement, Resourcetype, ResourcetypeInner, GroupMemberSet, GroupMembership, HrefElement, Resourcetype, ResourcetypeInner,
}; };
use rustical_store::auth::User; use rustical_store::auth::Principal;
mod service; mod service;
pub use service::*; pub use service::*;
@@ -14,7 +14,7 @@ pub use prop::*;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PrincipalResource { pub struct PrincipalResource {
principal: User, principal: Principal,
members: Vec<String>, members: Vec<String>,
} }
@@ -27,7 +27,7 @@ impl ResourceName for PrincipalResource {
impl Resource for PrincipalResource { impl Resource for PrincipalResource {
type Prop = PrincipalPropWrapper; type Prop = PrincipalPropWrapper;
type Error = Error; type Error = Error;
type Principal = User; type Principal = Principal;
fn is_collection(&self) -> bool { fn is_collection(&self) -> bool {
true true
@@ -43,7 +43,7 @@ impl Resource for PrincipalResource {
fn get_prop( fn get_prop(
&self, &self,
puri: &impl PrincipalUri, puri: &impl PrincipalUri,
user: &User, user: &Principal,
prop: &PrincipalPropWrapperName, prop: &PrincipalPropWrapperName,
) -> Result<Self::Prop, Self::Error> { ) -> Result<Self::Prop, Self::Error> {
let principal_href = HrefElement::new(puri.principal_uri(&self.principal.id)); let principal_href = HrefElement::new(puri.principal_uri(&self.principal.id));
@@ -53,7 +53,13 @@ impl Resource for PrincipalResource {
PrincipalPropWrapper::Principal(match prop { PrincipalPropWrapper::Principal(match prop {
PrincipalPropName::PrincipalUrl => PrincipalProp::PrincipalUrl(principal_href), PrincipalPropName::PrincipalUrl => PrincipalProp::PrincipalUrl(principal_href),
PrincipalPropName::AddressbookHomeSet => { PrincipalPropName::AddressbookHomeSet => {
PrincipalProp::AddressbookHomeSet(principal_href) PrincipalProp::AddressbookHomeSet(AddressbookHomeSet(
self.principal
.memberships()
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect(),
))
} }
PrincipalPropName::PrincipalAddress => PrincipalProp::PrincipalAddress(None), PrincipalPropName::PrincipalAddress => PrincipalProp::PrincipalAddress(None),
PrincipalPropName::GroupMembership => { PrincipalPropName::GroupMembership => {
@@ -99,7 +105,7 @@ impl Resource for PrincipalResource {
Some(&self.principal.id) Some(&self.principal.id)
} }
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> { fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_only( Ok(UserPrivilegeSet::owner_only(
user.is_principal(&self.principal.id), user.is_principal(&self.principal.id),
)) ))

View File

@@ -8,25 +8,28 @@ use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
#[xml(unit_variants_ident = "PrincipalPropName")] #[xml(unit_variants_ident = "PrincipalPropName")]
pub enum PrincipalProp { pub enum PrincipalProp {
// WebDAV Access Control (RFC 3744) // WebDAV Access Control (RFC 3744)
#[xml(rename = b"principal-URL")] #[xml(rename = "principal-URL")]
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
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),
// CardDAV (RFC 6352) // CardDAV (RFC 6352)
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookHomeSet(HrefElement), AddressbookHomeSet(AddressbookHomeSet),
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
PrincipalAddress(Option<HrefElement>), PrincipalAddress(Option<HrefElement>),
} }
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct AddressbookHomeSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)] #[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)]
pub enum PrincipalPropWrapper { pub enum PrincipalPropWrapper {

View File

@@ -5,7 +5,7 @@ use crate::{CardDavPrincipalUri, Error};
use async_trait::async_trait; use async_trait::async_trait;
use axum::Router; use axum::Router;
use rustical_dav::resource::{AxumMethods, ResourceService}; use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::auth::{AuthenticationProvider, User}; use rustical_store::auth::{AuthenticationProvider, Principal};
use rustical_store::{AddressbookStore, SubscriptionStore}; use rustical_store::{AddressbookStore, SubscriptionStore};
use std::sync::Arc; use std::sync::Arc;
@@ -51,7 +51,7 @@ impl<A: AddressbookStore, AP: AuthenticationProvider, S: SubscriptionStore> Reso
type MemberType = AddressbookResource; type MemberType = AddressbookResource;
type Resource = PrincipalResource; type Resource = PrincipalResource;
type Error = Error; type Error = Error;
type Principal = User; type Principal = Principal;
type PrincipalUri = CardDavPrincipalUri; type PrincipalUri = CardDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, addressbook"; const DAV_HEADER: &str = "1, 3, access-control, addressbook";
@@ -59,6 +59,7 @@ impl<A: AddressbookStore, AP: AuthenticationProvider, S: SubscriptionStore> Reso
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

View File

@@ -14,16 +14,12 @@ impl IntoResponse for InvalidOverwriteHeader {
} }
} }
#[derive(Debug, PartialEq, Default)] #[derive(Debug, PartialEq)]
pub enum Overwrite { pub struct Overwrite(pub bool);
#[default]
T,
F,
}
impl Overwrite { impl Default for Overwrite {
pub fn is_true(&self) -> bool { fn default() -> Self {
matches!(self, Self::T) Self(true)
} }
} }
@@ -47,9 +43,48 @@ impl TryFrom<&[u8]> for Overwrite {
fn try_from(value: &[u8]) -> Result<Self, Self::Error> { fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
match value { match value {
b"T" => Ok(Overwrite::T), b"T" => Ok(Self(true)),
b"F" => Ok(Overwrite::F), b"F" => Ok(Self(false)),
_ => Err(InvalidOverwriteHeader), _ => Err(InvalidOverwriteHeader),
} }
} }
} }
#[cfg(test)]
mod tests {
use axum::{extract::FromRequestParts, response::IntoResponse};
use http::Request;
use crate::header::Overwrite;
#[tokio::test]
async fn test_overwrite_default() {
let request = Request::put("asd").body(()).unwrap();
let (mut parts, _) = request.into_parts();
let overwrite = Overwrite::from_request_parts(&mut parts, &())
.await
.unwrap();
assert_eq!(
Overwrite(true),
overwrite,
"By default we want to overwrite!"
);
}
#[test]
fn test_overwrite() {
assert_eq!(
Overwrite(true),
Overwrite::try_from(b"T".as_slice()).unwrap()
);
assert_eq!(
Overwrite(false),
Overwrite::try_from(b"F".as_slice()).unwrap()
);
if let Err(err) = Overwrite::try_from(b"aslkdjlad".as_slice()) {
let _ = err.into_response();
} else {
unreachable!("should return error")
}
}
}

View File

@@ -1,8 +1,10 @@
use itertools::Itertools;
use quick_xml::name::Namespace; use quick_xml::name::Namespace;
use rustical_xml::{XmlDeserialize, XmlSerialize}; use rustical_xml::{XmlDeserialize, XmlSerialize};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, Eq, Hash, PartialEq)] // https://datatracker.ietf.org/doc/html/rfc3744
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, Eq, Hash, PartialEq, PartialOrd, Ord)]
pub enum UserPrivilege { pub enum UserPrivilege {
Read, Read,
Write, Write,
@@ -15,26 +17,25 @@ pub enum UserPrivilege {
} }
impl XmlSerialize for UserPrivilegeSet { impl XmlSerialize for UserPrivilegeSet {
fn serialize<W: std::io::Write>( fn serialize(
&self, &self,
ns: Option<Namespace>, ns: Option<Namespace>,
tag: Option<&[u8]>, tag: Option<&str>,
namespaces: &HashMap<Namespace, &[u8]>, namespaces: &HashMap<Namespace, &str>,
writer: &mut quick_xml::Writer<W>, writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
#[derive(XmlSerialize)] #[derive(XmlSerialize)]
pub struct FakeUserPrivilegeSet { pub struct FakeUserPrivilegeSet {
#[xml(rename = b"privilege", flatten)] #[xml(rename = "privilege", flatten)]
privileges: Vec<UserPrivilege>, privileges: Vec<UserPrivilege>,
} }
FakeUserPrivilegeSet { FakeUserPrivilegeSet {
privileges: self.privileges.iter().cloned().collect(), privileges: self.privileges.iter().cloned().sorted().collect(),
} }
.serialize(ns, tag, namespaces, writer) .serialize(ns, tag, namespaces, writer)
} }
#[allow(refining_impl_trait)]
fn attributes<'a>(&self) -> Option<Vec<quick_xml::events::attributes::Attribute<'a>>> { fn attributes<'a>(&self) -> Option<Vec<quick_xml::events::attributes::Attribute<'a>>> {
None None
} }
@@ -47,6 +48,12 @@ pub struct UserPrivilegeSet {
impl UserPrivilegeSet { impl UserPrivilegeSet {
pub fn has(&self, privilege: &UserPrivilege) -> bool { pub fn has(&self, privilege: &UserPrivilege) -> bool {
if (privilege == &UserPrivilege::WriteProperties
|| privilege == &UserPrivilege::WriteContent)
&& self.privileges.contains(&UserPrivilege::Write)
{
return true;
}
self.privileges.contains(privilege) || self.privileges.contains(&UserPrivilege::All) self.privileges.contains(privilege) || self.privileges.contains(&UserPrivilege::All)
} }
@@ -72,6 +79,15 @@ impl UserPrivilegeSet {
} }
} }
pub fn owner_write_properties(is_owner: bool) -> Self {
// Content is read-only but we can write properties
if is_owner {
Self::write_properties()
} else {
Self::default()
}
}
pub fn read_only() -> Self { pub fn read_only() -> Self {
Self { Self {
privileges: HashSet::from([ privileges: HashSet::from([
@@ -81,6 +97,17 @@ impl UserPrivilegeSet {
]), ]),
} }
} }
pub fn write_properties() -> Self {
Self {
privileges: HashSet::from([
UserPrivilege::Read,
UserPrivilege::WriteProperties,
UserPrivilege::ReadAcl,
UserPrivilege::ReadCurrentUserPrivilegeSet,
]),
}
}
} }
impl<const N: usize> From<[UserPrivilege; N]> for UserPrivilegeSet { impl<const N: usize> From<[UserPrivilege; N]> for UserPrivilegeSet {

View File

@@ -18,11 +18,6 @@ pub trait AxumMethods: Sized + Send + Sync + 'static {
None None
} }
#[inline]
fn head() -> Option<MethodFunction<Self>> {
None
}
#[inline] #[inline]
fn post() -> Option<MethodFunction<Self>> { fn post() -> Option<MethodFunction<Self>> {
None None
@@ -43,6 +38,11 @@ pub trait AxumMethods: Sized + Send + Sync + 'static {
None None
} }
#[inline]
fn import() -> Option<MethodFunction<Self>> {
None
}
#[inline] #[inline]
fn allow_header() -> Allow { fn allow_header() -> Allow {
let mut allow = vec![ let mut allow = vec![
@@ -58,8 +58,6 @@ pub trait AxumMethods: Sized + Send + Sync + 'static {
} }
if Self::get().is_some() { if Self::get().is_some() {
allow.push(Method::GET); allow.push(Method::GET);
}
if Self::head().is_some() {
allow.push(Method::HEAD); allow.push(Method::HEAD);
} }
if Self::post().is_some() { if Self::post().is_some() {
@@ -74,6 +72,9 @@ pub trait AxumMethods: Sized + Send + Sync + 'static {
if Self::put().is_some() { if Self::put().is_some() {
allow.push(Method::PUT); allow.push(Method::PUT);
} }
if Self::import().is_some() {
allow.push(Method::from_str("IMPORT").unwrap());
}
allow.into_iter().collect() allow.into_iter().collect()
} }

View File

@@ -72,16 +72,11 @@ where
return svc(self.resource_service.clone(), req); return svc(self.resource_service.clone(), req);
} }
} }
"GET" => { "GET" | "HEAD" => {
if let Some(svc) = RS::get() { if let Some(svc) = RS::get() {
return svc(self.resource_service.clone(), req); return svc(self.resource_service.clone(), req);
} }
} }
"HEAD" => {
if let Some(svc) = RS::head() {
return svc(self.resource_service.clone(), req);
}
}
"POST" => { "POST" => {
if let Some(svc) = RS::post() { if let Some(svc) = RS::post() {
return svc(self.resource_service.clone(), req); return svc(self.resource_service.clone(), req);
@@ -102,6 +97,11 @@ where
return svc(self.resource_service.clone(), req); return svc(self.resource_service.clone(), req);
} }
} }
"IMPORT" => {
if let Some(svc) = RS::import() {
return svc(self.resource_service.clone(), req);
}
}
_ => {} _ => {}
}; };
Box::pin(async move { Box::pin(async move {

View File

@@ -17,7 +17,7 @@ pub(crate) async fn axum_route_copy<R: ResourceService>(
State(resource_service): State<R>, State(resource_service): State<R>,
depth: Option<Depth>, depth: Option<Depth>,
principal: R::Principal, principal: R::Principal,
overwrite: Overwrite, Overwrite(overwrite): Overwrite,
matched_path: MatchedPath, matched_path: MatchedPath,
header_map: HeaderMap, header_map: HeaderMap,
) -> Result<Response, R::Error> { ) -> Result<Response, R::Error> {
@@ -39,7 +39,7 @@ pub(crate) async fn axum_route_copy<R: ResourceService>(
.map_err(|_| crate::Error::Forbidden)?; .map_err(|_| crate::Error::Forbidden)?;
if resource_service if resource_service
.copy_resource(&path, &dest_path, &principal, overwrite.is_true()) .copy_resource(&path, &dest_path, &principal, overwrite)
.await? .await?
{ {
// Overwritten // Overwritten

View File

@@ -45,10 +45,11 @@ pub async fn route_delete<R: ResourceService>(
if_match: Option<IfMatch>, if_match: Option<IfMatch>,
if_none_match: Option<IfNoneMatch>, if_none_match: Option<IfNoneMatch>,
) -> Result<(), R::Error> { ) -> Result<(), R::Error> {
let resource = resource_service.get_resource(path_components).await?; let resource = resource_service.get_resource(path_components, true).await?;
// Kind of a bodge since we don't get unbind from the parent
let privileges = resource.get_user_privileges(principal)?; let privileges = resource.get_user_privileges(principal)?;
if !privileges.has(&UserPrivilege::Write) { if !privileges.has(&UserPrivilege::WriteProperties) {
return Err(Error::Unauthorized.into()); return Err(Error::Unauthorized.into());
} }
@@ -59,12 +60,12 @@ pub async fn route_delete<R: ResourceService>(
return Err(crate::Error::PreconditionFailed.into()); return Err(crate::Error::PreconditionFailed.into());
} }
} }
if let Some(if_none_match) = if_none_match { if let Some(if_none_match) = if_none_match
if resource.satisfies_if_none_match(&if_none_match) { && resource.satisfies_if_none_match(&if_none_match)
{
// Precondition failed // Precondition failed
return Err(crate::Error::PreconditionFailed.into()); return Err(crate::Error::PreconditionFailed.into());
} }
}
resource_service resource_service
.delete_resource(path_components, !no_trash) .delete_resource(path_components, !no_trash)
.await?; .await?;

View File

@@ -17,7 +17,7 @@ pub(crate) async fn axum_route_move<R: ResourceService>(
State(resource_service): State<R>, State(resource_service): State<R>,
depth: Option<Depth>, depth: Option<Depth>,
principal: R::Principal, principal: R::Principal,
overwrite: Overwrite, Overwrite(overwrite): Overwrite,
matched_path: MatchedPath, matched_path: MatchedPath,
header_map: HeaderMap, header_map: HeaderMap,
) -> Result<Response, R::Error> { ) -> Result<Response, R::Error> {
@@ -39,7 +39,7 @@ pub(crate) async fn axum_route_move<R: ResourceService>(
.map_err(|_| crate::Error::Forbidden)?; .map_err(|_| crate::Error::Forbidden)?;
if resource_service if resource_service
.copy_resource(&path, &dest_path, &principal, overwrite.is_true()) .copy_resource(&path, &dest_path, &principal, overwrite)
.await? .await?
{ {
// Overwritten // Overwritten

View File

@@ -6,11 +6,7 @@ use crate::resource::Resource;
use crate::resource::ResourceName; use crate::resource::ResourceName;
use crate::resource::ResourceService; use crate::resource::ResourceService;
use crate::xml::MultistatusElement; use crate::xml::MultistatusElement;
use crate::xml::PropfindElement;
use crate::xml::PropfindType;
use axum::extract::{Extension, OriginalUri, Path, State}; use axum::extract::{Extension, OriginalUri, Path, State};
use rustical_xml::PropName;
use rustical_xml::XmlDocument;
use tracing::instrument; use tracing::instrument;
type RSMultistatus<R> = MultistatusElement< type RSMultistatus<R> = MultistatusElement<
@@ -49,43 +45,39 @@ pub(crate) async fn route_propfind<R: ResourceService>(
resource_service: &R, resource_service: &R,
puri: &impl PrincipalUri, puri: &impl PrincipalUri,
) -> Result<RSMultistatus<R>, R::Error> { ) -> Result<RSMultistatus<R>, R::Error> {
let resource = resource_service.get_resource(path_components).await?; let resource = resource_service
.get_resource(path_components, false)
.await?;
let privileges = resource.get_user_privileges(principal)?; let privileges = resource.get_user_privileges(principal)?;
if !privileges.has(&UserPrivilege::Read) { if !privileges.has(&UserPrivilege::Read) {
return Err(Error::Unauthorized.into()); return Err(Error::Unauthorized.into());
} }
// A request body is optional. If empty we MUST return all props // A request body is optional. If empty we MUST return all props
let propfind_self: PropfindElement<<<R::Resource as Resource>::Prop as PropName>::Names> = let propfind_self = R::Resource::parse_propfind(body).map_err(Error::XmlError)?;
if !body.is_empty() { let propfind_member = R::MemberType::parse_propfind(body).map_err(Error::XmlError)?;
PropfindElement::parse_str(body).map_err(Error::XmlError)?
} else {
PropfindElement {
prop: PropfindType::Allprop,
}
};
let propfind_member: PropfindElement<<<R::MemberType as Resource>::Prop as PropName>::Names> =
if !body.is_empty() {
PropfindElement::parse_str(body).map_err(Error::XmlError)?
} else {
PropfindElement {
prop: PropfindType::Allprop,
}
};
let mut member_responses = Vec::new(); let mut member_responses = Vec::new();
if depth != &Depth::Zero { if depth != &Depth::Zero {
// TODO: authorization check for member resources
for member in resource_service.get_members(path_components).await? { for member in resource_service.get_members(path_components).await? {
member_responses.push(member.propfind( member_responses.push(member.propfind(
&format!("{}/{}", path.trim_end_matches('/'), member.get_name()), &format!("{}/{}", path.trim_end_matches('/'), member.get_name()),
&propfind_member.prop, &propfind_member.prop,
propfind_member.include.as_ref(),
puri, puri,
principal, principal,
)?); )?);
} }
} }
let response = resource.propfind(path, &propfind_self.prop, puri, principal)?; let response = resource.propfind(
path,
&propfind_self.prop,
propfind_self.include.as_ref(),
puri,
principal,
)?;
Ok(MultistatusElement { Ok(MultistatusElement {
responses: vec![response], responses: vec![response],

View File

@@ -26,21 +26,21 @@ enum SetPropertyPropWrapper<T: XmlDeserialize> {
// We are <prop> // We are <prop>
#[derive(XmlDeserialize, Clone, Debug)] #[derive(XmlDeserialize, Clone, Debug)]
struct SetPropertyPropWrapperWrapper<T: XmlDeserialize>( struct SetPropertyPropWrapperWrapper<T: XmlDeserialize>(
#[xml(ty = "untagged")] SetPropertyPropWrapper<T>, #[xml(ty = "untagged", flatten)] Vec<SetPropertyPropWrapper<T>>,
); );
// We are <set> // We are <set>
#[derive(XmlDeserialize, Clone, Debug)] #[derive(XmlDeserialize, Clone, Debug)]
struct SetPropertyElement<T: XmlDeserialize> { struct SetPropertyElement<T: XmlDeserialize> {
#[xml(ns = "crate::namespace::NS_DAV")] #[xml(ns = "crate::namespace::NS_DAV")]
prop: T, prop: SetPropertyPropWrapperWrapper<T>,
} }
#[derive(XmlDeserialize, Clone, Debug)] #[derive(XmlDeserialize, Clone, Debug)]
struct TagName(#[xml(ty = "tag_name")] String); struct TagName(#[xml(ty = "tag_name")] String);
#[derive(XmlDeserialize, Clone, Debug)] #[derive(XmlDeserialize, Clone, Debug)]
struct PropertyElement(#[xml(ty = "untagged")] TagName); struct PropertyElement(#[xml(ty = "untagged", flatten)] Vec<TagName>);
#[derive(XmlDeserialize, Clone, Debug)] #[derive(XmlDeserialize, Clone, Debug)]
struct RemovePropertyElement { struct RemovePropertyElement {
@@ -57,7 +57,7 @@ enum Operation<T: XmlDeserialize> {
} }
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug)] #[derive(XmlDeserialize, XmlRootTag, Clone, Debug)]
#[xml(root = b"propertyupdate")] #[xml(root = "propertyupdate")]
#[xml(ns = "crate::namespace::NS_DAV")] #[xml(ns = "crate::namespace::NS_DAV")]
struct PropertyupdateElement<T: XmlDeserialize>(#[xml(ty = "untagged", flatten)] Vec<Operation<T>>); struct PropertyupdateElement<T: XmlDeserialize>(#[xml(ty = "untagged", flatten)] Vec<Operation<T>>);
@@ -81,11 +81,12 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
let href = path.to_owned(); let href = path.to_owned();
// Extract operations // Extract operations
let PropertyupdateElement::<SetPropertyPropWrapperWrapper<<R::Resource as Resource>::Prop>>( let PropertyupdateElement::<<R::Resource as Resource>::Prop>(operations) =
operations, XmlDocument::parse_str(body).map_err(Error::XmlError)?;
) = XmlDocument::parse_str(body).map_err(Error::XmlError)?;
let mut resource = resource_service.get_resource(path_components).await?; let mut resource = resource_service
.get_resource(path_components, false)
.await?;
let privileges = resource.get_user_privileges(principal)?; let privileges = resource.get_user_privileges(principal)?;
if !privileges.has(&UserPrivilege::Write) { if !privileges.has(&UserPrivilege::Write) {
return Err(Error::Unauthorized.into()); return Err(Error::Unauthorized.into());
@@ -98,17 +99,17 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
for operation in operations.into_iter() { for operation in operations.into_iter() {
match operation { match operation {
Operation::Set(SetPropertyElement { Operation::Set(SetPropertyElement {
prop: SetPropertyPropWrapperWrapper(property), prop: SetPropertyPropWrapperWrapper(properties),
}) => { }) => {
for property in properties {
match property { match property {
SetPropertyPropWrapper::Valid(prop) => { SetPropertyPropWrapper::Valid(prop) => {
let propname: <<R::Resource as Resource>::Prop as PropName>::Names = let propname: <<R::Resource as Resource>::Prop as PropName>::Names =
prop.clone().into(); prop.clone().into();
let (ns, propname): (Option<Namespace>, &str) = propname.into(); let (ns, propname): (Option<Namespace>, &str) = propname.into();
match resource.set_prop(prop) { match resource.set_prop(prop) {
Ok(()) => { Ok(()) => props_ok
props_ok.push((ns.map(NamespaceOwned::from), propname.to_owned())) .push((ns.map(NamespaceOwned::from), propname.to_owned())),
}
Err(Error::PropReadOnly) => props_conflict Err(Error::PropReadOnly) => props_conflict
.push((ns.map(NamespaceOwned::from), propname.to_owned())), .push((ns.map(NamespaceOwned::from), propname.to_owned())),
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
@@ -137,9 +138,12 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
} }
} }
} }
}
Operation::Remove(remove_el) => { Operation::Remove(remove_el) => {
let propname = remove_el.prop.0.0; for tagname in remove_el.prop.0 {
match <<R::Resource as Resource>::Prop as PropName>::Names::from_str(&propname) { let propname = tagname.0;
match <<R::Resource as Resource>::Prop as PropName>::Names::from_str(&propname)
{
Ok(prop) => match resource.remove_prop(&prop) { Ok(prop) => match resource.remove_prop(&prop) {
Ok(()) => props_ok.push((None, propname)), Ok(()) => props_ok.push((None, propname)),
Err(Error::PropReadOnly) => props_conflict.push({ Err(Error::PropReadOnly) => props_conflict.push({
@@ -154,6 +158,7 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
} }
} }
} }
}
if props_not_found.is_empty() && props_conflict.is_empty() { if props_not_found.is_empty() && props_conflict.is_empty() {
// Only save if no errors occured // Only save if no errors occured

View File

@@ -1,15 +1,16 @@
use crate::Principal; use crate::Principal;
use crate::privileges::UserPrivilegeSet; use crate::privileges::UserPrivilegeSet;
use crate::xml::multistatus::{PropTagWrapper, PropstatElement, PropstatWrapper}; use crate::xml::multistatus::{PropTagWrapper, PropstatElement, PropstatWrapper};
use crate::xml::{PropElement, PropfindType, Resourcetype}; use crate::xml::{PropElement, PropfindElement, PropfindType, Resourcetype};
use crate::xml::{TagList, multistatus::ResponseElement}; use crate::xml::{TagList, multistatus::ResponseElement};
use headers::{ETag, IfMatch, IfNoneMatch}; use headers::{ETag, IfMatch, IfNoneMatch};
use http::StatusCode; use http::StatusCode;
use itertools::Itertools; use itertools::Itertools;
use quick_xml::name::Namespace; use quick_xml::name::Namespace;
pub use resource_service::ResourceService; pub use resource_service::ResourceService;
use rustical_xml::{EnumVariants, NamespaceOwned, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{
use std::collections::HashSet; EnumVariants, NamespaceOwned, PropName, XmlDeserialize, XmlDocument, XmlSerialize,
};
use std::str::FromStr; use std::str::FromStr;
mod axum_methods; mod axum_methods;
@@ -102,10 +103,24 @@ pub trait Resource: Clone + Send + 'static {
principal: &Self::Principal, principal: &Self::Principal,
) -> Result<UserPrivilegeSet, Self::Error>; ) -> Result<UserPrivilegeSet, Self::Error>;
fn parse_propfind(
body: &str,
) -> Result<PropfindElement<<Self::Prop as PropName>::Names>, rustical_xml::XmlError> {
if !body.is_empty() {
PropfindElement::parse_str(body)
} else {
Ok(PropfindElement {
prop: PropfindType::Allprop,
include: None,
})
}
}
fn propfind( fn propfind(
&self, &self,
path: &str, path: &str,
prop: &PropfindType<<Self::Prop as PropName>::Names>, prop: &PropfindType<<Self::Prop as PropName>::Names>,
include: Option<&PropElement<<Self::Prop as PropName>::Names>>,
principal_uri: &impl PrincipalUri, principal_uri: &impl PrincipalUri,
principal: &Self::Principal, principal: &Self::Principal,
) -> Result<ResponseElement<Self::Prop>, Self::Error> { ) -> Result<ResponseElement<Self::Prop>, Self::Error> {
@@ -115,9 +130,8 @@ pub trait Resource: Clone + Send + 'static {
path.push('/'); path.push('/');
} }
// TODO: Support include element let (mut props, mut invalid_props): (Vec<<Self::Prop as PropName>::Names>, Vec<_>) =
let (props, invalid_props): (HashSet<<Self::Prop as PropName>::Names>, Vec<_>) = match prop match prop {
{
PropfindType::Propname => { PropfindType::Propname => {
let props = Self::list_props() let props = Self::list_props()
.into_iter() .into_iter()
@@ -141,11 +155,16 @@ pub trait Resource: Clone + Send + 'static {
vec![], vec![],
), ),
PropfindType::Prop(PropElement(valid_tags, invalid_tags)) => ( PropfindType::Prop(PropElement(valid_tags, invalid_tags)) => (
valid_tags.iter().cloned().collect(), valid_tags.iter().unique().cloned().collect(),
invalid_tags.to_owned(), invalid_tags.to_owned(),
), ),
}; };
if let Some(PropElement(valid_tags, invalid_tags)) = include {
props.extend(valid_tags.clone());
invalid_props.extend(invalid_tags.to_owned());
}
let prop_responses = props let prop_responses = props
.into_iter() .into_iter()
.map(|prop| self.get_prop(principal_uri, principal, &prop)) .map(|prop| self.get_prop(principal_uri, principal, &prop))

View File

@@ -34,7 +34,8 @@ pub trait ResourceService: Clone + Sized + Send + Sync + AxumMethods + 'static {
async fn get_resource( async fn get_resource(
&self, &self,
_path: &Self::PathComponents, path: &Self::PathComponents,
show_deleted: bool,
) -> Result<Self::Resource, Self::Error>; ) -> Result<Self::Resource, Self::Error>;
async fn save_resource( async fn save_resource(

View File

@@ -1,3 +1,72 @@
pub mod root; pub mod root;
pub use root::{RootResource, RootResourceService}; pub use root::{RootResource, RootResourceService};
#[cfg(test)]
pub mod test {
use crate::{
Error, Principal,
extensions::{CommonPropertiesExtension, CommonPropertiesProp},
namespace::NS_DAV,
privileges::UserPrivilegeSet,
resource::{PrincipalUri, Resource},
xml::{Resourcetype, ResourcetypeInner},
};
#[derive(Debug, Clone)]
pub struct TestPrincipal(pub String);
impl Principal for TestPrincipal {
fn get_id(&self) -> &str {
&self.0
}
}
impl Resource for TestPrincipal {
type Prop = CommonPropertiesProp;
type Error = Error;
type Principal = Self;
fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> crate::xml::Resourcetype {
Resourcetype(&[ResourcetypeInner(Some(NS_DAV), "collection")])
}
fn get_prop(
&self,
principal_uri: &impl crate::resource::PrincipalUri,
principal: &Self::Principal,
prop: &<Self::Prop as rustical_xml::PropName>::Names,
) -> Result<Self::Prop, Self::Error> {
<Self as CommonPropertiesExtension>::get_prop(self, principal_uri, principal, prop)
}
fn get_displayname(&self) -> Option<&str> {
Some(&self.0)
}
fn get_user_privileges(
&self,
principal: &Self::Principal,
) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_only(
principal.get_id() == self.get_id(),
))
}
}
#[derive(Debug, Clone)]
pub struct TestPrincipalUri;
impl PrincipalUri for TestPrincipalUri {
fn principal_collection(&self) -> String {
"/".to_owned()
}
fn principal_uri(&self, principal: &str) -> String {
format!("/{principal}/")
}
}
}

View File

@@ -86,7 +86,11 @@ where
const DAV_HEADER: &str = "1, 3, access-control"; const DAV_HEADER: &str = "1, 3, access-control";
async fn get_resource(&self, _: &()) -> Result<Self::Resource, Self::Error> { async fn get_resource(
&self,
_: &(),
_show_deleted: bool,
) -> Result<Self::Resource, Self::Error> {
Ok(RootResource::<PRS::Resource, P>::default()) Ok(RootResource::<PRS::Resource, P>::default())
} }
@@ -101,3 +105,33 @@ impl<PRS: ResourceService<Principal = P> + Clone, P: Principal, PURI: PrincipalU
for RootResourceService<PRS, P, PURI> for RootResourceService<PRS, P, PURI>
{ {
} }
#[cfg(test)]
mod test {
use crate::{
resource::Resource,
resources::{
RootResource,
test::{TestPrincipal, TestPrincipalUri},
},
};
#[test]
fn test_root_resource() {
let resource = RootResource::<TestPrincipal, TestPrincipal>::default();
let propfind = RootResource::<TestPrincipal, TestPrincipal>::parse_propfind(
r#"<?xml version="1.0" encoding="UTF-8"?><propfind xmlns="DAV:"><allprop/></propfind>"#,
)
.unwrap();
let _response = resource
.propfind(
"/",
&propfind.prop,
propfind.include.as_ref(),
&TestPrincipalUri,
&TestPrincipal("user".to_owned()),
)
.unwrap();
}
}

View File

@@ -1,12 +1,12 @@
use rustical_xml::{XmlRootTag, XmlSerialize}; use rustical_xml::{XmlRootTag, XmlSerialize};
#[derive(XmlSerialize, XmlRootTag)] #[derive(XmlSerialize, XmlRootTag)]
#[xml(ns = "crate::namespace::NS_DAV", root = b"error")] #[xml(ns = "crate::namespace::NS_DAV", root = "error")]
#[xml(ns_prefix( #[xml(ns_prefix(
crate::namespace::NS_DAV = b"", crate::namespace::NS_DAV = "",
crate::namespace::NS_CARDDAV = b"CARD", crate::namespace::NS_CARDDAV = "CARD",
crate::namespace::NS_CALDAV = b"CAL", crate::namespace::NS_CALDAV = "CAL",
crate::namespace::NS_CALENDARSERVER = b"CS", crate::namespace::NS_CALENDARSERVER = "CS",
crate::namespace::NS_DAVPUSH = b"PUSH" crate::namespace::NS_DAVPUSH = "PUSH"
))] ))]
pub struct ErrorElement<'t, T: XmlSerialize>(#[xml(ty = "untagged")] pub &'t T); pub struct ErrorElement<'t, T: XmlSerialize>(#[xml(ty = "untagged")] pub &'t T);

View File

@@ -1,4 +1,5 @@
use crate::xml::TagList; use crate::xml::TagList;
use headers::{CacheControl, ContentType, HeaderMapExt};
use http::StatusCode; use http::StatusCode;
use quick_xml::name::Namespace; use quick_xml::name::Namespace;
use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot}; use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot};
@@ -18,12 +19,12 @@ pub struct PropstatElement<PropType: XmlSerialize> {
pub status: StatusCode, pub status: StatusCode,
} }
fn xml_serialize_status<W: ::std::io::Write>( fn xml_serialize_status(
status: &StatusCode, status: &StatusCode,
ns: Option<Namespace>, ns: Option<Namespace>,
tag: Option<&[u8]>, tag: Option<&str>,
namespaces: &HashMap<Namespace, &[u8]>, namespaces: &HashMap<Namespace, &str>,
writer: &mut quick_xml::Writer<W>, writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
XmlSerialize::serialize(&format!("HTTP/1.1 {}", status), ns, tag, namespaces, writer) XmlSerialize::serialize(&format!("HTTP/1.1 {}", status), ns, tag, namespaces, writer)
} }
@@ -38,8 +39,15 @@ pub enum PropstatWrapper<T: XmlSerialize> {
// RFC 2518 // RFC 2518
// <!ELEMENT response (href, ((href*, status)|(propstat+)), // <!ELEMENT response (href, ((href*, status)|(propstat+)),
// responsedescription?) > // responsedescription?) >
#[derive(XmlSerialize)] #[derive(XmlSerialize, XmlRootTag)]
#[xml(ns = "crate::namespace::NS_DAV")] #[xml(ns = "crate::namespace::NS_DAV", root = "response")]
#[xml(ns_prefix(
crate::namespace::NS_DAV = "",
crate::namespace::NS_CARDDAV = "CARD",
crate::namespace::NS_CALDAV = "CAL",
crate::namespace::NS_CALENDARSERVER = "CS",
crate::namespace::NS_DAVPUSH = "PUSH"
))]
pub struct ResponseElement<PropstatType: XmlSerialize> { pub struct ResponseElement<PropstatType: XmlSerialize> {
pub href: String, pub href: String,
#[xml(serialize_with = "xml_serialize_optional_status")] #[xml(serialize_with = "xml_serialize_optional_status")]
@@ -48,12 +56,12 @@ pub struct ResponseElement<PropstatType: XmlSerialize> {
pub propstat: Vec<PropstatWrapper<PropstatType>>, pub propstat: Vec<PropstatWrapper<PropstatType>>,
} }
fn xml_serialize_optional_status<W: ::std::io::Write>( fn xml_serialize_optional_status(
val: &Option<StatusCode>, val: &Option<StatusCode>,
ns: Option<Namespace>, ns: Option<Namespace>,
tag: Option<&[u8]>, tag: Option<&str>,
namespaces: &HashMap<Namespace, &[u8]>, namespaces: &HashMap<Namespace, &str>,
writer: &mut quick_xml::Writer<W>, writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
XmlSerialize::serialize( XmlSerialize::serialize(
&val.map(|status| format!("HTTP/1.1 {}", status)), &val.map(|status| format!("HTTP/1.1 {}", status)),
@@ -78,18 +86,18 @@ impl<PT: XmlSerialize> Default for ResponseElement<PT> {
// <!ELEMENT multistatus (response+, responsedescription?) > // <!ELEMENT multistatus (response+, responsedescription?) >
// Extended by sync-token as specified in RFC 6578 // Extended by sync-token as specified in RFC 6578
#[derive(XmlSerialize, XmlRootTag)] #[derive(XmlSerialize, XmlRootTag)]
#[xml(root = b"multistatus", ns = "crate::namespace::NS_DAV")] #[xml(root = "multistatus", ns = "crate::namespace::NS_DAV")]
#[xml(ns_prefix( #[xml(ns_prefix(
crate::namespace::NS_DAV = b"", crate::namespace::NS_DAV = "",
crate::namespace::NS_CARDDAV = b"CARD", crate::namespace::NS_CARDDAV = "CARD",
crate::namespace::NS_CALDAV = b"CAL", crate::namespace::NS_CALDAV = "CAL",
crate::namespace::NS_CALENDARSERVER = b"CS", crate::namespace::NS_CALENDARSERVER = "CS",
crate::namespace::NS_DAVPUSH = b"PUSH" crate::namespace::NS_DAVPUSH = "PUSH"
))] ))]
pub struct MultistatusElement<PropType: XmlSerialize, MemberPropType: XmlSerialize> { pub struct MultistatusElement<PropType: XmlSerialize, MemberPropType: XmlSerialize> {
#[xml(rename = b"response", flatten)] #[xml(rename = "response", flatten)]
pub responses: Vec<ResponseElement<PropType>>, pub responses: Vec<ResponseElement<PropType>>,
#[xml(rename = b"response", flatten)] #[xml(rename = "response", flatten)]
pub member_responses: Vec<ResponseElement<MemberPropType>>, pub member_responses: Vec<ResponseElement<MemberPropType>>,
pub sync_token: Option<String>, pub sync_token: Option<String>,
} }
@@ -109,18 +117,16 @@ impl<T1: XmlSerialize, T2: XmlSerialize> axum::response::IntoResponse
{ {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
use axum::body::Body; use axum::body::Body;
use http::header;
let mut output: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into(); let output = match self.serialize_to_string() {
let mut writer = quick_xml::Writer::new_with_indent(&mut output, b' ', 4); Ok(out) => out,
if let Err(err) = self.serialize_root(&mut writer) { Err(err) => return crate::Error::from(err).into_response(),
return crate::Error::from(err).into_response(); };
}
let mut resp = axum::response::Response::builder().status(StatusCode::MULTI_STATUS); let mut resp = axum::response::Response::builder().status(StatusCode::MULTI_STATUS);
resp.headers_mut() let hdrs = resp.headers_mut().unwrap();
.unwrap() hdrs.typed_insert(ContentType::xml());
.insert(header::CONTENT_TYPE, "application/xml".try_into().unwrap()); hdrs.typed_insert(CacheControl::new().with_no_cache());
resp.body(Body::from(output)).unwrap() resp.body(Body::from(output)).unwrap()
} }
} }

View File

@@ -7,14 +7,15 @@ use rustical_xml::XmlError;
use rustical_xml::XmlRootTag; use rustical_xml::XmlRootTag;
#[derive(Debug, Clone, XmlDeserialize, XmlRootTag, PartialEq)] #[derive(Debug, Clone, XmlDeserialize, XmlRootTag, PartialEq)]
#[xml(root = b"propfind", ns = "crate::namespace::NS_DAV")] #[xml(root = "propfind", ns = "crate::namespace::NS_DAV")]
pub struct PropfindElement<PN: XmlDeserialize> { pub struct PropfindElement<PN: XmlDeserialize> {
#[xml(ty = "untagged")] #[xml(ty = "untagged")]
pub prop: PropfindType<PN>, pub prop: PropfindType<PN>,
#[xml(ns = "crate::namespace::NS_DAV")]
pub include: Option<PropElement<PN>>,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
// pub struct PropElement<PN: XmlDeserialize = Propname>(#[xml(ty = "untagged", flatten)] pub Vec<PN>);
pub struct PropElement<PN: XmlDeserialize>( pub struct PropElement<PN: XmlDeserialize>(
// valid // valid
pub Vec<PN>, pub Vec<PN>,
@@ -65,6 +66,9 @@ impl<PN: XmlDeserialize> XmlDeserialize for PropElement<PN> {
Event::Text(_) | Event::CData(_) => { Event::Text(_) | Event::CData(_) => {
return Err(XmlError::UnsupportedEvent("Not expecting text here")); return Err(XmlError::UnsupportedEvent("Not expecting text here"));
} }
Event::GeneralRef(_) => {
return Err(::rustical_xml::XmlError::UnsupportedEvent("GeneralRef"));
}
Event::Decl(_) | Event::Comment(_) | Event::DocType(_) | Event::PI(_) => { /* ignore */ Event::Decl(_) | Event::Comment(_) | Event::DocType(_) | Event::PI(_) => { /* ignore */
} }
Event::End(_end) => { Event::End(_end) => {

View File

@@ -16,27 +16,30 @@ mod tests {
use super::{Resourcetype, ResourcetypeInner}; use super::{Resourcetype, ResourcetypeInner};
#[derive(XmlSerialize, XmlRootTag)] #[derive(XmlSerialize, XmlRootTag)]
#[xml(root = b"document")] #[xml(root = "document")]
struct Document { struct Document {
resourcetype: Resourcetype, resourcetype: Resourcetype,
} }
#[test] #[test]
fn test_serialize_resourcetype() { fn test_serialize_resourcetype() {
let mut buf = Vec::new(); let out = Document {
let mut writer = quick_xml::Writer::new(&mut buf);
Document {
resourcetype: Resourcetype(&[ resourcetype: Resourcetype(&[
ResourcetypeInner(Some(crate::namespace::NS_DAV), "displayname"), ResourcetypeInner(Some(crate::namespace::NS_DAV), "displayname"),
ResourcetypeInner(Some(crate::namespace::NS_CALENDARSERVER), "calendar-color"), ResourcetypeInner(Some(crate::namespace::NS_CALENDARSERVER), "calendar-color"),
]), ]),
} }
.serialize_root(&mut writer) .serialize_to_string()
.unwrap(); .unwrap();
let out = String::from_utf8(buf).unwrap();
assert_eq!( assert_eq!(
out, out,
"<document><resourcetype><displayname xmlns=\"DAV:\"/><calendar-color xmlns=\"http://calendarserver.org/ns/\"/></resourcetype></document>" r#"<?xml version="1.0" encoding="utf-8"?>
<document>
<resourcetype>
<displayname xmlns="DAV:"/>
<calendar-color xmlns="http://calendarserver.org/ns/"/>
</resourcetype>
</document>"#
) )
} }
} }

View File

@@ -1,4 +1,4 @@
use rustical_xml::{ValueDeserialize, ValueSerialize, XmlDeserialize}; use rustical_xml::{ValueDeserialize, ValueSerialize, XmlDeserialize, XmlRootTag};
use super::PropfindType; use super::PropfindType;
@@ -32,11 +32,35 @@ impl ValueSerialize for SyncLevel {
} }
} }
// https://datatracker.ietf.org/doc/html/rfc5323#section-5.17
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq)]
pub struct LimitElement {
#[xml(ns = "crate::namespace::NS_DAV")]
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)]
pub struct NresultsElement(#[xml(ty = "text")] u64);
#[derive(XmlDeserialize, Clone, Debug, PartialEq, XmlRootTag)]
// <!ELEMENT sync-collection (sync-token, sync-level, limit?, prop)> // <!ELEMENT sync-collection (sync-token, sync-level, limit?, prop)>
// <!-- DAV:limit defined in RFC 5323, Section 5.17 --> // <!-- DAV:limit defined in RFC 5323, Section 5.17 -->
// <!-- DAV:prop defined in RFC 4918, Section 14.18 --> // <!-- DAV:prop defined in RFC 4918, Section 14.18 -->
#[xml(ns = "crate::namespace::NS_DAV")] #[xml(ns = "crate::namespace::NS_DAV", root = "sync-collection")]
pub struct SyncCollectionRequest<PN: XmlDeserialize> { pub struct SyncCollectionRequest<PN: XmlDeserialize> {
#[xml(ns = "crate::namespace::NS_DAV")] #[xml(ns = "crate::namespace::NS_DAV")]
pub sync_token: String, pub sync_token: String,
@@ -45,5 +69,48 @@ pub struct SyncCollectionRequest<PN: XmlDeserialize> {
#[xml(ns = "crate::namespace::NS_DAV", ty = "untagged")] #[xml(ns = "crate::namespace::NS_DAV", ty = "untagged")]
pub prop: PropfindType<PN>, pub prop: PropfindType<PN>,
#[xml(ns = "crate::namespace::NS_DAV")] #[xml(ns = "crate::namespace::NS_DAV")]
pub limit: Option<u64>, pub limit: Option<LimitElement>,
}
#[cfg(test)]
mod tests {
use crate::xml::{
PropElement, PropfindType,
sync_collection::{SyncCollectionRequest, SyncLevel},
};
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlDocument};
const SYNC_COLLECTION_REQUEST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<sync-collection xmlns="DAV:">
<sync-token />
<sync-level>1</sync-level>
<limit>
<nresults>100</nresults>
</limit>
<prop>
<getetag />
</prop>
</sync-collection>
"#;
#[derive(XmlDeserialize, PropName, EnumVariants, PartialEq)]
#[xml(unit_variants_ident = "TestPropName")]
enum TestProp {
Getetag(String),
}
#[test]
fn test_parse_sync_collection_request() {
let request =
SyncCollectionRequest::<TestPropName>::parse_str(SYNC_COLLECTION_REQUEST).unwrap();
assert_eq!(
request,
SyncCollectionRequest {
sync_token: "".to_owned(),
sync_level: SyncLevel::One,
prop: PropfindType::Prop(PropElement(vec![TestPropName::Getetag], vec![])),
limit: Some(100.into())
}
)
}
} }

View File

@@ -10,36 +10,31 @@ use std::collections::HashMap;
pub struct TagList(Vec<(Option<NamespaceOwned>, String)>); pub struct TagList(Vec<(Option<NamespaceOwned>, String)>);
impl XmlSerialize for TagList { impl XmlSerialize for TagList {
fn serialize<W: std::io::Write>( fn serialize(
&self, &self,
ns: Option<Namespace>, ns: Option<Namespace>,
tag: Option<&[u8]>, tag: Option<&str>,
namespaces: &HashMap<Namespace, &[u8]>, namespaces: &HashMap<Namespace, &str>,
writer: &mut quick_xml::Writer<W>, writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
let prefix = ns let prefix = ns
.map(|ns| namespaces.get(&ns)) .map(|ns| namespaces.get(&ns))
.unwrap_or(None) .unwrap_or(None)
.map(|prefix| { .map(|prefix| {
if !prefix.is_empty() { if !prefix.is_empty() {
[*prefix, b":"].concat() format!("{prefix}:")
} else { } else {
Vec::new() String::new()
} }
}); });
let has_prefix = prefix.is_some(); let has_prefix = prefix.is_some();
let tagname = tag.map(|tag| [&prefix.unwrap_or_default(), tag].concat()); let tagname = tag.map(|tag| [&prefix.unwrap_or_default(), tag].concat());
let qname = tagname
.as_ref()
.map(|tagname| ::quick_xml::name::QName(tagname));
if let Some(qname) = &qname { if let Some(tagname) = tagname.as_ref() {
let mut bytes_start = BytesStart::from(qname.to_owned()); let mut bytes_start = BytesStart::new(tagname);
if !has_prefix { if !has_prefix && let Some(ns) = &ns {
if let Some(ns) = &ns {
bytes_start.push_attribute((b"xmlns".as_ref(), ns.as_ref())); bytes_start.push_attribute((b"xmlns".as_ref(), ns.as_ref()));
} }
}
writer.write_event(Event::Start(bytes_start))?; writer.write_event(Event::Start(bytes_start))?;
} }
@@ -51,13 +46,12 @@ impl XmlSerialize for TagList {
el.write_empty()?; el.write_empty()?;
} }
if let Some(qname) = &qname { if let Some(tagname) = tagname.as_ref() {
writer.write_event(Event::End(BytesEnd::from(qname.to_owned())))?; writer.write_event(Event::End(BytesEnd::new(tagname)))?;
} }
Ok(()) Ok(())
} }
#[allow(refining_impl_trait)]
fn attributes<'a>(&self) -> Option<Vec<quick_xml::events::attributes::Attribute<'a>>> { fn attributes<'a>(&self) -> Option<Vec<quick_xml::events::attributes::Attribute<'a>>> {
None None
} }

View File

@@ -24,7 +24,6 @@ rustical_dav.workspace = true
rustical_store.workspace = true rustical_store.workspace = true
http.workspace = true http.workspace = true
base64.workspace = true base64.workspace = true
p256.workspace = true
rand.workspace = true
ece.workspace = true ece.workspace = true
axum.workspace = true axum.workspace = true
openssl.workspace = true

View File

@@ -25,10 +25,10 @@ pub struct ContentUpdate {
} }
#[derive(XmlSerialize, XmlRootTag, Debug)] #[derive(XmlSerialize, XmlRootTag, Debug)]
#[xml(root = b"push-message", ns = "rustical_dav::namespace::NS_DAVPUSH")] #[xml(root = "push-message", ns = "rustical_dav::namespace::NS_DAVPUSH")]
#[xml(ns_prefix( #[xml(ns_prefix(
rustical_dav::namespace::NS_DAVPUSH = b"", rustical_dav::namespace::NS_DAVPUSH = "",
rustical_dav::namespace::NS_DAV = b"D", rustical_dav::namespace::NS_DAV = "D",
))] ))]
struct PushMessage { struct PushMessage {
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")] #[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
@@ -99,13 +99,13 @@ impl<S: SubscriptionStore> DavPushController<S> {
content_update, content_update,
}; };
let mut output: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into(); let payload = match push_message.serialize_to_string() {
let mut writer = quick_xml::Writer::new_with_indent(&mut output, b' ', 4); Ok(payload) => payload,
if let Err(err) = push_message.serialize_root(&mut writer) { Err(err) => {
error!("Could not serialize push message: {}", err); error!("Could not serialize push message: {}", err);
return; return;
} }
let payload = String::from_utf8(output).unwrap(); };
for subsciption in subscriptions { for subsciption in subscriptions {
if let Some(allowed_push_servers) = &self.allowed_push_servers { if let Some(allowed_push_servers) = &self.allowed_push_servers {
@@ -183,6 +183,7 @@ impl<S: SubscriptionStore> DavPushController<S> {
header::CONTENT_TYPE, header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"), HeaderValue::from_static("application/octet-stream"),
); );
hdrs.insert("TTL", HeaderValue::from(60));
client.execute(request).await?; client.execute(request).await?;
Ok(()) Ok(())

View File

@@ -35,12 +35,12 @@ pub enum Trigger {
#[derive(XmlSerialize, XmlDeserialize, PartialEq, Clone, Debug)] #[derive(XmlSerialize, XmlDeserialize, PartialEq, Clone, Debug)]
pub struct ContentUpdate( pub struct ContentUpdate(
#[xml(rename = b"depth", ns = "rustical_dav::namespace::NS_DAV")] pub Depth, #[xml(rename = "depth", ns = "rustical_dav::namespace::NS_DAV")] pub Depth,
); );
#[derive(XmlSerialize, PartialEq, Clone, Debug)] #[derive(XmlSerialize, PartialEq, Clone, Debug)]
pub struct PropertyUpdate( pub struct PropertyUpdate(
#[xml(rename = b"depth", ns = "rustical_dav::namespace::NS_DAV")] pub Depth, #[xml(rename = "depth", ns = "rustical_dav::namespace::NS_DAV")] pub Depth,
); );
impl XmlDeserialize for PropertyUpdate { impl XmlDeserialize for PropertyUpdate {
@@ -51,8 +51,8 @@ impl XmlDeserialize for PropertyUpdate {
) -> Result<Self, rustical_xml::XmlError> { ) -> Result<Self, rustical_xml::XmlError> {
#[derive(XmlDeserialize, PartialEq, Clone, Debug)] #[derive(XmlDeserialize, PartialEq, Clone, Debug)]
struct FakePropertyUpdate( struct FakePropertyUpdate(
#[xml(rename = b"depth", ns = "rustical_dav::namespace::NS_DAV")] pub Depth, #[xml(rename = "depth", ns = "rustical_dav::namespace::NS_DAV")] pub Depth,
#[xml(rename = b"prop", ns = "rustical_dav::namespace::NS_DAV")] pub Unparsed, #[xml(rename = "prop", ns = "rustical_dav::namespace::NS_DAV")] pub Unparsed,
); );
let FakePropertyUpdate(depth, _) = FakePropertyUpdate::deserialize(reader, start, empty)?; let FakePropertyUpdate(depth, _) = FakePropertyUpdate::deserialize(reader, start, empty)?;
Ok(Self(depth)) Ok(Self(depth))

View File

@@ -17,7 +17,7 @@ pub struct WebPushSubscription {
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq)]
pub struct SubscriptionPublicKey { pub struct SubscriptionPublicKey {
#[xml(ty = "attr", rename = b"type")] #[xml(ty = "attr", rename = "type")]
pub ty: String, pub ty: String,
#[xml(ty = "text")] #[xml(ty = "text")]
pub key: String, pub key: String,
@@ -33,7 +33,7 @@ pub struct SubscriptionElement {
pub struct TriggerElement(#[xml(ty = "untagged", flatten)] Vec<Trigger>); pub struct TriggerElement(#[xml(ty = "untagged", flatten)] Vec<Trigger>);
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, XmlRootTag, Clone, Debug, PartialEq)]
#[xml(root = b"push-register")] #[xml(root = "push-register")]
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")] #[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
pub struct PushRegister { pub struct PushRegister {
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")] #[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]

View File

@@ -5,15 +5,14 @@
}, },
"compilerOptions": { "compilerOptions": {
"lib": [ "lib": [
"ES2020", "ES2024",
"DOM", "DOM",
"DOM.Iterable" "DOM.Iterable"
] ]
}, },
"imports": { "imports": {
"@deno/vite-plugin": "npm:@deno/vite-plugin@^1.0.4", "@deno/vite-plugin": "npm:@deno/vite-plugin@^1.0.5",
"lit": "npm:lit@^3.2.1", "lit": "npm:lit@^3.3.1",
"vite": "npm:vite@^6.1.1", "vite": "npm:vite@^7.1.7"
"webdav": "npm:webdav@^5.8.0"
} }
} }

View File

@@ -1,205 +1,276 @@
{ {
"version": "4", "version": "5",
"specifiers": { "specifiers": {
"npm:@deno/vite-plugin@^1.0.4": "1.0.4_vite@6.3.5__picomatch@4.0.2", "npm:@deno/vite-plugin@^1.0.5": "1.0.5_vite@7.1.7__picomatch@4.0.3",
"npm:lit@^3.2.1": "3.3.0", "npm:lit@^3.3.1": "3.3.1",
"npm:vite@*": "6.3.5_picomatch@4.0.2", "npm:vite@*": "7.1.7_picomatch@4.0.3",
"npm:vite@^6.1.1": "6.3.5_picomatch@4.0.2", "npm:vite@^7.1.7": "7.1.7_picomatch@4.0.3"
"npm:webdav@^5.8.0": "5.8.0"
}, },
"npm": { "npm": {
"@buttercup/fetch@0.2.1": { "@deno/vite-plugin@1.0.5_vite@7.1.7__picomatch@4.0.3": {
"integrity": "sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==", "integrity": "sha512-tLja5n4dyMhcze1NzvSs2iiriBymfBlDCZIrjMTxb9O2ru0gvmV6mn5oBD2teNw5Sd92cj3YJzKwsAs8tMJXlg==",
"dependencies": [
"node-fetch"
]
},
"@deno/vite-plugin@1.0.4_vite@6.3.5__picomatch@4.0.2": {
"integrity": "sha512-xg8YT8Wn2sGXSnJgiGTpBGX1Dov0c6fd1rAp8VsfrCUtyBRRWzwVMAnd3fQ4yq8h7LSVvJUxEFN4U421k/DQLA==",
"dependencies": [ "dependencies": [
"vite" "vite"
] ]
}, },
"@esbuild/aix-ppc64@0.25.5": { "@esbuild/aix-ppc64@0.25.10": {
"integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==" "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==",
"os": ["aix"],
"cpu": ["ppc64"]
}, },
"@esbuild/android-arm64@0.25.5": { "@esbuild/android-arm64@0.25.10": {
"integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==" "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==",
"os": ["android"],
"cpu": ["arm64"]
}, },
"@esbuild/android-arm@0.25.5": { "@esbuild/android-arm@0.25.10": {
"integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==" "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==",
"os": ["android"],
"cpu": ["arm"]
}, },
"@esbuild/android-x64@0.25.5": { "@esbuild/android-x64@0.25.10": {
"integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==" "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==",
"os": ["android"],
"cpu": ["x64"]
}, },
"@esbuild/darwin-arm64@0.25.5": { "@esbuild/darwin-arm64@0.25.10": {
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==" "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==",
"os": ["darwin"],
"cpu": ["arm64"]
}, },
"@esbuild/darwin-x64@0.25.5": { "@esbuild/darwin-x64@0.25.10": {
"integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==" "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==",
"os": ["darwin"],
"cpu": ["x64"]
}, },
"@esbuild/freebsd-arm64@0.25.5": { "@esbuild/freebsd-arm64@0.25.10": {
"integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==" "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==",
"os": ["freebsd"],
"cpu": ["arm64"]
}, },
"@esbuild/freebsd-x64@0.25.5": { "@esbuild/freebsd-x64@0.25.10": {
"integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==" "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==",
"os": ["freebsd"],
"cpu": ["x64"]
}, },
"@esbuild/linux-arm64@0.25.5": { "@esbuild/linux-arm64@0.25.10": {
"integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==" "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==",
"os": ["linux"],
"cpu": ["arm64"]
}, },
"@esbuild/linux-arm@0.25.5": { "@esbuild/linux-arm@0.25.10": {
"integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==" "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==",
"os": ["linux"],
"cpu": ["arm"]
}, },
"@esbuild/linux-ia32@0.25.5": { "@esbuild/linux-ia32@0.25.10": {
"integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==" "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==",
"os": ["linux"],
"cpu": ["ia32"]
}, },
"@esbuild/linux-loong64@0.25.5": { "@esbuild/linux-loong64@0.25.10": {
"integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==" "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==",
"os": ["linux"],
"cpu": ["loong64"]
}, },
"@esbuild/linux-mips64el@0.25.5": { "@esbuild/linux-mips64el@0.25.10": {
"integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==" "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==",
"os": ["linux"],
"cpu": ["mips64el"]
}, },
"@esbuild/linux-ppc64@0.25.5": { "@esbuild/linux-ppc64@0.25.10": {
"integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==" "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==",
"os": ["linux"],
"cpu": ["ppc64"]
}, },
"@esbuild/linux-riscv64@0.25.5": { "@esbuild/linux-riscv64@0.25.10": {
"integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==" "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==",
"os": ["linux"],
"cpu": ["riscv64"]
}, },
"@esbuild/linux-s390x@0.25.5": { "@esbuild/linux-s390x@0.25.10": {
"integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==" "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==",
"os": ["linux"],
"cpu": ["s390x"]
}, },
"@esbuild/linux-x64@0.25.5": { "@esbuild/linux-x64@0.25.10": {
"integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==" "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==",
"os": ["linux"],
"cpu": ["x64"]
}, },
"@esbuild/netbsd-arm64@0.25.5": { "@esbuild/netbsd-arm64@0.25.10": {
"integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==" "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==",
"os": ["netbsd"],
"cpu": ["arm64"]
}, },
"@esbuild/netbsd-x64@0.25.5": { "@esbuild/netbsd-x64@0.25.10": {
"integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==" "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==",
"os": ["netbsd"],
"cpu": ["x64"]
}, },
"@esbuild/openbsd-arm64@0.25.5": { "@esbuild/openbsd-arm64@0.25.10": {
"integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==" "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==",
"os": ["openbsd"],
"cpu": ["arm64"]
}, },
"@esbuild/openbsd-x64@0.25.5": { "@esbuild/openbsd-x64@0.25.10": {
"integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==" "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==",
"os": ["openbsd"],
"cpu": ["x64"]
}, },
"@esbuild/sunos-x64@0.25.5": { "@esbuild/openharmony-arm64@0.25.10": {
"integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==" "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==",
"os": ["openharmony"],
"cpu": ["arm64"]
}, },
"@esbuild/win32-arm64@0.25.5": { "@esbuild/sunos-x64@0.25.10": {
"integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==" "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==",
"os": ["sunos"],
"cpu": ["x64"]
}, },
"@esbuild/win32-ia32@0.25.5": { "@esbuild/win32-arm64@0.25.10": {
"integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==" "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==",
"os": ["win32"],
"cpu": ["arm64"]
}, },
"@esbuild/win32-x64@0.25.5": { "@esbuild/win32-ia32@0.25.10": {
"integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==" "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==",
"os": ["win32"],
"cpu": ["ia32"]
}, },
"@lit-labs/ssr-dom-shim@1.3.0": { "@esbuild/win32-x64@0.25.10": {
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==" "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==",
"os": ["win32"],
"cpu": ["x64"]
}, },
"@lit/reactive-element@2.1.0": { "@lit-labs/ssr-dom-shim@1.4.0": {
"integrity": "sha512-L2qyoZSQClcBmq0qajBVbhYEcG6iK0XfLn66ifLe/RfC0/ihpc+pl0Wdn8bJ8o+hj38cG0fGXRgSS20MuXn7qA==", "integrity": "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw=="
},
"@lit/reactive-element@2.1.1": {
"integrity": "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==",
"dependencies": [ "dependencies": [
"@lit-labs/ssr-dom-shim" "@lit-labs/ssr-dom-shim"
] ]
}, },
"@rollup/rollup-android-arm-eabi@4.43.0": { "@rollup/rollup-android-arm-eabi@4.52.2": {
"integrity": "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw==" "integrity": "sha512-o3pcKzJgSGt4d74lSZ+OCnHwkKBeAbFDmbEm5gg70eA8VkyCuC/zV9TwBnmw6VjDlRdF4Pshfb+WE9E6XY1PoQ==",
"os": ["android"],
"cpu": ["arm"]
}, },
"@rollup/rollup-android-arm64@4.43.0": { "@rollup/rollup-android-arm64@4.52.2": {
"integrity": "sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA==" "integrity": "sha512-cqFSWO5tX2vhC9hJTK8WAiPIm4Q8q/cU8j2HQA0L3E1uXvBYbOZMhE2oFL8n2pKB5sOCHY6bBuHaRwG7TkfJyw==",
"os": ["android"],
"cpu": ["arm64"]
}, },
"@rollup/rollup-darwin-arm64@4.43.0": { "@rollup/rollup-darwin-arm64@4.52.2": {
"integrity": "sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A==" "integrity": "sha512-vngduywkkv8Fkh3wIZf5nFPXzWsNsVu1kvtLETWxTFf/5opZmflgVSeLgdHR56RQh71xhPhWoOkEBvbehwTlVA==",
"os": ["darwin"],
"cpu": ["arm64"]
}, },
"@rollup/rollup-darwin-x64@4.43.0": { "@rollup/rollup-darwin-x64@4.52.2": {
"integrity": "sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg==" "integrity": "sha512-h11KikYrUCYTrDj6h939hhMNlqU2fo/X4NB0OZcys3fya49o1hmFaczAiJWVAFgrM1NCP6RrO7lQKeVYSKBPSQ==",
"os": ["darwin"],
"cpu": ["x64"]
}, },
"@rollup/rollup-freebsd-arm64@4.43.0": { "@rollup/rollup-freebsd-arm64@4.52.2": {
"integrity": "sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ==" "integrity": "sha512-/eg4CI61ZUkLXxMHyVlmlGrSQZ34xqWlZNW43IAU4RmdzWEx0mQJ2mN/Cx4IHLVZFL6UBGAh+/GXhgvGb+nVxw==",
"os": ["freebsd"],
"cpu": ["arm64"]
}, },
"@rollup/rollup-freebsd-x64@4.43.0": { "@rollup/rollup-freebsd-x64@4.52.2": {
"integrity": "sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg==" "integrity": "sha512-QOWgFH5X9+p+S1NAfOqc0z8qEpJIoUHf7OWjNUGOeW18Mx22lAUOiA9b6r2/vpzLdfxi/f+VWsYjUOMCcYh0Ng==",
"os": ["freebsd"],
"cpu": ["x64"]
}, },
"@rollup/rollup-linux-arm-gnueabihf@4.43.0": { "@rollup/rollup-linux-arm-gnueabihf@4.52.2": {
"integrity": "sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==" "integrity": "sha512-kDWSPafToDd8LcBYd1t5jw7bD5Ojcu12S3uT372e5HKPzQt532vW+rGFFOaiR0opxePyUkHrwz8iWYEyH1IIQA==",
"os": ["linux"],
"cpu": ["arm"]
}, },
"@rollup/rollup-linux-arm-musleabihf@4.43.0": { "@rollup/rollup-linux-arm-musleabihf@4.52.2": {
"integrity": "sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw==" "integrity": "sha512-gKm7Mk9wCv6/rkzwCiUC4KnevYhlf8ztBrDRT9g/u//1fZLapSRc+eDZj2Eu2wpJ+0RzUKgtNijnVIB4ZxyL+w==",
"os": ["linux"],
"cpu": ["arm"]
}, },
"@rollup/rollup-linux-arm64-gnu@4.43.0": { "@rollup/rollup-linux-arm64-gnu@4.52.2": {
"integrity": "sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA==" "integrity": "sha512-66lA8vnj5mB/rtDNwPgrrKUOtCLVQypkyDa2gMfOefXK6rcZAxKLO9Fy3GkW8VkPnENv9hBkNOFfGLf6rNKGUg==",
"os": ["linux"],
"cpu": ["arm64"]
}, },
"@rollup/rollup-linux-arm64-musl@4.43.0": { "@rollup/rollup-linux-arm64-musl@4.52.2": {
"integrity": "sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA==" "integrity": "sha512-s+OPucLNdJHvuZHuIz2WwncJ+SfWHFEmlC5nKMUgAelUeBUnlB4wt7rXWiyG4Zn07uY2Dd+SGyVa9oyLkVGOjA==",
"os": ["linux"],
"cpu": ["arm64"]
}, },
"@rollup/rollup-linux-loongarch64-gnu@4.43.0": { "@rollup/rollup-linux-loong64-gnu@4.52.2": {
"integrity": "sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg==" "integrity": "sha512-8wTRM3+gVMDLLDdaT6tKmOE3lJyRy9NpJUS/ZRWmLCmOPIJhVyXwjBo+XbrrwtV33Em1/eCTd5TuGJm4+DmYjw==",
"os": ["linux"],
"cpu": ["loong64"]
}, },
"@rollup/rollup-linux-powerpc64le-gnu@4.43.0": { "@rollup/rollup-linux-ppc64-gnu@4.52.2": {
"integrity": "sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw==" "integrity": "sha512-6yqEfgJ1anIeuP2P/zhtfBlDpXUb80t8DpbYwXQ3bQd95JMvUaqiX+fKqYqUwZXqdJDd8xdilNtsHM2N0cFm6A==",
"os": ["linux"],
"cpu": ["ppc64"]
}, },
"@rollup/rollup-linux-riscv64-gnu@4.43.0": { "@rollup/rollup-linux-riscv64-gnu@4.52.2": {
"integrity": "sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g==" "integrity": "sha512-sshYUiYVSEI2B6dp4jMncwxbrUqRdNApF2c3bhtLAU0qA8Lrri0p0NauOsTWh3yCCCDyBOjESHMExonp7Nzc0w==",
"os": ["linux"],
"cpu": ["riscv64"]
}, },
"@rollup/rollup-linux-riscv64-musl@4.43.0": { "@rollup/rollup-linux-riscv64-musl@4.52.2": {
"integrity": "sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q==" "integrity": "sha512-duBLgd+3pqC4MMwBrKkFxaZerUxZcYApQVC5SdbF5/e/589GwVvlRUnyqMFbM8iUSb1BaoX/3fRL7hB9m2Pj8Q==",
"os": ["linux"],
"cpu": ["riscv64"]
}, },
"@rollup/rollup-linux-s390x-gnu@4.43.0": { "@rollup/rollup-linux-s390x-gnu@4.52.2": {
"integrity": "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw==" "integrity": "sha512-tzhYJJidDUVGMgVyE+PmxENPHlvvqm1KILjjZhB8/xHYqAGeizh3GBGf9u6WdJpZrz1aCpIIHG0LgJgH9rVjHQ==",
"os": ["linux"],
"cpu": ["s390x"]
}, },
"@rollup/rollup-linux-x64-gnu@4.43.0": { "@rollup/rollup-linux-x64-gnu@4.52.2": {
"integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==" "integrity": "sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA==",
"os": ["linux"],
"cpu": ["x64"]
}, },
"@rollup/rollup-linux-x64-musl@4.43.0": { "@rollup/rollup-linux-x64-musl@4.52.2": {
"integrity": "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ==" "integrity": "sha512-LSeBHnGli1pPKVJ79ZVJgeZWWZXkEe/5o8kcn23M8eMKCUANejchJbF/JqzM4RRjOJfNRhKJk8FuqL1GKjF5oQ==",
"os": ["linux"],
"cpu": ["x64"]
}, },
"@rollup/rollup-win32-arm64-msvc@4.43.0": { "@rollup/rollup-openharmony-arm64@4.52.2": {
"integrity": "sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw==" "integrity": "sha512-uPj7MQ6/s+/GOpolavm6BPo+6CbhbKYyZHUDvZ/SmJM7pfDBgdGisFX3bY/CBDMg2ZO4utfhlApkSfZ92yXw7Q==",
"os": ["openharmony"],
"cpu": ["arm64"]
}, },
"@rollup/rollup-win32-ia32-msvc@4.43.0": { "@rollup/rollup-win32-arm64-msvc@4.52.2": {
"integrity": "sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw==" "integrity": "sha512-Z9MUCrSgIaUeeHAiNkm3cQyst2UhzjPraR3gYYfOjAuZI7tcFRTOD+4cHLPoS/3qinchth+V56vtqz1Tv+6KPA==",
"os": ["win32"],
"cpu": ["arm64"]
}, },
"@rollup/rollup-win32-x64-msvc@4.43.0": { "@rollup/rollup-win32-ia32-msvc@4.52.2": {
"integrity": "sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw==" "integrity": "sha512-+GnYBmpjldD3XQd+HMejo+0gJGwYIOfFeoBQv32xF/RUIvccUz20/V6Otdv+57NE70D5pa8W/jVGDoGq0oON4A==",
"os": ["win32"],
"cpu": ["ia32"]
}, },
"@types/estree@1.0.7": { "@rollup/rollup-win32-x64-gnu@4.52.2": {
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==" "integrity": "sha512-ApXFKluSB6kDQkAqZOKXBjiaqdF1BlKi+/eqnYe9Ee7U2K3pUDKsIyr8EYm/QDHTJIM+4X+lI0gJc3TTRhd+dA==",
"os": ["win32"],
"cpu": ["x64"]
},
"@rollup/rollup-win32-x64-msvc@4.52.2": {
"integrity": "sha512-ARz+Bs8kY6FtitYM96PqPEVvPXqEZmPZsSkXvyX19YzDqkCaIlhCieLLMI5hxO9SRZ2XtCtm8wxhy0iJ2jxNfw==",
"os": ["win32"],
"cpu": ["x64"]
},
"@types/estree@1.0.8": {
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
}, },
"@types/trusted-types@2.0.7": { "@types/trusted-types@2.0.7": {
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
}, },
"balanced-match@1.0.2": { "esbuild@0.25.10": {
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==",
}, "optionalDependencies": [
"base-64@1.0.0": {
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="
},
"brace-expansion@2.0.2": {
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dependencies": [
"balanced-match"
]
},
"byte-length@1.0.2": {
"integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q=="
},
"charenc@0.0.2": {
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="
},
"crypt@0.0.2": {
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="
},
"data-uri-to-buffer@4.0.1": {
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="
},
"entities@6.0.1": {
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="
},
"esbuild@0.25.5": {
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
"dependencies": [
"@esbuild/aix-ppc64", "@esbuild/aix-ppc64",
"@esbuild/android-arm", "@esbuild/android-arm",
"@esbuild/android-arm64", "@esbuild/android-arm64",
@@ -221,128 +292,75 @@
"@esbuild/netbsd-x64", "@esbuild/netbsd-x64",
"@esbuild/openbsd-arm64", "@esbuild/openbsd-arm64",
"@esbuild/openbsd-x64", "@esbuild/openbsd-x64",
"@esbuild/openharmony-arm64",
"@esbuild/sunos-x64", "@esbuild/sunos-x64",
"@esbuild/win32-arm64", "@esbuild/win32-arm64",
"@esbuild/win32-ia32", "@esbuild/win32-ia32",
"@esbuild/win32-x64" "@esbuild/win32-x64"
] ],
"scripts": true,
"bin": true
}, },
"fast-xml-parser@4.5.3": { "fdir@6.5.0_picomatch@4.0.3": {
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dependencies": [
"strnum"
]
},
"fdir@6.4.6_picomatch@4.0.2": {
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dependencies": [ "dependencies": [
"picomatch" "picomatch"
],
"optionalPeers": [
"picomatch"
] ]
}, },
"fetch-blob@3.2.0": {
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"dependencies": [
"node-domexception",
"web-streams-polyfill"
]
},
"formdata-polyfill@4.0.10": {
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"dependencies": [
"fetch-blob"
]
},
"fsevents@2.3.3": { "fsevents@2.3.3": {
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==" "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"os": ["darwin"],
"scripts": true
}, },
"hot-patcher@2.0.1": { "lit-element@4.2.1": {
"integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==" "integrity": "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==",
},
"is-buffer@1.1.6": {
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
"layerr@3.0.0": {
"integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="
},
"lit-element@4.2.0": {
"integrity": "sha512-MGrXJVAI5x+Bfth/pU9Kst1iWID6GHDLEzFEnyULB/sFiRLgkd8NPK/PeeXxktA3T6EIIaq8U3KcbTU5XFcP2Q==",
"dependencies": [ "dependencies": [
"@lit-labs/ssr-dom-shim", "@lit-labs/ssr-dom-shim",
"@lit/reactive-element", "@lit/reactive-element",
"lit-html" "lit-html"
] ]
}, },
"lit-html@3.3.0": { "lit-html@3.3.1": {
"integrity": "sha512-RHoswrFAxY2d8Cf2mm4OZ1DgzCoBKUKSPvA1fhtSELxUERq2aQQ2h05pO9j81gS1o7RIRJ+CePLogfyahwmynw==", "integrity": "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==",
"dependencies": [ "dependencies": [
"@types/trusted-types" "@types/trusted-types"
] ]
}, },
"lit@3.3.0": { "lit@3.3.1": {
"integrity": "sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==", "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
"dependencies": [ "dependencies": [
"@lit/reactive-element", "@lit/reactive-element",
"lit-element", "lit-element",
"lit-html" "lit-html"
] ]
}, },
"md5@2.3.0": {
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"dependencies": [
"charenc",
"crypt",
"is-buffer"
]
},
"minimatch@9.0.5": {
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dependencies": [
"brace-expansion"
]
},
"nanoid@3.3.11": { "nanoid@3.3.11": {
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==" "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
}, "bin": true
"nested-property@4.0.0": {
"integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA=="
},
"node-domexception@1.0.0": {
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="
},
"node-fetch@3.3.2": {
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"dependencies": [
"data-uri-to-buffer",
"fetch-blob",
"formdata-polyfill"
]
},
"path-posix@1.0.0": {
"integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA=="
}, },
"picocolors@1.1.1": { "picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
}, },
"picomatch@4.0.2": { "picomatch@4.0.3": {
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==" "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
}, },
"postcss@8.5.5": { "postcss@8.5.6": {
"integrity": "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dependencies": [ "dependencies": [
"nanoid", "nanoid",
"picocolors", "picocolors",
"source-map-js" "source-map-js"
] ]
}, },
"querystringify@2.2.0": { "rollup@4.52.2": {
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" "integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
},
"requires-port@1.0.0": {
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"rollup@4.43.0": {
"integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==",
"dependencies": [ "dependencies": [
"@types/estree"
],
"optionalDependencies": [
"@rollup/rollup-android-arm-eabi", "@rollup/rollup-android-arm-eabi",
"@rollup/rollup-android-arm64", "@rollup/rollup-android-arm64",
"@rollup/rollup-darwin-arm64", "@rollup/rollup-darwin-arm64",
@@ -353,84 +371,53 @@
"@rollup/rollup-linux-arm-musleabihf", "@rollup/rollup-linux-arm-musleabihf",
"@rollup/rollup-linux-arm64-gnu", "@rollup/rollup-linux-arm64-gnu",
"@rollup/rollup-linux-arm64-musl", "@rollup/rollup-linux-arm64-musl",
"@rollup/rollup-linux-loongarch64-gnu", "@rollup/rollup-linux-loong64-gnu",
"@rollup/rollup-linux-powerpc64le-gnu", "@rollup/rollup-linux-ppc64-gnu",
"@rollup/rollup-linux-riscv64-gnu", "@rollup/rollup-linux-riscv64-gnu",
"@rollup/rollup-linux-riscv64-musl", "@rollup/rollup-linux-riscv64-musl",
"@rollup/rollup-linux-s390x-gnu", "@rollup/rollup-linux-s390x-gnu",
"@rollup/rollup-linux-x64-gnu", "@rollup/rollup-linux-x64-gnu",
"@rollup/rollup-linux-x64-musl", "@rollup/rollup-linux-x64-musl",
"@rollup/rollup-openharmony-arm64",
"@rollup/rollup-win32-arm64-msvc", "@rollup/rollup-win32-arm64-msvc",
"@rollup/rollup-win32-ia32-msvc", "@rollup/rollup-win32-ia32-msvc",
"@rollup/rollup-win32-x64-gnu",
"@rollup/rollup-win32-x64-msvc", "@rollup/rollup-win32-x64-msvc",
"@types/estree",
"fsevents" "fsevents"
] ],
"bin": true
}, },
"source-map-js@1.2.1": { "source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
}, },
"strnum@1.1.2": { "tinyglobby@0.2.15_picomatch@4.0.3": {
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==" "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
},
"tinyglobby@0.2.14_picomatch@4.0.2": {
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dependencies": [ "dependencies": [
"fdir", "fdir",
"picomatch" "picomatch"
] ]
}, },
"url-join@5.0.0": { "vite@7.1.7_picomatch@4.0.3": {
"integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==" "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
},
"url-parse@1.5.10": {
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dependencies": [
"querystringify",
"requires-port"
]
},
"vite@6.3.5_picomatch@4.0.2": {
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dependencies": [ "dependencies": [
"esbuild", "esbuild",
"fdir", "fdir",
"fsevents",
"picomatch", "picomatch",
"postcss", "postcss",
"rollup", "rollup",
"tinyglobby" "tinyglobby"
] ],
}, "optionalDependencies": [
"web-streams-polyfill@3.3.3": { "fsevents"
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" ],
}, "bin": true
"webdav@5.8.0": {
"integrity": "sha512-iuFG7NamJ41Oshg4930iQgfIpRrUiatPWIekeznYgEf2EOraTRcDPTjy7gIOMtkdpKTaqPk1E68NO5PAGtJahA==",
"dependencies": [
"@buttercup/fetch",
"base-64",
"byte-length",
"entities",
"fast-xml-parser",
"hot-patcher",
"layerr",
"md5",
"minimatch",
"nested-property",
"node-fetch",
"path-posix",
"url-join",
"url-parse"
]
} }
}, },
"workspace": { "workspace": {
"dependencies": [ "dependencies": [
"npm:@deno/vite-plugin@^1.0.4", "npm:@deno/vite-plugin@^1.0.5",
"npm:lit@^3.2.1", "npm:lit@^3.3.1",
"npm:vite@^6.1.1", "npm:vite@^7.1.7"
"npm:webdav@^5.8.0"
] ]
} }
} }

View File

@@ -1,6 +1,7 @@
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { createClient } from "webdav"; import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { escapeXml } from ".";
@customElement("create-addressbook-form") @customElement("create-addressbook-form")
export class CreateAddressbookForm extends LitElement { export class CreateAddressbookForm extends LitElement {
@@ -13,26 +14,39 @@ export class CreateAddressbookForm extends LitElement {
return this return this
} }
client = createClient("/carddav") @property()
user: string = ''
@property() @property()
user: String = '' principal: string = ''
@property() @property()
id: String = '' addr_id: string = self.crypto.randomUUID()
@property() @property()
displayname: String = '' displayname: string = ''
@property() @property()
description: String = '' description: string = ''
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
override render() { override render() {
return html` return html`
<section> <button @click=${() => this.dialog.value.showModal()}>Create addressbook</button>
<h3>Create calendar</h3> <dialog ${ref(this.dialog)}>
<form @submit=${this.submit}> <h3>Create addressbook</h3>
<form @submit=${this.submit} ${ref(this.form)}>
<label>
principal (for group addressbooks)
<select name="principal" value=${this.user} @change=${e => this.principal = e.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map(membership => html`
<option value=${membership}>${membership}</option>
`)}
</select>
</label>
<br>
<label> <label>
id id
<input type="text" name="id" @change=${e => this.id = e.target.value} /> <input type="text" name="id" value=${this.addr_id} @change=${e => this.addr_id = e.target.value} />
</label> </label>
<br> <br>
<label> <label>
@@ -46,15 +60,16 @@ export class CreateAddressbookForm extends LitElement {
</label> </label>
<br> <br>
<button type="submit">Create</button> <button type="submit">Create</button>
<button type="submit" @click=${event => { event.preventDefault(); this.dialog.value.close(); this.form.value.reset() }} class="cancel">Cancel</button>
</form> </form>
</section> </dialog>
` `
} }
async submit(e: SubmitEvent) { async submit(e: SubmitEvent) {
console.log(this.displayname) console.log(this.displayname)
e.preventDefault() e.preventDefault()
if (!this.id) { if (!this.addr_id) {
alert("Empty id") alert("Empty id")
return return
} }
@@ -62,19 +77,29 @@ export class CreateAddressbookForm extends LitElement {
alert("Empty displayname") alert("Empty displayname")
return return
} }
// TODO: Escape user input: There's not really a security risk here but would be nicer let response = await fetch(`/carddav/principal/${this.principal || this.user}/${this.addr_id}`, {
await this.client.createDirectory(`/principal/${this.user}/${this.id}`, { method: 'MKCOL',
data: ` headers: {
'Content-Type': 'application/xml'
},
body: `
<mkcol xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav"> <mkcol xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
<set> <set>
<prop> <prop>
<displayname>${this.displayname}</displayname> <displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CARD:addressbook-description>${this.description}</CARD:addressbook-description>` : ''} ${this.description ? `<CARD:addressbook-description>${escapeXml(this.description)}</CARD:addressbook-description>` : ''}
</prop> </prop>
</set> </set>
</mkcol> </mkcol>
` `
}) })
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload() window.location.reload()
return null return null
} }

View File

@@ -1,44 +1,61 @@
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { createClient } from "webdav"; import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { escapeXml } from ".";
@customElement("create-calendar-form") @customElement("create-calendar-form")
export class CreateCalendarForm extends LitElement { export class CreateCalendarForm extends LitElement {
constructor() { constructor() {
super() super()
} }
protected override createRenderRoot() { protected override createRenderRoot() {
return this return this
} }
client = createClient("/caldav")
@property() @property()
user: String = '' user: string = ''
@property() @property()
id: String = '' principal: string = ''
@property() @property()
displayname: String = '' cal_id: string = self.crypto.randomUUID()
@property() @property()
description: String = '' displayname: string = ''
@property() @property()
color: String = '' description: string = ''
@property() @property()
subscriptionUrl: String = '' timezone_id: string = ''
@property()
color: string = ''
@property()
isSubscription: boolean = false
@property()
subscriptionUrl: string = ''
@property() @property()
components: Set<"VEVENT" | "VTODO" | "VJOURNAL"> = new Set() components: Set<"VEVENT" | "VTODO" | "VJOURNAL"> = new Set()
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
override render() { override render() {
return html` return html`
<section> <button @click=${() => this.dialog.value.showModal()}>Create calendar</button>
<dialog ${ref(this.dialog)}>
<h3>Create calendar</h3> <h3>Create calendar</h3>
<form @submit=${this.submit}> <form @submit=${this.submit} ${ref(this.form)}>
<label>
principal (for group calendars)
<select name="principal" value=${this.user} @change=${e => this.principal = e.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map(membership => html`
<option value=${membership}>${membership}</option>
`)}
</select>
</label>
<br>
<label> <label>
id id
<input type="text" name="id" @change=${e => this.id = e.target.value} /> <input type="text" name="id" value=${this.cal_id} @change=${e => this.cal_id = e.target.value} />
</label> </label>
<br> <br>
<label> <label>
@@ -46,6 +63,11 @@ export class CreateCalendarForm extends LitElement {
<input type="text" name="displayname" value=${this.displayname} @change=${e => this.displayname = e.target.value} /> <input type="text" name="displayname" value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label> </label>
<br> <br>
<label>
Timezone (optional)
<input type="text" name="timezone" .value=${this.timezone_id} @change=${e => this.timezone_id = e.target.value} />
</label>
<br>
<label> <label>
Description Description
<input type="text" name="description" @change=${e => this.description = e.target.value} /> <input type="text" name="description" @change=${e => this.description = e.target.value} />
@@ -56,28 +78,39 @@ export class CreateCalendarForm extends LitElement {
<input type="color" name="color" @change=${e => this.color = e.target.value} /> <input type="color" name="color" @change=${e => this.color = e.target.value} />
</label> </label>
<br> <br>
<br>
<label>
Calendar is subscription to external calendar
<input type="checkbox" name="is_subscription" @change=${e => this.isSubscription = e.target.checked} />
</label>
<br>
${this.isSubscription ? html`
<label> <label>
Subscription URL Subscription URL
<input type="text" name="subscription_url" @change=${e => this.subscriptionUrl = e.target.value} /> <input type="text" name="subscription_url" @change=${e => this.subscriptionUrl = e.target.value} />
</label> </label>
<br> <br>
`: html``}
<br>
${["VEVENT", "VTODO", "VJOURNAL"].map(comp => html` ${["VEVENT", "VTODO", "VJOURNAL"].map(comp => html`
<label> <label>
Support ${comp} Support ${comp}
<input type="checkbox" value=${comp} @change=${e => e.target.checked ? this.components.add(e.target.value) : this.components.delete(e.target.value)} /> <input type="checkbox" value=${comp} @change=${e => e.target.checked ? this.components.add(e.target.value) : this.components.delete(e.target.value)} />
</label> </label>
<br>
`)} `)}
<br> <br>
<button type="submit">Create</button> <button type="submit">Create</button>
<button type="submit" @click=${event => { event.preventDefault(); this.dialog.value.close(); this.form.value.reset() }} class="cancel">Cancel</button>
</form> </form>
</section> </dialog>
` `
} }
async submit(e: SubmitEvent) { async submit(e: SubmitEvent) {
console.log(this.displayname) console.log(this.displayname)
e.preventDefault() e.preventDefault()
if (!this.id) { if (!this.cal_id) {
alert("Empty id") alert("Empty id")
return return
} }
@@ -89,23 +122,34 @@ export class CreateCalendarForm extends LitElement {
alert("No calendar components selected") alert("No calendar components selected")
return return
} }
await this.client.createDirectory(`/principal/${this.user}/${this.id}`, {
data: ` let response = await fetch(`/caldav/principal/${this.principal || this.user}/${this.cal_id}`, {
method: 'MKCOL',
headers: {
'Content-Type': 'application/xml'
},
body: `
<mkcol xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/"> <mkcol xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<set> <set>
<prop> <prop>
<displayname>${this.displayname}</displayname> <displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CAL:calendar-description>${this.description}</CAL:calendar-description>` : ''} ${this.timezone_id ? `<CAL:calendar-timezone-id>${escapeXml(this.timezone_id)}</CAL:calendar-timezone-id>` : ''}
${this.color ? `<ICAL:calendar-color>${this.color}</ICAL:calendar-color>` : ''} ${this.description ? `<CAL:calendar-description>${escapeXml(this.description)}</CAL:calendar-description>` : ''}
${this.subscriptionUrl ? `<CS:source>${this.subscriptionUrl}</CS:source>` : ''} ${this.color ? `<ICAL:calendar-color>${escapeXml(this.color)}</ICAL:calendar-color>` : ''}
${(this.isSubscription && this.subscriptionUrl) ? `<CS:source><href>${escapeXml(this.subscriptionUrl)}</href></CS:source>` : ''}
<CAL:supported-calendar-component-set> <CAL:supported-calendar-component-set>
${Array.from(this.components.keys()).map(comp => `<CAL:comp name="${comp}" />`).join('\n')} ${Array.from(this.components.keys()).map(comp => `<CAL:comp name="${escapeXml(comp)}" />`).join('\n')}
</CAL:supported-calendar-component-set> </CAL:supported-calendar-component-set>
</prop> </prop>
</set> </set>
</mkcol> </mkcol>
` `
}) })
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload() window.location.reload()
return null return null
} }

View File

@@ -0,0 +1,43 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("delete-button")
export class DeleteButton extends LitElement {
constructor() {
super()
}
@property({ type: Boolean })
trash: boolean = false
@property()
href: string
protected createRenderRoot() {
return this
}
protected render() {
let text = this.trash ? 'Trash' : 'Delete'
return html`<button class="delete" @click=${e => this._onClick(e)}>${text}</button>`
}
async _onClick(event: Event) {
event.preventDefault()
if (!this.trash && !confirm('Do you want to delete this collection permanently?')) {
return
}
let response = await fetch(this.href, {
method: 'DELETE',
headers: {
'X-No-Trashbin': this.trash ? '0' : '1'
}
})
if (response.status < 200 || response.status >= 300) {
alert('An error occured, look into the console')
console.error(response)
return
}
window.location.reload()
}
}

View File

@@ -0,0 +1,103 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { escapeXml } from ".";
@customElement("edit-addressbook-form")
export class EditAddressbookForm extends LitElement {
constructor() {
super()
}
protected override createRenderRoot() {
return this
}
@property()
principal: string = ''
@property()
addr_id: string = ''
@property()
displayname: string = ''
@property()
description: string = ''
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
override render() {
return html`
<button @click=${() => this.dialog.value.showModal()}>Edit</button>
<dialog ${ref(this.dialog)}>
<h3>Edit addressbook</h3>
<form @submit=${this.submit} ${ref(this.form)}>
<label>
Displayname
<input type="text" name="displayname" .value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" .value=${this.description} @change=${e => this.description = e.target.value} />
</label>
<br>
<button type="submit">Submit</button>
<button type="submit" @click=${event => { event.preventDefault(); this.dialog.value.close(); this.form.value.reset() }} class="cancel">Cancel</button>
</form>
</dialog>
`
}
async submit(e: SubmitEvent) {
e.preventDefault()
if (!this.principal) {
alert("Empty principal")
return
}
if (!this.addr_id) {
alert("Empty id")
return
}
if (!this.displayname) {
alert("Empty displayname")
return
}
let response = await fetch(`/carddav/principal/${this.principal}/${this.addr_id}`, {
method: 'PROPPATCH',
headers: {
'Content-Type': 'application/xml'
},
body: `
<propertyupdate xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
<set>
<prop>
<displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CARD:addressbook-description>${escapeXml(this.description)}</CARD:addressbook-description>` : ''}
</prop>
</set>
<remove>
<prop>
${!this.description ? '<CARD:calendar-description />' : ''}
</prop>
</remove>
</propertyupdate>
`
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}
}
declare global {
interface HTMLElementTagNameMap {
'edit-addressbook-form': EditAddressbookForm
}
}

View File

@@ -0,0 +1,143 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { escapeXml } from ".";
@customElement("edit-calendar-form")
export class EditCalendarForm extends LitElement {
constructor() {
super()
}
protected override createRenderRoot() {
return this
}
@property()
principal: string
@property()
cal_id: string
@property()
displayname: string = ''
@property()
description: string = ''
@property()
timezone_id: string = ''
@property()
color: string = ''
@property({
converter: {
fromAttribute: (value, _type) => new Set(value ? JSON.parse(value) : []),
toAttribute: (value, _type) => JSON.stringify(value)
}
})
components: Set<"VEVENT" | "VTODO" | "VJOURNAL"> = new Set()
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
override render() {
return html`
<button @click=${() => this.dialog.value.showModal()}>Edit</button>
<dialog ${ref(this.dialog)}>
<h3>Edit calendar</h3>
<form @submit=${this.submit} ${ref(this.form)}>
<label>
Displayname
<input type="text" name="displayname" .value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label>
<br>
<label>
Timezone (optional)
<input type="text" name="timezone" .value=${this.timezone_id} @change=${e => this.timezone_id = e.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" .value=${this.description} @change=${e => this.description = e.target.value} />
</label>
<br>
<label>
Color
<input type="color" name="color" .value=${this.color} @change=${e => this.color = e.target.value} />
</label>
<br>
${["VEVENT", "VTODO", "VJOURNAL"].map(comp => html`
<label>
Support ${comp}
<input type="checkbox" value=${comp} ?checked=${this.components.has(comp)} @change=${e => e.target.checked ? this.components.add(e.target.value) : this.components.delete(e.target.value)} />
</label>
<br>
`)}
<br>
<button type="submit">Submit</button>
<button type="submit" @click=${event => { event.preventDefault(); this.dialog.value.close(); this.form.value.reset() }} class="cancel">Cancel</button>
</form>
</dialog>
`
}
async submit(e: SubmitEvent) {
e.preventDefault()
if (!this.principal) {
alert("Empty principal")
return
}
if (!this.cal_id) {
alert("Empty id")
return
}
if (!this.displayname) {
alert("Empty displayname")
return
}
if (!this.components.size) {
alert("No calendar components selected")
return
}
let response = await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
method: 'PROPPATCH',
headers: {
'Content-Type': 'application/xml'
},
body: `
<propertyupdate xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<set>
<prop>
<displayname>${escapeXml(this.displayname)}</displayname>
${this.timezone_id ? `<CAL:calendar-timezone-id>${escapeXml(this.timezone_id)}</CAL:calendar-timezone-id>` : ''}
${this.description ? `<CAL:calendar-description>${escapeXml(this.description)}</CAL:calendar-description>` : ''}
${this.color ? `<ICAL:calendar-color>${escapeXml(this.color)}</ICAL:calendar-color>` : ''}
<CAL:supported-calendar-component-set>
${Array.from(this.components.keys()).map(comp => `<CAL:comp name="${escapeXml(comp)}" />`).join('\n')}
</CAL:supported-calendar-component-set>
</prop>
</set>
<remove>
<prop>
${!this.timezone_id ? `<CAL:calendar-timezone-id />` : ''}
${!this.description ? '<CAL:calendar-description />' : ''}
${!this.color ? '<ICAL:calendar-color />' : ''}
</prop>
</remove>
</propertyupdate>
`
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}
}
declare global {
interface HTMLElementTagNameMap {
'edit-calendar-form': EditCalendarForm
}
}

View File

@@ -0,0 +1,9 @@
interface Window {
rusticalUser: {
id: String,
displayname: String | null,
memberships: Array<String>,
principal_type: "individual" | "group" | "room" | String
}
}

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