Compare commits

..

65 Commits

Author SHA1 Message Date
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
104 changed files with 2505 additions and 836 deletions

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

View File

@@ -0,0 +1,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"
}

378
Cargo.lock generated
View File

@@ -145,7 +145,7 @@ dependencies = [
"rustc-hash", "rustc-hash",
"serde", "serde",
"serde_derive", "serde_derive",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -181,9 +181,134 @@ checksum = "34921de3d57974069bad483fdfe0ec65d88c4ff892edd1ab4d8b03be0dda1b9b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]]
name = "async-attributes"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5"
dependencies = [
"quote",
"syn 1.0.109",
]
[[package]]
name = "async-channel"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
dependencies = [
"concurrent-queue",
"event-listener 2.5.3",
"futures-core",
]
[[package]]
name = "async-channel"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-global-executor"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c"
dependencies = [
"async-channel 2.3.1",
"async-executor",
"async-io",
"async-lock",
"blocking",
"futures-lite",
"once_cell",
]
[[package]]
name = "async-io"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1237c0ae75a0f3765f58910ff9cdd0a12eeb39ab2f4c7de23262f337f0aacbb3"
dependencies = [
"async-lock",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
name = "async-lock"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18"
dependencies = [
"event-listener 5.4.0",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-std"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "730294c1c08c2e0f85759590518f6333f0d5a0a766a27d519c1b244c3dfd8a24"
dependencies = [
"async-attributes",
"async-channel 1.9.0",
"async-global-executor",
"async-io",
"async-lock",
"crossbeam-utils",
"futures-channel",
"futures-core",
"futures-io",
"futures-lite",
"gloo-timers",
"kv-log-macro",
"log",
"memchr",
"once_cell",
"pin-project-lite",
"pin-utils",
"slab",
"wasm-bindgen-futures",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.88" version = "0.1.88"
@@ -192,7 +317,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -377,6 +502,19 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "blocking"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea"
dependencies = [
"async-channel 2.3.1",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.0" version = "3.19.0"
@@ -498,7 +636,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -630,7 +768,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -654,7 +792,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -665,7 +803,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -706,7 +844,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
"unicode-xid", "unicode-xid",
] ]
@@ -730,7 +868,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -845,6 +983,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "etcetera" name = "etcetera"
version = "0.8.0" version = "0.8.0"
@@ -856,6 +1004,12 @@ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
[[package]]
name = "event-listener"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]] [[package]]
name = "event-listener" name = "event-listener"
version = "5.4.0" version = "5.4.0"
@@ -867,6 +1021,22 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener 5.4.0",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]] [[package]]
name = "ff" name = "ff"
version = "0.13.1" version = "0.13.1"
@@ -1002,6 +1172,19 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.31" version = "0.3.31"
@@ -1010,7 +1193,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -1098,6 +1281,18 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "group" name = "group"
version = "0.13.0" version = "0.13.0"
@@ -1184,6 +1379,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
@@ -1578,6 +1779,15 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "kv-log-macro"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f"
dependencies = [
"log",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -1610,6 +1820,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.0" version = "0.8.0"
@@ -1632,6 +1848,9 @@ name = "log"
version = "0.4.27" version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
dependencies = [
"value-bag",
]
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
@@ -1876,7 +2095,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -2116,7 +2335,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"proc-macro2-diagnostics", "proc-macro2-diagnostics",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -2189,7 +2408,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -2204,6 +2423,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]] [[package]]
name = "pkcs1" name = "pkcs1"
version = "0.7.5" version = "0.7.5"
@@ -2231,6 +2461,21 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "polling"
version = "3.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b53a684391ad002dd6a596ceb6c74fd004fdce75f4be2e3f615068abbea5fd50"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"tracing",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.2" version = "0.1.2"
@@ -2290,7 +2535,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
"version_check", "version_check",
"yansi", "yansi",
] ]
@@ -2315,7 +2560,7 @@ dependencies = [
"itertools 0.14.0", "itertools 0.14.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -2482,7 +2727,7 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -2672,7 +2917,7 @@ dependencies = [
"regex", "regex",
"relative-path", "relative-path",
"rustc_version", "rustc_version",
"syn", "syn 2.0.104",
"unicode-ident", "unicode-ident",
] ]
@@ -2684,7 +2929,7 @@ checksum = "b3a8fb4672e840a587a66fc577a5491375df51ddb88f2a2c2a792598c326fe14"
dependencies = [ dependencies = [
"quote", "quote",
"rand 0.8.5", "rand 0.8.5",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -2717,7 +2962,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"rust-embed-utils", "rust-embed-utils",
"syn", "syn 2.0.104",
"walkdir", "walkdir",
] ]
@@ -2754,7 +2999,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical" name = "rustical"
version = "0.4.6" version = "0.6.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -2797,8 +3042,9 @@ dependencies = [
[[package]] [[package]]
name = "rustical_caldav" name = "rustical_caldav"
version = "0.4.6" version = "0.6.4"
dependencies = [ dependencies = [
"async-std",
"async-trait", "async-trait",
"axum", "axum",
"axum-extra", "axum-extra",
@@ -2812,6 +3058,7 @@ dependencies = [
"ical", "ical",
"percent-encoding", "percent-encoding",
"quick-xml", "quick-xml",
"rstest",
"rustical_dav", "rustical_dav",
"rustical_dav_push", "rustical_dav_push",
"rustical_ical", "rustical_ical",
@@ -2833,7 +3080,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_carddav" name = "rustical_carddav"
version = "0.4.6" version = "0.6.4"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -2865,7 +3112,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_dav" name = "rustical_dav"
version = "0.4.6" version = "0.6.4"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -2890,7 +3137,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_dav_push" name = "rustical_dav_push"
version = "0.4.6" version = "0.6.4"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -2916,7 +3163,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_frontend" name = "rustical_frontend"
version = "0.4.6" version = "0.6.4"
dependencies = [ dependencies = [
"askama", "askama",
"askama_web", "askama_web",
@@ -2949,7 +3196,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_ical" name = "rustical_ical"
version = "0.4.6" version = "0.6.4"
dependencies = [ dependencies = [
"axum", "axum",
"chrono", "chrono",
@@ -2967,7 +3214,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_oidc" name = "rustical_oidc"
version = "0.4.6" version = "0.6.4"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -2982,7 +3229,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_store" name = "rustical_store"
version = "0.4.6" version = "0.6.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -3016,7 +3263,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_store_sqlite" name = "rustical_store_sqlite"
version = "0.4.6" version = "0.6.4"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
@@ -3024,6 +3271,7 @@ dependencies = [
"password-auth", "password-auth",
"password-hash", "password-hash",
"pbkdf2", "pbkdf2",
"rstest",
"rustical_ical", "rustical_ical",
"rustical_store", "rustical_store",
"serde", "serde",
@@ -3036,13 +3284,26 @@ dependencies = [
[[package]] [[package]]
name = "rustical_xml" name = "rustical_xml"
version = "0.4.6" version = "0.6.4"
dependencies = [ dependencies = [
"quick-xml", "quick-xml",
"thiserror 2.0.12", "thiserror 2.0.12",
"xml_derive", "xml_derive",
] ]
[[package]]
name = "rustix"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.28" version = "0.23.28"
@@ -3164,7 +3425,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -3247,7 +3508,7 @@ dependencies = [
"darling", "darling",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -3381,7 +3642,7 @@ dependencies = [
"crc", "crc",
"crossbeam-queue", "crossbeam-queue",
"either", "either",
"event-listener", "event-listener 5.4.0",
"futures-core", "futures-core",
"futures-intrusive", "futures-intrusive",
"futures-io", "futures-io",
@@ -3415,7 +3676,7 @@ dependencies = [
"quote", "quote",
"sqlx-core", "sqlx-core",
"sqlx-macros-core", "sqlx-macros-core",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -3438,7 +3699,7 @@ dependencies = [
"sqlx-mysql", "sqlx-mysql",
"sqlx-postgres", "sqlx-postgres",
"sqlx-sqlite", "sqlx-sqlite",
"syn", "syn 2.0.104",
"tokio", "tokio",
"url", "url",
] ]
@@ -3591,7 +3852,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustversion", "rustversion",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -3600,6 +3861,17 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.104" version = "2.0.104"
@@ -3628,7 +3900,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -3657,7 +3929,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -3668,7 +3940,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -3763,7 +4035,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -4013,7 +4285,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -4181,6 +4453,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "value-bag"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@@ -4255,7 +4533,7 @@ dependencies = [
"log", "log",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -4290,7 +4568,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
"wasm-bindgen-backend", "wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -4395,7 +4673,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -4406,7 +4684,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -4613,7 +4891,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -4642,7 +4920,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
"synstructure", "synstructure",
] ]
@@ -4663,7 +4941,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -4683,7 +4961,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
"synstructure", "synstructure",
] ]
@@ -4723,5 +5001,5 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.104",
] ]

View File

@@ -2,7 +2,7 @@
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.4.6" version = "0.6.4"
edition = "2024" edition = "2024"
description = "A CalDAV server" description = "A CalDAV server"
repository = "https://github.com/lennart-k/rustical" repository = "https://github.com/lennart-k/rustical"
@@ -139,6 +139,7 @@ ece = { version = "2.3", default-features = false, features = [
"backend-openssl", "backend-openssl",
] } ] }
openssl = { version = "0.10", features = ["vendored"] } openssl = { version = "0.10", features = ["vendored"] }
async-std = { version = "1.13", features = ["attributes"] }
[dependencies] [dependencies]
rustical_store = { workspace = true } rustical_store = { workspace = true }

View File

@@ -12,13 +12,14 @@ a CalDAV/CardDAV server
- 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 - **[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
@@ -31,3 +32,4 @@ a CalDAV/CardDAV server
- Evolution - Evolution
- Apple Calendar - Apple Calendar
- Home Assistant integration - Home Assistant integration
- Thunderbird

View File

@@ -9,6 +9,8 @@ publish = false
[dev-dependencies] [dev-dependencies]
rustical_store_sqlite = { workspace = true, features = ["test"] } rustical_store_sqlite = { workspace = true, features = ["test"] }
rstest.workspace = true
async-std.workspace = true
[dependencies] [dependencies]
axum.workspace = true axum.workspace = true

View File

@@ -4,7 +4,7 @@ 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};
@@ -19,6 +19,7 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
Path((principal, calendar_id)): Path<(String, String)>, Path((principal, calendar_id)): Path<(String, String)>,
State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>, State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
user: Principal, user: Principal,
method: Method,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(crate::Error::Unauthorized); return Err(crate::Error::Unauthorized);
@@ -96,5 +97,9 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
)) ))
.unwrap(), .unwrap(),
); );
Ok(resp.body(Body::new(ical_calendar.generate())).unwrap()) if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(ical_calendar.generate())).unwrap())
}
} }

View File

@@ -16,6 +16,7 @@ pub(crate) struct TimeRangeElement {
#[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.3
struct ParamFilterElement { struct ParamFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
is_not_defined: Option<()>, is_not_defined: Option<()>,
@@ -32,11 +33,13 @@ struct TextMatchElement {
#[xml(ty = "attr")] #[xml(ty = "attr")]
collation: String, collation: String,
#[xml(ty = "attr")] #[xml(ty = "attr")]
negate_collation: String, // "yes" or "no", default: "no"
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<()>, is_not_defined: Option<()>,
@@ -46,6 +49,9 @@ pub(crate) struct PropFilterElement {
text_match: Option<TextMatchElement>, text_match: Option<TextMatchElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)] #[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
param_filter: Vec<ParamFilterElement>, param_filter: Vec<ParamFilterElement>,
#[xml(ty = "attr")]
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,
} }
@@ -203,3 +209,102 @@ pub async fn get_objects_calendar_query<C: CalendarStore>(
} }
Ok(objects) 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

@@ -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)?,
); );
} }

View File

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

@@ -34,7 +34,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),
@@ -201,10 +201,7 @@ impl Resource for CalendarResource {
if let Some(tzid) = &timezone_id { if let Some(tzid) = &timezone_id {
// Validate timezone id // Validate timezone id
chrono_tz::Tz::from_str(tzid).map_err(|_| { chrono_tz::Tz::from_str(tzid).map_err(|_| {
rustical_dav::Error::BadRequest(format!( rustical_dav::Error::BadRequest(format!("Invalid timezone-id: {tzid}"))
"Invalid timezone-id: {}",
tzid
))
})?; })?;
// TODO: Ensure that timezone is also updated (For now hope that clients play nice) // TODO: Ensure that timezone is also updated (For now hope that clients play nice)
} }

View File

@@ -6,7 +6,7 @@ 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::Principal; use rustical_store::auth::Principal;
@@ -22,6 +22,7 @@ pub async fn get_event<C: CalendarStore>(
}): Path<CalendarObjectPathComponents>, }): Path<CalendarObjectPathComponents>,
State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>, State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>,
user: Principal, user: Principal,
method: Method,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(crate::Error::Unauthorized); return Err(crate::Error::Unauthorized);
@@ -42,7 +43,11 @@ pub async fn get_event<C: CalendarStore>(
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());
Ok(resp.body(Body::new(event.get_ics().to_owned())).unwrap()) if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(event.get_ics().to_owned())).unwrap())
}
} }
#[instrument(skip(cal_store))] #[instrument(skip(cal_store))]

View File

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

View File

@@ -1,5 +1,3 @@
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;
@@ -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(
auth_provider: auth_provider.clone(), prefix,
sub_store: subscription_store.clone(), RootResourceService::<_, Principal, CalDavPrincipalUri>::new(PrincipalResourceService {
cal_store: store.clone(), auth_provider: auth_provider.clone(),
}; sub_store: subscription_store.clone(),
cal_store: store.clone(),
Router::new() simplified_home_set,
.nest( })
prefix, .axum_router()
RootResourceService::<_, Principal, CalDavPrincipalUri>::new(principal_service.clone()) .layer(AuthenticationLayer::new(auth_provider))
.axum_router() .layer(Extension(CalDavPrincipalUri(prefix))),
.layer(AuthenticationLayer::new(auth_provider)) )
.layer(Extension(CalDavPrincipalUri(prefix))),
)
.route(
"/.well-known/caldav",
any(async || Redirect::permanent(prefix)),
)
} }

View File

