Compare commits

...

102 Commits

Author SHA1 Message Date
Lennart
4592afac10 version 0.9.7 2025-09-18 21:11:44 +02:00
Lennart
e7ab7c2987 ical: Fix import UID grouping 2025-09-18 21:08:00 +02:00
Lennart
242f7b9076 calendar export: Fix overrides 2025-09-18 20:38:54 +02:00
Lennart
cb1356acad ical: Fix data model to allow calendar objects with overrides
#125
2025-09-18 20:38:37 +02:00
Lennart
55dadbb06b update Rust to 1.90 2025-09-18 16:45:48 +02:00
Lennart
4dd12bfe52 version 0.9.6 2025-09-17 11:35:20 +02:00
Lennart
5e004a6edc calendar import: Enable import to existing calendars (if no objects are overwritten) 2025-09-17 11:33:49 +02:00
Lennart
03e550c2f8 add some debug logging for invalid data in put_event
#125
2025-09-17 10:18:46 +02:00
Lennart
b2f5d5486c version 0.9.5 2025-09-17 10:06:07 +02:00
Lennart
db674d5895 Allow setting HTTP payload limit and set default to 4MB
#124
2025-09-17 10:06:07 +02:00
Lennart K
bc98d1be42 document thing to watch out for with Kubernetes #122 2025-09-16 15:34:31 +02:00
Lennart
4bb8cae9ea docs: Fix typo for env var configuration 2025-09-14 18:55:33 +02:00
Lennart
3774b358a5 version 0.9.4 2025-09-10 23:23:12 +02:00
Lennart
c6b612e5a0 Update dependencies 2025-09-10 23:20:40 +02:00
Lennart
91586ee797 migrate quick-xml to 0.38
fixes #120
2025-09-05 15:24:34 +02:00
Lennart K
87adf94947 Update Cargo.toml and Dockerfile 2025-09-04 13:05:14 +02:00
Lennart
f850f9b3a3 version 0.9.3 2025-09-02 23:38:41 +02:00
Lennart
0eb8359e26 rewrite combined calendar store in preparation for sharing 2025-09-02 23:30:16 +02:00
Lennart
7d961ea93b frontend: make button descriptions shorter to fit mobile screen 2025-09-02 23:19:15 +02:00
Lennart
375caedec6 update docs 2025-09-02 09:32:28 +02:00
Lennart
2d8d2eb194 Update README.md 2025-09-01 00:29:55 +02:00
Lennart
69e788b363 store: prevent objects from being commited to subscription calendar 2025-08-31 12:40:20 +02:00
Lennart
8ea5321503 Merge branch 'main' into sharing 2025-08-30 13:58:50 +02:00
Lennart
76c03fa4d4 clippy appeasement 2025-08-30 11:56:58 +02:00
Lennart
96b63848f0 version 0.9.2 2025-08-30 00:41:50 +02:00
Lennart
16e5cacefe Docker: Target Rust 1.89
fixes #116
2025-08-30 00:21:41 +02:00
Lennart
3819f623a6 update dependencies 2025-08-30 00:20:51 +02:00
Lennart
c4604d4376 xml: Comprehensive refactoring from byte strings to strings 2025-08-28 18:01:41 +02:00
Lennart K
85787e69bc xml: tiny refactoring 2025-08-28 15:24:19 +02:00
Lennart K
43b4150e28 xml: Change ns_prefix from LitByteStr to LitStr 2025-08-28 15:19:27 +02:00
Lennart K
c38fbe004f clippy appeasement 2025-08-28 15:09:01 +02:00
Lennart
bf5d874481 frontend tweaks 2025-08-28 14:53:17 +02:00
Lennart
c648ed315d version 0.9.1 2025-08-25 19:09:48 +02:00
Lennart
2cf481d4e6 make session cookie samesite=lax by default 2025-08-25 19:09:24 +02:00
Lennart
a4285fb2ac Outsource some Calendar info to CalendarMetadata struct 2025-08-24 12:52:28 +02:00
Lennart
f3a1f27caf version 0.9.0 2025-08-23 20:06:38 +02:00
Lennart
0829093571 frontend: add dialog backdrop 2025-08-23 20:00:42 +02:00
Lennart
bfe17d0b65 caldav import: Add safeguard against empty addressbooks 2025-08-23 19:55:29 +02:00
Lennart
9050484932 Add addressbook import to frontend 2025-08-23 19:50:34 +02:00
Lennart
1e90ff3d6c carddav: Remove enforcement of UID matching filename (Apple Contacts doesn't play well) 2025-08-23 19:42:58 +02:00
Lennart
94ace71745 carddav: Change addressbook PUT to IMPORT 2025-08-23 19:01:19 +02:00
Lennart
f22d5ca04b clippy appeasement 2025-08-23 19:00:15 +02:00
Lennart
68a2e7e2a2 carddav: Require UID in address object 2025-08-23 18:09:03 +02:00
Lennart
4e3c3f3a3b Add calendar import endpoint and frontend form 2025-08-23 12:24:42 +02:00
Lennart
b7cfd3301b Add import_calendar method to CalendarStore 2025-08-23 12:23:05 +02:00
Lennart
9c114dc204 export: Include vtimezones
fixes #112
2025-08-22 21:32:34 +02:00
Lennart
9decef093d dav: add new http IMPORT method 2025-08-20 13:48:50 +02:00
Lennart
de2a8a2a8e bump version to 0.8.6 2025-08-17 15:48:37 +02:00
Lennart
51d2293ff9 frontend: Show unauthorized messages instead of redirecting to the login screen for non-user resources 2025-08-17 15:47:35 +02:00
Lennart
5c77719ce4 Add log warning for failed login attempts 2025-08-17 15:38:29 +02:00
Lennart
91996465f9 ical: Remove unused generic around CalendarObject 2025-08-17 15:38:07 +02:00
Lennart
83f4506578 bump version to 0.8.5 2025-08-12 17:19:36 +02:00
Lennart
a5bbb82712 dav_push: Add TTL header to notifcation requests (thanks @drift8797)
see #108
2025-08-12 17:19:16 +02:00
Lennart
6a26f44dd7 bump version to 0.8.4 2025-08-10 14:01:25 +02:00
Lennart
f8a660c222 rename session cookie to rustical_session
To prevent possible clashes with other services, #105
2025-08-10 14:01:00 +02:00
Lennart
a991baaf7d Update version to 0.8.3 2025-08-10 13:51:09 +02:00
Lennart
61d226dada Update dependencies
Fixes #106
2025-08-10 13:49:51 +02:00
Lennart
ce0ce43418 some preparation for better testing 2025-08-10 13:14:45 +02:00
Lennart
038942ff16 Make order of user privileges deterministic during serialisation 2025-07-29 16:48:03 +02:00
Lennart
90c38e7703 dav: for propfind replace HashSet with Vec to make output deterministic 2025-07-29 15:49:58 +02:00
Lennart
0159a8d9c9 clippy appeasement 2025-07-29 15:07:04 +02:00
Lennart
aa8db47f57 dav: Make response xml serialize to make unit testing easier 2025-07-29 15:05:04 +02:00
Lennart
78f7a7e155 rustical_dav: Move propfind parsing to resource type 2025-07-29 14:53:16 +02:00
Lennart
e1a7a188f5 add comment about timezone 2025-07-29 12:53:44 +02:00
Lennart
a42004501b version 0.8.1 2025-07-26 17:37:44 +02:00
Lennart
89ce14ee86 update ical dependency 2025-07-26 17:37:25 +02:00
Lennart
7fc64d219c outsource some more ical logic to ical-rs fork 2025-07-26 13:32:28 +02:00
Lennart
03294ec106 version 0.8.0 2025-07-25 23:26:57 +02:00
Lennart
a22235d976 sqlite_store: Drop timezone column in favour of timezone_id 2025-07-25 23:01:51 +02:00
Lennart
1ba9a97b3f update .sqlx queries 2025-07-25 22:52:26 +02:00
Lennart
51036ec6d5 Update vtimezone-rs to fix missing timezones 2025-07-25 22:51:35 +02:00
Lennart
e1a10338e0 Calendar data model: Switch to only saving timezone id 2025-07-25 22:32:01 +02:00
Lennart
918f27e8c2 frontend: Fix timezone removal 2025-07-25 22:30:52 +02:00
Lennart
dd34dd23d1 ical: Work on calendar object data structure 2025-07-25 21:44:57 +02:00
Lennart
9910e4ee31 Remove duplicate UTC implementation from CalTimezone 2025-07-25 19:06:23 +02:00
Lennart
c22469dea6 update ical dependency 2025-07-25 18:38:21 +02:00
Lennart
f2899aec6b Move to own ical-rs fork and refactor timezone-related stuff 2025-07-25 18:22:06 +02:00
Lennart K
f9380ca7e4 clippy appeasement 2025-07-24 11:46:28 +02:00
Lennart
e7138b5f8c version 0.7.0 2025-07-23 21:32:12 +02:00
Lennart
84af24a2b7 frontend: fill id with uuid for creation forms 2025-07-23 21:31:10 +02:00
Lennart
4bd6271e33 Update vtimezones-rs 2025-07-23 21:15:15 +02:00
Lennart
d817c1384c frontend: Add error handling to collection forms 2025-07-23 20:48:28 +02:00
Lennart
f8abc22e63 clippy appeasement 2025-07-23 20:41:06 +02:00
Lennart
b7b5ca4f91 Update dependencies 2025-07-23 20:31:16 +02:00
Lennart
caca2d28ed update vtimezones-rs 2025-07-23 20:23:21 +02:00
Lennart
3db2f13c1b rename vzic-rs to vtimezones-rs 2025-07-23 18:19:23 +02:00
Lennart
db01024682 add comment 2025-07-23 18:08:04 +02:00
Lennart
b2f15f2d77 fix: Add timezone-id support to mkcalendar 2025-07-23 18:04:19 +02:00
Lennart
89dd94904b frontend: Add timezone fields to calendar forms 2025-07-23 17:59:54 +02:00
Lennart
5d0263abc1 caldav: Add vtimezone repository to date timezone with timezone-id 2025-07-23 17:55:55 +02:00
Lennart
0ef3e19bd3 caldav: Fix principal collection permissions 2025-07-23 11:28:14 +02:00
Lennart
44912057fc subscription store: Correctly return whether subscription already existed 2025-07-23 11:09:48 +02:00
Lennart
c4f613a803 Add example compose.yml 2025-07-23 11:05:05 +02:00
Lennart
eb8f301e45 update dependencies 2025-07-22 17:57:24 +02:00
Lennart
d59ae25eba v0.6.5 2025-07-22 16:57:08 +02:00
Lennart
d4daa35df6 auth: Make app token validation faster by supplying hint to the app token name 2025-07-22 16:48:04 +02:00
Lennart
ea43876410 auth: User faster app token hash 2025-07-22 16:10:19 +02:00
Lennart
18af1b9aa2 remove calendar-proxy from DAV header 2025-07-22 15:41:24 +02:00
Lennart
e69c75102c version 0.6.4 2025-07-22 10:55:28 +02:00
Lennart
09f1bd20ae close connection if request body might not have been consumed
hopefully fixes #77
2025-07-22 10:53:12 +02:00
Lennart
72f970a857 version 0.6.3 2025-07-20 13:39:25 +02:00
Lennart
08c250657e well-known: add second apple user agent 2025-07-20 13:38:57 +02:00
137 changed files with 3839 additions and 4745 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1092
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,10 @@
members = ["crates/*"]
[workspace.package]
version = "0.6.2"
version = "0.9.7"
edition = "2024"
description = "A CalDAV server"
documentation = "https://lennart-k.github.io/rustical/"
repository = "https://github.com/lennart-k/rustical"
license = "AGPL-3.0-or-later"
@@ -16,7 +17,7 @@ description.workspace = true
repository.workspace = true
license.workspace = true
resolver = "2"
publish = false
publish = true
[features]
debug = ["opentelemetry"]
@@ -48,7 +49,7 @@ rand_core = { version = "0.9", features = ["std"] }
chrono = { version = "0.4", features = ["serde"] }
regex = "1.10"
lazy_static = "1.5"
rstest = "0.25"
rstest = "0.26"
rstest_reuse = "0.7"
sha2 = "0.10"
tokio = { version = "1", features = [
@@ -61,7 +62,7 @@ tokio = { version = "1", features = [
url = "2.5"
base64 = "0.22"
thiserror = "2.0"
quick-xml = { version = "0.37" }
quick-xml = { version = "0.38" }
rust-embed = "8.5"
tower-sessions = "0.14"
futures-core = "0.3.31"
@@ -95,8 +96,12 @@ strum = "0.27"
strum_macros = "0.27"
serde_json = { version = "1.0", features = ["raw_value"] }
sqlx-sqlite = { version = "0.8", features = ["bundled"] }
ical = { version = "0.11", features = ["generator", "serde"] }
toml = "0.8"
ical = { git = "https://github.com/lennart-k/ical-rs", features = [
"generator",
"serde",
"chrono-tz",
] }
toml = "0.9"
tower = "0.5"
tower-http = { version = "0.6", features = [
"trace",
@@ -126,7 +131,7 @@ syn = { version = "2.0", features = ["full"] }
quote = "1.0"
proc-macro2 = "1.0"
heck = "0.5"
darling = "0.20"
darling = "0.21"
reqwest = { version = "0.12", features = [
"rustls-tls",
"charset",
@@ -135,6 +140,7 @@ reqwest = { version = "0.12", features = [
openidconnect = "4.0"
clap = { version = "4.5", features = ["derive", "env"] }
matchit-serde = { git = "https://github.com/lennart-k/matchit-serde", rev = "f0591d13" }
vtimezones-rs = "0.2"
ece = { version = "2.3", default-features = false, features = [
"backend-openssl",
] }

View File

@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM rust:1.88-alpine AS chef
FROM --platform=$BUILDPLATFORM rust:1.90-alpine AS chef
ARG TARGETPLATFORM
ARG BUILDPLATFORM
@@ -45,4 +45,5 @@ CMD ["/usr/local/bin/rustical"]
ENV RUSTICAL_DATA_STORE__SQLITE__DB_URL=/var/lib/rustical/db.sqlite3
LABEL org.opencontainers.image.authors="Lennart K github.com/lennart-k"
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
EXPOSE 4000

View File

@@ -4,14 +4,15 @@ a CalDAV/CardDAV server
> [!WARNING]
RustiCal is under **active development**!
While I've been successfully using RustiCal productively for a few weeks now,
While I've been successfully using RustiCal productively for some months now and there seems to be a growing user base,
you'd still be one of the first testers so expect bugs and rough edges.
If you still want to play around with it in its current state, absolutely feel free to do so and to open up an issue if something is not working. :)
If you still want to use it in its current state, absolutely feel free to do so and to open up an issue if something is not working. :)
## Features
- easy to backup, everything saved in one SQLite database
- also export feature in the frontend
- Import your existing calendars in the frontend
- **[WebDAV Push](https://github.com/bitfireAT/webdav-push/)** support, so near-instant synchronisation to DAVx5
- lightweight (the container image contains only one binary)
- adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks)

View File

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

22
compose.oidc.yml Normal file
View File

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

View File

@@ -11,6 +11,7 @@ publish = false
rustical_store_sqlite = { workspace = true, features = ["test"] }
rstest.workspace = true
async-std.workspace = true
serde_json.workspace = true
[dependencies]
axum.workspace = true
@@ -42,3 +43,4 @@ headers.workspace = true
tower-http.workspace = true
strum.workspace = true
strum_macros.workspace = true
vtimezones-rs.workspace = true

View File

@@ -37,52 +37,74 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
.await?;
let mut timezones = HashMap::new();
let mut vtimezones = HashMap::new();
let objects = cal_store.get_objects(&principal, &calendar_id).await?;
let mut ical_calendar_builder = IcalCalendarBuilder::version("4.0")
.gregorian()
.prodid("RustiCal");
if calendar.displayname.is_some() {
if let Some(displayname) = calendar.meta.displayname {
ical_calendar_builder = ical_calendar_builder.set(Property {
name: "X-WR-CALNAME".to_owned(),
value: calendar.displayname,
value: Some(displayname),
params: None,
});
}
if calendar.description.is_some() {
if let Some(description) = calendar.meta.description {
ical_calendar_builder = ical_calendar_builder.set(Property {
name: "X-WR-CALDESC".to_owned(),
value: calendar.description,
value: Some(description),
params: None,
});
}
if calendar.timezone_id.is_some() {
if let Some(timezone_id) = calendar.timezone_id {
ical_calendar_builder = ical_calendar_builder.set(Property {
name: "X-WR-TIMEZONE".to_owned(),
value: calendar.timezone_id,
value: Some(timezone_id),
params: None,
});
}
let mut ical_calendar = ical_calendar_builder.build();
for object in &objects {
vtimezones.extend(object.get_vtimezones());
match object.get_data() {
CalendarObjectComponent::Event(EventObject {
CalendarObjectComponent::Event(
EventObject {
event,
timezones: object_timezones,
..
}) => {
},
overrides,
) => {
timezones.extend(object_timezones);
ical_calendar.events.push(event.clone());
ical_calendar_builder = ical_calendar_builder.add_event(event.clone());
for _override in overrides {
ical_calendar_builder =
ical_calendar_builder.add_event(_override.event.clone());
}
CalendarObjectComponent::Todo(TodoObject { todo, .. }) => {
ical_calendar.todos.push(todo.clone());
}
CalendarObjectComponent::Journal(JournalObject { journal, .. }) => {
ical_calendar.journals.push(journal.clone());
CalendarObjectComponent::Todo(TodoObject(todo), overrides) => {
ical_calendar_builder = ical_calendar_builder.add_todo(todo.clone());
for _override in overrides {
ical_calendar_builder = ical_calendar_builder.add_todo(_override.0.clone());
}
}
CalendarObjectComponent::Journal(JournalObject(journal), overrides) => {
ical_calendar_builder = ical_calendar_builder.add_journal(journal.clone());
for _override in overrides {
ical_calendar_builder = ical_calendar_builder.add_journal(_override.0.clone());
}
}
}
}
for vtimezone in vtimezones.into_values() {
ical_calendar_builder = ical_calendar_builder.add_tz(vtimezone.to_owned());
}
let ical_calendar = ical_calendar_builder
.build()
.map_err(|parser_error| Error::IcalError(parser_error.into()))?;
let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap();

View File

@@ -0,0 +1,110 @@
use crate::Error;
use crate::calendar::CalendarResourceService;
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use ical::{
generator::Emitter,
parser::{Component, ComponentMut},
};
use rustical_dav::header::Overwrite;
use rustical_ical::{CalendarObject, CalendarObjectType};
use rustical_store::{
Calendar, CalendarMetadata, CalendarStore, SubscriptionStore, auth::Principal,
};
use std::io::BufReader;
use tracing::instrument;
#[instrument(skip(resource_service))]
pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
Path((principal, cal_id)): Path<(String, String)>,
user: Principal,
State(resource_service): State<CalendarResourceService<C, S>>,
overwrite: Overwrite,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
}
let mut parser = ical::IcalParser::new(BufReader::new(body.as_bytes()));
let mut cal = parser
.next()
.expect("input must contain calendar")
.unwrap()
.mutable();
if parser.next().is_some() {
return Err(rustical_ical::Error::InvalidData(
"multiple calendars, only one allowed".to_owned(),
)
.into());
}
// Extract calendar metadata
let displayname = cal
.get_property("X-WR-CALNAME")
.and_then(|prop| prop.value.to_owned());
let description = cal
.get_property("X-WR-CALDESC")
.and_then(|prop| prop.value.to_owned());
let timezone_id = cal
.get_property("X-WR-TIMEZONE")
.and_then(|prop| prop.value.to_owned());
// These properties should not appear in the expanded calendar objects
cal.remove_property("X-WR-CALNAME");
cal.remove_property("X-WR-CALDESC");
cal.remove_property("X-WR-TIMEZONE");
let cal = cal.verify().unwrap();
// Make sure timezone is valid
if let Some(timezone_id) = timezone_id.as_ref() {
assert!(
vtimezones_rs::VTIMEZONES.contains_key(timezone_id),
"Invalid calendar timezone id"
);
}
// Extract necessary component types
let mut cal_components = vec![];
if !cal.events.is_empty() {
cal_components.push(CalendarObjectType::Event);
}
if !cal.journals.is_empty() {
cal_components.push(CalendarObjectType::Journal);
}
if !cal.todos.is_empty() {
cal_components.push(CalendarObjectType::Todo);
}
let expanded_cals = cal.expand_calendar();
// Janky way to convert between IcalCalendar and CalendarObject
let objects = expanded_cals
.into_iter()
.map(|cal| cal.generate())
.map(CalendarObject::from_ics)
.collect::<Result<Vec<_>, _>>()?;
let new_cal = Calendar {
principal,
id: cal_id,
meta: CalendarMetadata {
displayname,
order: 0,
description,
color: None,
},
timezone_id,
deleted_at: None,
synctoken: 0,
subscription_url: None,
push_topic: uuid::Uuid::new_v4().to_string(),
components: cal_components,
};
let cal_store = resource_service.cal_store;
cal_store
.import_calendar(new_cal, objects, overwrite.is_true())
.await?;
Ok(StatusCode::OK.into_response())
}

View File

@@ -4,10 +4,11 @@ use crate::calendar::prop::SupportedCalendarComponentSet;
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use http::{Method, StatusCode};
use ical::IcalParser;
use rustical_dav::xml::HrefElement;
use rustical_ical::CalendarObjectType;
use rustical_store::auth::Principal;
use rustical_store::{Calendar, CalendarStore, SubscriptionStore};
use rustical_store::{Calendar, CalendarMetadata, CalendarStore, SubscriptionStore};
use rustical_xml::{Unparsed, XmlDeserialize, XmlDocument, XmlRootTag};
use tracing::instrument;
@@ -45,7 +46,7 @@ pub struct PropElement {
}
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug)]
#[xml(root = b"mkcalendar")]
#[xml(root = "mkcalendar")]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
struct MkcalendarRequest {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
@@ -53,7 +54,7 @@ struct MkcalendarRequest {
}
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug)]
#[xml(root = b"mkcol")]
#[xml(root = "mkcol")]
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
struct MkcolRequest {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
@@ -82,15 +83,42 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
request.displayname = None
}
let timezone_id = if let Some(tzid) = request.calendar_timezone_id {
Some(tzid)
} else if let Some(tz) = request.calendar_timezone {
// TODO: Proper error (calendar-timezone precondition)
let calendar = IcalParser::new(tz.as_bytes())
.next()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?
.map_err(|_| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?;
let timezone = calendar
.timezones
.first()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?;
let timezone: chrono_tz::Tz = timezone
.try_into()
.map_err(|_| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?;
Some(timezone.name().to_owned())
} else {
None
};
let calendar = Calendar {
id: cal_id.to_owned(),
principal: principal.to_owned(),
meta: CalendarMetadata {
order: request.calendar_order.unwrap_or(0),
displayname: request.displayname,
timezone: request.calendar_timezone,
timezone_id: request.calendar_timezone_id,
color: request.calendar_color,
description: request.calendar_description,
},
timezone_id,
deleted_at: None,
synctoken: 0,
subscription_url: request.source.map(|href| href.href),

View File

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

View File

@@ -116,20 +116,18 @@ impl CompFilterElement {
// TODO: Implement prop-filter (and comp-filter?) at some point
if let Some(time_range) = &self.time_range {
if let Some(start) = &time_range.start {
if let Some(last_occurence) = cal_object.get_last_occurence().unwrap_or(None) {
if start.deref() > &last_occurence.utc() {
if let Some(start) = &time_range.start
&& let Some(last_occurence) = cal_object.get_last_occurence().unwrap_or(None)
&& start.deref() > &last_occurence.utc()
{
return false;
}
};
}
if let Some(end) = &time_range.end {
if let Some(first_occurence) = cal_object.get_first_occurence().unwrap_or(None) {
if end.deref() < &first_occurence.utc() {
if let Some(end) = &time_range.end
&& let Some(first_occurence) = cal_object.get_first_occurence().unwrap_or(None)
&& end.deref() < &first_occurence.utc()
{
return false;
}
};
}
}
true
}
@@ -156,8 +154,9 @@ impl From<&FilterElement> for CalendarQuery {
for comp_filter in comp_filter_vcalendar.comp_filter.iter() {
// A calendar object cannot contain both VEVENT and VTODO, so we only have to handle
// whatever we get first
if matches!(comp_filter.name.as_str(), "VEVENT" | "VTODO") {
if let Some(time_range) = &comp_filter.time_range {
if matches!(comp_filter.name.as_str(), "VEVENT" | "VTODO")
&& let Some(time_range) = &comp_filter.time_range
{
let start = time_range.start.as_ref().map(|start| start.date_naive());
let end = time_range.end.as_ref().map(|end| end.date_naive());
return CalendarQuery {
@@ -166,7 +165,6 @@ impl From<&FilterElement> for CalendarQuery {
};
}
}
}
Default::default()
}
}

View File

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

View File

@@ -3,6 +3,7 @@ use crate::Error;
use crate::calendar::prop::ReportMethod;
use chrono::{DateTime, Utc};
use derive_more::derive::{From, Into};
use ical::IcalParser;
use rustical_dav::extensions::{
CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp,
};
@@ -15,7 +16,7 @@ use rustical_store::Calendar;
use rustical_store::auth::Principal;
use rustical_xml::{EnumVariants, PropName};
use rustical_xml::{XmlDeserialize, XmlSerialize};
use std::str::FromStr;
use serde::Deserialize;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "CalendarPropName")]
@@ -62,7 +63,7 @@ pub enum CalendarPropWrapper {
Common(CommonPropertiesProp),
}
#[derive(Clone, Debug, From, Into)]
#[derive(Clone, Debug, From, Into, Deserialize)]
pub struct CalendarResource {
pub cal: Calendar,
pub read_only: bool,
@@ -127,13 +128,15 @@ impl Resource for CalendarResource {
Ok(match prop {
CalendarPropWrapperName::Calendar(prop) => CalendarPropWrapper::Calendar(match prop {
CalendarPropName::CalendarColor => {
CalendarProp::CalendarColor(self.cal.color.clone())
CalendarProp::CalendarColor(self.cal.meta.color.clone())
}
CalendarPropName::CalendarDescription => {
CalendarProp::CalendarDescription(self.cal.description.clone())
CalendarProp::CalendarDescription(self.cal.meta.description.clone())
}
CalendarPropName::CalendarTimezone => {
CalendarProp::CalendarTimezone(self.cal.timezone.clone())
CalendarProp::CalendarTimezone(self.cal.timezone_id.as_ref().and_then(|tzid| {
vtimezones_rs::VTIMEZONES.get(tzid).map(|tz| tz.to_string())
}))
}
// chrono_tz uses the IANA database
CalendarPropName::TimezoneServiceSet => CalendarProp::TimezoneServiceSet(
@@ -143,7 +146,7 @@ impl Resource for CalendarResource {
CalendarProp::CalendarTimezoneId(self.cal.timezone_id.clone())
}
CalendarPropName::CalendarOrder => {
CalendarProp::CalendarOrder(Some(self.cal.order))
CalendarProp::CalendarOrder(Some(self.cal.meta.order))
}
CalendarPropName::SupportedCalendarComponentSet => {
CalendarProp::SupportedCalendarComponentSet(self.cal.components.clone().into())
@@ -184,32 +187,56 @@ impl Resource for CalendarResource {
match prop {
CalendarPropWrapper::Calendar(prop) => match prop {
CalendarProp::CalendarColor(color) => {
self.cal.color = color;
self.cal.meta.color = color;
Ok(())
}
CalendarProp::CalendarDescription(description) => {
self.cal.description = description;
self.cal.meta.description = description;
Ok(())
}
CalendarProp::CalendarTimezone(timezone) => {
// TODO: Ensure that timezone-id is also updated
self.cal.timezone = timezone;
if let Some(tz) = timezone {
// TODO: Proper error (calendar-timezone precondition)
let calendar = IcalParser::new(tz.as_bytes())
.next()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?
.map_err(|_| {
rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
)
})?;
let timezone =
calendar
.timezones
.first()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?;
let timezone: chrono_tz::Tz = timezone.try_into().map_err(|_| {
rustical_dav::Error::BadRequest("No timezone data provided".to_owned())
})?;
self.cal.timezone_id = Some(timezone.name().to_owned());
}
Ok(())
}
CalendarProp::TimezoneServiceSet(_) => Err(rustical_dav::Error::PropReadOnly),
CalendarProp::CalendarTimezoneId(timezone_id) => {
if let Some(tzid) = &timezone_id {
// Validate timezone id
chrono_tz::Tz::from_str(tzid).map_err(|_| {
rustical_dav::Error::BadRequest(format!("Invalid timezone-id: {tzid}"))
})?;
// TODO: Ensure that timezone is also updated (For now hope that clients play nice)
if let Some(tzid) = &timezone_id
&& !vtimezones_rs::VTIMEZONES.contains_key(tzid)
{
return Err(rustical_dav::Error::BadRequest(format!(
"Invalid timezone-id: {tzid}"
)));
}
self.cal.timezone_id = timezone_id;
Ok(())
}
CalendarProp::CalendarOrder(order) => {
self.cal.order = order.unwrap_or_default();
self.cal.meta.order = order.unwrap_or_default();
Ok(())
}
CalendarProp::SupportedCalendarComponentSet(comp_set) => {
@@ -237,24 +264,20 @@ impl Resource for CalendarResource {
match prop {
CalendarPropWrapperName::Calendar(prop) => match prop {
CalendarPropName::CalendarColor => {
self.cal.color = None;
self.cal.meta.color = None;
Ok(())
}
CalendarPropName::CalendarDescription => {
self.cal.description = None;
self.cal.meta.description = None;
Ok(())
}
CalendarPropName::CalendarTimezone => {
self.cal.timezone = None;
Ok(())
}
CalendarPropName::TimezoneServiceSet => Err(rustical_dav::Error::PropReadOnly),
CalendarPropName::CalendarTimezoneId => {
CalendarPropName::CalendarTimezone | CalendarPropName::CalendarTimezoneId => {
self.cal.timezone_id = None;
Ok(())
}
CalendarPropName::TimezoneServiceSet => Err(rustical_dav::Error::PropReadOnly),
CalendarPropName::CalendarOrder => {
self.cal.order = 0;
self.cal.meta.order = 0;
Ok(())
}
CalendarPropName::SupportedCalendarComponentSet => {
@@ -277,10 +300,10 @@ impl Resource for CalendarResource {
}
fn get_displayname(&self) -> Option<&str> {
self.cal.displayname.as_deref()
self.cal.meta.displayname.as_deref()
}
fn set_displayname(&mut self, name: Option<String>) -> Result<(), rustical_dav::Error> {
self.cal.displayname = name;
self.cal.meta.displayname = name;
Ok(())
}
@@ -305,3 +328,15 @@ impl Resource for CalendarResource {
))
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_tzdb_version() {
// Ensure that both chrono_tz and vzic_rs use the same tzdb version
assert_eq!(
chrono_tz::IANA_TZDB_VERSION,
vtimezones_rs::IANA_TZDB_VERSION
);
}
}

View File

@@ -1,4 +1,5 @@
use crate::calendar::methods::get::route_get;
use crate::calendar::methods::import::route_import;
use crate::calendar::methods::mkcalendar::route_mkcalendar;
use crate::calendar::methods::post::route_post;
use crate::calendar::methods::report::route_report_calendar;
@@ -51,7 +52,7 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
type Principal = Principal;
type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy, webdav-push";
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, webdav-push";
async fn get_resource(
&self,
@@ -138,6 +139,13 @@ impl<C: CalendarStore, S: SubscriptionStore> AxumMethods for CalendarResourceSer
})
}
fn import() -> Option<rustical_dav::resource::MethodFunction<Self>> {
Some(|state, req| {
let mut service = Handler::with_state(route_import::<C, S>, state);
Box::pin(Service::call(&mut service, req))
})
}
fn mkcalendar() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>>
{
Some(|state, req| {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ use rustical_ical::CalendarObject;
use rustical_store::CalendarStore;
use rustical_store::auth::Principal;
use std::str::FromStr;
use tracing::instrument;
use tracing::{debug, instrument};
#[instrument(skip(cal_store))]
pub async fn get_event<C: CalendarStore>(
@@ -78,12 +78,14 @@ pub async fn put_event<C: CalendarStore>(
true
};
let object = match CalendarObject::from_ics(object_id, body) {
let object = match CalendarObject::from_ics(body.clone()) {
Ok(obj) => obj,
Err(_) => {
debug!("invalid calendar data:\n{body}");
return Err(Error::PreconditionFailed(Precondition::ValidCalendarData));
}
};
assert_eq!(object.get_id(), object_id);
cal_store
.put_object(principal, calendar_id, object, overwrite)
.await?;

View File

@@ -41,11 +41,6 @@ impl Resource for PrincipalResource {
Resourcetype(&[
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "collection"),
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "principal"),
// https://github.com/apple/ccs-calendarserver/blob/13c706b985fb728b9aab42dc0fef85aae21921c3/doc/Extensions/caldav-proxy.txt
// ResourcetypeInner(
// Some(rustical_dav::namespace::NS_CALENDARSERVER),
// "calendar-proxy-write",
// ),
])
}
@@ -126,7 +121,7 @@ impl Resource for PrincipalResource {
}
fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_read(
Ok(UserPrivilegeSet::owner_only(
user.is_principal(&self.principal.id),
))
}

View File

@@ -16,13 +16,13 @@ pub enum PrincipalProp {
CalendarUserAddressSet(HrefElement),
// WebDAV Access Control (RFC 3744)
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = b"principal-URL")]
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = "principal-URL")]
PrincipalUrl(HrefElement),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
GroupMembership(GroupMembership),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
GroupMemberSet(GroupMemberSet),
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = b"alternate-URI-set")]
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = "alternate-URI-set")]
AlternateUriSet,
// #[xml(ns = "rustical_dav::namespace::NS_DAV")]
// PrincipalCollectionSet(HrefElement),

View File

@@ -46,7 +46,7 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
type Principal = Principal;
type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy";
const DAV_HEADER: &str = "1, 3, access-control, calendar-access";
async fn get_resource(
&self,

View File

@@ -1,14 +1,19 @@
use std::sync::Arc;
use crate::principal::PrincipalResourceService;
use crate::{
CalDavPrincipalUri,
principal::{PrincipalResource, PrincipalResourceService},
};
use rstest::rstest;
use rustical_dav::resource::ResourceService;
use rustical_dav::resource::{Resource, ResourceService};
use rustical_store::auth::{Principal, PrincipalType::Individual};
use rustical_store_sqlite::{
SqliteStore,
calendar_store::SqliteCalendarStore,
principal_store::SqlitePrincipalStore,
tests::{get_test_calendar_store, get_test_principal_store, get_test_subscription_store},
};
use rustical_xml::XmlSerializeRoot;
#[rstest]
#[tokio::test]
@@ -44,4 +49,35 @@ async fn test_principal_resource(
}
#[tokio::test]
async fn test_propfind() {}
async fn test_propfind() {
let propfind = PrincipalResource::parse_propfind(
r#"<?xml version="1.0" encoding="UTF-8"?><propfind xmlns="DAV:"><allprop/></propfind>"#,
)
.unwrap();
let principal = Principal {
id: "user".to_string(),
displayname: None,
principal_type: Individual,
password: None,
memberships: vec!["group".to_string()],
};
let resource = PrincipalResource {
principal: principal.clone(),
members: vec![],
simplified_home_set: false,
};
let response = resource
.propfind(
&format!("/caldav/principal/{}", principal.id),
&propfind.prop,
propfind.include.as_ref(),
&CalDavPrincipalUri("/caldav"),
&principal,
)
.unwrap();
let _output = response.serialize_to_string().unwrap();
}

View File

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

View File

@@ -22,7 +22,7 @@ pub struct MkcolAddressbookProp {
resourcetype: Option<Resourcetype>,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
displayname: Option<String>,
#[xml(rename = b"addressbook-description")]
#[xml(rename = "addressbook-description")]
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
description: Option<String>,
}
@@ -34,7 +34,7 @@ pub struct PropElement<T: XmlDeserialize> {
}
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug, PartialEq)]
#[xml(root = b"mkcol")]
#[xml(root = "mkcol")]
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
struct MkcolRequest {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]

View File

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

View File

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

View File

@@ -3,8 +3,8 @@ use super::methods::report::route_report_addressbook;
use crate::address_object::AddressObjectResourceService;
use crate::address_object::resource::AddressObjectResource;
use crate::addressbook::methods::get::route_get;
use crate::addressbook::methods::import::route_import;
use crate::addressbook::methods::post::route_post;
use crate::addressbook::methods::put::route_put;
use crate::addressbook::resource::AddressbookResource;
use crate::{CardDavPrincipalUri, Error};
use async_trait::async_trait;
@@ -139,9 +139,9 @@ impl<AS: AddressbookStore, S: SubscriptionStore> AxumMethods for AddressbookReso
})
}
fn put() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
fn import() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| {
let mut service = Handler::with_state(route_put::<AS, S>, state);
let mut service = Handler::with_state(route_import::<AS, S>, state);
Box::pin(Service::call(&mut service, req))
})
}

View File

@@ -8,14 +8,14 @@ use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
#[xml(unit_variants_ident = "PrincipalPropName")]
pub enum PrincipalProp {
// WebDAV Access Control (RFC 3744)
#[xml(rename = b"principal-URL")]
#[xml(rename = "principal-URL")]
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
PrincipalUrl(HrefElement),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
GroupMembership(GroupMembership),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
GroupMemberSet(GroupMemberSet),
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = b"alternate-URI-set")]
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = "alternate-URI-set")]
AlternateUriSet,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
PrincipalCollectionSet(HrefElement),

View File

@@ -1,9 +1,10 @@
use itertools::Itertools;
use quick_xml::name::Namespace;
use rustical_xml::{XmlDeserialize, XmlSerialize};
use std::collections::{HashMap, HashSet};
// https://datatracker.ietf.org/doc/html/rfc3744
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, Eq, Hash, PartialEq)]
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, Eq, Hash, PartialEq, PartialOrd, Ord)]
pub enum UserPrivilege {
Read,
Write,
@@ -19,18 +20,18 @@ impl XmlSerialize for UserPrivilegeSet {
fn serialize(
&self,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
tag: Option<&str>,
namespaces: &HashMap<Namespace, &str>,
writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> {
#[derive(XmlSerialize)]
pub struct FakeUserPrivilegeSet {
#[xml(rename = b"privilege", flatten)]
#[xml(rename = "privilege", flatten)]
privileges: Vec<UserPrivilege>,
}
FakeUserPrivilegeSet {
privileges: self.privileges.iter().cloned().collect(),
privileges: self.privileges.iter().cloned().sorted().collect(),
}
.serialize(ns, tag, namespaces, writer)
}

View File

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

View File

@@ -97,6 +97,11 @@ where
return svc(self.resource_service.clone(), req);
}
}
"IMPORT" => {
if let Some(svc) = RS::import() {
return svc(self.resource_service.clone(), req);
}
}
_ => {}
};
Box::pin(async move {

View File

@@ -60,12 +60,12 @@ pub async fn route_delete<R: ResourceService>(
return Err(crate::Error::PreconditionFailed.into());
}
}
if let Some(if_none_match) = if_none_match {
if resource.satisfies_if_none_match(&if_none_match) {
if let Some(if_none_match) = if_none_match
&& resource.satisfies_if_none_match(&if_none_match)
{
// Precondition failed
return Err(crate::Error::PreconditionFailed.into());
}
}
resource_service
.delete_resource(path_components, !no_trash)
.await?;

View File

@@ -6,11 +6,7 @@ use crate::resource::Resource;
use crate::resource::ResourceName;
use crate::resource::ResourceService;
use crate::xml::MultistatusElement;
use crate::xml::PropfindElement;
use crate::xml::PropfindType;
use axum::extract::{Extension, OriginalUri, Path, State};
use rustical_xml::PropName;
use rustical_xml::XmlDocument;
use tracing::instrument;
type RSMultistatus<R> = MultistatusElement<
@@ -58,24 +54,8 @@ pub(crate) async fn route_propfind<R: ResourceService>(
}
// A request body is optional. If empty we MUST return all props
let propfind_self: PropfindElement<<<R::Resource as Resource>::Prop as PropName>::Names> =
if !body.is_empty() {
PropfindElement::parse_str(body).map_err(Error::XmlError)?
} else {
PropfindElement {
prop: PropfindType::Allprop,
include: None,
}
};
let propfind_member: PropfindElement<<<R::MemberType as Resource>::Prop as PropName>::Names> =
if !body.is_empty() {
PropfindElement::parse_str(body).map_err(Error::XmlError)?
} else {
PropfindElement {
prop: PropfindType::Allprop,
include: None,
}
};
let propfind_self = R::Resource::parse_propfind(body).map_err(Error::XmlError)?;
let propfind_member = R::MemberType::parse_propfind(body).map_err(Error::XmlError)?;
let mut member_responses = Vec::new();
if depth != &Depth::Zero {

View File

@@ -57,7 +57,7 @@ enum Operation<T: XmlDeserialize> {
}
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug)]
#[xml(root = b"propertyupdate")]
#[xml(root = "propertyupdate")]
#[xml(ns = "crate::namespace::NS_DAV")]
struct PropertyupdateElement<T: XmlDeserialize>(#[xml(ty = "untagged", flatten)] Vec<Operation<T>>);

View File

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

View File

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

View File

@@ -22,8 +22,8 @@ pub struct PropstatElement<PropType: XmlSerialize> {
fn xml_serialize_status(
status: &StatusCode,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
tag: Option<&str>,
namespaces: &HashMap<Namespace, &str>,
writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> {
XmlSerialize::serialize(&format!("HTTP/1.1 {}", status), ns, tag, namespaces, writer)
@@ -39,8 +39,15 @@ pub enum PropstatWrapper<T: XmlSerialize> {
// RFC 2518
// <!ELEMENT response (href, ((href*, status)|(propstat+)),
// responsedescription?) >
#[derive(XmlSerialize)]
#[xml(ns = "crate::namespace::NS_DAV")]
#[derive(XmlSerialize, XmlRootTag)]
#[xml(ns = "crate::namespace::NS_DAV", root = "response")]
#[xml(ns_prefix(
crate::namespace::NS_DAV = "",
crate::namespace::NS_CARDDAV = "CARD",
crate::namespace::NS_CALDAV = "CAL",
crate::namespace::NS_CALENDARSERVER = "CS",
crate::namespace::NS_DAVPUSH = "PUSH"
))]
pub struct ResponseElement<PropstatType: XmlSerialize> {
pub href: String,
#[xml(serialize_with = "xml_serialize_optional_status")]
@@ -52,8 +59,8 @@ pub struct ResponseElement<PropstatType: XmlSerialize> {
fn xml_serialize_optional_status(
val: &Option<StatusCode>,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
tag: Option<&str>,
namespaces: &HashMap<Namespace, &str>,
writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> {
XmlSerialize::serialize(
@@ -79,18 +86,18 @@ impl<PT: XmlSerialize> Default for ResponseElement<PT> {
// <!ELEMENT multistatus (response+, responsedescription?) >
// Extended by sync-token as specified in RFC 6578
#[derive(XmlSerialize, XmlRootTag)]
#[xml(root = b"multistatus", ns = "crate::namespace::NS_DAV")]
#[xml(root = "multistatus", ns = "crate::namespace::NS_DAV")]
#[xml(ns_prefix(
crate::namespace::NS_DAV = b"",
crate::namespace::NS_CARDDAV = b"CARD",
crate::namespace::NS_CALDAV = b"CAL",
crate::namespace::NS_CALENDARSERVER = b"CS",
crate::namespace::NS_DAVPUSH = b"PUSH"
crate::namespace::NS_DAV = "",
crate::namespace::NS_CARDDAV = "CARD",
crate::namespace::NS_CALDAV = "CAL",
crate::namespace::NS_CALENDARSERVER = "CS",
crate::namespace::NS_DAVPUSH = "PUSH"
))]
pub struct MultistatusElement<PropType: XmlSerialize, MemberPropType: XmlSerialize> {
#[xml(rename = b"response", flatten)]
#[xml(rename = "response", flatten)]
pub responses: Vec<ResponseElement<PropType>>,
#[xml(rename = b"response", flatten)]
#[xml(rename = "response", flatten)]
pub member_responses: Vec<ResponseElement<MemberPropType>>,
pub sync_token: Option<String>,
}

View File

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

View File

@@ -16,7 +16,7 @@ mod tests {
use super::{Resourcetype, ResourcetypeInner};
#[derive(XmlSerialize, XmlRootTag)]
#[xml(root = b"document")]
#[xml(root = "document")]
struct Document {
resourcetype: Resourcetype,
}

View File

@@ -60,7 +60,7 @@ pub struct NresultsElement(#[xml(ty = "text")] u64);
// <!ELEMENT sync-collection (sync-token, sync-level, limit?, prop)>
// <!-- DAV:limit defined in RFC 5323, Section 5.17 -->
// <!-- DAV:prop defined in RFC 4918, Section 14.18 -->
#[xml(ns = "crate::namespace::NS_DAV", root = b"sync-collection")]
#[xml(ns = "crate::namespace::NS_DAV", root = "sync-collection")]
pub struct SyncCollectionRequest<PN: XmlDeserialize> {
#[xml(ns = "crate::namespace::NS_DAV")]
pub sync_token: String,

View File

@@ -13,8 +13,8 @@ impl XmlSerialize for TagList {
fn serialize(
&self,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
tag: Option<&str>,
namespaces: &HashMap<Namespace, &str>,
writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> {
let prefix = ns
@@ -22,24 +22,19 @@ impl XmlSerialize for TagList {
.unwrap_or(None)
.map(|prefix| {
if !prefix.is_empty() {
[*prefix, b":"].concat()
format!("{prefix}:")
} else {
Vec::new()
String::new()
}
});
let has_prefix = prefix.is_some();
let tagname = tag.map(|tag| [&prefix.unwrap_or_default(), tag].concat());
let qname = tagname
.as_ref()
.map(|tagname| ::quick_xml::name::QName(tagname));
if let Some(qname) = &qname {
let mut bytes_start = BytesStart::from(qname.to_owned());
if !has_prefix {
if let Some(ns) = &ns {
if let Some(tagname) = tagname.as_ref() {
let mut bytes_start = BytesStart::new(tagname);
if !has_prefix && let Some(ns) = &ns {
bytes_start.push_attribute((b"xmlns".as_ref(), ns.as_ref()));
}
}
writer.write_event(Event::Start(bytes_start))?;
}
@@ -51,8 +46,8 @@ impl XmlSerialize for TagList {
el.write_empty()?;
}
if let Some(qname) = &qname {
writer.write_event(Event::End(BytesEnd::from(qname.to_owned())))?;
if let Some(tagname) = tagname.as_ref() {
writer.write_event(Event::End(BytesEnd::new(tagname)))?;
}
Ok(())
}

View File

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

View File

@@ -25,10 +25,10 @@ pub struct ContentUpdate {
}
#[derive(XmlSerialize, XmlRootTag, Debug)]
#[xml(root = b"push-message", ns = "rustical_dav::namespace::NS_DAVPUSH")]
#[xml(root = "push-message", ns = "rustical_dav::namespace::NS_DAVPUSH")]
#[xml(ns_prefix(
rustical_dav::namespace::NS_DAVPUSH = b"",
rustical_dav::namespace::NS_DAV = b"D",
rustical_dav::namespace::NS_DAVPUSH = "",
rustical_dav::namespace::NS_DAV = "D",
))]
struct PushMessage {
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
@@ -183,6 +183,7 @@ impl<S: SubscriptionStore> DavPushController<S> {
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
);
hdrs.insert("TTL", HeaderValue::from(60));
client.execute(request).await?;
Ok(())

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { createClient } from "webdav";
import { escapeXml } from ".";
@customElement("create-addressbook-form")
@@ -15,14 +14,12 @@ export class CreateAddressbookForm extends LitElement {
return this
}
client = createClient("/carddav")
@property()
user: string = ''
@property()
principal: string = ''
@property()
addr_id: string = ''
addr_id: string = self.crypto.randomUUID()
@property()
displayname: string = ''
@property()
@@ -49,7 +46,7 @@ export class CreateAddressbookForm extends LitElement {
<br>
<label>
id
<input type="text" name="id" @change=${e => this.addr_id = e.target.value} />
<input type="text" name="id" value=${this.addr_id} @change=${e => this.addr_id = e.target.value} />
</label>
<br>
<label>
@@ -80,8 +77,12 @@ export class CreateAddressbookForm extends LitElement {
alert("Empty displayname")
return
}
await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.addr_id}`, {
data: `
let response = await fetch(`/carddav/principal/${this.principal || this.user}/${this.addr_id}`, {
method: 'MKCOL',
headers: {
'Content-Type': 'application/xml'
},
body: `
<mkcol xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
<set>
<prop>
@@ -91,7 +92,14 @@ export class CreateAddressbookForm extends LitElement {
</set>
</mkcol>
`
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}

View File

@@ -1,7 +1,6 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { createClient } from "webdav";
import { escapeXml } from ".";
@customElement("create-calendar-form")
@@ -14,19 +13,19 @@ export class CreateCalendarForm extends LitElement {
return this
}
client = createClient("/caldav")
@property()
user: string = ''
@property()
principal: string = ''
@property()
cal_id: string = ''
cal_id: string = self.crypto.randomUUID()
@property()
displayname: string = ''
@property()
description: string = ''
@property()
timezone_id: string = ''
@property()
color: string = ''
@property()
isSubscription: boolean = false
@@ -38,7 +37,6 @@ export class CreateCalendarForm extends LitElement {
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
override render() {
return html`
<button @click=${() => this.dialog.value.showModal()}>Create calendar</button>
@@ -57,7 +55,7 @@ export class CreateCalendarForm extends LitElement {
<br>
<label>
id
<input type="text" name="id" @change=${e => this.cal_id = e.target.value} />
<input type="text" name="id" value=${this.cal_id} @change=${e => this.cal_id = e.target.value} />
</label>
<br>
<label>
@@ -65,6 +63,11 @@ export class CreateCalendarForm extends LitElement {
<input type="text" name="displayname" value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label>
<br>
<label>
Timezone (optional)
<input type="text" name="timezone" .value=${this.timezone_id} @change=${e => this.timezone_id = e.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" @change=${e => this.description = e.target.value} />
@@ -119,12 +122,18 @@ export class CreateCalendarForm extends LitElement {
alert("No calendar components selected")
return
}
await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.cal_id}`, {
data: `
let response = await fetch(`/caldav/principal/${this.principal || this.user}/${this.cal_id}`, {
method: 'MKCOL',
headers: {
'Content-Type': 'application/xml'
},
body: `
<mkcol xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<set>
<prop>
<displayname>${escapeXml(this.displayname)}</displayname>
${this.timezone_id ? `<CAL:calendar-timezone-id>${escapeXml(this.timezone_id)}</CAL:calendar-timezone-id>` : ''}
${this.description ? `<CAL:calendar-description>${escapeXml(this.description)}</CAL:calendar-description>` : ''}
${this.color ? `<ICAL:calendar-color>${escapeXml(this.color)}</ICAL:calendar-color>` : ''}
${(this.isSubscription && this.subscriptionUrl) ? `<CS:source><href>${escapeXml(this.subscriptionUrl)}</href></CS:source>` : ''}
@@ -136,6 +145,11 @@ export class CreateCalendarForm extends LitElement {
</mkcol>
`
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}

View File

@@ -17,7 +17,7 @@ export class DeleteButton extends LitElement {
}
protected render() {
let text = this.trash ? 'Move to trash' : 'Delete'
let text = this.trash ? 'Trash' : 'Delete'
return html`<button class="delete" @click=${e => this._onClick(e)}>${text}</button>`
}

View File

@@ -28,9 +28,9 @@ export class EditAddressbookForm extends LitElement {
override render() {
return html`
<button @click=${() => this.dialog.value.showModal()}>Edit addressbook</button>
<button @click=${() => this.dialog.value.showModal()}>Edit</button>
<dialog ${ref(this.dialog)}>
<h3>Create addressbook</h3>
<h3>Edit addressbook</h3>
<form @submit=${this.submit} ${ref(this.form)}>
<label>
Displayname
@@ -63,7 +63,7 @@ export class EditAddressbookForm extends LitElement {
alert("Empty displayname")
return
}
await fetch(`/carddav/principal/${this.principal}/${this.addr_id}`, {
let response = await fetch(`/carddav/principal/${this.principal}/${this.addr_id}`, {
method: 'PROPPATCH',
headers: {
'Content-Type': 'application/xml'
@@ -85,6 +85,12 @@ export class EditAddressbookForm extends LitElement {
`
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}

View File

@@ -23,6 +23,8 @@ export class EditCalendarForm extends LitElement {
@property()
description: string = ''
@property()
timezone_id: string = ''
@property()
color: string = ''
@property({
converter: {
@@ -38,15 +40,20 @@ export class EditCalendarForm extends LitElement {
override render() {
return html`
<button @click=${() => this.dialog.value.showModal()}>Edit calendar</button>
<button @click=${() => this.dialog.value.showModal()}>Edit</button>
<dialog ${ref(this.dialog)}>
<h3>Create calendar</h3>
<h3>Edit calendar</h3>
<form @submit=${this.submit} ${ref(this.form)}>
<label>
Displayname
<input type="text" name="displayname" .value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label>
<br>
<label>
Timezone (optional)
<input type="text" name="timezone" .value=${this.timezone_id} @change=${e => this.timezone_id = e.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" .value=${this.description} @change=${e => this.description = e.target.value} />
@@ -90,7 +97,7 @@ export class EditCalendarForm extends LitElement {
alert("No calendar components selected")
return
}
await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
let response = await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
method: 'PROPPATCH',
headers: {
'Content-Type': 'application/xml'
@@ -100,6 +107,7 @@ export class EditCalendarForm extends LitElement {
<set>
<prop>
<displayname>${escapeXml(this.displayname)}</displayname>
${this.timezone_id ? `<CAL:calendar-timezone-id>${escapeXml(this.timezone_id)}</CAL:calendar-timezone-id>` : ''}
${this.description ? `<CAL:calendar-description>${escapeXml(this.description)}</CAL:calendar-description>` : ''}
${this.color ? `<ICAL:calendar-color>${escapeXml(this.color)}</ICAL:calendar-color>` : ''}
<CAL:supported-calendar-component-set>
@@ -109,6 +117,7 @@ export class EditCalendarForm extends LitElement {
</set>
<remove>
<prop>
${!this.timezone_id ? `<CAL:calendar-timezone-id />` : ''}
${!this.description ? '<CAL:calendar-description />' : ''}
${!this.color ? '<ICAL:calendar-color />' : ''}
</prop>
@@ -116,6 +125,12 @@ export class EditCalendarForm extends LitElement {
</propertyupdate>
`
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}

View File

@@ -0,0 +1,92 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
@customElement("import-addressbook-form")
export class ImportAddressbookForm extends LitElement {
constructor() {
super()
}
protected override createRenderRoot() {
return this
}
@property()
user: string = ''
@property()
principal: string
@property()
addressbook_id: string = self.crypto.randomUUID()
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
file: File;
override render() {
return html`
<button @click=${() => this.dialog.value.showModal()}>Import addressbook</button>
<dialog ${ref(this.dialog)}>
<h3>Import addressbook</h3>
<form @submit=${this.submit} ${ref(this.form)}>
<label>
principal (for group addressbook)
<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>
id
<input type="text" name="id" value=${this.addressbook_id} @change=${e => this.addressbook_id = e.target.value} />
</label>
<br>
<label>
file
<input type="file" accept="text/vcard" name="file" @change=${e => this.file = e.target.files[0]} />
</label>
<button type="submit">Import</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()
this.principal ||= this.user
if (!this.principal) {
alert("Empty principal")
return
}
if (!this.addressbook_id) {
alert("Empty id")
return
}
let response = await fetch(`/carddav/principal/${this.principal}/${this.addressbook_id}`, {
method: 'IMPORT',
headers: {
'Content-Type': 'text/vcard'
},
body: this.file,
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}
}
declare global {
interface HTMLElementTagNameMap {
'import-addressbook-form': ImportAddressbookForm
}
}

View File

@@ -0,0 +1,92 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
@customElement("import-calendar-form")
export class ImportCalendarForm extends LitElement {
constructor() {
super()
}
protected override createRenderRoot() {
return this
}
@property()
user: string = ''
@property()
principal: string
@property()
cal_id: string = self.crypto.randomUUID()
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
file: File;
override render() {
return html`
<button @click=${() => this.dialog.value.showModal()}>Import calendar</button>
<dialog ${ref(this.dialog)}>
<h3>Import calendar</h3>
<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>
id
<input type="text" name="id" value=${this.cal_id} @change=${e => this.cal_id = e.target.value} />
</label>
<br>
<label>
file
<input type="file" accept="text/calendar" name="file" @change=${e => this.file = e.target.files[0]} />
</label>
<button type="submit">Import</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()
this.principal ||= this.user
if (!this.principal) {
alert("Empty principal")
return
}
if (!this.cal_id) {
alert("Empty id")
return
}
let response = await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
method: 'IMPORT',
headers: {
'Content-Type': 'text/calendar'
},
body: this.file,
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}
}
declare global {
interface HTMLElementTagNameMap {
'import-calendar-form': ImportCalendarForm
}
}

View File

@@ -16,8 +16,10 @@ export default defineConfig({
input: [
"lib/create-calendar-form.ts",
"lib/edit-calendar-form.ts",
"lib/import-calendar-form.ts",
"lib/create-addressbook-form.ts",
"lib/edit-addressbook-form.ts",
"lib/import-addressbook-form.ts",
"lib/delete-button.ts",
],
output: {
@@ -25,7 +27,7 @@ export default defineConfig({
format: "es",
manualChunks: {
lit: ["lit"],
webdav: ["webdav"],
// webdav: ["webdav"],
}
}
},

View File

@@ -1,7 +1,7 @@
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";
import { a as an } from "./webdav-D0R7xCzX.mjs";
import { e, n } from "./ref-CPp9J0V5.mjs";
import { e as escapeXml } from "./index-_IB1wMbZ.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
@@ -15,10 +15,9 @@ var __decorateClass = (decorators, target, key, kind) => {
let CreateAddressbookForm = class extends i {
constructor() {
super();
this.client = an("/carddav");
this.user = "";
this.principal = "";
this.addr_id = "";
this.addr_id = self.crypto.randomUUID();
this.displayname = "";
this.description = "";
this.dialog = e();
@@ -45,7 +44,7 @@ let CreateAddressbookForm = class extends i {
<br>
<label>
id
<input type="text" name="id" @change=${(e2) => this.addr_id = e2.target.value} />
<input type="text" name="id" value=${this.addr_id} @change=${(e2) => this.addr_id = e2.target.value} />
</label>
<br>
<label>
@@ -79,8 +78,12 @@ let CreateAddressbookForm = class extends i {
alert("Empty displayname");
return;
}
await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.addr_id}`, {
data: `
let response = await fetch(`/carddav/principal/${this.principal || this.user}/${this.addr_id}`, {
method: "MKCOL",
headers: {
"Content-Type": "application/xml"
},
body: `
<mkcol xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
<set>
<prop>
@@ -91,6 +94,10 @@ let CreateAddressbookForm = class extends i {
</mkcol>
`
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
window.location.reload();
return null;
}

View File

@@ -1,7 +1,7 @@
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";
import { a as an } from "./webdav-D0R7xCzX.mjs";
import { e, n } from "./ref-CPp9J0V5.mjs";
import { e as escapeXml } from "./index-_IB1wMbZ.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
@@ -15,12 +15,12 @@ var __decorateClass = (decorators, target, key, kind) => {
let CreateCalendarForm = class extends i {
constructor() {
super();
this.client = an("/caldav");
this.user = "";
this.principal = "";
this.cal_id = "";
this.cal_id = self.crypto.randomUUID();
this.displayname = "";
this.description = "";
this.timezone_id = "";
this.color = "";
this.isSubscription = false;
this.subscriptionUrl = "";
@@ -49,7 +49,7 @@ let CreateCalendarForm = class extends i {
<br>
<label>
id
<input type="text" name="id" @change=${(e2) => this.cal_id = e2.target.value} />
<input type="text" name="id" value=${this.cal_id} @change=${(e2) => this.cal_id = e2.target.value} />
</label>
<br>
<label>
@@ -57,6 +57,11 @@ let CreateCalendarForm = class extends i {
<input type="text" name="displayname" value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} />
</label>
<br>
<label>
Timezone (optional)
<input type="text" name="timezone" .value=${this.timezone_id} @change=${(e2) => this.timezone_id = e2.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" @change=${(e2) => this.description = e2.target.value} />
@@ -114,12 +119,17 @@ let CreateCalendarForm = class extends i {
alert("No calendar components selected");
return;
}
await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.cal_id}`, {
data: `
let response = await fetch(`/caldav/principal/${this.principal || this.user}/${this.cal_id}`, {
method: "MKCOL",
headers: {
"Content-Type": "application/xml"
},
body: `
<mkcol xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<set>
<prop>
<displayname>${escapeXml(this.displayname)}</displayname>
${this.timezone_id ? `<CAL:calendar-timezone-id>${escapeXml(this.timezone_id)}</CAL:calendar-timezone-id>` : ""}
${this.description ? `<CAL:calendar-description>${escapeXml(this.description)}</CAL:calendar-description>` : ""}
${this.color ? `<ICAL:calendar-color>${escapeXml(this.color)}</ICAL:calendar-color>` : ""}
${this.isSubscription && this.subscriptionUrl ? `<CS:source><href>${escapeXml(this.subscriptionUrl)}</href></CS:source>` : ""}
@@ -131,6 +141,10 @@ let CreateCalendarForm = class extends i {
</mkcol>
`
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
window.location.reload();
return null;
}
@@ -150,6 +164,9 @@ __decorateClass([
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "description", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "timezone_id", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "color", 2);

View File

@@ -19,7 +19,7 @@ let DeleteButton = class extends i {
return this;
}
render() {
let text = this.trash ? "Move to trash" : "Delete";
let text = this.trash ? "Trash" : "Delete";
return x`<button class="delete" @click=${(e) => this._onClick(e)}>${text}</button>`;
}
async _onClick(event) {

View File

@@ -1,6 +1,7 @@
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";
import { e, n } from "./ref-CPp9J0V5.mjs";
import { e as escapeXml } from "./index-_IB1wMbZ.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
@@ -26,9 +27,9 @@ let EditAddressbookForm = class extends i {
}
render() {
return x`
<button @click=${() => this.dialog.value.showModal()}>Edit addressbook</button>
<button @click=${() => this.dialog.value.showModal()}>Edit</button>
<dialog ${n(this.dialog)}>
<h3>Create addressbook</h3>
<h3>Edit addressbook</h3>
<form @submit=${this.submit} ${n(this.form)}>
<label>
Displayname
@@ -64,7 +65,7 @@ let EditAddressbookForm = class extends i {
alert("Empty displayname");
return;
}
await fetch(`/carddav/principal/${this.principal}/${this.addr_id}`, {
let response = await fetch(`/carddav/principal/${this.principal}/${this.addr_id}`, {
method: "PROPPATCH",
headers: {
"Content-Type": "application/xml"
@@ -85,6 +86,10 @@ let EditAddressbookForm = class extends i {
</propertyupdate>
`
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
window.location.reload();
return null;
}

View File

@@ -1,6 +1,7 @@
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";
import { e, n } from "./ref-CPp9J0V5.mjs";
import { e as escapeXml } from "./index-_IB1wMbZ.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
@@ -16,6 +17,7 @@ let EditCalendarForm = class extends i {
super();
this.displayname = "";
this.description = "";
this.timezone_id = "";
this.color = "";
this.components = /* @__PURE__ */ new Set();
this.dialog = e();
@@ -26,15 +28,20 @@ let EditCalendarForm = class extends i {
}
render() {
return x`
<button @click=${() => this.dialog.value.showModal()}>Edit calendar</button>
<button @click=${() => this.dialog.value.showModal()}>Edit</button>
<dialog ${n(this.dialog)}>
<h3>Create calendar</h3>
<h3>Edit 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>
Timezone (optional)
<input type="text" name="timezone" .value=${this.timezone_id} @change=${(e2) => this.timezone_id = e2.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" .value=${this.description} @change=${(e2) => this.description = e2.target.value} />
@@ -81,7 +88,7 @@ let EditCalendarForm = class extends i {
alert("No calendar components selected");
return;
}
await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
let response = await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
method: "PROPPATCH",
headers: {
"Content-Type": "application/xml"
@@ -91,6 +98,7 @@ let EditCalendarForm = class extends i {
<set>
<prop>
<displayname>${escapeXml(this.displayname)}</displayname>
${this.timezone_id ? `<CAL:calendar-timezone-id>${escapeXml(this.timezone_id)}</CAL:calendar-timezone-id>` : ""}
${this.description ? `<CAL:calendar-description>${escapeXml(this.description)}</CAL:calendar-description>` : ""}
${this.color ? `<ICAL:calendar-color>${escapeXml(this.color)}</ICAL:calendar-color>` : ""}
<CAL:supported-calendar-component-set>
@@ -100,6 +108,7 @@ let EditCalendarForm = class extends i {
</set>
<remove>
<prop>
${!this.timezone_id ? `<CAL:calendar-timezone-id />` : ""}
${!this.description ? "<CAL:calendar-description />" : ""}
${!this.color ? "<ICAL:calendar-color />" : ""}
</prop>
@@ -107,6 +116,10 @@ let EditCalendarForm = class extends i {
</propertyupdate>
`
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
window.location.reload();
return null;
}
@@ -123,6 +136,9 @@ __decorateClass([
__decorateClass([
n$1()
], EditCalendarForm.prototype, "description", 2);
__decorateClass([
n$1()
], EditCalendarForm.prototype, "timezone_id", 2);
__decorateClass([
n$1()
], EditCalendarForm.prototype, "color", 2);

View File

@@ -0,0 +1,100 @@
import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n } from "./ref-CPp9J0V5.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 ImportAddressbookForm = class extends i {
constructor() {
super();
this.user = "";
this.addressbook_id = self.crypto.randomUUID();
this.dialog = e();
this.form = e();
}
createRenderRoot() {
return this;
}
render() {
return x`
<button @click=${() => this.dialog.value.showModal()}>Import addressbook</button>
<dialog ${n(this.dialog)}>
<h3>Import addressbook</h3>
<form @submit=${this.submit} ${n(this.form)}>
<label>
principal (for group addressbook)
<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>
id
<input type="text" name="id" value=${this.addressbook_id} @change=${(e2) => this.addressbook_id = e2.target.value} />
</label>
<br>
<label>
file
<input type="file" accept="text/vcard" name="file" @change=${(e2) => this.file = e2.target.files[0]} />
</label>
<button type="submit">Import</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();
this.principal || (this.principal = this.user);
if (!this.principal) {
alert("Empty principal");
return;
}
if (!this.addressbook_id) {
alert("Empty id");
return;
}
let response = await fetch(`/carddav/principal/${this.principal}/${this.addressbook_id}`, {
method: "IMPORT",
headers: {
"Content-Type": "text/vcard"
},
body: this.file
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
window.location.reload();
return null;
}
};
__decorateClass([
n$1()
], ImportAddressbookForm.prototype, "user", 2);
__decorateClass([
n$1()
], ImportAddressbookForm.prototype, "principal", 2);
__decorateClass([
n$1()
], ImportAddressbookForm.prototype, "addressbook_id", 2);
ImportAddressbookForm = __decorateClass([
t("import-addressbook-form")
], ImportAddressbookForm);
export {
ImportAddressbookForm
};

View File

@@ -0,0 +1,100 @@
import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n } from "./ref-CPp9J0V5.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 ImportCalendarForm = class extends i {
constructor() {
super();
this.user = "";
this.cal_id = self.crypto.randomUUID();
this.dialog = e();
this.form = e();
}
createRenderRoot() {
return this;
}
render() {
return x`
<button @click=${() => this.dialog.value.showModal()}>Import calendar</button>
<dialog ${n(this.dialog)}>
<h3>Import calendar</h3>
<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>
id
<input type="text" name="id" value=${this.cal_id} @change=${(e2) => this.cal_id = e2.target.value} />
</label>
<br>
<label>
file
<input type="file" accept="text/calendar" name="file" @change=${(e2) => this.file = e2.target.files[0]} />
</label>
<button type="submit">Import</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();
this.principal || (this.principal = this.user);
if (!this.principal) {
alert("Empty principal");
return;
}
if (!this.cal_id) {
alert("Empty id");
return;
}
let response = await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
method: "IMPORT",
headers: {
"Content-Type": "text/calendar"
},
body: this.file
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
window.location.reload();
return null;
}
};
__decorateClass([
n$1()
], ImportCalendarForm.prototype, "user", 2);
__decorateClass([
n$1()
], ImportCalendarForm.prototype, "principal", 2);
__decorateClass([
n$1()
], ImportCalendarForm.prototype, "cal_id", 2);
ImportCalendarForm = __decorateClass([
t("import-calendar-form")
], ImportCalendarForm);
export {
ImportCalendarForm
};

View File

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

View File

@@ -122,11 +122,7 @@ const o = /* @__PURE__ */ new WeakMap(), n = e$1(class extends f {
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 {
escapeXml as a,
e,
n
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -290,6 +290,7 @@ ul.collection-list {
.color-chip {
background: var(--color);
grid-area: color-chip;
margin-left: 8px;
}
.actions {
@@ -317,6 +318,10 @@ dialog {
padding: 32px;
}
dialog::backdrop {
background: color-mix(in srgb, var(--background-color), transparent 50%);
}
footer {
display: flex;
justify-content: center;
@@ -342,6 +347,17 @@ select {
}
}
form {
input[type="text"],
input[type="password"],
input[type="color"],
textarea,
select {
width: 100%;
}
}
svg.icon {
stroke-width: 2px;
color: var(--text-on-background-color);

View File

@@ -65,4 +65,5 @@
{% endif %}
<create-addressbook-form user="{{ user.id }}"></create-addressbook-form>
<import-addressbook-form user="{{ user.id }}"></import-addressbook-form>

View File

@@ -1,13 +1,13 @@
<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()) %}
{% let color = calendar.meta.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()) }}
{{ calendar.meta.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
<div class="comps">
{% for comp in calendar.components %}
<span>{{ comp }}</span>
@@ -15,7 +15,7 @@
</div>
</span>
<span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
{% if let Some(description) = calendar.meta.description %}{{ description }}{% endif %}
</span>
{% if let Some(subscription_url) = calendar.subscription_url %}
<span class="subscription-url">{{ subscription_url }}</span>
@@ -28,9 +28,10 @@
<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() }}"
timezone_id="{{ calendar.timezone_id.as_deref().unwrap_or_default() }}"
displayname="{{ calendar.meta.displayname.as_deref().unwrap_or_default() }}"
description="{{ calendar.meta.description.as_deref().unwrap_or_default() }}"
color="{{ calendar.meta.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>
@@ -50,13 +51,13 @@
<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()) %}
{% let color = calendar.meta.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()) }}
{{ calendar.meta.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
<div class="comps">
{% for comp in calendar.components %}
<span>{{ comp }}</span>
@@ -64,7 +65,7 @@
</div>
</span>
<span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
{% if let Some(description) = calendar.meta.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}/restore" method="POST"
@@ -83,4 +84,5 @@
</ul>
{% endif %}
<create-calendar-form user="{{ user.id }}"></create-calendar-form>
<import-calendar-form user="{{ user.id }}"></import-calendar-form>

View File

@@ -4,9 +4,9 @@
{% endblock %}
{% block content %}
{% let name = calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) %}
{% let name = calendar.meta.displayname.to_owned().unwrap_or(calendar.id.to_owned()) %}
<h1>{{ calendar.principal }}/{{ name }}</h1>
{% if let Some(description) = calendar.description %}<p>{{ description }}</p>{% endif%}
{% if let Some(description) = calendar.meta.description %}<p>{{ description }}</p>{% endif%}
{% if let Some(subscription_url) = calendar.subscription_url %}
<h2>Subscription URL</h2>
@@ -25,9 +25,6 @@
{% if let Some(timezone_id) = calendar.timezone_id %}
<p>{{ timezone_id }}</p>
{% endif %}
{% if let Some(timezone) = calendar.timezone %}
<textarea rows="16" readonly>{{ timezone }}</textarea>
{% endif %}
<pre>{{ calendar|json }}</pre>

View File

@@ -7,8 +7,10 @@ window.rusticalUser = JSON.parse(document.querySelector('#data-rustical-user').i
</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/import-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/edit-addressbook-form.mjs" async></script>
<script type="module" src="/frontend/assets/js/import-addressbook-form.mjs" async></script>
<script type="module" src="/frontend/assets/js/delete-button.mjs" async></script>
{% endblock %}
{% block header_center %}

View File

@@ -13,6 +13,7 @@ use tower::Service;
#[derive(Clone, RustEmbed, Default)]
#[folder = "public/assets"]
#[allow(dead_code)] // Since this is not used with the frontend-dev feature
pub struct Assets;
#[derive(Clone, Default)]

View File

@@ -45,38 +45,38 @@ pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: Addres
frontend_config: FrontendConfig,
oidc_config: Option<OidcConfig>,
) -> Router {
let mut router = Router::new();
router = router
.route("/", get(route_root))
.route("/user", get(route_get_home))
.route("/user/{user}", get(route_user_named::<CS, AS, AP>))
let user_router = Router::new()
.route("/", get(route_get_home))
.route("/{user}", get(route_user_named::<CS, AS, AP>))
// App token management
.route("/user/{user}/app_token", post(route_post_app_token::<AP>))
.route("/{user}/app_token", post(route_post_app_token::<AP>))
.route(
// POST because HTML5 forms don't support DELETE method
"/user/{user}/app_token/{id}/delete",
"/{user}/app_token/{id}/delete",
post(route_delete_app_token::<AP>),
)
// Calendar
.route("/user/{user}/calendar", get(route_calendars::<CS>))
.route("/{user}/calendar", get(route_calendars::<CS>))
.route("/{user}/calendar/{calendar}", get(route_calendar::<CS>))
.route(
"/user/{user}/calendar/{calendar}",
get(route_calendar::<CS>),
)
.route(
"/user/{user}/calendar/{calendar}/restore",
"/{user}/calendar/{calendar}/restore",
post(route_calendar_restore::<CS>),
)
// Addressbook
.route("/user/{user}/addressbook", get(route_addressbooks::<AS>))
.route("/{user}/addressbook", get(route_addressbooks::<AS>))
.route(
"/user/{user}/addressbook/{addressbook}",
"/{user}/addressbook/{addressbook}",
get(route_addressbook::<AS>),
)
.route(
"/user/{user}/addressbook/{addressbook}/restore",
"/{user}/addressbook/{addressbook}/restore",
post(route_addressbook_restore::<AS>),
)
.layer(middleware::from_fn(unauthorized_handler));
let router = Router::new()
.route("/", get(route_root))
.nest("/user", user_router)
.route("/login", get(route_get_login).post(route_post_login::<AP>))
.route("/logout", post(route_post_logout));
@@ -109,8 +109,7 @@ pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: Addres
.layer(Extension(cal_store.clone()))
.layer(Extension(addr_store.clone()))
.layer(Extension(frontend_config.clone()))
.layer(Extension(oidc_config.clone()))
.layer(middleware::from_fn(unauthorized_handler));
.layer(Extension(oidc_config.clone()));
Router::new()
.nest(prefix, router)

View File

@@ -56,9 +56,13 @@ pub async fn route_post_app_token<AP: AuthenticationProvider>(
assert!(!name.is_empty());
assert_eq!(user_id, user.id);
let token = generate_app_token();
auth_provider
let mut token_id = auth_provider
.add_app_token(&user.id, name.to_owned(), token.clone())
.await?;
// Get first 4 characters of token identifier
token_id.truncate(4);
// This will be a hint for the token validator which app token hash to verify against
let token = format!("{token_id}_{token}");
if apple {
let profile = AppleConfig {
token_name: name,

View File

@@ -13,7 +13,7 @@ use http::StatusCode;
use rustical_store::auth::AuthenticationProvider;
use serde::Deserialize;
use tower_sessions::Session;
use tracing::instrument;
use tracing::{instrument, warn};
use url::Url;
#[derive(Template, WebTemplate)]
@@ -98,6 +98,7 @@ pub async fn route_post_login<AP: AuthenticationProvider>(
session.insert("user", user.id).await.unwrap();
Redirect::to(&redirect_uri).into_response()
} else {
warn!("Failed password login attempt as {username}");
StatusCode::UNAUTHORIZED.into_response()
}
}

View File

@@ -20,19 +20,21 @@ impl TryFrom<VcardContact> for AddressObject {
type Error = Error;
fn try_from(vcard: VcardContact) -> Result<Self, Self::Error> {
let id = vcard
.get_property("UID")
.ok_or(Error::InvalidData("Missing UID".to_owned()))?
.value
.clone()
.ok_or(Error::InvalidData("Missing UID".to_owned()))?;
let uid = vcard
.get_uid()
.ok_or(Error::InvalidData("missing UID".to_owned()))?
.to_owned();
let vcf = vcard.generate();
Ok(Self { id, vcf, vcard })
Ok(Self {
vcf,
vcard,
id: uid,
})
}
}
impl AddressObject {
pub fn from_vcf(object_id: String, vcf: String) -> Result<Self, Error> {
pub fn from_vcf(id: String, vcf: String) -> Result<Self, Error> {
let mut parser = vcard::VcardParser::new(BufReader::new(vcf.as_bytes()));
let vcard = parser.next().ok_or(Error::MissingContact)??;
if parser.next().is_some() {
@@ -40,11 +42,7 @@ impl AddressObject {
"multiple vcards, only one allowed".to_owned(),
));
}
Ok(Self {
id: object_id,
vcf,
vcard,
})
Ok(Self { id, vcf, vcard })
}
pub fn get_id(&self) -> &str {
@@ -53,7 +51,7 @@ impl AddressObject {
pub fn get_etag(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(&self.id);
hasher.update(self.get_id());
hasher.update(self.get_vcf());
format!("\"{:x}\"", hasher.finalize())
}
@@ -95,9 +93,7 @@ impl AddressObject {
let uid = format!("{}-anniversary", self.get_id());
let year_suffix = year.map(|year| format!(" ({year})")).unwrap_or_default();
Some(CalendarObject::from_ics(
uid.clone(),
format!(
Some(CalendarObject::from_ics(format!(
r#"BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
@@ -116,8 +112,7 @@ DESCRIPTION:💍 {fullname}{year_suffix}
END:VALARM
END:VEVENT
END:VCALENDAR"#,
),
)?)
))?)
} else {
None
},
@@ -139,9 +134,7 @@ END:VCALENDAR"#,
let uid = format!("{}-birthday", self.get_id());
let year_suffix = year.map(|year| format!(" ({year})")).unwrap_or_default();
Some(CalendarObject::from_ics(
uid.clone(),
format!(
Some(CalendarObject::from_ics(format!(
r#"BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
@@ -160,8 +153,7 @@ DESCRIPTION:🎂 {fullname}{year_suffix}
END:VALARM
END:VEVENT
END:VCALENDAR"#,
),
)?)
))?)
} else {
None
},

View File

@@ -1,53 +0,0 @@
use crate::CalDateTimeError;
use chrono::Duration;
use lazy_static::lazy_static;
lazy_static! {
static ref RE_DURATION: regex::Regex = regex::Regex::new(r"^(?<sign>[+-])?P((?P<W>\d+)W)?((?P<D>\d+)D)?(T((?P<H>\d+)H)?((?P<M>\d+)M)?((?P<S>\d+)S)?)?$").unwrap();
}
pub fn parse_duration(string: &str) -> Result<Duration, CalDateTimeError> {
let captures = RE_DURATION
.captures(string)
.ok_or(CalDateTimeError::InvalidDurationFormat(string.to_string()))?;
let mut duration = Duration::zero();
if let Some(weeks) = captures.name("W") {
duration += Duration::weeks(weeks.as_str().parse().unwrap());
}
if let Some(days) = captures.name("D") {
duration += Duration::days(days.as_str().parse().unwrap());
}
if let Some(hours) = captures.name("H") {
duration += Duration::hours(hours.as_str().parse().unwrap());
}
if let Some(minutes) = captures.name("M") {
duration += Duration::minutes(minutes.as_str().parse().unwrap());
}
if let Some(seconds) = captures.name("S") {
duration += Duration::seconds(seconds.as_str().parse().unwrap());
}
if let Some(sign) = captures.name("sign") {
if sign.as_str() == "-" {
duration = -duration;
}
}
Ok(duration)
}
#[cfg(test)]
mod tests {
use chrono::Duration;
use crate::parse_duration;
#[test]
fn test_parse_duration() {
assert_eq!(parse_duration("P12W").unwrap(), Duration::weeks(12));
assert_eq!(parse_duration("P12D").unwrap(), Duration::days(12));
assert_eq!(parse_duration("PT12H").unwrap(), Duration::hours(12));
assert_eq!(parse_duration("PT12M").unwrap(), Duration::minutes(12));
assert_eq!(parse_duration("PT12S").unwrap(), Duration::seconds(12));
}
}

View File

@@ -1,24 +1,26 @@
use crate::CalDateTime;
use crate::Error;
use crate::{CalDateTime, ComponentMut, parse_duration};
use chrono::{DateTime, Duration, Utc};
use ical::{
generator::IcalEvent,
parser::{Component, ical::component::IcalTimeZone},
property::Property,
};
use ical::parser::ComponentMut;
use ical::{generator::IcalEvent, parser::Component, property::Property};
use rrule::{RRule, RRuleSet};
use std::{collections::HashMap, str::FromStr};
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct EventObject {
pub event: IcalEvent,
pub timezones: HashMap<String, IcalTimeZone>,
pub(crate) ics: String,
// If a timezone is None that means that in the VCALENDAR object there's a timezone defined
// with that name but its not from the Olson DB
pub timezones: HashMap<String, Option<chrono_tz::Tz>>,
}
impl EventObject {
pub fn get_uid(&self) -> &str {
self.event.get_uid()
}
pub fn get_dtstart(&self) -> Result<Option<CalDateTime>, Error> {
if let Some(dtstart) = self.event.get_property("DTSTART") {
if let Some(dtstart) = self.event.get_dtstart() {
Ok(Some(CalDateTime::parse_prop(dtstart, &self.timezones)?))
} else {
Ok(None)
@@ -26,7 +28,7 @@ impl EventObject {
}
pub fn get_dtend(&self) -> Result<Option<CalDateTime>, Error> {
if let Some(dtend) = self.event.get_property("DTEND") {
if let Some(dtend) = self.event.get_dtend() {
Ok(Some(CalDateTime::parse_prop(dtend, &self.timezones)?))
} else {
Ok(None)
@@ -34,33 +36,21 @@ impl EventObject {
}
pub fn get_last_occurence(&self) -> Result<Option<CalDateTime>, Error> {
if let Some(_rrule) = self.event.get_property("RRULE") {
if self.event.get_rrule().is_some() {
// TODO: understand recurrence rules
return Ok(None);
}
if let Some(dtend) = self.event.get_property("DTEND") {
return Ok(Some(CalDateTime::parse_prop(dtend, &self.timezones)?));
if let Some(dtend) = self.get_dtend()? {
return Ok(Some(dtend));
};
let duration = self.get_duration()?.unwrap_or(Duration::days(1));
let duration = self.event.get_duration().unwrap_or(Duration::days(1));
let first_occurence = self.get_dtstart()?;
Ok(first_occurence.map(|first_occurence| first_occurence + duration))
}
pub fn get_duration(&self) -> Result<Option<Duration>, Error> {
if let Some(Property {
value: Some(duration),
..
}) = self.event.get_property("DURATION")
{
Ok(Some(parse_duration(duration)?))
} else {
Ok(None)
}
}
pub fn recurrence_ruleset(&self) -> Result<Option<rrule::RRuleSet>, Error> {
let dtstart: DateTime<rrule::Tz> = if let Some(dtstart) = self.get_dtstart()? {
if let Some(dtend) = self.get_dtend()? {
@@ -106,6 +96,7 @@ impl EventObject {
&self,
start: Option<DateTime<Utc>>,
end: Option<DateTime<Utc>>,
overrides: &[EventObject],
) -> Result<Vec<IcalEvent>, Error> {
if let Some(mut rrule_set) = self.recurrence_ruleset()? {
if let Some(start) = start {
@@ -121,14 +112,31 @@ impl EventObject {
.get_dtend()?
.map(|dtend| dtend.as_datetime().into_owned() - dtstart.as_datetime().into_owned());
for date in dates {
'recurrence: for date in dates {
let date = CalDateTime::from(date);
let dateformat = if dtstart.is_date() {
date.format_date()
} else {
date.format()
};
let mut ev = self.event.clone();
for _override in overrides {
if let Some(override_id) = &_override
.event
.get_recurrence_id()
.as_ref()
.expect("overrides have a recurrence id")
.value
&& override_id == &dateformat
{
// We have an override for this occurence
//
events.push(_override.event.clone());
continue 'recurrence;
}
}
let mut ev = self.event.clone().mutable();
ev.remove_property("RRULE");
ev.remove_property("RDATE");
ev.remove_property("EXDATE");
@@ -163,7 +171,7 @@ impl EventObject {
params: dtstart_prop.params,
});
}
events.push(ev);
events.push(ev.verify()?);
}
Ok(events)
} else {
@@ -242,15 +250,19 @@ END:VEVENT\r\n",
#[test]
fn test_expand_recurrence() {
let event = CalendarObject::from_ics(
"318ec6503573d9576818daf93dac07317058d95c".to_string(),
ICS.to_string(),
)
.unwrap();
let event = event.event().unwrap();
let event = CalendarObject::from_ics(ICS.to_string()).unwrap();
let (event, overrides) = if let crate::CalendarObjectComponent::Event(
main_event,
overrides,
) = event.get_data()
{
(main_event, overrides)
} else {
panic!()
};
let events: Vec<String> = event
.expand_recurrence(None, None)
.expand_recurrence(None, None, overrides)
.unwrap()
.into_iter()
.map(|event| Emitter::generate(&event))

View File

@@ -1,7 +1,11 @@
use derive_more::From;
use ical::parser::ical::component::IcalJournal;
#[derive(Debug, Clone)]
pub struct JournalObject {
pub journal: IcalJournal,
pub(crate) ics: String,
#[derive(Debug, Clone, From)]
pub struct JournalObject(pub IcalJournal);
impl JournalObject {
pub fn get_uid(&self) -> &str {
self.0.get_uid()
}
}

View File

@@ -4,15 +4,15 @@ use crate::Error;
use chrono::DateTime;
use chrono::Utc;
use derive_more::Display;
use ical::{
generator::{Emitter, IcalCalendar},
parser::{Component, ical::component::IcalTimeZone},
};
use ical::generator::{Emitter, IcalCalendar};
use ical::parser::ical::component::IcalTimeZone;
use ical::property::Property;
use serde::Deserialize;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{collections::HashMap, io::BufReader};
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Display)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Display)]
// specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6
pub enum CalendarObjectType {
#[serde(rename = "VEVENT")]
@@ -47,8 +47,7 @@ impl rustical_xml::ValueDeserialize for CalendarObjectType {
"VJOURNAL" => Ok(Self::Journal),
_ => Err(rustical_xml::XmlError::InvalidValue(
rustical_xml::ParseValueError::Other(format!(
"Invalid value '{}', must be VEVENT, VTODO, or VJOURNAL",
val
"Invalid value '{val}', must be VEVENT, VTODO, or VJOURNAL"
)),
)),
}
@@ -57,20 +56,84 @@ impl rustical_xml::ValueDeserialize for CalendarObjectType {
#[derive(Debug, Clone)]
pub enum CalendarObjectComponent {
Event(EventObject),
Todo(TodoObject),
Journal(JournalObject),
Event(EventObject, Vec<EventObject>),
Todo(TodoObject, Vec<TodoObject>),
Journal(JournalObject, Vec<JournalObject>),
}
impl CalendarObjectComponent {
fn from_events(mut events: Vec<EventObject>) -> Result<Self, Error> {
let main_event = events
.extract_if(.., |event| event.event.get_recurrence_id().is_none())
.next()
.expect("there must be one main event");
let overrides = events;
for event in &overrides {
if event.get_uid() != main_event.get_uid() {
return Err(Error::InvalidData(
"Calendar object contains multiple UIDs".to_owned(),
));
}
if event.event.get_recurrence_id().is_none() {
return Err(Error::InvalidData(
"Calendar object can only contain one main component".to_owned(),
));
}
}
Ok(Self::Event(main_event, overrides))
}
fn from_todos(mut todos: Vec<TodoObject>) -> Result<Self, Error> {
let main_todo = todos
.extract_if(.., |todo| todo.0.get_recurrence_id().is_none())
.next()
.expect("there must be one main event");
let overrides = todos;
for todo in &overrides {
if todo.get_uid() != main_todo.get_uid() {
return Err(Error::InvalidData(
"Calendar object contains multiple UIDs".to_owned(),
));
}
if todo.0.get_recurrence_id().is_none() {
return Err(Error::InvalidData(
"Calendar object can only contain one main component".to_owned(),
));
}
}
Ok(Self::Todo(main_todo, overrides))
}
fn from_journals(mut journals: Vec<JournalObject>) -> Result<Self, Error> {
let main_journal = journals
.extract_if(.., |journal| journal.0.get_recurrence_id().is_none())
.next()
.expect("there must be one main event");
let overrides = journals;
for journal in &overrides {
if journal.get_uid() != main_journal.get_uid() {
return Err(Error::InvalidData(
"Calendar object contains multiple UIDs".to_owned(),
));
}
if journal.0.get_recurrence_id().is_none() {
return Err(Error::InvalidData(
"Calendar object can only contain one main component".to_owned(),
));
}
}
Ok(Self::Journal(main_journal, overrides))
}
}
#[derive(Debug, Clone)]
pub struct CalendarObject {
id: String,
data: CalendarObjectComponent,
cal: IcalCalendar,
properties: Vec<Property>,
ics: String,
vtimezones: HashMap<String, IcalTimeZone>,
}
impl CalendarObject {
pub fn from_ics(object_id: String, ics: String) -> Result<Self, Error> {
pub fn from_ics(ics: String) -> Result<Self, Error> {
let mut parser = ical::IcalParser::new(BufReader::new(ics.as_bytes()));
let cal = parser.next().ok_or(Error::MissingCalendar)??;
if parser.next().is_some() {
@@ -78,65 +141,70 @@ impl CalendarObject {
"multiple calendars, only one allowed".to_owned(),
));
}
if cal.events.len()
+ cal.alarms.len()
+ cal.todos.len()
+ cal.journals.len()
+ cal.free_busys.len()
if !cal.events.is_empty() as u8
+ !cal.todos.is_empty() as u8
+ !cal.journals.is_empty() as u8
+ !cal.free_busys.is_empty() as u8
!= 1
{
// https://datatracker.ietf.org/doc/html/rfc4791#section-4.1
return Err(Error::InvalidData(
"iCalendar object is only allowed to have exactly one component".to_owned(),
"iCalendar object must have exactly one component type".to_owned(),
));
}
let timezones: HashMap<String, IcalTimeZone> = cal
let timezones: HashMap<String, Option<chrono_tz::Tz>> = cal
.timezones
.clone()
.into_iter()
.filter_map(|timezone| {
let timezone_prop = timezone.get_property("TZID")?.to_owned();
let tzid = timezone_prop.value?;
Some((tzid, timezone))
})
.map(|timezone| (timezone.get_tzid().to_owned(), (&timezone).try_into().ok()))
.collect();
if let Some(event) = cal.events.first() {
return Ok(CalendarObject {
id: object_id,
cal: cal.clone(),
data: CalendarObjectComponent::Event(EventObject {
event: event.clone(),
timezones,
let vtimezones = cal
.timezones
.clone()
.into_iter()
.map(|timezone| (timezone.get_tzid().to_owned(), timezone))
.collect();
let data = if !cal.events.is_empty() {
CalendarObjectComponent::from_events(
cal.events
.into_iter()
.map(|event| EventObject {
event,
timezones: timezones.clone(),
})
.collect(),
)?
} else if !cal.todos.is_empty() {
CalendarObjectComponent::from_todos(
cal.todos.into_iter().map(|todo| todo.into()).collect(),
)?
} else if !cal.journals.is_empty() {
CalendarObjectComponent::from_journals(
cal.journals
.into_iter()
.map(|journal| journal.into())
.collect(),
)?
} else {
return Err(Error::InvalidData(
"iCalendar component type not supported :(".to_owned(),
));
};
Ok(Self {
data,
properties: cal.properties,
ics,
}),
});
}
if let Some(todo) = cal.todos.first() {
return Ok(CalendarObject {
id: object_id,
cal: cal.clone(),
data: CalendarObjectComponent::Todo(TodoObject {
todo: todo.clone(),
ics,
}),
});
}
if let Some(journal) = cal.journals.first() {
return Ok(CalendarObject {
id: object_id,
cal: cal.clone(),
data: CalendarObjectComponent::Journal(JournalObject {
journal: journal.clone(),
ics,
}),
});
vtimezones,
})
}
Err(Error::InvalidData(
"iCalendar component type not supported :(".to_owned(),
))
pub fn get_vtimezones(&self) -> &HashMap<String, IcalTimeZone> {
&self.vtimezones
}
pub fn get_data(&self) -> &CalendarObjectComponent {
@@ -144,60 +212,66 @@ impl CalendarObject {
}
pub fn get_id(&self) -> &str {
&self.id
match &self.data {
// We've made sure before that the first component exists and all components share the
// same UID
CalendarObjectComponent::Todo(todo, _) => todo.0.get_uid(),
CalendarObjectComponent::Event(event, _) => event.event.get_uid(),
CalendarObjectComponent::Journal(journal, _) => journal.0.get_uid(),
}
}
pub fn get_etag(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(&self.id);
hasher.update(self.get_id());
hasher.update(self.get_ics());
format!("\"{:x}\"", hasher.finalize())
}
pub fn get_ics(&self) -> &str {
match &self.data {
CalendarObjectComponent::Todo(todo) => &todo.ics,
CalendarObjectComponent::Event(event) => &event.ics,
CalendarObjectComponent::Journal(journal) => &journal.ics,
}
&self.ics
}
pub fn get_component_name(&self) -> &str {
match self.data {
CalendarObjectComponent::Todo(_) => "VTODO",
CalendarObjectComponent::Event(_) => "VEVENT",
CalendarObjectComponent::Journal(_) => "VJOURNAL",
}
self.get_object_type().as_str()
}
pub fn get_object_type(&self) -> CalendarObjectType {
match self.data {
CalendarObjectComponent::Todo(_) => CalendarObjectType::Todo,
CalendarObjectComponent::Event(_) => CalendarObjectType::Event,
CalendarObjectComponent::Journal(_) => CalendarObjectType::Journal,
CalendarObjectComponent::Todo(_, _) => CalendarObjectType::Todo,
CalendarObjectComponent::Event(_, _) => CalendarObjectType::Event,
CalendarObjectComponent::Journal(_, _) => CalendarObjectType::Journal,
}
}
pub fn get_first_occurence(&self) -> Result<Option<CalDateTime>, Error> {
match &self.data {
CalendarObjectComponent::Event(event) => event.get_dtstart(),
CalendarObjectComponent::Event(main_event, overrides) => Ok(overrides
.iter()
.chain([main_event].into_iter())
.map(|event| event.get_dtstart())
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.min()),
_ => Ok(None),
}
}
pub fn get_last_occurence(&self) -> Result<Option<CalDateTime>, Error> {
match &self.data {
CalendarObjectComponent::Event(event) => event.get_last_occurence(),
CalendarObjectComponent::Event(main_event, overrides) => Ok(overrides
.iter()
.chain([main_event].into_iter())
.map(|event| event.get_last_occurence())
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.max()),
_ => Ok(None),
}
}
pub fn event(&self) -> Option<&EventObject> {
match &self.data {
CalendarObjectComponent::Event(event) => Some(event),
_ => None,
}
}
pub fn expand_recurrence(
&self,
start: Option<DateTime<Utc>>,
@@ -205,9 +279,12 @@ impl CalendarObject {
) -> Result<String, Error> {
// Only events can be expanded
match &self.data {
CalendarObjectComponent::Event(event) => {
let mut cal = self.cal.clone();
cal.events = event.expand_recurrence(start, end)?;
CalendarObjectComponent::Event(main_event, overrides) => {
let cal = IcalCalendar {
properties: self.properties.clone(),
events: main_event.expand_recurrence(start, end, overrides)?,
..Default::default()
};
Ok(cal.generate())
}
_ => Ok(self.get_ics().to_string()),

View File

@@ -1,7 +1,11 @@
use derive_more::From;
use ical::parser::ical::component::IcalTodo;
#[derive(Debug, Clone)]
pub struct TodoObject {
pub todo: IcalTodo,
pub(crate) ics: String,
#[derive(Debug, Clone, From)]
pub struct TodoObject(pub IcalTodo);
impl TodoObject {
pub fn get_uid(&self) -> &str {
self.0.get_uid()
}
}

View File

@@ -1,14 +1,8 @@
mod property_ext;
pub use property_ext::*;
mod timestamp;
mod timezone;
pub use timestamp::*;
pub use timezone::*;
mod duration;
pub use duration::parse_duration;
mod icalendar;
pub use icalendar::*;

View File

@@ -1,46 +0,0 @@
use ical::{generator::IcalEvent, property::Property};
pub trait IcalProperty {
fn get_param(&self, name: &str) -> Option<Vec<&str>>;
fn get_value_type(&self) -> Option<&str>;
fn get_tzid(&self) -> Option<&str>;
}
impl IcalProperty for ical::property::Property {
fn get_param(&self, name: &str) -> Option<Vec<&str>> {
self.params
.as_ref()?
.iter()
.find(|(key, _)| name == key)
.map(|(_, value)| value.iter().map(String::as_str).collect())
}
fn get_value_type(&self) -> Option<&str> {
self.get_param("VALUE")
.and_then(|params| params.into_iter().next())
}
fn get_tzid(&self) -> Option<&str> {
self.get_param("TZID")
.and_then(|params| params.into_iter().next())
}
}
pub trait ComponentMut {
fn remove_property(&mut self, name: &str);
fn set_property(&mut self, prop: Property);
fn push_property(&mut self, prop: Property);
}
impl ComponentMut for IcalEvent {
fn remove_property(&mut self, name: &str) {
self.properties.retain(|prop| prop.name != name);
}
fn set_property(&mut self, prop: Property) {
self.remove_property(&prop.name);
self.push_property(prop);
}
fn push_property(&mut self, prop: Property) {
self.properties.push(prop);
}
}

View File

@@ -1,12 +1,8 @@
use super::timezone::CalTimezone;
use crate::IcalProperty;
use super::timezone::ICalTimezone;
use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use chrono_tz::Tz;
use derive_more::derive::Deref;
use ical::{
parser::{Component, ical::component::IcalTimeZone},
property::Property,
};
use ical::property::Property;
use lazy_static::lazy_static;
use rustical_xml::{ValueDeserialize, ValueSerialize};
use std::{borrow::Cow, collections::HashMap, ops::Add};
@@ -68,8 +64,8 @@ pub enum CalDateTime {
// Form 2, example: 19980119T070000Z -> UTC
// Form 3, example: TZID=America/New_York:19980119T020000 -> Olson
// https://en.wikipedia.org/wiki/Tz_database
DateTime(DateTime<CalTimezone>),
Date(NaiveDate, CalTimezone),
DateTime(DateTime<ICalTimezone>),
Date(NaiveDate, ICalTimezone),
}
impl From<CalDateTime> for DateTime<rrule::Tz> {
@@ -106,13 +102,13 @@ impl Ord for CalDateTime {
impl From<DateTime<Local>> for CalDateTime {
fn from(value: DateTime<Local>) -> Self {
CalDateTime::DateTime(value.with_timezone(&CalTimezone::Local))
CalDateTime::DateTime(value.with_timezone(&ICalTimezone::Local))
}
}
impl From<DateTime<Utc>> for CalDateTime {
fn from(value: DateTime<Utc>) -> Self {
CalDateTime::DateTime(value.with_timezone(&CalTimezone::Utc))
CalDateTime::DateTime(value.with_timezone(&ICalTimezone::Olson(chrono_tz::UTC)))
}
}
@@ -136,7 +132,7 @@ impl Add<Duration> for CalDateTime {
impl CalDateTime {
pub fn parse_prop(
prop: &Property,
timezones: &HashMap<String, IcalTimeZone>,
timezones: &HashMap<String, Option<chrono_tz::Tz>>,
) -> Result<Self, CalDateTimeError> {
let prop_value = prop
.value
@@ -145,28 +141,9 @@ impl CalDateTime {
"empty property".to_owned(),
))?;
// Use the TZID parameter from the property
let timezone = if let Some(tzid) = prop.get_tzid() {
let timezone = if let Some(tzid) = prop.get_param("TZID") {
if let Some(timezone) = timezones.get(tzid) {
// X-LIC-LOCATION is often used to refer to a standardised timezone from the Olson
// database
if let Some(olson_name) = timezone
.get_property("X-LIC-LOCATION")
.map(|prop| prop.value.to_owned())
.unwrap_or_default()
{
if let Ok(tz) = olson_name.parse::<Tz>() {
Some(tz)
} else {
return Err(CalDateTimeError::InvalidOlson(olson_name));
}
} else {
// If the TZID matches a name from the Olson database (e.g. Europe/Berlin) we
// guess that we can just use it
tzid.parse::<Tz>().ok()
// TODO: If None: Too bad, we need to manually parse it
// For now it's just treated as localtime
}
timezone.to_owned()
} else {
// TZID refers to timezone that does not exist
return Err(CalDateTimeError::InvalidTZID(tzid.to_string()));
@@ -174,6 +151,7 @@ impl CalDateTime {
} else {
// No explicit timezone specified.
// This is valid and will be localtime or UTC depending on the value
// We will stick to this default as documented in https://github.com/lennart-k/rustical/issues/102
None
};
@@ -183,7 +161,7 @@ impl CalDateTime {
pub fn format(&self) -> String {
match self {
Self::DateTime(datetime) => match datetime.timezone() {
CalTimezone::Utc => datetime.format(UTC_DATE_TIME).to_string(),
ICalTimezone::Olson(chrono_tz::UTC) => datetime.format(UTC_DATE_TIME).to_string(),
_ => datetime.format(LOCAL_DATE_TIME).to_string(),
},
Self::Date(date, _) => date.format(LOCAL_DATE).to_string(),
@@ -208,7 +186,7 @@ impl CalDateTime {
matches!(&self, Self::Date(_, _))
}
pub fn as_datetime(&self) -> Cow<DateTime<CalTimezone>> {
pub fn as_datetime(&self) -> Cow<'_, DateTime<ICalTimezone>> {
match self {
Self::DateTime(datetime) => Cow::Borrowed(datetime),
Self::Date(date, tz) => Cow::Owned(
@@ -232,7 +210,7 @@ impl CalDateTime {
}
return Ok(CalDateTime::DateTime(
datetime
.and_local_timezone(CalTimezone::Local)
.and_local_timezone(ICalTimezone::Local)
.earliest()
.ok_or(CalDateTimeError::LocalTimeGap)?,
));
@@ -242,8 +220,8 @@ impl CalDateTime {
return Ok(datetime.and_utc().into());
}
let timezone = timezone
.map(CalTimezone::Olson)
.unwrap_or(CalTimezone::Local);
.map(ICalTimezone::Olson)
.unwrap_or(ICalTimezone::Local);
if let Ok(date) = NaiveDate::parse_from_str(value, LOCAL_DATE) {
return Ok(CalDateTime::Date(date, timezone));
}
@@ -275,7 +253,7 @@ impl CalDateTime {
CalDateTime::Date(
NaiveDate::from_ymd_opt(year, month, day)
.ok_or(CalDateTimeError::ParseError(value.to_string()))?,
CalTimezone::Local,
ICalTimezone::Local,
),
false,
));
@@ -287,7 +265,7 @@ impl CalDateTime {
self.as_datetime().to_utc()
}
pub fn timezone(&self) -> CalTimezone {
pub fn timezone(&self) -> ICalTimezone {
match &self {
CalDateTime::DateTime(datetime) => datetime.timezone(),
CalDateTime::Date(_, tz) => tz.to_owned(),
@@ -423,7 +401,7 @@ mod tests {
(
CalDateTime::Date(
NaiveDate::from_ymd_opt(1985, 4, 12).unwrap(),
crate::CalTimezone::Local
crate::ICalTimezone::Local
),
true
)
@@ -433,7 +411,7 @@ mod tests {
(
CalDateTime::Date(
NaiveDate::from_ymd_opt(1985, 4, 12).unwrap(),
crate::CalTimezone::Local
crate::ICalTimezone::Local
),
true
)
@@ -443,7 +421,7 @@ mod tests {
(
CalDateTime::Date(
NaiveDate::from_ymd_opt(1972, 4, 12).unwrap(),
crate::CalTimezone::Local
crate::ICalTimezone::Local
),
false
)

View File

@@ -1,29 +1,26 @@
use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone, Utc};
use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone};
use chrono_tz::Tz;
use derive_more::{Display, From};
#[derive(Debug, Clone, From, PartialEq, Eq)]
pub enum CalTimezone {
pub enum ICalTimezone {
Local,
Utc,
Olson(Tz),
}
impl From<CalTimezone> for rrule::Tz {
fn from(value: CalTimezone) -> Self {
impl From<ICalTimezone> for rrule::Tz {
fn from(value: ICalTimezone) -> Self {
match value {
CalTimezone::Local => Self::LOCAL,
CalTimezone::Utc => Self::UTC,
CalTimezone::Olson(tz) => Self::Tz(tz),
ICalTimezone::Local => Self::LOCAL,
ICalTimezone::Olson(tz) => Self::Tz(tz),
}
}
}
impl From<rrule::Tz> for CalTimezone {
impl From<rrule::Tz> for ICalTimezone {
fn from(value: rrule::Tz) -> Self {
match value {
rrule::Tz::Local(_) => Self::Local,
rrule::Tz::Tz(chrono_tz::UTC) => Self::Utc,
rrule::Tz::Tz(tz) => Self::Olson(tz),
}
}
@@ -32,7 +29,6 @@ impl From<rrule::Tz> for CalTimezone {
#[derive(Debug, Clone, PartialEq, Display)]
pub enum CalTimezoneOffset {
Local(chrono::FixedOffset),
Utc(chrono::Utc),
Olson(chrono_tz::TzOffset),
}
@@ -40,19 +36,17 @@ impl chrono::Offset for CalTimezoneOffset {
fn fix(&self) -> chrono::FixedOffset {
match self {
Self::Local(local) => local.fix(),
Self::Utc(utc) => utc.fix(),
Self::Olson(olson) => olson.fix(),
}
}
}
impl TimeZone for CalTimezone {
impl TimeZone for ICalTimezone {
type Offset = CalTimezoneOffset;
fn from_offset(offset: &Self::Offset) -> Self {
match offset {
CalTimezoneOffset::Local(_) => Self::Local,
CalTimezoneOffset::Utc(_) => Self::Utc,
CalTimezoneOffset::Olson(offset) => Self::Olson(Tz::from_offset(offset)),
}
}
@@ -62,9 +56,6 @@ impl TimeZone for CalTimezone {
Self::Local => Local
.offset_from_local_date(local)
.map(CalTimezoneOffset::Local),
Self::Utc => Utc
.offset_from_local_date(local)
.map(CalTimezoneOffset::Utc),
Self::Olson(tz) => tz
.offset_from_local_date(local)
.map(CalTimezoneOffset::Olson),
@@ -79,9 +70,6 @@ impl TimeZone for CalTimezone {
Self::Local => Local
.offset_from_local_datetime(local)
.map(CalTimezoneOffset::Local),
Self::Utc => Utc
.offset_from_local_datetime(local)
.map(CalTimezoneOffset::Utc),
Self::Olson(tz) => tz
.offset_from_local_datetime(local)
.map(CalTimezoneOffset::Olson),
@@ -91,7 +79,6 @@ impl TimeZone for CalTimezone {
fn offset_from_utc_datetime(&self, utc: &NaiveDateTime) -> Self::Offset {
match self {
Self::Local => CalTimezoneOffset::Local(Local.offset_from_utc_datetime(utc)),
Self::Utc => CalTimezoneOffset::Utc(Utc.offset_from_utc_datetime(utc)),
Self::Olson(tz) => CalTimezoneOffset::Olson(tz.offset_from_utc_datetime(utc)),
}
}
@@ -99,7 +86,6 @@ impl TimeZone for CalTimezone {
fn offset_from_utc_date(&self, utc: &NaiveDate) -> Self::Offset {
match self {
Self::Local => CalTimezoneOffset::Local(Local.offset_from_utc_date(utc)),
Self::Utc => CalTimezoneOffset::Utc(Utc.offset_from_utc_date(utc)),
Self::Olson(tz) => CalTimezoneOffset::Olson(tz.offset_from_utc_date(utc)),
}
}

View File

@@ -0,0 +1,30 @@
use rustical_ical::CalendarObject;
const MULTI_VEVENT: &str = r#"
BEGIN:VCALENDAR
PRODID:-//Example Corp.//CalDAV Client//EN
VERSION:2.0
BEGIN:VEVENT
UID:2@example.com
SUMMARY:Weekly Meeting
DTSTAMP:20041210T183838Z
DTSTART:20041206T120000Z
DTEND:20041206T130000Z
RRULE:FREQ=WEEKLY
END:VEVENT
BEGIN:VEVENT
UID:2@example.com
SUMMARY:Weekly Meeting
RECURRENCE-ID:20041213T120000Z
DTSTAMP:20041210T183838Z
DTSTART:20041213T130000Z
DTEND:20041213T140000Z
END:VEVENT
END:VCALENDAR
"#;
#[test]
fn parse_calendar_object() {
let object = CalendarObject::from_ics(MULTI_VEVENT.to_string()).unwrap();
object.expand_recurrence(None, None).unwrap();
}

View File

@@ -192,8 +192,8 @@ pub async fn route_get_oidc_callback<US: UserStore + Clone>(
.await
.map_err(|e| OidcError::UserInfo(e.to_string()))?;
if let Some(require_group) = &oidc_config.require_group {
if !user_info_claims
if let Some(require_group) = &oidc_config.require_group
&& !user_info_claims
.additional_claims()
.groups
.clone()
@@ -206,7 +206,6 @@ pub async fn route_get_oidc_callback<US: UserStore + Clone>(
)
.into_response());
}
}
let user_id = match oidc_config.claim_userid {
UserIdClaim::Sub => user_info_claims.subject().to_string(),

View File

@@ -22,7 +22,6 @@ chrono-tz = { workspace = true }
derive_more = { workspace = true, features = ["as_ref"] }
rustical_xml.workspace = true
tokio.workspace = true
rand.workspace = true
clap.workspace = true
rustical_dav.workspace = true
rustical_ical.workspace = true
@@ -33,6 +32,7 @@ headers.workspace = true
tower.workspace = true
futures-core.workspace = true
tower-sessions.workspace = true
vtimezones-rs.workspace = true
[dev-dependencies]
rstest = { workspace = true }

View File

@@ -76,8 +76,8 @@ pub trait AddressbookStore: Send + Sync + 'static {
async fn import_addressbook(
&self,
principal: String,
addressbook: Addressbook,
objects: Vec<AddressObject>,
merge_existing: bool,
) -> Result<(), Error>;
}

View File

@@ -72,13 +72,12 @@ where
let mut inner = self.inner.clone();
Box::pin(async move {
if let Some(session) = request.extensions().get::<Session>() {
if let Ok(Some(user_id)) = session.get::<String>("user").await {
if let Ok(Some(user)) = ap.get_principal(&user_id).await {
if let Some(session) = request.extensions().get::<Session>()
&& let Ok(Some(user_id)) = session.get::<String>("user").await
&& let Ok(Some(user)) = ap.get_principal(&user_id).await
{
request.extensions_mut().insert(user);
}
}
}
if let Some(auth) = auth_header {
let user_id = auth.username();

View File

@@ -1,17 +1,28 @@
use std::str::FromStr;
use crate::synctoken::format_synctoken;
use chrono::NaiveDateTime;
use rustical_ical::CalendarObjectType;
use serde::Serialize;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Clone, Serialize)]
pub struct Calendar {
pub principal: String,
pub id: String,
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct CalendarMetadata {
// Attributes that may be outsourced
pub displayname: Option<String>,
pub order: i64,
pub description: Option<String>,
pub color: Option<String>,
pub timezone: Option<String>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Calendar {
// Attributes that may be outsourced
#[serde(flatten)]
pub meta: CalendarMetadata,
// Common calendar attributes
pub principal: String,
pub id: String,
pub timezone_id: Option<String>,
pub deleted_at: Option<NaiveDateTime>,
pub synctoken: i64,
@@ -24,4 +35,16 @@ impl Calendar {
pub fn format_synctoken(&self) -> String {
format_synctoken(self.synctoken)
}
pub fn get_timezone(&self) -> Option<chrono_tz::Tz> {
self.timezone_id
.as_ref()
.and_then(|tzid| chrono_tz::Tz::from_str(tzid).ok())
}
pub fn get_vtimezone(&self) -> Option<&'static str> {
self.timezone_id
.as_ref()
.and_then(|tzid| vtimezones_rs::VTIMEZONES.get(tzid).cloned())
}
}

View File

@@ -34,6 +34,12 @@ pub trait CalendarStore: Send + Sync + 'static {
use_trashbin: bool,
) -> Result<(), Error>;
async fn restore_calendar(&self, principal: &str, name: &str) -> Result<(), Error>;
async fn import_calendar(
&self,
calendar: Calendar,
objects: Vec<CalendarObject>,
merge_existing: bool,
) -> Result<(), Error>;
async fn sync_changes(
&self,

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