@@ -18,6 +18,8 @@ pub mod tests;
pub struct PrincipalResource { pub struct PrincipalResource {
principal: Principal, principal: Principal,
members: Vec<String>, members: Vec<String>,
// If true only return the principal as the calendar home set, otherwise also groups
simplified_home_set: bool,
} }
impl ResourceName for PrincipalResource { impl ResourceName for PrincipalResource {
@@ -64,9 +66,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())
} }

View File

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

@@ -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,
} }
} }
} }
@@ -58,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

@@ -1,33 +1,39 @@
use crate::principal::PrincipalResourceService; use std::sync::Arc;
use rustical_dav::resource::ResourceService;
use rustical_store::auth::{AuthenticationProvider, Principal, PrincipalType};
use rustical_store_sqlite::tests::get_test_stores;
use crate::principal::PrincipalResourceService;
use rstest::rstest;
use rustical_dav::resource::ResourceService;
use rustical_store_sqlite::{
SqliteStore,
calendar_store::SqliteCalendarStore,
principal_store::SqlitePrincipalStore,
tests::{get_test_calendar_store, get_test_principal_store, get_test_subscription_store},
};
#[rstest]
#[tokio::test] #[tokio::test]
async fn test_principal_resource() { async fn test_principal_resource(
let (_, cal_store, sub_store, auth_provider, _) = get_test_stores().await; #[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 { let service = PrincipalResourceService {
cal_store, cal_store: Arc::new(cal_store.await),
sub_store, sub_store: Arc::new(sub_store.await),
auth_provider: auth_provider.clone(), auth_provider: Arc::new(auth_provider.await),
simplified_home_set: false,
}; };
auth_provider
.insert_principal(
Principal {
id: "user".to_owned(),
displayname: None,
memberships: vec![],
password: None,
principal_type: PrincipalType::Individual,
},
true,
)
.await
.unwrap();
assert!(matches!( assert!(matches!(
service.get_resource(&("anonymous".to_owned(),), true).await, service
.get_resource(&("invalid-user".to_owned(),), true)
.await,
Err(crate::Error::NotFound) Err(crate::Error::NotFound)
)); ));
@@ -37,3 +43,5 @@ async fn test_principal_resource() {
.unwrap(); .unwrap();
} }
#[tokio::test]
async fn test_propfind() {}

View File

@@ -7,6 +7,7 @@ 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;
@@ -25,6 +26,7 @@ pub async fn get_object<AS: AddressbookStore>(
}): Path<AddressObjectPathComponents>, }): Path<AddressObjectPathComponents>,
State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>, State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>,
user: Principal, user: Principal,
method: Method,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
@@ -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());
Ok(resp.body(Body::new(object.get_vcf().to_owned())).unwrap()) if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(object.get_vcf().to_owned())).unwrap())
}
} }
#[instrument(skip(addr_store, body))] #[instrument(skip(addr_store, body))]

View File

@@ -5,7 +5,7 @@ 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;
@@ -20,6 +20,7 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>, Path((principal, addressbook_id)): Path<(String, String)>,
State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>, State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,
user: Principal, user: Principal,
method: Method,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
@@ -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(),
); );
Ok(resp.body(Body::new(vcf)).unwrap()) if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(vcf)).unwrap())
}
} }

View File

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

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

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

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

View File

@@ -22,11 +22,14 @@ pub enum PrincipalProp {
// 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

@@ -16,12 +16,12 @@ 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<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>, namespaces: &HashMap<Namespace, &[u8]>,
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 {
@@ -35,7 +35,6 @@ impl XmlSerialize for UserPrivilegeSet {
.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
} }

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
@@ -58,8 +53,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() {

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

View File

@@ -64,6 +64,7 @@ pub(crate) async fn route_propfind<R: ResourceService>(
} else { } else {
PropfindElement { PropfindElement {
prop: PropfindType::Allprop, prop: PropfindType::Allprop,
include: None,
} }
}; };
let propfind_member: PropfindElement<<<R::MemberType as Resource>::Prop as PropName>::Names> = let propfind_member: PropfindElement<<<R::MemberType as Resource>::Prop as PropName>::Names> =
@@ -72,22 +73,31 @@ pub(crate) async fn route_propfind<R: ResourceService>(
} else { } else {
PropfindElement { PropfindElement {
prop: PropfindType::Allprop, prop: PropfindType::Allprop,
include: None,
} }
}; };
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 {
@@ -81,9 +81,8 @@ 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 let mut resource = resource_service
.get_resource(path_components, false) .get_resource(path_components, false)
@@ -100,59 +99,63 @@ 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),
}) => { }) => {
match property { for property in properties {
SetPropertyPropWrapper::Valid(prop) => { match property {
let propname: <<R::Resource as Resource>::Prop as PropName>::Names = SetPropertyPropWrapper::Valid(prop) => {
prop.clone().into(); let propname: <<R::Resource as Resource>::Prop as PropName>::Names =
let (ns, propname): (Option<Namespace>, &str) = propname.into(); prop.clone().into();
match resource.set_prop(prop) { let (ns, propname): (Option<Namespace>, &str) = propname.into();
Ok(()) => { match resource.set_prop(prop) {
props_ok.push((ns.map(NamespaceOwned::from), propname.to_owned())) Ok(()) => props_ok
} .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()),
}; };
} }
SetPropertyPropWrapper::Invalid(invalid) => { SetPropertyPropWrapper::Invalid(invalid) => {
let propname = invalid.tag_name(); let propname = invalid.tag_name();
if let Some(full_propname) = <R::Resource as Resource>::list_props() if let Some(full_propname) = <R::Resource as Resource>::list_props()
.into_iter() .into_iter()
.find_map(|(ns, tag)| { .find_map(|(ns, tag)| {
if tag == propname.as_str() { if tag == propname.as_str() {
Some((ns.map(NamespaceOwned::from), tag.to_owned())) Some((ns.map(NamespaceOwned::from), tag.to_owned()))
} else { } else {
None None
} }
}) })
{ {
// This happens in following cases: // This happens in following cases:
// - read-only properties with #[serde(skip_deserializing)] // - read-only properties with #[serde(skip_deserializing)]
// - internal properties // - internal properties
props_conflict.push(full_propname) props_conflict.push(full_propname)
} else { } else {
props_not_found.push((None, propname)); props_not_found.push((None, propname));
}
} }
} }
} }
} }
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;
Ok(prop) => match resource.remove_prop(&prop) { match <<R::Resource as Resource>::Prop as PropName>::Names::from_str(&propname)
Ok(()) => props_ok.push((None, propname)), {
Err(Error::PropReadOnly) => props_conflict.push({ Ok(prop) => match resource.remove_prop(&prop) {
let (ns, tag) = prop.into(); Ok(()) => props_ok.push((None, propname)),
(ns.map(NamespaceOwned::from), tag.to_owned()) Err(Error::PropReadOnly) => props_conflict.push({
}), let (ns, tag) = prop.into();
Err(err) => return Err(err.into()), (ns.map(NamespaceOwned::from), tag.to_owned())
}, }),
// I guess removing a nonexisting property should be successful :) Err(err) => return Err(err.into()),
Err(_) => props_ok.push((None, propname)), },
}; // I guess removing a nonexisting property should be successful :)
Err(_) => props_ok.push((None, propname)),
};
}
} }
} }
} }

View File

@@ -106,6 +106,7 @@ pub trait Resource: Clone + Send + 'static {
&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,36 +116,40 @@ pub trait Resource: Clone + Send + 'static {
path.push('/'); path.push('/');
} }
// TODO: Support include element let (mut props, mut invalid_props): (HashSet<<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() .map(|(ns, tag)| (ns.map(NamespaceOwned::from), tag.to_string()))
.map(|(ns, tag)| (ns.map(NamespaceOwned::from), tag.to_string())) .collect_vec();
.collect_vec();
return Ok(ResponseElement { return Ok(ResponseElement {
href: path.to_owned(), href: path.to_owned(),
propstat: vec![PropstatWrapper::TagList(PropstatElement { propstat: vec![PropstatWrapper::TagList(PropstatElement {
prop: TagList::from(props), prop: TagList::from(props),
status: StatusCode::OK, status: StatusCode::OK,
})], })],
..Default::default() ..Default::default()
}); });
} }
PropfindType::Allprop => ( PropfindType::Allprop => (
Self::list_props() Self::list_props()
.iter() .iter()
.map(|(_ns, name)| <Self::Prop as PropName>::Names::from_str(name).unwrap()) .map(|(_ns, name)| <Self::Prop as PropName>::Names::from_str(name).unwrap())
.collect(), .collect(),
vec![], vec![],
), ),
PropfindType::Prop(PropElement(valid_tags, invalid_tags)) => ( PropfindType::Prop(PropElement(valid_tags, invalid_tags)) => (
valid_tags.iter().cloned().collect(), valid_tags.iter().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()

View File

@@ -19,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<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>, namespaces: &HashMap<Namespace, &[u8]>,
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)
} }
@@ -49,12 +49,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<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>, namespaces: &HashMap<Namespace, &[u8]>,
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)),
@@ -111,11 +111,10 @@ 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;
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);
let hdrs = resp.headers_mut().unwrap(); let hdrs = resp.headers_mut().unwrap();

View File

@@ -11,10 +11,11 @@ use rustical_xml::XmlRootTag;
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>,

View File

@@ -23,20 +23,23 @@ mod tests {
#[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 = b"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,12 +10,12 @@ 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<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>, namespaces: &HashMap<Namespace, &[u8]>,
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))
@@ -57,7 +57,6 @@ impl XmlSerialize for TagList {
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

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

View File

@@ -2,6 +2,7 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { createClient } from "webdav"; import { createClient } from "webdav";
import { escapeXml } from ".";
@customElement("create-addressbook-form") @customElement("create-addressbook-form")
export class CreateAddressbookForm extends LitElement { export class CreateAddressbookForm extends LitElement {
@@ -17,13 +18,15 @@ export class CreateAddressbookForm extends LitElement {
client = createClient("/carddav") client = createClient("/carddav")
@property() @property()
user: String = '' user: string = ''
@property() @property()
addr_id: String = '' principal: string = ''
@property() @property()
displayname: String = '' addr_id: string = ''
@property() @property()
description: String = '' displayname: string = ''
@property()
description: string = ''
dialog: Ref<HTMLDialogElement> = createRef() dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef() form: Ref<HTMLFormElement> = createRef()
@@ -34,6 +37,16 @@ export class CreateAddressbookForm extends LitElement {
<dialog ${ref(this.dialog)}> <dialog ${ref(this.dialog)}>
<h3>Create addressbook</h3> <h3>Create addressbook</h3>
<form @submit=${this.submit} ${ref(this.form)}> <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.addr_id = e.target.value} /> <input type="text" name="id" @change=${e => this.addr_id = e.target.value} />
@@ -67,14 +80,13 @@ 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 await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.addr_id}`, {
await this.client.createDirectory(`/principal/${this.user}/${this.addr_id}`, {
data: ` data: `
<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>

View File

@@ -2,6 +2,7 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { createClient } from "webdav"; import { createClient } from "webdav";
import { escapeXml } from ".";
@customElement("create-calendar-form") @customElement("create-calendar-form")
export class CreateCalendarForm extends LitElement { export class CreateCalendarForm extends LitElement {
@@ -16,17 +17,21 @@ export class CreateCalendarForm extends LitElement {
client = createClient("/caldav") client = createClient("/caldav")
@property() @property()
user: String = '' user: string = ''
@property() @property()
cal_id: String = '' principal: string = ''
@property() @property()
displayname: String = '' cal_id: string = ''
@property() @property()
description: String = '' displayname: string = ''
@property() @property()
color: String = '' description: string = ''
@property() @property()
subscriptionUrl: String = '' 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()
@@ -40,6 +45,16 @@ export class CreateCalendarForm extends LitElement {
<dialog ${ref(this.dialog)}> <dialog ${ref(this.dialog)}>
<h3>Create calendar</h3> <h3>Create calendar</h3>
<form @submit=${this.submit} ${ref(this.form)}> <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.cal_id = e.target.value} /> <input type="text" name="id" @change=${e => this.cal_id = e.target.value} />
@@ -60,16 +75,26 @@ 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> <label>
Subscription URL Calendar is subscription to external calendar
<input type="text" name="subscription_url" @change=${e => this.subscriptionUrl = e.target.value} /> <input type="checkbox" name="is_subscription" @change=${e => this.isSubscription = e.target.checked} />
</label> </label>
<br> <br>
${this.isSubscription ? html`
<label>
Subscription URL
<input type="text" name="subscription_url" @change=${e => this.subscriptionUrl = e.target.value} />
</label>
<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>
@@ -94,17 +119,17 @@ 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.cal_id}`, { await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.cal_id}`, {
data: ` data: `
<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.description ? `<CAL:calendar-description>${escapeXml(this.description)}</CAL:calendar-description>` : ''}
${this.color ? `<ICAL:calendar-color>${this.color}</ICAL:calendar-color>` : ''} ${this.color ? `<ICAL:calendar-color>${escapeXml(this.color)}</ICAL:calendar-color>` : ''}
${this.subscriptionUrl ? `<CS:source><href>${this.subscriptionUrl}</href></CS:source>` : ''} ${(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>

View File

@@ -1,6 +1,5 @@
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";
@customElement("delete-button") @customElement("delete-button")
export class DeleteButton extends LitElement { export class DeleteButton extends LitElement {

View File

@@ -0,0 +1,97 @@
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 addressbook</button>
<dialog ${ref(this.dialog)}>
<h3>Create 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
}
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>
`
})
window.location.reload()
return null
}
}
declare global {
interface HTMLElementTagNameMap {
'edit-addressbook-form': EditAddressbookForm
}
}

View File

@@ -0,0 +1,128 @@
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()
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 calendar</button>
<dialog ${ref(this.dialog)}>
<h3>Create 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>
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
}
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.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.description ? '<CAL:calendar-description />' : ''}
${!this.color ? '<ICAL:calendar-color />' : ''}
</prop>
</remove>
</propertyupdate>
`
})
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
}
}

View File

@@ -0,0 +1,7 @@
export function escapeXml(unsafe: string): string {
return unsafe.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}

View File

@@ -15,7 +15,9 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
input: [ input: [
"lib/create-calendar-form.ts", "lib/create-calendar-form.ts",
"lib/edit-calendar-form.ts",
"lib/create-addressbook-form.ts", "lib/create-addressbook-form.ts",
"lib/edit-addressbook-form.ts",
"lib/delete-button.ts", "lib/delete-button.ts",
], ],
output: { output: {

View File

@@ -1,6 +1,6 @@
import { i, x } from "./lit-z6_uA4GX.mjs"; import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs"; import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n } from "./ref-CPp9J0V5.mjs"; import { e, n, a as escapeXml } from "./index-b86iLJlP.mjs";
import { a as an } from "./webdav-D0R7xCzX.mjs"; import { a as an } from "./webdav-D0R7xCzX.mjs";
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -17,6 +17,7 @@ let CreateAddressbookForm = class extends i {
super(); super();
this.client = an("/carddav"); this.client = an("/carddav");
this.user = ""; this.user = "";
this.principal = "";
this.addr_id = ""; this.addr_id = "";
this.displayname = ""; this.displayname = "";
this.description = ""; this.description = "";
@@ -32,6 +33,16 @@ let CreateAddressbookForm = class extends i {
<dialog ${n(this.dialog)}> <dialog ${n(this.dialog)}>
<h3>Create addressbook</h3> <h3>Create addressbook</h3>
<form @submit=${this.submit} ${n(this.form)}> <form @submit=${this.submit} ${n(this.form)}>
<label>
principal (for group addressbooks)
<select name="principal" value=${this.user} @change=${(e2) => this.principal = e2.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map((membership) => x`
<option value=${membership}>${membership}</option>
`)}
</select>
</label>
<br>
<label> <label>
id id
<input type="text" name="id" @change=${(e2) => this.addr_id = e2.target.value} /> <input type="text" name="id" @change=${(e2) => this.addr_id = e2.target.value} />
@@ -68,13 +79,13 @@ let CreateAddressbookForm = class extends i {
alert("Empty displayname"); alert("Empty displayname");
return; return;
} }
await this.client.createDirectory(`/principal/${this.user}/${this.addr_id}`, { await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.addr_id}`, {
data: ` data: `
<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>
@@ -87,6 +98,9 @@ let CreateAddressbookForm = class extends i {
__decorateClass([ __decorateClass([
n$1() n$1()
], CreateAddressbookForm.prototype, "user", 2); ], CreateAddressbookForm.prototype, "user", 2);
__decorateClass([
n$1()
], CreateAddressbookForm.prototype, "principal", 2);
__decorateClass([ __decorateClass([
n$1() n$1()
], CreateAddressbookForm.prototype, "addr_id", 2); ], CreateAddressbookForm.prototype, "addr_id", 2);

View File

@@ -1,6 +1,6 @@
import { i, x } from "./lit-z6_uA4GX.mjs"; import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs"; import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n } from "./ref-CPp9J0V5.mjs"; import { e, n, a as escapeXml } from "./index-b86iLJlP.mjs";
import { a as an } from "./webdav-D0R7xCzX.mjs"; import { a as an } from "./webdav-D0R7xCzX.mjs";
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -17,10 +17,12 @@ let CreateCalendarForm = class extends i {
super(); super();
this.client = an("/caldav"); this.client = an("/caldav");
this.user = ""; this.user = "";
this.principal = "";
this.cal_id = ""; this.cal_id = "";
this.displayname = ""; this.displayname = "";
this.description = ""; this.description = "";
this.color = ""; this.color = "";
this.isSubscription = false;
this.subscriptionUrl = ""; this.subscriptionUrl = "";
this.components = /* @__PURE__ */ new Set(); this.components = /* @__PURE__ */ new Set();
this.dialog = e(); this.dialog = e();
@@ -35,6 +37,16 @@ let CreateCalendarForm = class extends i {
<dialog ${n(this.dialog)}> <dialog ${n(this.dialog)}>
<h3>Create calendar</h3> <h3>Create calendar</h3>
<form @submit=${this.submit} ${n(this.form)}> <form @submit=${this.submit} ${n(this.form)}>
<label>
principal (for group calendars)
<select name="principal" value=${this.user} @change=${(e2) => this.principal = e2.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map((membership) => x`
<option value=${membership}>${membership}</option>
`)}
</select>
</label>
<br>
<label> <label>
id id
<input type="text" name="id" @change=${(e2) => this.cal_id = e2.target.value} /> <input type="text" name="id" @change=${(e2) => this.cal_id = e2.target.value} />
@@ -55,16 +67,26 @@ let CreateCalendarForm = class extends i {
<input type="color" name="color" @change=${(e2) => this.color = e2.target.value} /> <input type="color" name="color" @change=${(e2) => this.color = e2.target.value} />
</label> </label>
<br> <br>
<br>
<label> <label>
Subscription URL Calendar is subscription to external calendar
<input type="text" name="subscription_url" @change=${(e2) => this.subscriptionUrl = e2.target.value} /> <input type="checkbox" name="is_subscription" @change=${(e2) => this.isSubscription = e2.target.checked} />
</label> </label>
<br> <br>
${this.isSubscription ? x`
<label>
Subscription URL
<input type="text" name="subscription_url" @change=${(e2) => this.subscriptionUrl = e2.target.value} />
</label>
<br>
` : x``}
<br>
${["VEVENT", "VTODO", "VJOURNAL"].map((comp) => x` ${["VEVENT", "VTODO", "VJOURNAL"].map((comp) => x`
<label> <label>
Support ${comp} Support ${comp}
<input type="checkbox" value=${comp} @change=${(e2) => e2.target.checked ? this.components.add(e2.target.value) : this.components.delete(e2.target.value)} /> <input type="checkbox" value=${comp} @change=${(e2) => e2.target.checked ? this.components.add(e2.target.value) : this.components.delete(e2.target.value)} />
</label> </label>
<br>
`)} `)}
<br> <br>
<button type="submit">Create</button> <button type="submit">Create</button>
@@ -92,17 +114,17 @@ let CreateCalendarForm = class extends i {
alert("No calendar components selected"); alert("No calendar components selected");
return; return;
} }
await this.client.createDirectory(`/principal/${this.user}/${this.cal_id}`, { await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.cal_id}`, {
data: ` data: `
<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.description ? `<CAL:calendar-description>${escapeXml(this.description)}</CAL:calendar-description>` : ""}
${this.color ? `<ICAL:calendar-color>${this.color}</ICAL:calendar-color>` : ""} ${this.color ? `<ICAL:calendar-color>${escapeXml(this.color)}</ICAL:calendar-color>` : ""}
${this.subscriptionUrl ? `<CS:source><href>${this.subscriptionUrl}</href></CS:source>` : ""} ${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>
@@ -116,6 +138,9 @@ let CreateCalendarForm = class extends i {
__decorateClass([ __decorateClass([
n$1() n$1()
], CreateCalendarForm.prototype, "user", 2); ], CreateCalendarForm.prototype, "user", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "principal", 2);
__decorateClass([ __decorateClass([
n$1() n$1()
], CreateCalendarForm.prototype, "cal_id", 2); ], CreateCalendarForm.prototype, "cal_id", 2);
@@ -128,6 +153,9 @@ __decorateClass([
__decorateClass([ __decorateClass([
n$1() n$1()
], CreateCalendarForm.prototype, "color", 2); ], CreateCalendarForm.prototype, "color", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "isSubscription", 2);
__decorateClass([ __decorateClass([
n$1() n$1()
], CreateCalendarForm.prototype, "subscriptionUrl", 2); ], CreateCalendarForm.prototype, "subscriptionUrl", 2);

View File

@@ -0,0 +1,109 @@
import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n, a as escapeXml } from "./index-b86iLJlP.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
for (var i2 = decorators.length - 1, decorator; i2 >= 0; i2--)
if (decorator = decorators[i2])
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
if (kind && result) __defProp(target, key, result);
return result;
};
let EditAddressbookForm = class extends i {
constructor() {
super();
this.principal = "";
this.addr_id = "";
this.displayname = "";
this.description = "";
this.dialog = e();
this.form = e();
}
createRenderRoot() {
return this;
}
render() {
return x`
<button @click=${() => this.dialog.value.showModal()}>Edit addressbook</button>
<dialog ${n(this.dialog)}>
<h3>Create addressbook</h3>
<form @submit=${this.submit} ${n(this.form)}>
<label>
Displayname
<input type="text" name="displayname" .value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" .value=${this.description} @change=${(e2) => this.description = e2.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(e2) {
e2.preventDefault();
if (!this.principal) {
alert("Empty principal");
return;
}
if (!this.addr_id) {
alert("Empty id");
return;
}
if (!this.displayname) {
alert("Empty displayname");
return;
}
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>
`
});
window.location.reload();
return null;
}
};
__decorateClass([
n$1()
], EditAddressbookForm.prototype, "principal", 2);
__decorateClass([
n$1()
], EditAddressbookForm.prototype, "addr_id", 2);
__decorateClass([
n$1()
], EditAddressbookForm.prototype, "displayname", 2);
__decorateClass([
n$1()
], EditAddressbookForm.prototype, "description", 2);
EditAddressbookForm = __decorateClass([
t("edit-addressbook-form")
], EditAddressbookForm);
export {
EditAddressbookForm
};

View File

@@ -0,0 +1,142 @@
import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n, a as escapeXml } from "./index-b86iLJlP.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
for (var i2 = decorators.length - 1, decorator; i2 >= 0; i2--)
if (decorator = decorators[i2])
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
if (kind && result) __defProp(target, key, result);
return result;
};
let EditCalendarForm = class extends i {
constructor() {
super();
this.displayname = "";
this.description = "";
this.color = "";
this.components = /* @__PURE__ */ new Set();
this.dialog = e();
this.form = e();
}
createRenderRoot() {
return this;
}
render() {
return x`
<button @click=${() => this.dialog.value.showModal()}>Edit calendar</button>
<dialog ${n(this.dialog)}>
<h3>Create calendar</h3>
<form @submit=${this.submit} ${n(this.form)}>
<label>
Displayname
<input type="text" name="displayname" .value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" .value=${this.description} @change=${(e2) => this.description = e2.target.value} />
</label>
<br>
<label>
Color
<input type="color" name="color" .value=${this.color} @change=${(e2) => this.color = e2.target.value} />
</label>
<br>
${["VEVENT", "VTODO", "VJOURNAL"].map((comp) => x`
<label>
Support ${comp}
<input type="checkbox" value=${comp} ?checked=${this.components.has(comp)} @change=${(e2) => e2.target.checked ? this.components.add(e2.target.value) : this.components.delete(e2.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(e2) {
e2.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;
}
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.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.description ? "<CAL:calendar-description />" : ""}
${!this.color ? "<ICAL:calendar-color />" : ""}
</prop>
</remove>
</propertyupdate>
`
});
window.location.reload();
return null;
}
};
__decorateClass([
n$1()
], EditCalendarForm.prototype, "principal", 2);
__decorateClass([
n$1()
], EditCalendarForm.prototype, "cal_id", 2);
__decorateClass([
n$1()
], EditCalendarForm.prototype, "displayname", 2);
__decorateClass([
n$1()
], EditCalendarForm.prototype, "description", 2);
__decorateClass([
n$1()
], EditCalendarForm.prototype, "color", 2);
__decorateClass([
n$1({
converter: {
fromAttribute: (value, _type) => new Set(value ? JSON.parse(value) : []),
toAttribute: (value, _type) => JSON.stringify(value)
}
})
], EditCalendarForm.prototype, "components", 2);
EditCalendarForm = __decorateClass([
t("edit-calendar-form")
], EditCalendarForm);
export {
EditCalendarForm
};

View File

@@ -122,7 +122,11 @@ const o = /* @__PURE__ */ new WeakMap(), n = e$1(class extends f {
this.rt(this.ct); this.rt(this.ct);
} }
}); });
function escapeXml(unsafe) {
return unsafe.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
export { export {
escapeXml as a,
e, e,
n n
}; };

View File

@@ -1,21 +1,41 @@
:root { :root {
--background-color: #FFF; --background-color: #FFF;
--background-darker: #EEE; --background-darker: #EEE;
--text-on-background-color: #111;
--primary-color: #2F2FE1; --primary-color: #2F2FE1;
--primary-color-dark: color-mix(in srgb, var(--primary-color), #000000 80%); --primary-color-dark: color-mix(in srgb, var(--primary-color), #000000 80%);
--text-on-primary-color: #FFF; --text-on-primary-color: #FFF;
/* --color-red: #FE2060; */ /* --color-red: #FE2060; */
/* --color-red: #EE1D59; */ /* --color-red: #EE1D59; */
--color-red: #E31B39; --color-red: #E31B39;
--dilute-color: black;
--border-color: black;
} }
html { @media (prefers-color-scheme: dark) {
:root {
--background-color: #222;
--background-darker: #292929;
--text-on-background-color: #CACACA;
--primary-color: color-mix(in srgb, #2F2FE1, white 15%);
--primary-color-dark: color-mix(in srgb, var(--primary-color), #000000 80%);
--text-on-primary-color: #FFF;
/* --color-red: #FE2060; */
--color-red: #EE1D59;
--dilute-color: white;
--border-color: color-mix(in srgb, var(--background-color), var(--dilute-color) 15%);
}
}
html,
dialog {
background-color: var(--background-color); background-color: var(--background-color);
color: var(--text-on-background-color);
} }
body { body {
/* position: relative; */ /* position: relative; */
font-family: sans-serif; font-family: 'Noto Sans', Helvetica, Arial, sans-serif;
margin: 0 auto; margin: 0 auto;
max-width: 1200px; max-width: 1200px;
min-height: 100%; min-height: 100%;
@@ -29,32 +49,65 @@ body {
padding: 12px; padding: 12px;
} }
a {
color: var(--text-on-background-color);
}
header { header {
background: var(--background-darker); background: var(--background-darker);
height: 60px; min-height: 60px;
font-weight: bold; font-weight: bold;
display: flex; display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center; align-items: center;
padding: 12px; padding: 4px 12px;
border: 2px solid black; border: 2px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
margin: 12px; margin: 12px;
box-shadow: 4px 2px 12px -5px black; box-shadow: 4px 2px 12px -5px black;
a { display: flex;
justify-content: space-between;
a.logo {
font-size: 2em; font-size: 2em;
text-decoration: none; text-decoration: none;
color: black; }
nav {
display: flex;
border-radius: 12px;
background: color-mix(in srgb, var(--background-darker), var(--dilute-color) 5%);
a {
text-decoration: none;
margin: 4px 8px;
padding: 8px 12px;
border-radius: 12px;
background: color-mix(in srgb, var(--background-darker), var(--dilute-color) 2%);
&:hover {
background: color-mix(in srgb, var(--background-darker), var(--dilute-color) 20%);
}
&.active {
background: color-mix(in srgb, var(--background-darker), var(--dilute-color) 15%);
}
svg.icon {
width: 1.3em;
vertical-align: bottom;
margin-right: 6px;
}
}
} }
.logout_form { .logout_form {
display: contents; display: contents;
button {
margin-left: auto;
}
} }
} }
@@ -69,7 +122,7 @@ button,
.button { .button {
border: none; border: none;
background: var(--primary-color); background: var(--primary-color);
padding: 10px 16px; padding: 8px 16px;
border-radius: 8px; border-radius: 8px;
color: var(--text-on-primary-color); color: var(--text-on-primary-color);
font-size: 0.9em; font-size: 0.9em;
@@ -97,7 +150,7 @@ input[type="password"] {
} }
section { section {
border: 1px solid black; border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
box-shadow: 4px 2px 12px -8px black; box-shadow: 4px 2px 12px -8px black;
border-collapse: collapse; border-collapse: collapse;
@@ -108,7 +161,7 @@ section {
} }
table { table {
border: 1px solid black; border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
box-shadow: 4px 2px 12px -6px black; box-shadow: 4px 2px 12px -6px black;
border-collapse: collapse; border-collapse: collapse;
@@ -118,7 +171,7 @@ table {
td, td,
th { th {
padding: 8px; padding: 8px;
border: 1px solid black; border: 1px solid var(--border-color);
width: max-content; width: max-content;
} }
@@ -126,12 +179,8 @@ table {
height: 40px; height: 40px;
} }
/* tr:nth-of-type(2n+1) { */
/* background: var(--background-darker); */
/* } */
tr:hover { tr:hover {
background: #DDD; background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%);
} }
tr:first-child th { tr:first-child th {
@@ -151,88 +200,109 @@ table {
} }
} }
#page-user { ul.collection-list {
ul { padding-left: 0;
padding-left: 0;
li.collection-list-item { li.collection-list-item {
list-style: none; list-style: none;
display: contents; display: block;
position: relative;
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 5%);
border: 2px solid var(--border-color);
border-radius: 12px;
margin: 12px 0;
box-shadow: 4px 2px 12px -6px black;
overflow: hidden;
a { a {
background: #EEE; position: absolute;
display: grid; inset: 2px;
min-height: 80px; }
grid-template-areas:
". . color-chip"
"title comps color-chip"
"description description color-chip"
"subscription-url subscription-url color-chip"
"actions actions color-chip"
". . color-chip";
grid-template-rows: 12px auto auto auto auto 12px;
grid-template-columns: min-content auto 80px;
row-gap: 4px;
color: inherit;
text-decoration: none;
padding-left: 12px;
border: 2px solid black; .inner {
border-radius: 12px; display: grid;
margin: 12px; min-height: 80px;
box-shadow: 4px 2px 12px -6px black; height: fit-content;
overflow: hidden; grid-template-areas:
". color-chip"
"title color-chip"
"description color-chip"
"subscription-url color-chip"
"metadata color-chip"
"actions color-chip"
". color-chip";
grid-template-rows: 12px auto auto auto auto auto 12px;
grid-template-columns: auto 80px;
row-gap: 4px;
color: inherit;
text-decoration: none;
padding-left: 12px;
.title { position: relative;
font-weight: bold; z-index: 1;
grid-area: title; pointer-events: none;
margin-right: 12px;
white-space: nowrap; a,
} button {
pointer-events: all;
cursor: pointer;
}
.title {
font-weight: bold;
grid-area: title;
margin-right: 12px;
white-space: nowrap;
}
span {
margin: 8px initial;
}
.comps {
display: inline;
span { span {
margin: 8px initial; margin: 0 2px;
} background: var(--primary-color);
color: var(--text-on-primary-color);
.comps { font-size: .8em;
grid-area: comps; padding: 3px 8px;
border-radius: 12px;
span {
margin: 0 2px;
background: var(--primary-color);
color: var(--text-on-primary-color);
font-size: .8em;
padding: 3px 8px;
border-radius: 12px;
}
}
.description {
grid-area: description;
white-space: nowrap;
}
.subscription-url {
grid-area: subscription-url;
white-space: nowrap;
}
.color-chip {
background: var(--color);
grid-area: color-chip;
}
.actions {
grid-area: actions;
width: fit-content;
display: flex;
gap: 12px;
}
&:hover {
background: #DDD;
} }
} }
.description {
grid-area: description;
white-space: nowrap;
}
.metadata {
grid-area: metadata;
white-space: nowrap;
}
.subscription-url {
grid-area: subscription-url;
white-space: nowrap;
}
.color-chip {
background: var(--color);
grid-area: color-chip;
}
.actions {
pointer-events: all;
grid-area: actions;
width: fit-content;
display: flex;
gap: 12px;
}
}
&:hover {
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%);
} }
} }
} }
@@ -242,7 +312,7 @@ textarea {
} }
dialog { dialog {
border: 1px solid black; border: 1px solid var(--border-color);
border-radius: 16px; border-radius: 16px;
padding: 32px; padding: 32px;
} }
@@ -252,6 +322,28 @@ footer {
justify-content: center; justify-content: center;
margin-top: 32px; margin-top: 32px;
gap: 24px; gap: 24px;
/* position: absolute; */
bottom: 20px; bottom: 20px;
} }
input[type="text"],
input[type="password"],
input[type="color"],
select {
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%);
border: 2px solid var(--border-color);
padding: 6px 6px;
color: var(--text-on-background-color);
margin: 2px;
border-radius: 8px;
&:hover,
&:focus {
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 20%);
}
}
svg.icon {
stroke-width: 2px;
color: var(--text-on-background-color);
stroke: var(--text-on-background-color);
}

View File

@@ -1,56 +0,0 @@
<section>
<h2>Addressbooks</h2>
<ul>
{% for addressbook in addressbooks %}
<li class="collection-list-item">
<a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}">
<span class="title">
{%- if addressbook.principal != user.id -%}{{ addressbook.principal }}/{%- endif -%}
{{ addressbook.displayname.to_owned().unwrap_or(addressbook.id.to_owned()) }}
</span>
<span class="description">
{% if let Some(description) = addressbook.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}" target="_blank"
method="GET">
<button type="submit">Download</button>
</form>
<delete-button trash
href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button>
</div>
</a>
</li>
{% else %}
You do not have any addressbooks yet
{% endfor %}
</ul>
{%if !deleted_addressbooks.is_empty() %}
<h3>Deleted Addressbooks</h3>
<ul>
{% for addressbook in deleted_addressbooks %}
<li class="collection-list-item">
<a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}">
<span class="title">
{%- if addressbook.principal != user.id -%}{{ addressbook.principal }}/{%- endif -%}
{{ addressbook.displayname.to_owned().unwrap_or(addressbook.id.to_owned()) }}
</span>
<span class="description">
{% if let Some(description) = addressbook.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}/restore"
method="POST" class="restore-form">
<button type="submit">Restore</button>
</form>
<delete-button href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button>
</div>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
<create-addressbook-form user="{{ user.id }}"></create-addressbook-form>
</section>

View File

@@ -1,72 +0,0 @@
<section>
<h2>Calendars</h2>
<ul>
{% for calendar in calendars %}
{% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %}
<li class="collection-list-item" style="--color: {{ color }}">
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id }}">
<span class="title">
{%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%}
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
</span>
<div class="comps">
{% for comp in calendar.components %}
<span>{{ comp }}</span>
{% endfor %}
</div>
<span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
</span>
{% if let Some(subscription_url) = calendar.subscription_url %}
<span class="subscription-url">{{ subscription_url }}</span>
{% endif %}
<div class="actions">
<form action="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}" target="_blank" method="GET">
<button type="submit">Download</button>
</form>
{% if !calendar.id.starts_with("_birthdays_") %}
<delete-button trash href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button>
{% endif %}
</div>
<div class="color-chip"></div>
</a>
</li>
{% else %}
You do not have any calendars yet
{% endfor %}
</ul>
{%if !deleted_calendars.is_empty() %}
<h3>Deleted Calendars</h3>
<ul>
{% for calendar in deleted_calendars %}
{% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %}
<li class="collection-list-item" style="--color: {{ color }}">
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}">
<span class="title">
{%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%}
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
</span>
<div class="comps">
{% for comp in calendar.components %}
<span>{{ comp }}</span>
{% endfor %}
</div>
<span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}/restore" method="POST"
class="restore-form">
<button type="submit">Restore</button>
</form>
<delete-button href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button>
</div>
<div class="color-chip"></div>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
<create-calendar-form user="{{ user.id }}"></create-calendar-form>
</section>

View File

@@ -1,58 +0,0 @@
<section>
<h2>Profile</h2>
{% let groups = user.memberships_without_self() %}
{% if groups.len() > 0 %}
<h3>Groups</h3>
<ul>
{% for group in groups %}
<li>{{ group }}</li>
{% endfor %}
</ul>
{% endif %}
<h3>App tokens</h3>
<table id="app-tokens">
<tr>
<th>Name</th>
<th>Created at</th>
<th></th>
</tr>
{% for app_token in app_tokens %}
<tr>
<td>{{ app_token.name }}</td>
<td>
{% if let Some(created_at) = app_token.created_at %}
{{ chrono_humanize::HumanTime::from(created_at.to_owned()) }}
{% endif %}
</td>
<td>
<form action="/frontend/user/{{ user.id }}/app_token/{{ app_token.id }}/delete" method="POST">
<button type="submit" class="delete">Delete</button>
</form>
</td>
</tr>
{% endfor %}
<tr class="generate">
<td>
<form action="/frontend/user/{{ user.id }}/app_token" method="POST" id="form_generate_app_token">
<label class="font_bold" for="generate_app_token_name">App name</label>
<input type="text" name="name" id="generate_app_token_name" />
</form>
</td>
<td></td>
<td>
<button type="submit" form="form_generate_app_token">Generate</button>
{% if is_apple %}
<button type="submit" form="form_generate_app_token" name="apple" value="true">Apple Configuration Profile
(contains token)</button>
{% endif %}
</td>
</tr>
</table>
{% if let Some(hostname) = davx5_hostname %}
<a
href="intent://{{ hostname | urlencode }}#Intent;action=android.intent.action.VIEW;component=at.bitfire.davdroid.ui.setup.LoginActivity;scheme=davx5;package=at.bitfire.davdroid;S.loginFlow=1;end">Configure
in DAVx5</a>
{% endif %}
</section>

View File

@@ -0,0 +1,68 @@
<h2>{{user.id }}'s Addressbooks</h2>
<ul class="collection-list">
{% for (meta, addressbook) in addressbooks %}
<li class="collection-list-item">
<a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}"></a>
<div class="inner">
<span class="title">
{%- if addressbook.principal != user.id -%}{{ addressbook.principal }}/{%- endif -%}
{{ addressbook.displayname.to_owned().unwrap_or(addressbook.id.to_owned()) }}
</span>
<span class="description">
{% if let Some(description) = addressbook.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}" target="_blank"
method="GET">
<button type="submit">Download</button>
</form>
<edit-addressbook-form
principal="{{ addressbook.principal }}"
addr_id="{{ addressbook.id }}"
displayname="{{ addressbook.displayname.as_deref().unwrap_or_default() }}"
description="{{ addressbook.description.as_deref().unwrap_or_default() }}"
></edit-addressbook-form>
<delete-button trash
href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button>
</div>
<div class="metadata">
{{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects
</div>
</div>
</li>
{% else %}
You do not have any addressbooks yet
{% endfor %}
</ul>
{%if !deleted_addressbooks.is_empty() %}
<h3>Deleted Addressbooks</h3>
<ul class="collection-list">
{% for (meta, addressbook) in deleted_addressbooks %}
<li class="collection-list-item">
<a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}"></a>
<div class="inner">
<span class="title">
{%- if addressbook.principal != user.id -%}{{ addressbook.principal }}/{%- endif -%}
{{ addressbook.displayname.to_owned().unwrap_or(addressbook.id.to_owned()) }}
</span>
<span class="description">
{% if let Some(description) = addressbook.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}/restore"
method="POST" class="restore-form">
<button type="submit">Restore</button>
</form>
<delete-button href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button>
</div>
<div class="metadata">
{{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects
</div>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
<create-addressbook-form user="{{ user.id }}"></create-addressbook-form>

View File

@@ -0,0 +1,86 @@
<h2>{{ user.id }}'s Calendars</h2>
<ul class="collection-list">
{% for (meta, calendar) in calendars %}
{% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %}
<li class="collection-list-item" style="--color: {{ color }}">
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id }}"></a>
<div class="inner">
<span class="title">
{%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%}
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
<div class="comps">
{% for comp in calendar.components %}
<span>{{ comp }}</span>
{% endfor %}
</div>
</span>
<span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
</span>
{% if let Some(subscription_url) = calendar.subscription_url %}
<span class="subscription-url">{{ subscription_url }}</span>
{% endif %}
<div class="actions">
<form action="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}" target="_blank" method="GET">
<button type="submit">Download</button>
</form>
{% if !calendar.id.starts_with("_birthdays_") %}
<edit-calendar-form
principal="{{ calendar.principal }}"
cal_id="{{ calendar.id }}"
displayname="{{ calendar.displayname.as_deref().unwrap_or_default() }}"
description="{{ calendar.description.as_deref().unwrap_or_default() }}"
color="{{ calendar.color.as_deref().unwrap_or_default() }}"
components="{{ calendar.components | json }}"
></edit-calendar-form>
<delete-button trash href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button>
{% endif %}
</div>
<div class="metadata">
{{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects
</div>
<div class="color-chip"></div>
</div>
</li>
{% else %}
You do not have any calendars yet
{% endfor %}
</ul>
{%if !deleted_calendars.is_empty() %}
<h3>Deleted Calendars</h3>
<ul class="collection-list">
{% for (meta, calendar) in deleted_calendars %}
{% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %}
<li class="collection-list-item" style="--color: {{ color }}">
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}"></a>
<div class="inner">
<span class="title">
{%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%}
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
<div class="comps">
{% for comp in calendar.components %}
<span>{{ comp }}</span>
{% endfor %}
</div>
</span>
<span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}/restore" method="POST"
class="restore-form">
<button type="submit">Restore</button>
</form>
<delete-button href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button>
</div>
<div class="metadata">
{{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects
</div>
<div class="color-chip"></div>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
<create-calendar-form user="{{ user.id }}"></create-calendar-form>

View File

@@ -0,0 +1,56 @@
<h2>{{ user.id }}'s Profile</h2>
{% let groups = user.memberships_without_self() %}
{% if groups.len() > 0 %}
<h3>Groups</h3>
<ul>
{% for group in groups %}
<li>{{ group }}</li>
{% endfor %}
</ul>
{% endif %}
<h3>App tokens</h3>
<table id="app-tokens">
<tr>
<th>Name</th>
<th>Created at</th>
<th></th>
</tr>
{% for app_token in app_tokens %}
<tr>
<td>{{ app_token.name }}</td>
<td>
{% if let Some(created_at) = app_token.created_at %}
{{ chrono_humanize::HumanTime::from(created_at.to_owned()) }}
{% endif %}
</td>
<td>
<form action="/frontend/user/{{ user.id }}/app_token/{{ app_token.id }}/delete" method="POST">
<button type="submit" class="delete">Delete</button>
</form>
</td>
</tr>
{% endfor %}
<tr class="generate">
<td>
<form action="/frontend/user/{{ user.id }}/app_token" method="POST" id="form_generate_app_token">
<label class="font_bold" for="generate_app_token_name">App name</label>
<input type="text" name="name" id="generate_app_token_name" />
</form>
</td>
<td></td>
<td>
<button type="submit" form="form_generate_app_token">Generate</button>
{% if is_apple %}
<button type="submit" form="form_generate_app_token" name="apple" value="true">Apple Configuration Profile
(contains token)</button>
{% endif %}
</td>
</tr>
</table>
{% if let Some(hostname) = davx5_hostname %}
<a
href="intent://{{ hostname | urlencode }}#Intent;action=android.intent.action.VIEW;component=at.bitfire.davdroid.ui.setup.LoginActivity;scheme=davx5;package=at.bitfire.davdroid;S.loginFlow=1;end">Configure
in DAVx5</a>
{% endif %}

View File

@@ -0,0 +1,7 @@
<!-- Adapted from https://iconoir.com/ -->
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
<path d="M15 4V2M15 4V6M15 4H10.5M3 10V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V10H3Z" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M3 10V6C3 4.89543 3.89543 4 5 4H7" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M7 2V6" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M21 10V6C21 4.89543 20.1046 4 19 4H18.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 608 B

View File

@@ -0,0 +1,7 @@
<!-- Adapted from https://iconoir.com/ -->
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
<path d="M1 20V19C1 15.134 4.13401 12 8 12V12C11.866 12 15 15.134 15 19V20" stroke-linecap="round"></path>
<path d="M13 14V14C13 11.2386 15.2386 9 18 9V9C20.7614 9 23 11.2386 23 14V14.5" stroke-linecap="round"></path>
<path d="M8 12C10.2091 12 12 10.2091 12 8C12 5.79086 10.2091 4 8 4C5.79086 4 4 5.79086 4 8C4 10.2091 5.79086 12 8 12Z" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M18 9C19.6569 9 21 7.65685 21 6C21 4.34315 19.6569 3 18 3C16.3431 3 15 4.34315 15 6C15 7.65685 16.3431 9 18 9Z" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 739 B

View File

@@ -0,0 +1,5 @@
<!-- Adapted from https://iconoir.com/ -->
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
<path d="M5 20V19C5 15.134 8.13401 12 12 12V12C15.866 12 19 15.134 19 19V20" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M12 12C14.2091 12 16 10.2091 16 8C16 5.79086 14.2091 4 12 4C9.79086 4 8 5.79086 8 8C8 10.2091 9.79086 12 12 12Z" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 476 B

View File

@@ -12,7 +12,8 @@
<body> <body>
{% block header %} {% block header %}
<header> <header>
<a href="/frontend/user">RustiCal</a> <a class="logo" href="/frontend/user">RustiCal</a>
{% block header_center %}{% endblock %}
<form method="POST" action="/frontend/logout" class="logout_form"> <form method="POST" action="/frontend/logout" class="logout_form">
<button type="submit">Log out</button> <button type="submit">Log out</button>
</form> </form>
@@ -21,9 +22,9 @@
<div id="app"> <div id="app">
{% block content %}<p>Placeholder</p>{% endblock %} {% block content %}<p>Placeholder</p>{% endblock %}
</div> </div>
<footer>
<a href="{{ env!("CARGO_PKG_REPOSITORY") }}" target="_blank">RustiCal {{ env!("CARGO_PKG_VERSION") }}</a>
<a href="/frontend/assets/licenses.html" target="_blank">Open Source Licenses</a>
</footer>
</body> </body>
<footer>
<a href="{{ env!("CARGO_PKG_REPOSITORY") }}" target="_blank">RustiCal {{ env!("CARGO_PKG_VERSION") }}</a>
<a href="/frontend/assets/licenses.html" target="_blank">Open Source Licenses</a>
</footer>
</html> </html>

View File

@@ -1,19 +1,25 @@
{% extends "layouts/default.html" %} {% extends "layouts/default.html" %}
{% block imports %} {% block imports %}
<template id="data-rustical-user">{{ user|json }}</template>
<script>
window.rusticalUser = JSON.parse(document.querySelector('#data-rustical-user').innerHTML)
</script>
<script type="module" src="/frontend/assets/js/create-calendar-form.mjs" async></script> <script type="module" src="/frontend/assets/js/create-calendar-form.mjs" async></script>
<script type="module" src="/frontend/assets/js/edit-calendar-form.mjs" async></script>
<script type="module" src="/frontend/assets/js/create-addressbook-form.mjs" async></script> <script type="module" src="/frontend/assets/js/create-addressbook-form.mjs" async></script>
<script type="module" src="/frontend/assets/js/edit-addressbook-form.mjs" async></script>
<script type="module" src="/frontend/assets/js/delete-button.mjs" async></script> <script type="module" src="/frontend/assets/js/delete-button.mjs" async></script>
{% endblock %} {% endblock %}
{% block header_center %}
<nav class="header-center">
<a href="/frontend/user/{{ user.id }}" {% if S::name() == "profile" %}class="active"{% endif %}>{% include "icons/user.svg" %}Profile</a>
<a href="/frontend/user/{{ user.id }}/calendar" {% if S::name() == "calendars" %}class="active"{% endif %}>{% include "icons/calendar.svg" %}Calendars</a>
<a href="/frontend/user/{{ user.id }}/addressbook" {% if S::name() == "addressbooks" %}class="active"{% endif %}>{% include "icons/group.svg" %}Addressbooks</a>
</nav>
{% endblock %}
{% block content %} {% block content %}
<div id="page-user"> {{ section|safe }}
<h1>Welcome {{ user.id }}!</h1>
{% include "components/profile_section.html" %}
{% include "components/calendars_section.html" %}
{% include "components/addressbooks_section.html" %}
{% endblock %} {% endblock %}

View File

@@ -8,6 +8,7 @@ use axum::{
}; };
use headers::{ContentType, HeaderMapExt}; use headers::{ContentType, HeaderMapExt};
use http::{Method, StatusCode}; use http::{Method, StatusCode};
use routes::{addressbooks::route_addressbooks, calendars::route_calendars};
use rustical_oidc::{OidcConfig, OidcServiceConfig, route_get_oidc_callback, route_post_oidc}; use rustical_oidc::{OidcConfig, OidcServiceConfig, route_get_oidc_callback, route_post_oidc};
use rustical_store::{ use rustical_store::{
AddressbookStore, CalendarStore, AddressbookStore, CalendarStore,
@@ -20,6 +21,7 @@ mod assets;
mod config; mod config;
pub mod nextcloud_login; pub mod nextcloud_login;
mod oidc_user_store; mod oidc_user_store;
pub(crate) mod pages;
mod routes; mod routes;
pub use config::FrontendConfig; pub use config::FrontendConfig;
@@ -56,6 +58,7 @@ pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: Addres
post(route_delete_app_token::<AP>), post(route_delete_app_token::<AP>),
) )
// Calendar // Calendar
.route("/user/{user}/calendar", get(route_calendars::<CS>))
.route( .route(
"/user/{user}/calendar/{calendar}", "/user/{user}/calendar/{calendar}",
get(route_calendar::<CS>), get(route_calendar::<CS>),
@@ -65,6 +68,7 @@ pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: Addres
post(route_calendar_restore::<CS>), post(route_calendar_restore::<CS>),
) )
// Addressbook // Addressbook
.route("/user/{user}/addressbook", get(route_addressbooks::<AS>))
.route( .route(
"/user/{user}/addressbook/{addressbook}", "/user/{user}/addressbook/{addressbook}",
get(route_addressbook::<AS>), get(route_addressbook::<AS>),
@@ -137,15 +141,14 @@ async fn unauthorized_handler(mut request: Request, next: Next) -> Response {
return resp return resp
.body(Body::new(format!( .body(Body::new(format!(
r#"<!Doctype html> r#"<!Doctype html>
<html> <html>
<head> <head>
<meta http-equiv="refresh" content="1; url={login_url}" /> <meta http-equiv="refresh" content="1; url={login_url}" />
</head> </head>
<body> <body>
Unauthorized, redirecting to <a href="{login_url}">login page</a> Unauthorized, redirecting to <a href="{login_url}">login page</a>
</body> </body>
<html> </html>"#,
"#,
))) )))
.unwrap(); .unwrap();
} }

View File

@@ -0,0 +1 @@
pub mod user;

View File

@@ -0,0 +1,14 @@
use askama::Template;
use askama_web::WebTemplate;
use rustical_store::auth::Principal;
pub trait Section: Template {
fn name() -> &'static str;
}
#[derive(Template, WebTemplate)]
#[template(path = "pages/user.html")]
pub struct UserPage<S: Section> {
pub user: Principal,
pub section: S,
}

View File

@@ -0,0 +1,75 @@
use std::sync::Arc;
use askama::Template;
use askama_web::WebTemplate;
use axum::{Extension, extract::Path, response::IntoResponse};
use http::StatusCode;
use rustical_store::{Addressbook, AddressbookStore, CollectionMetadata, auth::Principal};
use crate::pages::user::{Section, UserPage};
impl Section for AddressbooksSection {
fn name() -> &'static str {
"addressbooks"
}
}
#[derive(Template, WebTemplate)]
#[template(path = "components/sections/addressbooks_section.html")]
pub struct AddressbooksSection {
pub user: Principal,
pub addressbooks: Vec<(CollectionMetadata, Addressbook)>,
pub deleted_addressbooks: Vec<(CollectionMetadata, Addressbook)>,
}
pub async fn route_addressbooks<AS: AddressbookStore>(
Path(user_id): Path<String>,
Extension(addr_store): Extension<Arc<AS>>,
user: Principal,
) -> impl IntoResponse {
if user_id != user.id {
return StatusCode::UNAUTHORIZED.into_response();
}
let mut addressbooks = vec![];
for group in user.memberships() {
addressbooks.extend(addr_store.get_addressbooks(group).await.unwrap());
}
let mut deleted_addressbooks = vec![];
for group in user.memberships() {
deleted_addressbooks.extend(addr_store.get_deleted_addressbooks(group).await.unwrap());
}
let mut addressbook_infos = vec![];
for addressbook in addressbooks {
addressbook_infos.push((
addr_store
.addressbook_metadata(&addressbook.principal, &addressbook.id)
.await
.unwrap(),
addressbook,
));
}
let mut deleted_addressbook_infos = vec![];
for addressbook in deleted_addressbooks {
deleted_addressbook_infos.push((
addr_store
.addressbook_metadata(&addressbook.principal, &addressbook.id)
.await
.unwrap(),
addressbook,
));
}
UserPage {
section: AddressbooksSection {
user: user.clone(),
addressbooks: addressbook_infos,
deleted_addressbooks: deleted_addressbook_infos,
},
user,
}
.into_response()
}

View File

@@ -64,7 +64,7 @@ pub async fn route_post_app_token<AP: AuthenticationProvider>(
token_name: name, token_name: name,
account_description: format!("{}@{}", &user.id, &hostname), account_description: format!("{}@{}", &user.id, &hostname),
hostname: hostname.clone(), hostname: hostname.clone(),
caldav_principal_url: format!("https://{hostname}/caldav/principal/{user_id}"), caldav_principal_url: format!("https://{hostname}/caldav-compat/principal/{user_id}"),
carddav_principal_url: format!("https://{hostname}/carddav/principal/{user_id}"), carddav_principal_url: format!("https://{hostname}/carddav/principal/{user_id}"),
user: user.id.to_owned(), user: user.id.to_owned(),
token, token,
@@ -79,13 +79,12 @@ pub async fn route_post_app_token<AP: AuthenticationProvider>(
hdrs.typed_insert( hdrs.typed_insert(
ContentType::from_str("application/x-apple-aspen-config; charset=utf-8").unwrap(), ContentType::from_str("application/x-apple-aspen-config; charset=utf-8").unwrap(),
); );
let filename = format!("rustical-{}.mobileconfig", user_id); let filename = format!("rustical-{user_id}.mobileconfig");
let filename = utf8_percent_encode(&filename, CONTROLS); let filename = utf8_percent_encode(&filename, CONTROLS);
hdrs.insert( hdrs.insert(
header::CONTENT_DISPOSITION, header::CONTENT_DISPOSITION,
HeaderValue::from_str(&format!( HeaderValue::from_str(&format!(
"attachement; filename*=UTF-8''{} filename={}", "attachement; filename*=UTF-8''{filename} filename={filename}",
filename, filename
)) ))
.unwrap(), .unwrap(),
); );

View File

@@ -0,0 +1,74 @@
use std::sync::Arc;
use crate::pages::user::{Section, UserPage};
use askama::Template;
use askama_web::WebTemplate;
use axum::{Extension, extract::Path, response::IntoResponse};
use http::StatusCode;
use rustical_store::{Calendar, CalendarStore, CollectionMetadata, auth::Principal};
impl Section for CalendarsSection {
fn name() -> &'static str {
"calendars"
}
}
#[derive(Template, WebTemplate)]
#[template(path = "components/sections/calendars_section.html")]
pub struct CalendarsSection {
pub user: Principal,
pub calendars: Vec<(CollectionMetadata, Calendar)>,
pub deleted_calendars: Vec<(CollectionMetadata, Calendar)>,
}
pub async fn route_calendars<CS: CalendarStore>(
Path(user_id): Path<String>,
Extension(cal_store): Extension<Arc<CS>>,
user: Principal,
) -> impl IntoResponse {
if user_id != user.id {
return StatusCode::UNAUTHORIZED.into_response();
}
let mut calendars = vec![];
for group in user.memberships() {
calendars.extend(cal_store.get_calendars(group).await.unwrap());
}
let mut calendar_infos = vec![];
for calendar in calendars {
calendar_infos.push((
cal_store
.calendar_metadata(&calendar.principal, &calendar.id)
.await
.unwrap(),
calendar,
));
}
let mut deleted_calendars = vec![];
for group in user.memberships() {
deleted_calendars.extend(cal_store.get_deleted_calendars(group).await.unwrap());
}
let mut deleted_calendar_infos = vec![];
for calendar in deleted_calendars {
deleted_calendar_infos.push((
cal_store
.calendar_metadata(&calendar.principal, &calendar.id)
.await
.unwrap(),
calendar,
));
}
UserPage {
section: CalendarsSection {
user: user.clone(),
calendars: calendar_infos,
deleted_calendars: deleted_calendar_infos,
},
user,
}
.into_response()
}

View File

@@ -1,5 +1,7 @@
pub mod addressbook; pub mod addressbook;
pub mod addressbooks;
pub mod app_token; pub mod app_token;
pub mod calendar; pub mod calendar;
pub mod calendars;
pub mod login; pub mod login;
pub mod user; pub mod user;

View File

@@ -11,19 +11,23 @@ use axum_extra::{TypedHeader, extract::Host};
use headers::UserAgent; use headers::UserAgent;
use http::StatusCode; use http::StatusCode;
use rustical_store::{ use rustical_store::{
Addressbook, AddressbookStore, Calendar, CalendarStore, AddressbookStore, CalendarStore,
auth::{AppToken, AuthenticationProvider, Principal}, auth::{AppToken, AuthenticationProvider, Principal},
}; };
use crate::pages::user::{Section, UserPage};
impl Section for ProfileSection {
fn name() -> &'static str {
"profile"
}
}
#[derive(Template, WebTemplate)] #[derive(Template, WebTemplate)]
#[template(path = "pages/user.html")] #[template(path = "components/sections/profile_section.html")]
pub struct UserPage { pub struct ProfileSection {
pub user: Principal, pub user: Principal,
pub app_tokens: Vec<AppToken>, pub app_tokens: Vec<AppToken>,
pub calendars: Vec<Calendar>,
pub deleted_calendars: Vec<Calendar>,
pub addressbooks: Vec<Addressbook>,
pub deleted_addressbooks: Vec<Addressbook>,
pub is_apple: bool, pub is_apple: bool,
pub davx5_hostname: Option<String>, pub davx5_hostname: Option<String>,
} }
@@ -69,14 +73,13 @@ pub async fn route_user_named<
let davx5_hostname = user_agent.as_str().contains("Android").then_some(host); let davx5_hostname = user_agent.as_str().contains("Android").then_some(host);
UserPage { UserPage {
app_tokens: auth_provider.get_app_tokens(&user.id).await.unwrap(), section: ProfileSection {
calendars, user: user.clone(),
deleted_calendars, app_tokens: auth_provider.get_app_tokens(&user.id).await.unwrap(),
addressbooks, is_apple,
deleted_addressbooks, davx5_hostname,
},
user, user,
is_apple,
davx5_hostname,
} }
.into_response() .into_response()
} }

View File

@@ -15,8 +15,11 @@ use std::{collections::HashMap, io::BufReader};
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Display)] #[derive(Debug, Clone, Serialize, PartialEq, Eq, Display)]
// specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6 // specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6
pub enum CalendarObjectType { pub enum CalendarObjectType {
#[serde(rename = "VEVENT")]
Event = 0, Event = 0,
#[serde(rename = "VTODO")]
Todo = 1, Todo = 1,
#[serde(rename = "VJOURNAL")]
Journal = 2, Journal = 2,
} }

View File

@@ -1,4 +1,4 @@
use crate::{Error, addressbook::Addressbook}; use crate::{CollectionMetadata, Error, addressbook::Addressbook};
use async_trait::async_trait; use async_trait::async_trait;
use rustical_ical::AddressObject; use rustical_ical::AddressObject;
@@ -35,6 +35,12 @@ pub trait AddressbookStore: Send + Sync + 'static {
synctoken: i64, synctoken: i64,
) -> Result<(Vec<AddressObject>, Vec<String>, i64), Error>; ) -> Result<(Vec<AddressObject>, Vec<String>, i64), Error>;
async fn addressbook_metadata(
&self,
principal: &str,
addressbook_id: &str,
) -> Result<CollectionMetadata, Error>;
async fn get_objects( async fn get_objects(
&self, &self,
principal: &str, principal: &str,

View File

@@ -25,6 +25,7 @@ pub struct Principal {
pub displayname: Option<String>, pub displayname: Option<String>,
#[serde(default)] #[serde(default)]
pub principal_type: PrincipalType, pub principal_type: PrincipalType,
#[serde(skip_serializing)]
pub password: Option<Secret<String>>, pub password: Option<Secret<String>>,
#[serde(default)] #[serde(default)]
pub memberships: Vec<String>, pub memberships: Vec<String>,

View File

@@ -13,7 +13,7 @@ pub enum PrincipalType {
Resource, Resource,
Room, Room,
Unknown, Unknown,
// TODO: X-Name, IANA-token // X-Name, IANA-token
} }
impl TryFrom<&str> for PrincipalType { impl TryFrom<&str> for PrincipalType {

View File

@@ -1,4 +1,4 @@
use crate::{Calendar, error::Error}; use crate::{Calendar, CollectionMetadata, error::Error};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::NaiveDate; use chrono::NaiveDate;
use rustical_ical::CalendarObject; use rustical_ical::CalendarObject;
@@ -53,6 +53,12 @@ pub trait CalendarStore: Send + Sync + 'static {
self.get_objects(principal, cal_id).await self.get_objects(principal, cal_id).await
} }
async fn calendar_metadata(
&self,
principal: &str,
cal_id: &str,
) -> Result<CollectionMetadata, Error>;
async fn get_objects( async fn get_objects(
&self, &self,
principal: &str, principal: &str,

View File

@@ -135,6 +135,20 @@ impl<CS: CalendarStore, BS: CalendarStore> CalendarStore for CombinedCalendarSto
} }
} }
#[inline]
async fn calendar_metadata(
&self,
principal: &str,
cal_id: &str,
) -> Result<crate::CollectionMetadata, Error> {
if cal_id.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store
.calendar_metadata(principal, cal_id)
.await
} else {
self.cal_store.calendar_metadata(principal, cal_id).await
}
}
#[inline] #[inline]
async fn get_objects( async fn get_objects(
&self, &self,

View File

@@ -16,7 +16,7 @@ fn birthday_calendar(addressbook: Addressbook) -> Calendar {
id: format!("{}{}", BIRTHDAYS_PREFIX, addressbook.id), id: format!("{}{}", BIRTHDAYS_PREFIX, addressbook.id),
displayname: addressbook displayname: addressbook
.displayname .displayname
.map(|name| format!("{} birthdays", name)), .map(|name| format!("{name} birthdays")),
order: 0, order: 0,
description: None, description: None,
color: None, color: None,
@@ -104,6 +104,17 @@ impl<AS: AddressbookStore> CalendarStore for ContactBirthdayStore<AS> {
Ok((objects, deleted_objects, new_synctoken)) Ok((objects, deleted_objects, new_synctoken))
} }
async fn calendar_metadata(
&self,
principal: &str,
cal_id: &str,
) -> Result<crate::CollectionMetadata, Error> {
let cal_id = cal_id
.strip_prefix(BIRTHDAYS_PREFIX)
.ok_or(Error::NotFound)?;
self.0.addressbook_metadata(principal, cal_id).await
}
async fn get_objects( async fn get_objects(
&self, &self,
principal: &str, principal: &str,

View File

@@ -37,3 +37,11 @@ pub struct CollectionOperation {
pub topic: String, pub topic: String,
pub data: CollectionOperationInfo, pub data: CollectionOperationInfo,
} }
#[derive(Default, Debug, Clone)]
pub struct CollectionMetadata {
pub len: usize,
pub deleted_len: usize,
pub size: u64,
pub deleted_size: u64,
}

View File

@@ -1,7 +1,7 @@
const SYNC_NAMESPACE: &str = "github.com/lennart-k/rustical/ns/"; const SYNC_NAMESPACE: &str = "github.com/lennart-k/rustical/ns/";
pub fn format_synctoken(synctoken: i64) -> String { pub fn format_synctoken(synctoken: i64) -> String {
format!("{}{}", SYNC_NAMESPACE, synctoken) format!("{SYNC_NAMESPACE}{synctoken}")
} }
pub fn parse_synctoken(synctoken: &str) -> Option<i64> { pub fn parse_synctoken(synctoken: &str) -> Option<i64> {

View File

@@ -8,7 +8,10 @@ license.workspace = true
publish = false publish = false
[features] [features]
test = [] test = ["dep:rstest"]
[dev-dependencies]
rstest.workspace = true
[dependencies] [dependencies]
tokio.workspace = true tokio.workspace = true
@@ -25,3 +28,4 @@ password-hash.workspace = true
uuid.workspace = true uuid.workspace = true
pbkdf2.workspace = true pbkdf2.workspace = true
rustical_ical.workspace = true rustical_ical.workspace = true
rstest = { workspace = true, optional = true }

View File

@@ -3,8 +3,8 @@ use async_trait::async_trait;
use derive_more::derive::Constructor; use derive_more::derive::Constructor;
use rustical_ical::AddressObject; use rustical_ical::AddressObject;
use rustical_store::{ use rustical_store::{
Addressbook, AddressbookStore, CollectionOperation, CollectionOperationInfo, Error, Addressbook, AddressbookStore, CollectionMetadata, CollectionOperation,
synctoken::format_synctoken, CollectionOperationInfo, Error, synctoken::format_synctoken,
}; };
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction}; use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
@@ -223,6 +223,28 @@ impl SqliteAddressbookStore {
Ok((objects, deleted_objects, new_synctoken)) Ok((objects, deleted_objects, new_synctoken))
} }
async fn _list_objects<'e, E: Executor<'e, Database = Sqlite>>(
executor: E,
principal: &str,
addressbook_id: &str,
) -> Result<Vec<(u64, bool)>, rustical_store::Error> {
struct ObjectEntry {
length: u64,
deleted: bool,
}
Ok(sqlx::query_as!(
ObjectEntry,
"SELECT length(vcf) AS 'length!: u64', deleted_at AS 'deleted!: bool' FROM addressobjects WHERE principal = ? AND addressbook_id = ?",
principal,
addressbook_id
)
.fetch_all(executor)
.await.map_err(crate::Error::from)?
.into_iter()
.map(|row| (row.length, row.deleted))
.collect())
}
async fn _get_objects<'e, E: Executor<'e, Database = Sqlite>>( async fn _get_objects<'e, E: Executor<'e, Database = Sqlite>>(
executor: E, executor: E,
principal: &str, principal: &str,
@@ -442,6 +464,29 @@ impl AddressbookStore for SqliteAddressbookStore {
Self::_sync_changes(&self.db, principal, addressbook_id, synctoken).await Self::_sync_changes(&self.db, principal, addressbook_id, synctoken).await
} }
#[instrument]
async fn addressbook_metadata(
&self,
principal: &str,
addressbook_id: &str,
) -> Result<CollectionMetadata, rustical_store::Error> {
let mut sizes = vec![];
let mut deleted_sizes = vec![];
for (size, deleted) in Self::_list_objects(&self.db, principal, addressbook_id).await? {
if deleted {
deleted_sizes.push(size)
} else {
sizes.push(size)
}
}
Ok(CollectionMetadata {
len: sizes.len(),
deleted_len: deleted_sizes.len(),
size: sizes.iter().sum(),
deleted_size: deleted_sizes.iter().sum(),
})
}
#[instrument] #[instrument]
async fn get_objects( async fn get_objects(
&self, &self,

View File

@@ -5,7 +5,7 @@ use derive_more::derive::Constructor;
use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectType}; use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectType};
use rustical_store::calendar_store::CalendarQuery; use rustical_store::calendar_store::CalendarQuery;
use rustical_store::synctoken::format_synctoken; use rustical_store::synctoken::format_synctoken;
use rustical_store::{Calendar, CalendarStore, Error}; use rustical_store::{Calendar, CalendarStore, CollectionMetadata, Error};
use rustical_store::{CollectionOperation, CollectionOperationInfo}; use rustical_store::{CollectionOperation, CollectionOperationInfo};
use sqlx::types::chrono::NaiveDateTime; use sqlx::types::chrono::NaiveDateTime;
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction}; use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
@@ -242,6 +242,28 @@ impl SqliteCalendarStore {
Ok(()) Ok(())
} }
async fn _list_objects<'e, E: Executor<'e, Database = Sqlite>>(
executor: E,
principal: &str,
cal_id: &str,
) -> Result<Vec<(u64, bool)>, rustical_store::Error> {
struct ObjectEntry {
length: u64,
deleted: bool,
}
Ok(sqlx::query_as!(
ObjectEntry,
"SELECT length(ics) AS 'length!: u64', deleted_at AS 'deleted!: bool' FROM calendarobjects WHERE principal = ? AND cal_id = ?",
principal,
cal_id
)
.fetch_all(executor)
.await.map_err(crate::Error::from)?
.into_iter()
.map(|row| (row.length, row.deleted))
.collect())
}
async fn _get_objects<'e, E: Executor<'e, Database = Sqlite>>( async fn _get_objects<'e, E: Executor<'e, Database = Sqlite>>(
executor: E, executor: E,
principal: &str, principal: &str,
@@ -552,6 +574,28 @@ impl CalendarStore for SqliteCalendarStore {
Self::_calendar_query(&self.db, principal, cal_id, query).await Self::_calendar_query(&self.db, principal, cal_id, query).await
} }
async fn calendar_metadata(
&self,
principal: &str,
cal_id: &str,
) -> Result<CollectionMetadata, Error> {
let mut sizes = vec![];
let mut deleted_sizes = vec![];
for (size, deleted) in Self::_list_objects(&self.db, principal, cal_id).await? {
if deleted {
deleted_sizes.push(size)
} else {
sizes.push(size)
}
}
Ok(CollectionMetadata {
len: sizes.len(),
deleted_len: deleted_sizes.len(),
size: sizes.iter().sum(),
deleted_size: deleted_sizes.iter().sum(),
})
}
#[instrument] #[instrument]
async fn get_objects( async fn get_objects(
&self, &self,

View File

@@ -114,9 +114,11 @@ impl AuthenticationProvider for SqlitePrincipalStore {
let password = user.password.map(Secret::into_inner); let password = user.password.map(Secret::into_inner);
sqlx::query!( sqlx::query!(
r#" r#"
REPLACE INTO principals INSERT INTO principals
(id, displayname, principal_type, password_hash) (id, displayname, principal_type, password_hash) VALUES (?, ?, ?, ?)
VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET
(displayname, principal_type, password_hash)
= (excluded.displayname, excluded.principal_type, excluded.password_hash)
"#, "#,
user.id, user.id,
user.displayname, user.displayname,
@@ -204,7 +206,7 @@ impl AuthenticationProvider for SqlitePrincipalStore {
None, None,
None, None,
Params { Params {
rounds: 100, rounds: 10,
..Default::default() ..Default::default()
}, },
&salt, &salt,

View File

@@ -2,41 +2,54 @@ use crate::{
SqliteStore, addressbook_store::SqliteAddressbookStore, calendar_store::SqliteCalendarStore, SqliteStore, addressbook_store::SqliteAddressbookStore, calendar_store::SqliteCalendarStore,
principal_store::SqlitePrincipalStore, principal_store::SqlitePrincipalStore,
}; };
use rustical_store::{ use rustical_store::auth::{AuthenticationProvider, Principal, PrincipalType};
AddressbookStore, CalendarStore, CollectionOperation, SubscriptionStore,
auth::AuthenticationProvider,
};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::sync::Arc; use tokio::sync::OnceCell;
use tokio::sync::mpsc::Receiver;
pub async fn get_test_stores() -> ( static DB: OnceCell<SqlitePool> = OnceCell::const_new();
Arc<impl AddressbookStore>,
Arc<impl CalendarStore>,
Arc<impl SubscriptionStore>,
Arc<impl AuthenticationProvider>,
Receiver<CollectionOperation>,
) {
let db = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::migrate!("./migrations").run(&db).await.unwrap();
// let db = create_db_pool("sqlite::memory:", true).await.unwrap();
// Channel to watch for changes (for DAV Push)
let (send, recv) = tokio::sync::mpsc::channel(1000);
let addressbook_store = Arc::new(SqliteAddressbookStore::new(db.clone(), send.clone())); async fn get_test_db() -> SqlitePool {
let cal_store = Arc::new(SqliteCalendarStore::new(db.clone(), send)); DB.get_or_init(async || {
let subscription_store = Arc::new(SqliteStore::new(db.clone())); let db = SqlitePool::connect("sqlite::memory:").await.unwrap();
let principal_store = Arc::new(SqlitePrincipalStore::new(db.clone())); sqlx::migrate!("./migrations").run(&db).await.unwrap();
(
addressbook_store, // Populate with test data
cal_store, let principal_store = SqlitePrincipalStore::new(db.clone());
subscription_store, principal_store
principal_store, .insert_principal(
recv, Principal {
) id: "user".to_owned(),
displayname: None,
memberships: vec![],
password: None,
principal_type: PrincipalType::Individual,
},
false,
)
.await
.unwrap();
db
})
.await
.clone()
} }
#[tokio::test] #[rstest::fixture]
async fn test_create_store() { pub async fn get_test_addressbook_store() -> SqliteAddressbookStore {
get_test_stores().await; let (send, _recv) = tokio::sync::mpsc::channel(1000);
SqliteAddressbookStore::new(get_test_db().await, send)
}
#[rstest::fixture]
pub async fn get_test_calendar_store() -> SqliteCalendarStore {
let (send, _recv) = tokio::sync::mpsc::channel(1000);
SqliteCalendarStore::new(get_test_db().await, send)
}
#[rstest::fixture]
pub async fn get_test_subscription_store() -> SqliteStore {
SqliteStore::new(get_test_db().await)
}
#[rstest::fixture]
pub async fn get_test_principal_store() -> SqlitePrincipalStore {
SqlitePrincipalStore::new(get_test_db().await)
} }

View File

@@ -16,8 +16,8 @@ impl Enum {
quote! { quote! {
impl #impl_generics ::rustical_xml::XmlDeserialize for #name #type_generics #where_clause { impl #impl_generics ::rustical_xml::XmlDeserialize for #name #type_generics #where_clause {
fn deserialize<R: ::std::io::BufRead>( fn deserialize<R: ::std::io::BufRead>(
reader: &mut quick_xml::NsReader<R>, reader: &mut ::quick_xml::NsReader<R>,
start: &quick_xml::events::BytesStart, start: &::quick_xml::events::BytesStart,
empty: bool empty: bool
) -> Result<Self, rustical_xml::XmlError> { ) -> Result<Self, rustical_xml::XmlError> {
#(#variant_branches);* #(#variant_branches);*
@@ -37,8 +37,8 @@ impl Enum {
quote! { quote! {
impl #impl_generics ::rustical_xml::XmlDeserialize for #name #type_generics #where_clause { impl #impl_generics ::rustical_xml::XmlDeserialize for #name #type_generics #where_clause {
fn deserialize<R: std::io::BufRead>( fn deserialize<R: std::io::BufRead>(
reader: &mut quick_xml::NsReader<R>, reader: &mut ::quick_xml::NsReader<R>,
start: &quick_xml::events::BytesStart, start: &::quick_xml::events::BytesStart,
empty: bool empty: bool
) -> Result<Self, rustical_xml::XmlError> { ) -> Result<Self, rustical_xml::XmlError> {
let (_ns, name) = reader.resolve_element(start.name()); let (_ns, name) = reader.resolve_element(start.name());

View File

@@ -13,12 +13,12 @@ impl Enum {
quote! { quote! {
impl #impl_generics ::rustical_xml::XmlSerialize for #ident #type_generics #where_clause { impl #impl_generics ::rustical_xml::XmlSerialize for #ident #type_generics #where_clause {
fn serialize<W: ::std::io::Write>( fn serialize(
&self, &self,
ns: Option<::quick_xml::name::Namespace>, ns: Option<::quick_xml::name::Namespace>,
tag: Option<&[u8]>, tag: Option<&[u8]>,
namespaces: &::std::collections::HashMap<::quick_xml::name::Namespace, &[u8]>, namespaces: &::std::collections::HashMap<::quick_xml::name::Namespace, &[u8]>,
writer: &mut ::quick_xml::Writer<W> writer: &mut ::quick_xml::Writer<&mut Vec<u8>>
) -> ::std::io::Result<()> { ) -> ::std::io::Result<()> {
use ::quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; use ::quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};

View File

@@ -88,12 +88,12 @@ impl NamedStruct {
quote! { quote! {
impl #impl_generics ::rustical_xml::XmlSerialize for #ident #type_generics #where_clause { impl #impl_generics ::rustical_xml::XmlSerialize for #ident #type_generics #where_clause {
fn serialize<W: ::std::io::Write>( fn serialize(
&self, &self,
ns: Option<::quick_xml::name::Namespace>, ns: Option<::quick_xml::name::Namespace>,
tag: Option<&[u8]>, tag: Option<&[u8]>,
namespaces: &::std::collections::HashMap<::quick_xml::name::Namespace, &[u8]>, namespaces: &::std::collections::HashMap<::quick_xml::name::Namespace, &[u8]>,
writer: &mut ::quick_xml::Writer<W> writer: &mut ::quick_xml::Writer<&mut Vec<u8>>
) -> ::std::io::Result<()> { ) -> ::std::io::Result<()> {
use ::quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; use ::quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};

View File

@@ -118,8 +118,8 @@ impl NamedStruct {
quote! { quote! {
impl #impl_generics ::rustical_xml::XmlDeserialize for #ident #type_generics #where_clause { impl #impl_generics ::rustical_xml::XmlDeserialize for #ident #type_generics #where_clause {
fn deserialize<R: ::std::io::BufRead>( fn deserialize<R: ::std::io::BufRead>(
reader: &mut quick_xml::NsReader<R>, reader: &mut ::quick_xml::NsReader<R>,
start: &quick_xml::events::BytesStart, start: &::quick_xml::events::BytesStart,
empty: bool empty: bool
) -> Result<Self, rustical_xml::XmlError> { ) -> Result<Self, rustical_xml::XmlError> {
use quick_xml::events::Event; use quick_xml::events::Event;

View File

@@ -1,30 +1,30 @@
use crate::XmlRootTag; use crate::XmlRootTag;
use quick_xml::{ use quick_xml::{
events::{attributes::Attribute, BytesStart, Event}, events::{BytesStart, Event, attributes::Attribute},
name::{Namespace, QName}, name::{Namespace, QName},
}; };
use std::collections::HashMap; use std::collections::HashMap;
pub use xml_derive::XmlSerialize; pub use xml_derive::XmlSerialize;
pub trait XmlSerialize { pub trait XmlSerialize {
fn serialize<W: std::io::Write>( fn serialize(
&self, &self,
ns: Option<Namespace>, ns: Option<Namespace>,
tag: Option<&[u8]>, tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>, namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut quick_xml::Writer<W>, writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()>; ) -> std::io::Result<()>;
fn attributes<'a>(&self) -> Option<impl IntoIterator<Item: Into<Attribute<'a>>>>; fn attributes<'a>(&self) -> Option<Vec<Attribute<'a>>>;
} }
impl<T: XmlSerialize> XmlSerialize for Option<T> { impl<T: XmlSerialize> XmlSerialize for Option<T> {
fn serialize<W: std::io::Write>( fn serialize(
&self, &self,
ns: Option<Namespace>, ns: Option<Namespace>,
tag: Option<&[u8]>, tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>, namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut quick_xml::Writer<W>, writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
if let Some(some) = self { if let Some(some) = self {
some.serialize(ns, tag, namespaces, writer) some.serialize(ns, tag, namespaces, writer)
@@ -33,36 +33,36 @@ impl<T: XmlSerialize> XmlSerialize for Option<T> {
} }
} }
#[allow(refining_impl_trait)]
fn attributes<'a>(&self) -> Option<Vec<Attribute<'a>>> { fn attributes<'a>(&self) -> Option<Vec<Attribute<'a>>> {
None None
} }
} }
pub trait XmlSerializeRoot { pub trait XmlSerializeRoot {
fn serialize_root<W: std::io::Write>( fn serialize_root(&self, writer: &mut quick_xml::Writer<&mut Vec<u8>>) -> std::io::Result<()>;
&self,
writer: &mut quick_xml::Writer<W>, fn serialize_to_string(&self) -> std::io::Result<String> {
) -> std::io::Result<()>; let mut buf: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into();
let mut writer = quick_xml::Writer::new_with_indent(&mut buf, b' ', 4);
self.serialize_root(&mut writer)?;
Ok(String::from_utf8_lossy(&buf).to_string())
}
} }
impl<T: XmlSerialize + XmlRootTag> XmlSerializeRoot for T { impl<T: XmlSerialize + XmlRootTag> XmlSerializeRoot for T {
fn serialize_root<W: std::io::Write>( fn serialize_root(&self, writer: &mut quick_xml::Writer<&mut Vec<u8>>) -> std::io::Result<()> {
&self,
writer: &mut quick_xml::Writer<W>,
) -> std::io::Result<()> {
let namespaces = Self::root_ns_prefixes(); let namespaces = Self::root_ns_prefixes();
self.serialize(Self::root_ns(), Some(Self::root_tag()), &namespaces, writer) self.serialize(Self::root_ns(), Some(Self::root_tag()), &namespaces, writer)
} }
} }
impl XmlSerialize for () { impl XmlSerialize for () {
fn serialize<W: std::io::Write>( fn serialize(
&self, &self,
ns: Option<Namespace>, ns: Option<Namespace>,
tag: Option<&[u8]>, tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>, namespaces: &HashMap<Namespace, &[u8]>,
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))
@@ -89,7 +89,6 @@ impl XmlSerialize for () {
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

@@ -104,12 +104,12 @@ impl<T: ValueDeserialize> XmlDeserialize for T {
} }
impl<T: ValueSerialize> XmlSerialize for T { impl<T: ValueSerialize> XmlSerialize for T {
fn serialize<W: std::io::Write>( fn serialize(
&self, &self,
ns: Option<Namespace>, ns: Option<Namespace>,
tag: Option<&[u8]>, tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>, namespaces: &HashMap<Namespace, &[u8]>,
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))
@@ -140,7 +140,6 @@ impl<T: ValueSerialize> XmlSerialize for T {
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

@@ -198,6 +198,7 @@ fn test_struct_generics() {
#[derive(XmlDeserialize, XmlRootTag)] #[derive(XmlDeserialize, XmlRootTag)]
#[xml(root = b"document")] #[xml(root = b"document")]
struct Document<T: XmlDeserialize> { struct Document<T: XmlDeserialize> {
#[allow(dead_code)]
child: T, child: T,
} }
@@ -218,6 +219,7 @@ fn test_struct_unparsed() {
#[derive(XmlDeserialize, XmlRootTag)] #[derive(XmlDeserialize, XmlRootTag)]
#[xml(root = b"document")] #[xml(root = b"document")]
struct Document { struct Document {
#[allow(dead_code)]
child: Unparsed, child: Unparsed,
} }

View File

@@ -23,12 +23,16 @@ enum ExtensionProp {
enum CalendarProp { enum CalendarProp {
// WebDAV (RFC 2518) // WebDAV (RFC 2518)
#[xml(ns = "NS_DAV")] #[xml(ns = "NS_DAV")]
#[allow(dead_code)]
Displayname(Option<String>), Displayname(Option<String>),
#[xml(ns = "NS_DAV")] #[xml(ns = "NS_DAV")]
#[allow(dead_code)]
Getcontenttype(&'static str), Getcontenttype(&'static str),
#[xml(ns = "NS_DAV", rename = b"principal-URL")] #[xml(ns = "NS_DAV", rename = b"principal-URL")]
#[allow(dead_code)]
PrincipalUrl, PrincipalUrl,
#[allow(dead_code)]
Topic, Topic,
} }

View File

@@ -4,29 +4,34 @@ use rustical_xml::{Unparsed, XmlDeserialize, XmlDocument, XmlRootTag};
fn test_propertyupdate() { fn test_propertyupdate() {
#[derive(XmlDeserialize)] #[derive(XmlDeserialize)]
struct SetPropertyElement<T: XmlDeserialize> { struct SetPropertyElement<T: XmlDeserialize> {
#[allow(dead_code)]
prop: T, prop: T,
} }
#[derive(XmlDeserialize)] #[derive(XmlDeserialize)]
struct TagName { struct TagName {
#[xml(ty = "tag_name")] #[xml(ty = "tag_name")]
#[allow(dead_code)]
name: String, name: String,
} }
#[derive(XmlDeserialize)] #[derive(XmlDeserialize)]
struct PropertyElement { struct PropertyElement {
#[xml(ty = "untagged")] #[xml(ty = "untagged")]
#[allow(dead_code)]
property: TagName, property: TagName,
} }
#[derive(XmlDeserialize)] #[derive(XmlDeserialize)]
struct RemovePropertyElement { struct RemovePropertyElement {
#[allow(dead_code)]
prop: PropertyElement, prop: PropertyElement,
} }
#[derive(XmlDeserialize)] #[derive(XmlDeserialize)]
enum Operation<T: XmlDeserialize> { enum Operation<T: XmlDeserialize> {
Set(SetPropertyElement<T>), Set(SetPropertyElement<T>),
#[allow(dead_code)]
Remove(RemovePropertyElement), Remove(RemovePropertyElement),
} }
@@ -34,10 +39,11 @@ fn test_propertyupdate() {
#[xml(root = b"propertyupdate")] #[xml(root = b"propertyupdate")]
struct PropertyupdateElement<T: XmlDeserialize> { struct PropertyupdateElement<T: XmlDeserialize> {
#[xml(ty = "untagged", flatten)] #[xml(ty = "untagged", flatten)]
#[allow(dead_code)]
operations: Vec<Operation<T>>, operations: Vec<Operation<T>>,
} }
let doc = PropertyupdateElement::<Unparsed>::parse_str( PropertyupdateElement::<Unparsed>::parse_str(
r#" r#"
<propertyupdate> <propertyupdate>
<set> <set>

View File

@@ -11,17 +11,22 @@ fn test_struct_value_tagged() {
#[derive(Debug, XmlSerialize, PartialEq)] #[derive(Debug, XmlSerialize, PartialEq)]
enum Prop { enum Prop {
Test(String), Test(String),
Hello(usize), // Hello(usize),
Unit, // Unit,
} }
let mut buf = Vec::new(); let out = Document {
let mut writer = quick_xml::Writer::new(&mut buf);
Document {
prop: Prop::Test("asd".to_owned()), prop: Prop::Test("asd".to_owned()),
} }
.serialize_root(&mut writer) .serialize_to_string()
.unwrap(); .unwrap();
let out = String::from_utf8(buf).unwrap(); assert_eq!(
assert_eq!(out, "<propfind><prop><test>asd</test></prop></propfind>"); out,
r#"<?xml version="1.0" encoding="utf-8"?>
<propfind>
<prop>
<test>asd</test>
</prop>
</propfind>"#
);
} }

View File

@@ -1,11 +1,7 @@
use std::{
borrow::{Borrow, Cow},
collections::HashMap,
};
use quick_xml::Writer; use quick_xml::Writer;
use quick_xml::name::Namespace; use quick_xml::name::Namespace;
use rustical_xml::{XmlDocument, XmlRootTag, XmlSerialize, XmlSerializeRoot}; use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot};
use std::collections::HashMap;
use xml_derive::XmlDeserialize; use xml_derive::XmlDeserialize;
#[test] #[test]
@@ -22,16 +18,13 @@ fn test_struct_document() {
text: String, text: String,
} }
let mut buf = Vec::new();
let mut writer = quick_xml::Writer::new(&mut buf);
Document { Document {
child: Child { child: Child {
text: "asd".to_owned(), text: "asd".to_owned(),
}, },
} }
.serialize_root(&mut writer) .serialize_to_string()
.unwrap(); .unwrap();
let out = String::from_utf8(buf).unwrap();
} }
#[test] #[test]
@@ -51,17 +44,14 @@ fn test_struct_untagged_attr() {
text: String, text: String,
} }
let mut buf = Vec::new();
let mut writer = quick_xml::Writer::new(&mut buf);
Document { Document {
name: "okay".to_owned(), name: "okay".to_owned(),
child: Child { child: Child {
text: "asd".to_owned(), text: "asd".to_owned(),
}, },
} }
.serialize_root(&mut writer) .serialize_to_string()
.unwrap(); .unwrap();
let out = String::from_utf8(buf).unwrap();
} }
#[test] #[test]
@@ -73,16 +63,20 @@ fn test_struct_value_tagged() {
num: usize, num: usize,
} }
let mut buf = Vec::new(); let out = Document {
let mut writer = quick_xml::Writer::new(&mut buf);
Document {
href: "okay".to_owned(), href: "okay".to_owned(),
num: 123, num: 123,
} }
.serialize_root(&mut writer) .serialize_to_string()
.unwrap(); .unwrap();
let out = String::from_utf8(buf).unwrap(); assert_eq!(
assert_eq!(out, "<document><href>okay</href><num>123</num></document>"); out,
r#"<?xml version="1.0" encoding="utf-8"?>
<document>
<href>okay</href>
<num>123</num>
</document>"#
);
} }
#[test] #[test]
@@ -94,15 +88,16 @@ fn test_struct_value_untagged() {
href: String, href: String,
} }
let mut buf = Vec::new(); let out = Document {
let mut writer = quick_xml::Writer::new(&mut buf);
Document {
href: "okays".to_owned(), href: "okays".to_owned(),
} }
.serialize_root(&mut writer) .serialize_to_string()
.unwrap(); .unwrap();
let out = String::from_utf8(buf).unwrap(); assert_eq!(
assert_eq!(out, "<document>okays</document>"); out,
r#"<?xml version="1.0" encoding="utf-8"?>
<document>okays</document>"#
);
} }
#[test] #[test]
@@ -114,17 +109,18 @@ fn test_struct_vec() {
href: Vec<String>, href: Vec<String>,
} }
let mut buf = Vec::new(); let out = Document {
let mut writer = quick_xml::Writer::new(&mut buf);
Document {
href: vec!["okay".to_owned(), "wow".to_owned()], href: vec!["okay".to_owned(), "wow".to_owned()],
} }
.serialize_root(&mut writer) .serialize_to_string()
.unwrap(); .unwrap();
let out = String::from_utf8(buf).unwrap();
assert_eq!( assert_eq!(
out, out,
"<document><href>okay</href><href>wow</href></document>" r#"<?xml version="1.0" encoding="utf-8"?>
<document>
<href>okay</href>
<href>wow</href>
</document>"#
); );
} }
@@ -137,25 +133,28 @@ fn test_struct_serialize_with() {
href: String, href: String,
} }
fn serialize_href<W: ::std::io::Write>( fn serialize_href(
val: &str, val: &str,
ns: Option<Namespace>, ns: Option<Namespace>,
tag: Option<&[u8]>, tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>, namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut Writer<W>, writer: &mut Writer<&mut Vec<u8>>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
val.to_uppercase().serialize(ns, tag, namespaces, writer) val.to_uppercase().serialize(ns, tag, namespaces, writer)
} }
let mut buf = Vec::new(); let out = Document {
let mut writer = quick_xml::Writer::new(&mut buf);
Document {
href: "okay".to_owned(), href: "okay".to_owned(),
} }
.serialize_root(&mut writer) .serialize_to_string()
.unwrap(); .unwrap();
let out = String::from_utf8(buf).unwrap(); assert_eq!(
assert_eq!(out, "<document><href>OKAY</href></document>"); out,
r#"<?xml version="1.0" encoding="utf-8"?>
<document>
<href>OKAY</href>
</document>"#
);
} }
#[test] #[test]
@@ -173,8 +172,6 @@ fn test_struct_tag_list() {
name: String, name: String,
} }
let mut buf = Vec::new();
let mut writer = quick_xml::Writer::new(&mut buf);
Document { Document {
tags: vec![ tags: vec![
Tag { Tag {
@@ -188,10 +185,8 @@ fn test_struct_tag_list() {
}, },
], ],
} }
.serialize_root(&mut writer) .serialize_to_string()
.unwrap(); .unwrap();
let out = String::from_utf8(buf).unwrap();
dbg!(out);
} }
#[test] #[test]
@@ -205,15 +200,11 @@ fn test_struct_ns() {
child: String, child: String,
} }
let mut buf = Vec::new();
let mut writer = quick_xml::Writer::new(&mut buf);
Document { Document {
child: "hello!".to_string(), child: "hello!".to_string(),
} }
.serialize_root(&mut writer) .serialize_to_string()
.unwrap(); .unwrap();
let out = String::from_utf8(buf).unwrap();
dbg!(out);
} }
#[test] #[test]
@@ -227,16 +218,11 @@ fn test_struct_tuple() {
#[derive(Debug, XmlSerialize, PartialEq, Default)] #[derive(Debug, XmlSerialize, PartialEq, Default)]
struct Child(#[xml(ty = "tag_name")] String, #[xml(ty = "text")] String); struct Child(#[xml(ty = "tag_name")] String, #[xml(ty = "text")] String);
let mut buf = Vec::new();
let mut writer = quick_xml::Writer::new(&mut buf);
Document { Document {
child: Child("child".to_owned(), "Hello!".to_owned()), child: Child("child".to_owned(), "Hello!".to_owned()),
} }
.serialize_root(&mut writer) .serialize_to_string()
.unwrap(); .unwrap();
let out = String::from_utf8(buf).unwrap();
dbg!(out);
} }
#[test] #[test]
@@ -247,11 +233,7 @@ fn test_tuple_struct() {
#[xml(root = b"document")] #[xml(root = b"document")]
struct Document(#[xml(ns = "NS", rename = b"okay")] String); struct Document(#[xml(ns = "NS", rename = b"okay")] String);
let mut buf = Vec::new();
let mut writer = quick_xml::Writer::new(&mut buf);
Document("hello!".to_string()) Document("hello!".to_string())
.serialize_root(&mut writer) .serialize_to_string()
.unwrap(); .unwrap();
let out = String::from_utf8(buf).unwrap();
dbg!(out);
} }

View File

@@ -12,13 +12,14 @@ a CalDAV/CardDAV server
- 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 - **[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](setup/oidc.md) support (with option to disable password login) - **[OpenID Connect](setup/oidc.md)** support (with option to disable password login)
- Group-based **sharing**
## Tested Clients ## Tested Clients
@@ -27,3 +28,4 @@ a CalDAV/CardDAV server
- Evolution - Evolution
- Apple Calendar - Apple Calendar
- Home Assistant integration - Home Assistant integration
- Thunderbird

View File

@@ -16,6 +16,9 @@ docker run \
1. Mount config file 1. Mount config file
2. Alternatively specify configuration using environment variables 2. Alternatively specify configuration using environment variables
!!! info
Note that you are expected to run RustiCal behind a reverse proxy with HTTPS. (The frontend will only work on non-localhost addresses with https) and clients like Apple Calendar also expect HTTPS.
## User management ## User management
In case you already have an OIDC server set up, see [here](setup/oidc.md) how to set up OIDC login and maybe skip this section. In case you already have an OIDC server set up, see [here](setup/oidc.md) how to set up OIDC login and maybe skip this section.

80
docs/setup/client.md Normal file
View File

@@ -0,0 +1,80 @@
# Client Setup
## Common
Following resources are available.
```
/.well-known/caldav
# CalDAV root
/caldav
# Principal home
/caldav/principal/<user_id>
# Calendar home
/caldav/principal/<user_id>/<calendar_id>
/caldav/principal/<user_id>/_birthdays_<addressbook_id>
# CalDAV root
/caldav-compat
/caldav-compat/principal...
```
```
/.well-known/carddav
# CardDAV root
/carddav
# Principal home
/carddav/principal/<user_id>
# Addressbook home
/carddav/principal/<user_id>/<addressbook_id>
```
### Authentication
Authenticate with HTTP Basic authentication using your user id and a generated app token.
## `/caldav` vs `/caldav-compat` (relevant for group sharing)
To discover shared calendars the `calendar-home-set` property is used to list all principals the user has access to.
However, some clients don't support `calendar-home-set` containing multiple paths (e.g. Apple Calendar).
As a workaround `/caldav-compat` offers the same endpoints as `/caldav` with the only difference being that it does not return all calendar homes in `calendar-home-set`.
This means that clients under this path will probably not auto-discover group calendars so you can instead add them one-by-one using the principal path `/caldav-compat/principal/<principal_id>`.
## DAVx5
You can set up DAVx5 through the Nextcloud login flow. Collections including group collections will automatically be discovered.
## Apple Calendar
You can download a configuration profile from the frontend in the app token section.
**Note**: Since Apple Calendar does not properly support the `calendar-home-set` property the `/caldav-compat` endpoints should be used.
That also means that Apple Calendar is not able to automatically discover group collections so in that case you'll have to manually add all principals with `/caldav-compat/principal/<principal_id>`.
## Evolution
Set up a collection account in the account settings.
Evolution correctly uses all calendar homes so group collections work properly.
## Home Assistant CalDAV integration
The underlying library `python-caldav` does not support multiple calendar homes so you should use the `/caldav-compat` endpoints.
As URL specify
```
https://<your-host>/caldav-compat
```
For group collections explicitly specify
```
https://<your-host>/caldav-compat/principal/<principal>
```
## Thunderbird
- Go to `New Account -> Calendar -> On The Network`
- Specify the root path of RustiCal
- Thunderbird will properly discover group calendars

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