Compare commits

...

29 Commits

Author SHA1 Message Date
Lennart K
008e40e17f integration tests: Also use health command 2026-01-29 11:26:20 +01:00
Lennart K
0703b7b470 Add another integration test 2026-01-29 10:25:00 +01:00
Lennart K
233cf2ea37 fix test snapshots 2026-01-28 21:05:56 +01:00
Lennart K
494f31f992 Add more abstract integration test 2026-01-28 20:54:55 +01:00
Lennart K
c1758e2cba cmd_default: Add notifier to detect when rustical has started 2026-01-28 20:16:41 +01:00
Lennart K
af60a446ad refactoring of integration tests 2026-01-28 18:38:03 +01:00
Lennart K
c763a682ed update .gitignore 2026-01-27 23:07:19 +01:00
Lennart K
8ab9c61b0f Move commands to lib.rs 2026-01-27 23:06:57 +01:00
Lennart
8b2bb1b0d6 docs: Mention NixOS package 2026-01-26 12:20:04 +01:00
Lennart
da72aa26cb update README.md 2026-01-24 22:53:51 +01:00
Lennart
b89ff1a2b5 version 0.12.3 2026-01-24 22:49:02 +01:00
Lennart
246a1aa738 Add truncation for automatically derived timezones 2026-01-24 22:48:08 +01:00
Lennart
bb0484ac4a version 0.12.2 2026-01-24 20:09:42 +01:00
Lennart
1b3da2a99b update caldata-rs 2026-01-24 20:07:38 +01:00
Lennart
3b01ae1cf6 update test snapshots 2026-01-24 19:52:13 +01:00
Lennart K
d918a255a9 PUT calendar_object: Allow omission of timezones as in RFC7809 2026-01-24 19:44:58 +01:00
Lennart K
6a31d3000c Update vtimezones-rs 2026-01-24 18:05:42 +01:00
Lennart K
d5892ab56b Migrate ical-rs to caldata-rs 2026-01-22 11:01:00 +01:00
Lennart K
11a61cf8b1 version 0.12.1 2026-01-20 13:20:04 +01:00
Lennart Kämmle
227d4bc61a Merge pull request #171 from wrvsrx/fix-anniversayr-typo
Fix a typo about anniversary
2026-01-20 13:17:44 +01:00
wrvsrx
d9afc85222 Fix a typo about anniversary 2026-01-20 19:45:50 +08:00
Lennart
c9fe5706a9 clippy appeasement 2026-01-19 17:03:14 +01:00
Lennart
1b6214d426 MKCALENDAR: Handling of invalid timezones 2026-01-19 16:36:25 +01:00
Lennart
be34cc3091 xml: Implement namespace for Unparsed 2026-01-19 16:22:21 +01:00
Lennart
99287f85f4 version 0.12.0 2026-01-19 15:48:56 +01:00
Lennart
df3143cd4c Fix status code for failed preconditions 2026-01-19 15:37:41 +01:00
Lennart Kämmle
92a3418f8e Merge pull request #164 from lennart-k/feat/ical-rewrite
ical-rs overhaul
2026-01-19 15:14:14 +01:00
Lennart
ea2f841269 ical-rs: Pin version to Git commit 2026-01-19 15:04:54 +01:00
Lennart
15e1509fe3 sqlite_store: Add option to skip broken objects and add validation on start-up 2026-01-19 14:48:21 +01:00
119 changed files with 1236 additions and 743 deletions

2
.gitignore vendored
View File

@@ -3,7 +3,7 @@ crates/*/target
# For libraries ignore Cargo.lock # For libraries ignore Cargo.lock
crates/*/Cargo.lock crates/*/Cargo.lock
db.sqlite3* **/*.sqlite3*
config.toml config.toml
principals.toml principals.toml

146
Cargo.lock generated
View File

@@ -181,9 +181,9 @@ dependencies = [
[[package]] [[package]]
name = "askama_web" name = "askama_web"
version = "0.15.0" version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0d6576f8e59513752a3e2673ca602fb403be7d0d0aacba5cd8b219838ab58fe" checksum = "5911a65ac3916ef133167a855d52978f9fbf54680a093e0ef29e20b7e94a4523"
dependencies = [ dependencies = [
"askama", "askama",
"askama_web_derive", "askama_web_derive",
@@ -565,6 +565,24 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "caldata"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f36de4a8034d98c95e7fe874b828272d823cfbd68e9571fe7bf6c419e852cbe2"
dependencies = [
"chrono",
"chrono-tz",
"derive_more",
"itertools 0.14.0",
"lazy_static",
"phf 0.13.1",
"regex",
"rrule",
"thiserror 2.0.18",
"vtimezones-rs",
]
[[package]] [[package]]
name = "cast" name = "cast"
version = "0.3.0" version = "0.3.0"
@@ -573,9 +591,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.53" version = "1.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -1768,22 +1786,6 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "ical"
version = "0.12.0-dev"
source = "git+https://github.com/lennart-k/ical-rs?branch=dev#8697656303f182ce173efdaf6aa7e842ffdb3f33"
dependencies = [
"chrono",
"chrono-tz",
"derive_more",
"itertools 0.14.0",
"lazy_static",
"phf 0.13.1",
"regex",
"rrule",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.1.1" version = "2.1.1"
@@ -2025,9 +2027,9 @@ checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]] [[package]]
name = "libm" name = "libm"
version = "0.2.15" version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libredox" name = "libredox"
@@ -2200,9 +2202,9 @@ dependencies = [
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]] [[package]]
name = "num-integer" name = "num-integer"
@@ -2610,22 +2612,12 @@ dependencies = [
[[package]] [[package]]
name = "phf_codegen" name = "phf_codegen"
version = "0.12.1" version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efbdcb6f01d193b17f0b9c3360fa7e0e620991b193ff08702f78b3ce365d7e61" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [ dependencies = [
"phf_generator 0.12.1", "phf_generator",
"phf_shared 0.12.1", "phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b"
dependencies = [
"fastrand",
"phf_shared 0.12.1",
] ]
[[package]] [[package]]
@@ -2644,7 +2636,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [ dependencies = [
"phf_generator 0.13.1", "phf_generator",
"phf_shared 0.13.1", "phf_shared 0.13.1",
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2825,9 +2817,9 @@ dependencies = [
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.105" version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -2934,9 +2926,9 @@ dependencies = [
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.43" version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@@ -3317,18 +3309,19 @@ dependencies = [
[[package]] [[package]]
name = "rustical" name = "rustical"
version = "0.11.17" version = "0.12.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
"async-trait", "async-trait",
"axum", "axum",
"axum-extra", "axum-extra",
"caldata",
"clap", "clap",
"figment", "figment",
"futures-util",
"headers", "headers",
"http", "http",
"ical",
"insta", "insta",
"opentelemetry", "opentelemetry",
"opentelemetry-otlp", "opentelemetry-otlp",
@@ -3351,7 +3344,9 @@ dependencies = [
"serde", "serde",
"similar-asserts", "similar-asserts",
"sqlx", "sqlx",
"tempfile",
"tokio", "tokio",
"tokio-util",
"toml 0.9.11+spec-1.1.0", "toml 0.9.11+spec-1.1.0",
"tower", "tower",
"tower-http", "tower-http",
@@ -3364,20 +3359,20 @@ dependencies = [
[[package]] [[package]]
name = "rustical_caldav" name = "rustical_caldav"
version = "0.11.17" version = "0.12.3"
dependencies = [ dependencies = [
"async-std", "async-std",
"async-trait", "async-trait",
"axum", "axum",
"axum-extra", "axum-extra",
"base64 0.22.1", "base64 0.22.1",
"caldata",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"derive_more", "derive_more",
"futures-util", "futures-util",
"headers", "headers",
"http", "http",
"ical",
"insta", "insta",
"percent-encoding", "percent-encoding",
"quick-xml", "quick-xml",
@@ -3406,17 +3401,17 @@ dependencies = [
[[package]] [[package]]
name = "rustical_carddav" name = "rustical_carddav"
version = "0.11.17" version = "0.12.3"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"axum-extra", "axum-extra",
"base64 0.22.1", "base64 0.22.1",
"caldata",
"chrono", "chrono",
"derive_more", "derive_more",
"futures-util", "futures-util",
"http", "http",
"ical",
"insta", "insta",
"percent-encoding", "percent-encoding",
"quick-xml", "quick-xml",
@@ -3440,16 +3435,16 @@ dependencies = [
[[package]] [[package]]
name = "rustical_dav" name = "rustical_dav"
version = "0.11.17" version = "0.12.3"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"axum-extra", "axum-extra",
"caldata",
"derive_more", "derive_more",
"futures-util", "futures-util",
"headers", "headers",
"http", "http",
"ical",
"itertools 0.14.0", "itertools 0.14.0",
"log", "log",
"matchit 0.9.1", "matchit 0.9.1",
@@ -3466,7 +3461,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_dav_push" name = "rustical_dav_push"
version = "0.11.17" version = "0.12.3"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3491,7 +3486,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_frontend" name = "rustical_frontend"
version = "0.11.17" version = "0.12.3"
dependencies = [ dependencies = [
"askama", "askama",
"askama_web", "askama_web",
@@ -3527,13 +3522,13 @@ dependencies = [
[[package]] [[package]]
name = "rustical_ical" name = "rustical_ical"
version = "0.11.17" version = "0.12.3"
dependencies = [ dependencies = [
"axum", "axum",
"caldata",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"derive_more", "derive_more",
"ical",
"regex", "regex",
"rrule", "rrule",
"rstest", "rstest",
@@ -3546,7 +3541,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_oidc" name = "rustical_oidc"
version = "0.11.17" version = "0.12.3"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3562,11 +3557,12 @@ dependencies = [
[[package]] [[package]]
name = "rustical_store" name = "rustical_store"
version = "0.11.17" version = "0.12.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"axum", "axum",
"caldata",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"clap", "clap",
@@ -3574,7 +3570,6 @@ dependencies = [
"futures-core", "futures-core",
"headers", "headers",
"http", "http",
"ical",
"regex", "regex",
"rrule", "rrule",
"rstest", "rstest",
@@ -3595,13 +3590,13 @@ dependencies = [
[[package]] [[package]]
name = "rustical_store_sqlite" name = "rustical_store_sqlite"
version = "0.11.17" version = "0.12.3"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"caldata",
"chrono", "chrono",
"criterion", "criterion",
"derive_more", "derive_more",
"ical",
"password-auth", "password-auth",
"password-hash", "password-hash",
"pbkdf2", "pbkdf2",
@@ -3620,7 +3615,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_xml" name = "rustical_xml"
version = "0.11.17" version = "0.12.3"
dependencies = [ dependencies = [
"quick-xml", "quick-xml",
"thiserror 2.0.18", "thiserror 2.0.18",
@@ -3980,9 +3975,9 @@ dependencies = [
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.1" version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.60.2",
@@ -4356,9 +4351,9 @@ dependencies = [
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.45" version = "0.3.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa", "itoa",
@@ -4371,15 +4366,15 @@ dependencies = [
[[package]] [[package]]
name = "time-core" name = "time-core"
version = "0.1.7" version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]] [[package]]
name = "time-macros" name = "time-macros"
version = "0.2.25" version = "0.2.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4"
dependencies = [ dependencies = [
"num-conv", "num-conv",
"time-core", "time-core",
@@ -4479,6 +4474,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"futures-util",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
] ]
@@ -4951,12 +4947,12 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "vtimezones-rs" name = "vtimezones-rs"
version = "0.2.0" version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6728de8767c8dea44f41b88115a205ed23adc3302e1b4342be59d922934dae5" checksum = "1e4e9cf6888a927b6cec4aa2416f379885b92dd2aa4476bc83718fe58051f67e"
dependencies = [ dependencies = [
"glob", "glob",
"phf 0.12.1", "phf 0.13.1",
"phf_codegen", "phf_codegen",
] ]
@@ -5442,7 +5438,7 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]] [[package]]
name = "xml_derive" name = "xml_derive"
version = "0.11.17" version = "0.12.3"
dependencies = [ dependencies = [
"darling 0.23.0", "darling 0.23.0",
"heck", "heck",
@@ -5563,6 +5559,6 @@ dependencies = [
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.15" version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2" checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"

View File

@@ -2,7 +2,7 @@
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.11.17" version = "0.12.3"
rust-version = "1.92" rust-version = "1.92"
edition = "2024" edition = "2024"
description = "A CalDAV server" description = "A CalDAV server"
@@ -32,8 +32,11 @@ opentelemetry = [
"dep:tracing-opentelemetry", "dep:tracing-opentelemetry",
] ]
[profile.dev] [lib]
debug = 0 doc = true
name = "rustical"
path = "src/lib.rs"
test = true
[workspace.dependencies] [workspace.dependencies]
rustical_dav = { path = "./crates/dav/", features = ["ical"] } rustical_dav = { path = "./crates/dav/", features = ["ical"] }
@@ -70,6 +73,7 @@ tokio = { version = "1.48", features = [
"rt-multi-thread", "rt-multi-thread",
"full", "full",
] } ] }
tokio-util = { version = "0.7", features = ["rt"] }
url = "2.5" url = "2.5"
base64 = "0.22" base64 = "0.22"
thiserror = "2.0" thiserror = "2.0"
@@ -107,9 +111,7 @@ strum = "0.27"
strum_macros = "0.27" strum_macros = "0.27"
serde_json = { version = "1.0", features = ["raw_value"] } serde_json = { version = "1.0", features = ["raw_value"] }
sqlx-sqlite = { version = "0.8", features = ["bundled"] } sqlx-sqlite = { version = "0.8", features = ["bundled"] }
ical = { git = "https://github.com/lennart-k/ical-rs", branch = "dev", features = [ caldata = { version = "0.14.0", features = ["chrono-tz", "vtimezones-rs"] }
"chrono-tz",
] }
toml = "0.9" toml = "0.9"
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = [ tower-http = { version = "0.6", features = [
@@ -139,7 +141,7 @@ reqwest = { version = "0.12", features = [
openidconnect = "4.0" openidconnect = "4.0"
clap = { version = "4.5", features = ["derive", "env"] } clap = { version = "4.5", features = ["derive", "env"] }
matchit-serde = { git = "https://github.com/lennart-k/matchit-serde", rev = "e18e65d7" } matchit-serde = { git = "https://github.com/lennart-k/matchit-serde", rev = "e18e65d7" }
vtimezones-rs = "0.2" vtimezones-rs = "0.3"
ece = { version = "2.3", default-features = false, features = [ ece = { version = "2.3", default-features = false, features = [
"backend-openssl", "backend-openssl",
] } ] }
@@ -154,6 +156,7 @@ rstest.workspace = true
rustical_store_sqlite = { workspace = true, features = ["test"] } rustical_store_sqlite = { workspace = true, features = ["test"] }
insta.workspace = true insta.workspace = true
similar-asserts.workspace = true similar-asserts.workspace = true
tempfile = "3.24"
[dependencies] [dependencies]
rustical_store.workspace = true rustical_store.workspace = true
@@ -161,10 +164,11 @@ rustical_store_sqlite.workspace = true
rustical_caldav.workspace = true rustical_caldav.workspace = true
rustical_carddav.workspace = true rustical_carddav.workspace = true
rustical_frontend.workspace = true rustical_frontend.workspace = true
ical.workspace = true caldata.workspace = true
toml.workspace = true toml.workspace = true
serde.workspace = true serde.workspace = true
tokio.workspace = true tokio.workspace = true
tokio-util.workspace = true
tracing.workspace = true tracing.workspace = true
anyhow.workspace = true anyhow.workspace = true
clap.workspace = true clap.workspace = true
@@ -203,3 +207,4 @@ tower-http.workspace = true
axum-extra.workspace = true axum-extra.workspace = true
headers.workspace = true headers.workspace = true
http.workspace = true http.workspace = true
futures-util.workspace = true

View File

@@ -24,6 +24,7 @@ a CalDAV/CardDAV server
- Apple configuration profiles (skip copy-pasting passwords and instead generate the configuration in the frontend) - Apple configuration profiles (skip copy-pasting passwords and instead generate the configuration in the frontend)
- **OpenID Connect** support (with option to disable password login) - **OpenID Connect** support (with option to disable password login)
- Group-based **sharing** - Group-based **sharing**
- Partial [RFC 7809](https://datatracker.ietf.org/doc/html/rfc7809) support. RustiCal will accept timezones by reference and handle omitted timezones in objects.
## Getting Started ## Getting Started

View File

@@ -34,7 +34,7 @@ rustical_store.workspace = true
chrono.workspace = true chrono.workspace = true
chrono-tz.workspace = true chrono-tz.workspace = true
sha2.workspace = true sha2.workspace = true
ical.workspace = true caldata.workspace = true
percent-encoding.workspace = true percent-encoding.workspace = true
rustical_xml.workspace = true rustical_xml.workspace = true
uuid.workspace = true uuid.workspace = true

View File

@@ -3,11 +3,11 @@ use crate::calendar::CalendarResourceService;
use axum::body::Body; use axum::body::Body;
use axum::extract::State; use axum::extract::State;
use axum::{extract::Path, response::Response}; use axum::{extract::Path, response::Response};
use caldata::component::IcalCalendar;
use caldata::generator::Emitter;
use caldata::parser::ContentLine;
use headers::{ContentType, HeaderMapExt}; use headers::{ContentType, HeaderMapExt};
use http::{HeaderValue, Method, StatusCode, header}; use http::{HeaderValue, Method, StatusCode, header};
use ical::component::IcalCalendar;
use ical::generator::Emitter;
use ical::property::ContentLine;
use percent_encoding::{CONTROLS, utf8_percent_encode}; use percent_encoding::{CONTROLS, utf8_percent_encode};
use rustical_store::{CalendarStore, SubscriptionStore, auth::Principal}; use rustical_store::{CalendarStore, SubscriptionStore, auth::Principal};
use std::str::FromStr; use std::str::FromStr;

View File

@@ -4,8 +4,9 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use caldata::component::{Component, ComponentMut};
use caldata::{IcalParser, parser::ParserOptions};
use http::StatusCode; use http::StatusCode;
use ical::parser::{Component, ComponentMut};
use rustical_dav::header::Overwrite; use rustical_dav::header::Overwrite;
use rustical_ical::CalendarObjectType; use rustical_ical::CalendarObjectType;
use rustical_store::{ use rustical_store::{
@@ -25,7 +26,7 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
} }
let parser = ical::IcalParser::from_slice(body.as_bytes()); let parser = IcalParser::from_slice(body.as_bytes());
let mut cal = match parser.expect_one() { let mut cal = match parser.expect_one() {
Ok(cal) => cal.mutable(), Ok(cal) => cal.mutable(),
Err(err) => return Ok((StatusCode::BAD_REQUEST, err.to_string()).into_response()), Err(err) => return Ok((StatusCode::BAD_REQUEST, err.to_string()).into_response()),
@@ -49,7 +50,7 @@ pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
cal.remove_property("X-WR-CALDESC"); cal.remove_property("X-WR-CALDESC");
cal.remove_property("X-WR-CALCOLOR"); cal.remove_property("X-WR-CALCOLOR");
cal.remove_property("X-WR-TIMEZONE"); cal.remove_property("X-WR-TIMEZONE");
let cal = cal.build(None).unwrap(); let cal = cal.build(&ParserOptions::default(), None).unwrap();
// Make sure timezone is valid // Make sure timezone is valid
if let Some(timezone_id) = timezone_id.as_ref() { if let Some(timezone_id) = timezone_id.as_ref() {

View File

@@ -1,10 +1,13 @@
use std::str::FromStr;
use crate::Error; use crate::Error;
use crate::calendar::CalendarResourceService; use crate::calendar::CalendarResourceService;
use crate::calendar::prop::SupportedCalendarComponentSet; use crate::calendar::prop::SupportedCalendarComponentSet;
use crate::error::Precondition;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use caldata::IcalParser;
use http::{Method, StatusCode}; use http::{Method, StatusCode};
use ical::IcalParser;
use rustical_dav::xml::HrefElement; use rustical_dav::xml::HrefElement;
use rustical_ical::CalendarObjectType; use rustical_ical::CalendarObjectType;
use rustical_store::auth::Principal; use rustical_store::auth::Principal;
@@ -84,21 +87,33 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
} }
let timezone_id = if let Some(tzid) = request.calendar_timezone_id { let timezone_id = if let Some(tzid) = request.calendar_timezone_id {
if chrono_tz::Tz::from_str(&tzid).is_err() {
return Err(Error::PreconditionFailed(Precondition::CalendarTimezone(
"Invalid timezone ID in calendar-timezone-id",
)));
}
Some(tzid) Some(tzid)
} else if let Some(tz) = request.calendar_timezone { } else if let Some(tz) = request.calendar_timezone {
// TODO: Proper error (calendar-timezone precondition)
let calendar = IcalParser::from_slice(tz.as_bytes()) let calendar = IcalParser::from_slice(tz.as_bytes())
.next() .next()
.ok_or_else(|| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))? .ok_or(Error::PreconditionFailed(Precondition::CalendarTimezone(
.map_err(|_| rustical_dav::Error::BadRequest("Error parsing timezone".to_owned()))?; "No timezone data provided",
)))?
.map_err(|_| {
Error::PreconditionFailed(Precondition::CalendarTimezone("Error parsing timezone"))
})?;
let timezone = calendar.vtimezones.values().next().ok_or_else(|| { let timezone = calendar
rustical_dav::Error::BadRequest("No timezone data provided".to_owned()) .vtimezones
})?; .values()
.next()
.ok_or(Error::PreconditionFailed(Precondition::CalendarTimezone(
"No timezone data provided",
)))?;
let timezone: Option<chrono_tz::Tz> = timezone.into(); let timezone: Option<chrono_tz::Tz> = timezone.into();
let timezone = timezone.ok_or_else(|| { let timezone = timezone.ok_or(Error::PreconditionFailed(
rustical_dav::Error::BadRequest("Cannot translate VTIMEZONE into IANA TZID".to_owned()) Precondition::CalendarTimezone("No timezone data provided"),
})?; ))?;
Some(timezone.name().to_owned()) Some(timezone.name().to_owned())
} else { } else {

View File

@@ -2,9 +2,12 @@ use crate::calendar::methods::report::calendar_query::{
TimeRangeElement, TimeRangeElement,
prop_filter::{PropFilterElement, PropFilterable}, prop_filter::{PropFilterElement, PropFilterable},
}; };
use ical::{ use caldata::{
component::{CalendarInnerData, IcalAlarm, IcalCalendarObject, IcalEvent, IcalTodo}, component::{
parser::{Component, ical::component::IcalTimeZone}, CalendarInnerData, Component, IcalAlarm, IcalCalendarObject, IcalEvent, IcalTimeZone,
IcalTodo,
},
parser::ContentLine,
}; };
use rustical_xml::XmlDeserialize; use rustical_xml::XmlDeserialize;
@@ -112,10 +115,7 @@ impl CompFilterable for CalendarInnerData {
} }
impl PropFilterable for IcalAlarm { impl PropFilterable for IcalAlarm {
fn get_named_properties<'a>( fn get_named_properties<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a ContentLine> {
&'a self,
name: &'a str,
) -> impl Iterator<Item = &'a ical::property::ContentLine> {
Component::get_named_properties(self, name) Component::get_named_properties(self, name)
} }
} }
@@ -139,7 +139,7 @@ impl PropFilterable for CalendarInnerData {
fn get_named_properties<'a>( fn get_named_properties<'a>(
&'a self, &'a self,
name: &'a str, name: &'a str,
) -> Box<dyn Iterator<Item = &'a ical::property::ContentLine> + 'a> { ) -> Box<dyn Iterator<Item = &'a ContentLine> + 'a> {
// TODO: If we were pedantic, we would have to do recurrence expansion first // TODO: If we were pedantic, we would have to do recurrence expansion first
// and take into account the overrides :( // and take into account the overrides :(
match self { match self {
@@ -151,10 +151,7 @@ impl PropFilterable for CalendarInnerData {
} }
impl PropFilterable for IcalCalendarObject { impl PropFilterable for IcalCalendarObject {
fn get_named_properties<'a>( fn get_named_properties<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a ContentLine> {
&'a self,
name: &'a str,
) -> impl Iterator<Item = &'a ical::property::ContentLine> {
Component::get_named_properties(self, name) Component::get_named_properties(self, name)
} }
} }
@@ -185,10 +182,7 @@ impl CompFilterable for IcalCalendarObject {
} }
impl PropFilterable for IcalTimeZone { impl PropFilterable for IcalTimeZone {
fn get_named_properties<'a>( fn get_named_properties<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a ContentLine> {
&'a self,
name: &'a str,
) -> impl Iterator<Item = &'a ical::property::ContentLine> {
Component::get_named_properties(self, name) Component::get_named_properties(self, name)
} }
} }

View File

@@ -1,6 +1,6 @@
use super::comp_filter::{CompFilterElement, CompFilterable}; use super::comp_filter::{CompFilterElement, CompFilterable};
use crate::calendar_object::CalendarObjectPropWrapperName; use crate::calendar_object::CalendarObjectPropWrapperName;
use ical::{component::IcalCalendarObject, property::ContentLine}; use caldata::{component::IcalCalendarObject, parser::ContentLine};
use rustical_dav::xml::{PropfindType, TextMatchElement}; use rustical_dav::xml::{PropfindType, TextMatchElement};
use rustical_ical::UtcDateTime; use rustical_ical::UtcDateTime;
use rustical_store::calendar_store::CalendarQuery; use rustical_store::calendar_store::CalendarQuery;

View File

@@ -1,5 +1,5 @@
use super::{ParamFilterElement, TimeRangeElement}; use super::{ParamFilterElement, TimeRangeElement};
use ical::{property::ContentLine, types::CalDateTime}; use caldata::{parser::ContentLine, types::CalDateTime};
use rustical_dav::xml::TextMatchElement; use rustical_dav::xml::TextMatchElement;
use rustical_ical::UtcDateTime; use rustical_ical::UtcDateTime;
use rustical_xml::XmlDeserialize; use rustical_xml::XmlDeserialize;

View File

@@ -1,10 +1,10 @@
use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData}; use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData};
use crate::Error; use crate::Error;
use crate::calendar::prop::{ReportMethod, SupportedCollationSet}; use crate::calendar::prop::{ReportMethod, SupportedCollationSet};
use caldata::IcalParser;
use caldata::types::CalDateTime;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use derive_more::derive::{From, Into}; use derive_more::derive::{From, Into};
use ical::IcalParser;
use ical::types::CalDateTime;
use rustical_dav::extensions::{ use rustical_dav::extensions::{
CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp, CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp,
}; };

View File

@@ -6,7 +6,7 @@ use crate::calendar::methods::report::route_report_calendar;
use crate::calendar::resource::CalendarResource; use crate::calendar::resource::CalendarResource;
use crate::calendar_object::CalendarObjectResourceService; use crate::calendar_object::CalendarObjectResourceService;
use crate::calendar_object::resource::CalendarObjectResource; use crate::calendar_object::resource::CalendarObjectResource;
use crate::{CalDavPrincipalUri, Error}; use crate::{CalDavConfig, CalDavPrincipalUri, Error};
use async_trait::async_trait; use async_trait::async_trait;
use axum::Router; use axum::Router;
use axum::extract::Request; use axum::extract::Request;
@@ -23,6 +23,7 @@ use tower::Service;
pub struct CalendarResourceService<C: CalendarStore, S: SubscriptionStore> { pub struct CalendarResourceService<C: CalendarStore, S: SubscriptionStore> {
pub(crate) cal_store: Arc<C>, pub(crate) cal_store: Arc<C>,
pub(crate) sub_store: Arc<S>, pub(crate) sub_store: Arc<S>,
pub(crate) config: Arc<CalDavConfig>,
} }
impl<C: CalendarStore, S: SubscriptionStore> Clone for CalendarResourceService<C, S> { impl<C: CalendarStore, S: SubscriptionStore> Clone for CalendarResourceService<C, S> {
@@ -30,15 +31,17 @@ impl<C: CalendarStore, S: SubscriptionStore> Clone for CalendarResourceService<C
Self { Self {
cal_store: self.cal_store.clone(), cal_store: self.cal_store.clone(),
sub_store: self.sub_store.clone(), sub_store: self.sub_store.clone(),
config: self.config.clone(),
} }
} }
} }
impl<C: CalendarStore, S: SubscriptionStore> CalendarResourceService<C, S> { impl<C: CalendarStore, S: SubscriptionStore> CalendarResourceService<C, S> {
pub const fn new(cal_store: Arc<C>, sub_store: Arc<S>) -> Self { pub const fn new(cal_store: Arc<C>, sub_store: Arc<S>, config: Arc<CalDavConfig>) -> Self {
Self { Self {
cal_store, cal_store,
sub_store, sub_store,
config,
} }
} }
} }
@@ -112,7 +115,8 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
Router::new() Router::new()
.nest( .nest(
"/{object_id}", "/{object_id}",
CalendarObjectResourceService::new(self.cal_store.clone()).axum_router(), CalendarObjectResourceService::new(self.cal_store.clone(), self.config.clone())
.axum_router(),
) )
.route_service("/", self.axum_service()) .route_service("/", self.axum_service())
} }

View File

@@ -12,7 +12,7 @@ PRODID:-//github.com/lennart-k/vzic-rs//RustiCal Calendar server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VTIMEZONE BEGIN:VTIMEZONE
TZID:Europe/Berlin TZID:Europe/Berlin
LAST-MODIFIED:20250723T190331Z LAST-MODIFIED:20260124T185655Z
X-LIC-LOCATION:Europe/Berlin X-LIC-LOCATION:Europe/Berlin
X-PROLEPTIC-TZNAME:LMT X-PROLEPTIC-TZNAME:LMT
BEGIN:STANDARD BEGIN:STANDARD

View File

@@ -5,6 +5,7 @@ use axum::body::Body;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
use caldata::parser::ParserOptions;
use headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch}; use headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch};
use http::{HeaderMap, HeaderValue, Method, StatusCode}; use http::{HeaderMap, HeaderValue, Method, StatusCode};
use rustical_ical::CalendarObject; use rustical_ical::CalendarObject;
@@ -20,7 +21,10 @@ pub async fn get_event<C: CalendarStore>(
calendar_id, calendar_id,
object_id, object_id,
}): Path<CalendarObjectPathComponents>, }): Path<CalendarObjectPathComponents>,
State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>, State(CalendarObjectResourceService {
cal_store,
config: _,
}): State<CalendarObjectResourceService<C>>,
user: Principal, user: Principal,
method: Method, method: Method,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
@@ -57,7 +61,9 @@ pub async fn put_event<C: CalendarStore>(
calendar_id, calendar_id,
object_id, object_id,
}): Path<CalendarObjectPathComponents>, }): Path<CalendarObjectPathComponents>,
State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>, State(CalendarObjectResourceService { cal_store, config }): State<
CalendarObjectResourceService<C>,
>,
user: Principal, user: Principal,
mut if_none_match: Option<TypedHeader<IfNoneMatch>>, mut if_none_match: Option<TypedHeader<IfNoneMatch>>,
header_map: HeaderMap, header_map: HeaderMap,
@@ -94,11 +100,16 @@ pub async fn put_event<C: CalendarStore>(
true true
}; };
let object = match CalendarObject::from_ics(body.clone()) { let object = match CalendarObject::import(
&body,
Some(ParserOptions {
rfc7809: config.rfc7809,
}),
) {
Ok(object) => object, Ok(object) => object,
Err(err) => { Err(err) => {
warn!("invalid calendar data:\n{body}"); warn!("invalid calendar data:\n{body}");
warn!("{err:#?}"); warn!("{err}");
return Err(Error::PreconditionFailed(Precondition::ValidCalendarData)); return Err(Error::PreconditionFailed(Precondition::ValidCalendarData));
} }
}; };

View File

@@ -3,8 +3,8 @@ use super::prop::{
CalendarObjectPropWrapperName, CalendarObjectPropWrapperName,
}; };
use crate::Error; use crate::Error;
use caldata::generator::Emitter;
use derive_more::derive::{From, Into}; use derive_more::derive::{From, Into};
use ical::generator::Emitter;
use rustical_dav::{ use rustical_dav::{
extensions::CommonPropertiesExtension, extensions::CommonPropertiesExtension,
privileges::UserPrivilegeSet, privileges::UserPrivilegeSet,

View File

@@ -1,5 +1,5 @@
use crate::{ use crate::{
CalDavPrincipalUri, Error, CalDavConfig, CalDavPrincipalUri, Error,
calendar_object::{ calendar_object::{
methods::{get_event, put_event}, methods::{get_event, put_event},
resource::CalendarObjectResource, resource::CalendarObjectResource,
@@ -24,19 +24,21 @@ pub struct CalendarObjectPathComponents {
pub struct CalendarObjectResourceService<C: CalendarStore> { pub struct CalendarObjectResourceService<C: CalendarStore> {
pub(crate) cal_store: Arc<C>, pub(crate) cal_store: Arc<C>,
pub(crate) config: Arc<CalDavConfig>,
} }
impl<C: CalendarStore> Clone for CalendarObjectResourceService<C> { impl<C: CalendarStore> Clone for CalendarObjectResourceService<C> {
fn clone(&self) -> Self { fn clone(&self) -> Self {
Self { Self {
cal_store: self.cal_store.clone(), cal_store: self.cal_store.clone(),
config: self.config.clone(),
} }
} }
} }
impl<C: CalendarStore> CalendarObjectResourceService<C> { impl<C: CalendarStore> CalendarObjectResourceService<C> {
pub const fn new(cal_store: Arc<C>) -> Self { pub const fn new(cal_store: Arc<C>, config: Arc<CalDavConfig>) -> Self {
Self { cal_store } Self { cal_store, config }
} }
} }

View File

@@ -12,6 +12,9 @@ pub enum Precondition {
#[error("valid-calendar-data")] #[error("valid-calendar-data")]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
ValidCalendarData, ValidCalendarData,
#[error("calendar-timezone")]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarTimezone(&'static str),
} }
impl IntoResponse for Precondition { impl IntoResponse for Precondition {
@@ -23,7 +26,7 @@ impl IntoResponse for Precondition {
if let Err(err) = error.serialize_root(&mut writer) { if let Err(err) = error.serialize_root(&mut writer) {
return rustical_dav::Error::from(err).into_response(); return rustical_dav::Error::from(err).into_response();
} }
let mut res = Response::builder().status(StatusCode::PRECONDITION_FAILED); let mut res = Response::builder().status(StatusCode::FORBIDDEN);
res.headers_mut().unwrap().typed_insert(ContentType::xml()); res.headers_mut().unwrap().typed_insert(ContentType::xml());
res.body(Body::from(output)).unwrap() res.body(Body::from(output)).unwrap()
} }
@@ -72,7 +75,10 @@ impl Error {
Self::XmlDecodeError(_) => StatusCode::BAD_REQUEST, Self::XmlDecodeError(_) => StatusCode::BAD_REQUEST,
Self::ChronoParseError(_) | Self::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR, Self::ChronoParseError(_) | Self::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR,
Self::NotFound => StatusCode::NOT_FOUND, Self::NotFound => StatusCode::NOT_FOUND,
Self::PreconditionFailed(_err) => StatusCode::PRECONDITION_FAILED, // The correct status code for a failed precondition is not PreconditionFailed but
// Forbidden (or Conflict):
// https://datatracker.ietf.org/doc/html/rfc4791#section-1.3
Self::PreconditionFailed(_err) => StatusCode::FORBIDDEN,
} }
} }
} }
@@ -82,10 +88,7 @@ impl IntoResponse for Error {
if let Self::PreconditionFailed(precondition) = self { if let Self::PreconditionFailed(precondition) = self {
return precondition.into_response(); return precondition.into_response();
} }
if matches!( if matches!(self.status_code(), StatusCode::INTERNAL_SERVER_ERROR) {
self.status_code(),
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::PRECONDITION_FAILED
) {
error!("{self}"); error!("{self}");
} }
(self.status_code(), self.to_string()).into_response() (self.status_code(), self.to_string()).into_response()

View File

@@ -8,6 +8,7 @@ use rustical_dav::resources::RootResourceService;
use rustical_store::auth::middleware::AuthenticationLayer; use rustical_store::auth::middleware::AuthenticationLayer;
use rustical_store::auth::{AuthenticationProvider, Principal}; use rustical_store::auth::{AuthenticationProvider, Principal};
use rustical_store::{CalendarStore, SubscriptionStore}; use rustical_store::{CalendarStore, SubscriptionStore};
use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
pub mod calendar; pub mod calendar;
@@ -34,6 +35,7 @@ pub fn caldav_router<AP: AuthenticationProvider, C: CalendarStore, S: Subscripti
store: Arc<C>, store: Arc<C>,
subscription_store: Arc<S>, subscription_store: Arc<S>,
simplified_home_set: bool, simplified_home_set: bool,
config: Arc<CalDavConfig>,
) -> Router { ) -> Router {
Router::new().nest( Router::new().nest(
prefix, prefix,
@@ -42,9 +44,27 @@ pub fn caldav_router<AP: AuthenticationProvider, C: CalendarStore, S: Subscripti
sub_store: subscription_store, sub_store: subscription_store,
cal_store: store, cal_store: store,
simplified_home_set, simplified_home_set,
config,
}) })
.axum_router() .axum_router()
.layer(AuthenticationLayer::new(auth_provider)) .layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CalDavPrincipalUri(prefix))), .layer(Extension(CalDavPrincipalUri(prefix))),
) )
} }
const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct CalDavConfig {
#[serde(default = "default_true")]
rfc7809: bool,
}
impl Default for CalDavConfig {
fn default() -> Self {
Self { rfc7809: true }
}
}

View File

@@ -1,7 +1,7 @@
use crate::calendar::CalendarResourceService; use crate::calendar::CalendarResourceService;
use crate::calendar::resource::CalendarResource; use crate::calendar::resource::CalendarResource;
use crate::principal::PrincipalResource; use crate::principal::PrincipalResource;
use crate::{CalDavPrincipalUri, Error}; use crate::{CalDavConfig, CalDavPrincipalUri, Error};
use async_trait::async_trait; use async_trait::async_trait;
use axum::Router; use axum::Router;
use rustical_dav::resource::{AxumMethods, ResourceService}; use rustical_dav::resource::{AxumMethods, ResourceService};
@@ -20,6 +20,7 @@ pub struct PrincipalResourceService<
pub(crate) cal_store: Arc<CS>, pub(crate) cal_store: Arc<CS>,
// If true only return the principal as the calendar home set, otherwise also groups // If true only return the principal as the calendar home set, otherwise also groups
pub(crate) simplified_home_set: bool, pub(crate) simplified_home_set: bool,
pub(crate) config: Arc<CalDavConfig>,
} }
impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Clone impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Clone
@@ -31,6 +32,7 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Clone
sub_store: self.sub_store.clone(), sub_store: self.sub_store.clone(),
cal_store: self.cal_store.clone(), cal_store: self.cal_store.clone(),
simplified_home_set: self.simplified_home_set, simplified_home_set: self.simplified_home_set,
config: self.config.clone(),
} }
} }
} }
@@ -84,7 +86,11 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
Router::new() Router::new()
.nest( .nest(
"/{calendar_id}", "/{calendar_id}",
CalendarResourceService::new(self.cal_store.clone(), self.sub_store.clone()) CalendarResourceService::new(
self.cal_store.clone(),
self.sub_store.clone(),
self.config.clone(),
)
.axum_router(), .axum_router(),
) )
.route_service("/", self.axum_service()) .route_service("/", self.axum_service())

View File

@@ -27,6 +27,7 @@ async fn test_principal_resource(
sub_store: Arc::new(sub_store), sub_store: Arc::new(sub_store),
auth_provider: Arc::new(auth_provider), auth_provider: Arc::new(auth_provider),
simplified_home_set: false, simplified_home_set: false,
config: Default::default(),
}; };
// We don't have any calendars here // We don't have any calendars here

View File

@@ -32,7 +32,7 @@ rustical_ical.workspace = true
http.workspace = true http.workspace = true
tower-http.workspace = true tower-http.workspace = true
percent-encoding.workspace = true percent-encoding.workspace = true
ical.workspace = true caldata.workspace = true
strum.workspace = true strum.workspace = true
strum_macros.workspace = true strum_macros.workspace = true
rstest.workspace = true rstest.workspace = true

View File

@@ -7,8 +7,8 @@ use crate::{
AddressObjectPropWrapperName, AddressObjectPropWrapperName,
}, },
}; };
use caldata::property::VcardFNProperty;
use derive_more::derive::{From, Into}; use derive_more::derive::{From, Into};
use ical::parser::VcardFNProperty;
use rustical_dav::{ use rustical_dav::{
extensions::CommonPropertiesExtension, extensions::CommonPropertiesExtension,
privileges::UserPrivilegeSet, privileges::UserPrivilegeSet,

View File

@@ -4,11 +4,12 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use http::StatusCode; use caldata::{
use ical::{ VcardParser,
parser::{Component, ComponentMut, vcard}, component::{Component, ComponentMut},
property::ContentLine, parser::{ContentLine, ParserOptions},
}; };
use http::StatusCode;
use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::Principal}; use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::Principal};
use tracing::instrument; use tracing::instrument;
@@ -23,7 +24,7 @@ pub async fn route_import<AS: AddressbookStore, S: SubscriptionStore>(
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
} }
let parser = vcard::VcardParser::from_slice(body.as_bytes()); let parser = VcardParser::from_slice(body.as_bytes());
let mut objects = vec![]; let mut objects = vec![];
for res in parser { for res in parser {
@@ -36,7 +37,7 @@ pub async fn route_import<AS: AddressbookStore, S: SubscriptionStore>(
value: Some(uuid::Uuid::new_v4().to_string()), value: Some(uuid::Uuid::new_v4().to_string()),
params: vec![].into(), params: vec![].into(),
}); });
card = card_mut.build(None).unwrap(); card = card_mut.build(&ParserOptions::default(), None).unwrap();
} }
// TODO: Make nicer // TODO: Make nicer
let uid = card.get_uid().unwrap(); let uid = card.get_uid().unwrap();

View File

@@ -2,8 +2,8 @@ use crate::{
address_object::AddressObjectPropWrapperName, address_object::AddressObjectPropWrapperName,
addressbook::methods::report::addressbook_query::PropFilterElement, addressbook::methods::report::addressbook_query::PropFilterElement,
}; };
use caldata::parser::ContentLine;
use derive_more::{From, Into}; use derive_more::{From, Into};
use ical::property::ContentLine;
use rustical_dav::xml::{PropfindType, TextMatchElement}; use rustical_dav::xml::{PropfindType, TextMatchElement};
use rustical_ical::{AddressObject, UtcDateTime}; use rustical_ical::{AddressObject, UtcDateTime};
use rustical_xml::{ValueDeserialize, XmlDeserialize, XmlRootTag}; use rustical_xml::{ValueDeserialize, XmlDeserialize, XmlRootTag};

View File

@@ -1,5 +1,5 @@
use super::{Allof, ParamFilterElement}; use super::{Allof, ParamFilterElement};
use ical::{parser::Component, property::ContentLine}; use caldata::{component::Component, parser::ContentLine};
use rustical_dav::xml::TextMatchElement; use rustical_dav::xml::TextMatchElement;
use rustical_ical::AddressObject; use rustical_ical::AddressObject;
use rustical_xml::XmlDeserialize; use rustical_xml::XmlDeserialize;

View File

@@ -28,7 +28,7 @@ headers.workspace = true
strum.workspace = true strum.workspace = true
matchit.workspace = true matchit.workspace = true
matchit-serde.workspace = true matchit-serde.workspace = true
ical = { workspace = true, optional = true } caldata = { workspace = true, optional = true }
[features] [features]
ical = ["dep:ical"] ical = ["dep:caldata"]

View File

@@ -51,19 +51,18 @@ impl Error {
_ => StatusCode::BAD_REQUEST, _ => StatusCode::BAD_REQUEST,
}, },
Self::PropReadOnly => StatusCode::CONFLICT, Self::PropReadOnly => StatusCode::CONFLICT,
Self::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
Self::InternalError | Self::IOError(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::InternalError | Self::IOError(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Forbidden => StatusCode::FORBIDDEN, // The correct status code for a failed precondition is not PreconditionFailed but
// Forbidden (or Conflict):
// https://datatracker.ietf.org/doc/html/rfc4791#section-1.3
Self::PreconditionFailed | Self::Forbidden => StatusCode::FORBIDDEN,
} }
} }
} }
impl axum::response::IntoResponse for Error { impl axum::response::IntoResponse for Error {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
if matches!( if matches!(self.status_code(), StatusCode::INTERNAL_SERVER_ERROR) {
self.status_code(),
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::PRECONDITION_FAILED
) {
error!("{self}"); error!("{self}");
} }

View File

@@ -71,6 +71,7 @@ pub async fn axum_route_proppatch<R: ResourceService>(
route_proppatch(&path, uri.path(), &body, &principal, &resource_service).await route_proppatch(&path, uri.path(), &body, &principal, &resource_service).await
} }
#[allow(clippy::too_many_lines)]
pub async fn route_proppatch<R: ResourceService>( pub async fn route_proppatch<R: ResourceService>(
path_components: &R::PathComponents, path_components: &R::PathComponents,
path: &str, path: &str,
@@ -116,12 +117,14 @@ pub async fn route_proppatch<R: ResourceService>(
} }
} }
SetPropertyPropWrapper::Invalid(invalid) => { SetPropertyPropWrapper::Invalid(invalid) => {
let propname = invalid.tag_name(); let Unparsed(propns, propname) = invalid;
if let Some(full_propname) = <R::Resource as Resource>::list_props() if let Some(full_propname) = <R::Resource as Resource>::list_props()
.into_iter() .into_iter()
.find_map(|(ns, tag)| { .find_map(|(ns, tag)| {
if tag == propname.as_str() { if (ns, tag)
== (propns.as_ref().map(NamespaceOwned::as_ref), &propname)
{
Some((ns.map(NamespaceOwned::from), tag.to_owned())) Some((ns.map(NamespaceOwned::from), tag.to_owned()))
} else { } else {
None None
@@ -133,7 +136,7 @@ pub async fn route_proppatch<R: ResourceService>(
// - internal properties // - internal properties
props_conflict.push(full_propname); props_conflict.push(full_propname);
} else { } else {
props_not_found.push((None, propname)); props_not_found.push((propns, propname));
} }
} }
} }

View File

@@ -1,4 +1,4 @@
use ical::property::ContentLine; use caldata::parser::ContentLine;
use rustical_xml::{ValueDeserialize, XmlDeserialize}; use rustical_xml::{ValueDeserialize, XmlDeserialize};
use std::borrow::Cow; use std::borrow::Cow;

View File

@@ -15,7 +15,7 @@ chrono-tz.workspace = true
thiserror.workspace = true thiserror.workspace = true
derive_more.workspace = true derive_more.workspace = true
rustical_xml.workspace = true rustical_xml.workspace = true
ical.workspace = true caldata.workspace = true
regex.workspace = true regex.workspace = true
rrule.workspace = true rrule.workspace = true
serde.workspace = true serde.workspace = true

View File

@@ -1,20 +1,23 @@
use crate::{CalendarObject, Error}; use crate::{CalendarObject, Error};
use chrono::{NaiveDate, Utc}; use caldata::{
use ical::component::{ VcardParser,
CalendarInnerDataBuilder, IcalAlarmBuilder, IcalCalendarObjectBuilder, IcalEventBuilder, component::{
}; CalendarInnerDataBuilder, ComponentMut, IcalAlarmBuilder, IcalCalendarObjectBuilder,
use ical::generator::Emitter; IcalEventBuilder, VcardContact,
use ical::parser::vcard::{self, component::VcardContact}; },
use ical::parser::{ generator::Emitter,
Calscale, ComponentMut, IcalCALSCALEProperty, IcalDTENDProperty, IcalDTSTAMPProperty, parser::{ContentLine, ParserOptions},
property::{
Calscale, IcalCALSCALEProperty, IcalDTENDProperty, IcalDTSTAMPProperty,
IcalDTSTARTProperty, IcalPRODIDProperty, IcalRRULEProperty, IcalSUMMARYProperty, IcalDTSTARTProperty, IcalPRODIDProperty, IcalRRULEProperty, IcalSUMMARYProperty,
IcalUIDProperty, IcalVERSIONProperty, IcalVersion, VcardANNIVERSARYProperty, VcardBDAYProperty, IcalUIDProperty, IcalVERSIONProperty, IcalVersion, VcardANNIVERSARYProperty,
VcardFNProperty, VcardBDAYProperty, VcardFNProperty,
},
types::{CalDate, PartialDate, Timezone},
}; };
use ical::property::ContentLine; use chrono::{NaiveDate, Utc};
use ical::types::{CalDate, PartialDate};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::HashMap; use std::collections::BTreeMap;
use std::str::FromStr; use std::str::FromStr;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -32,7 +35,7 @@ impl From<VcardContact> for AddressObject {
impl AddressObject { impl AddressObject {
pub fn from_vcf(vcf: String) -> Result<Self, Error> { pub fn from_vcf(vcf: String) -> Result<Self, Error> {
let parser = vcard::VcardParser::from_slice(vcf.as_bytes()); let parser = VcardParser::from_slice(vcf.as_bytes());
let vcard = parser.expect_one()?; let vcard = parser.expect_one()?;
Ok(Self { vcf, vcard }) Ok(Self { vcf, vcard })
} }
@@ -70,7 +73,7 @@ impl AddressObject {
let Some(dtstart) = NaiveDate::from_ymd_opt(year.unwrap_or(1900), month, day) else { let Some(dtstart) = NaiveDate::from_ymd_opt(year.unwrap_or(1900), month, day) else {
return Ok(None); return Ok(None);
}; };
let start_date = CalDate(dtstart, ical::types::Timezone::Local); let start_date = CalDate(dtstart, Timezone::Local);
let Some(end_date) = start_date.succ_opt() else { let Some(end_date) = start_date.succ_opt() else {
// start_date is MAX_DATE, this should never happen but FAPP also not raise an error // start_date is MAX_DATE, this should never happen but FAPP also not raise an error
return Ok(None); return Ok(None);
@@ -131,9 +134,9 @@ impl AddressObject {
.into(), .into(),
], ],
inner: Some(CalendarInnerDataBuilder::Event(vec![event])), inner: Some(CalendarInnerDataBuilder::Event(vec![event])),
vtimezones: HashMap::default(), vtimezones: BTreeMap::default(),
} }
.build(None)? .build(&ParserOptions::default(), None)?
.into(), .into(),
)) ))
} }

View File

@@ -1,9 +1,13 @@
use std::sync::OnceLock;
use crate::Error; use crate::Error;
use caldata::{
IcalObjectParser,
component::{CalendarInnerData, IcalCalendarObject},
generator::Emitter,
parser::ParserOptions,
};
use derive_more::Display; use derive_more::Display;
use ical::IcalObjectParser;
use ical::component::CalendarInnerData;
use ical::component::IcalCalendarObject;
use ical::generator::Emitter;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -64,15 +68,35 @@ impl rustical_xml::ValueDeserialize for CalendarObjectType {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CalendarObject { pub struct CalendarObject {
inner: IcalCalendarObject, inner: IcalCalendarObject,
ics: String, ics: OnceLock<String>,
} }
impl CalendarObject { impl CalendarObject {
// This function parses iCalendar data but doesn't cache it
// This is meant for iCalendar data coming from outside that might need to be normalised.
// For example if timezones are omitted this can be fixed by this function.
pub fn import(ics: &str, options: Option<ParserOptions>) -> Result<Self, Error> {
let parser =
IcalObjectParser::from_slice(ics.as_bytes()).with_options(options.unwrap_or_default());
let inner = parser.expect_one()?;
Ok(Self {
inner,
ics: OnceLock::new(),
})
}
// This function parses iCalendar data and then caches the parsed iCalendar data.
// This function is only meant for loading data from a data store where we know the iCalendar
// is already in the desired form.
pub fn from_ics(ics: String) -> Result<Self, Error> { pub fn from_ics(ics: String) -> Result<Self, Error> {
let parser = IcalObjectParser::from_slice(ics.as_bytes()); let parser = IcalObjectParser::from_slice(ics.as_bytes());
let inner = parser.expect_one()?; let inner = parser.expect_one()?;
Ok(Self { inner, ics }) Ok(Self {
inner,
ics: ics.into(),
})
} }
#[must_use] #[must_use]
@@ -95,7 +119,7 @@ impl CalendarObject {
#[must_use] #[must_use]
pub fn get_ics(&self) -> &str { pub fn get_ics(&self) -> &str {
&self.ics self.ics.get_or_init(|| self.inner.generate())
} }
#[must_use] #[must_use]
@@ -113,7 +137,7 @@ impl From<CalendarObject> for IcalCalendarObject {
impl From<IcalCalendarObject> for CalendarObject { impl From<IcalCalendarObject> for CalendarObject {
fn from(value: IcalCalendarObject) -> Self { fn from(value: IcalCalendarObject) -> Self {
Self { Self {
ics: value.generate(), ics: value.generate().into(),
inner: value, inner: value,
} }
} }

View File

@@ -1,7 +1,7 @@
#![warn(clippy::all, clippy::pedantic, clippy::nursery)] #![warn(clippy::all, clippy::pedantic, clippy::nursery)]
#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)] #![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
mod timestamp; mod timestamp;
use ical::parser::ParserError; use caldata::parser::ParserError;
pub use timestamp::*; pub use timestamp::*;
mod calendar_object; mod calendar_object;

View File

@@ -13,7 +13,7 @@ anyhow.workspace = true
async-trait.workspace = true async-trait.workspace = true
serde.workspace = true serde.workspace = true
sha2.workspace = true sha2.workspace = true
ical.workspace = true caldata.workspace = true
chrono.workspace = true chrono.workspace = true
regex.workspace = true regex.workspace = true
thiserror.workspace = true thiserror.workspace = true

View File

@@ -26,7 +26,7 @@ pub enum Error {
Other(#[from] anyhow::Error), Other(#[from] anyhow::Error),
#[error(transparent)] #[error(transparent)]
IcalError(#[from] ical::parser::ParserError), IcalError(#[from] caldata::parser::ParserError),
} }
impl Error { impl Error {
@@ -36,7 +36,6 @@ impl Error {
Self::NotFound => StatusCode::NOT_FOUND, Self::NotFound => StatusCode::NOT_FOUND,
Self::AlreadyExists => StatusCode::CONFLICT, Self::AlreadyExists => StatusCode::CONFLICT,
Self::ReadOnly => StatusCode::FORBIDDEN, Self::ReadOnly => StatusCode::FORBIDDEN,
// TODO: Can also be Bad Request, depending on when this is raised
Self::IcalError(_err) => StatusCode::INTERNAL_SERVER_ERROR, Self::IcalError(_err) => StatusCode::INTERNAL_SERVER_ERROR,
Self::InvalidPrincipalType(_) => StatusCode::BAD_REQUEST, Self::InvalidPrincipalType(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
@@ -53,9 +52,7 @@ impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
if matches!( if matches!(
self.status_code(), self.status_code(),
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR | StatusCode::CONFLICT
| StatusCode::PRECONDITION_FAILED
| StatusCode::CONFLICT
) { ) {
error!("{self}"); error!("{self}");
} }

View File

@@ -20,7 +20,7 @@ rstest.workspace = true
criterion.workspace = true criterion.workspace = true
[dependencies] [dependencies]
ical.workspace = true caldata.workspace = true
tokio.workspace = true tokio.workspace = true
rustical_store.workspace = true rustical_store.workspace = true
async-trait.workspace = true async-trait.workspace = true

View File

@@ -338,7 +338,7 @@ impl CalendarStore for SqliteAddressbookStore {
out_objects.push((format!("{object_id}-birthday"), birthday)); out_objects.push((format!("{object_id}-birthday"), birthday));
} }
if let Some(anniversary) = object.get_anniversary_object()? { if let Some(anniversary) = object.get_anniversary_object()? {
out_objects.push((format!("{object_id}-anniversayr"), anniversary)); out_objects.push((format!("{object_id}-anniversary"), anniversary));
} }
} }
@@ -382,7 +382,7 @@ impl CalendarStore for SqliteAddressbookStore {
objects.push((format!("{object_id}-birthday"), birthday)); objects.push((format!("{object_id}-birthday"), birthday));
} }
if let Some(anniversary) = object.get_anniversary_object()? { if let Some(anniversary) = object.get_anniversary_object()? {
objects.push((format!("{object_id}-anniversayr"), anniversary)); objects.push((format!("{object_id}-anniversary"), anniversary));
} }
} }
Ok(objects) Ok(objects)

View File

@@ -1,6 +1,7 @@
use super::ChangeOperation; use super::ChangeOperation;
use crate::BEGIN_IMMEDIATE; use crate::BEGIN_IMMEDIATE;
use async_trait::async_trait; use async_trait::async_trait;
use caldata::parser::ParserError;
use derive_more::derive::Constructor; use derive_more::derive::Constructor;
use rustical_ical::AddressObject; use rustical_ical::AddressObject;
use rustical_store::{ use rustical_store::{
@@ -9,7 +10,7 @@ use rustical_store::{
}; };
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction}; use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tracing::{error_span, instrument, warn}; use tracing::{error, error_span, instrument, warn};
pub mod birthday_calendar; pub mod birthday_calendar;
@@ -18,6 +19,12 @@ struct AddressObjectRow {
id: String, id: String,
vcf: String, vcf: String,
} }
impl From<AddressObjectRow> for (String, Result<AddressObject, ParserError>) {
fn from(row: AddressObjectRow) -> Self {
let result = AddressObject::from_vcf(row.vcf);
(row.id, result)
}
}
impl TryFrom<AddressObjectRow> for (String, AddressObject) { impl TryFrom<AddressObjectRow> for (String, AddressObject) {
type Error = rustical_store::Error; type Error = rustical_store::Error;
@@ -31,6 +38,7 @@ impl TryFrom<AddressObjectRow> for (String, AddressObject) {
pub struct SqliteAddressbookStore { pub struct SqliteAddressbookStore {
db: SqlitePool, db: SqlitePool,
sender: Sender<CollectionOperation>, sender: Sender<CollectionOperation>,
skip_broken: bool,
} }
impl SqliteAddressbookStore { impl SqliteAddressbookStore {
@@ -88,6 +96,36 @@ impl SqliteAddressbookStore {
Ok(()) Ok(())
} }
#[allow(clippy::missing_panics_doc)]
pub async fn validate_objects(&self, principal: &str) -> Result<(), Error> {
let mut success = true;
for addressbook in self.get_addressbooks(principal).await? {
for (object_id, res) in Self::_get_objects(&self.db, principal, &addressbook.id).await?
{
if let Err(err) = res {
warn!(
"Invalid address object found at {principal}/{addr_id}/{object_id}.vcf. Error: {err}",
addr_id = addressbook.id
);
success = false;
}
}
}
if !success {
if self.skip_broken {
error!(
"Not all address objects are valid. Since data_store.sqlite.skip_broken=true they will be hidden. You are still advised to manually remove or repair the object. If you need help feel free to open up an issue on GitHub."
);
} else {
error!(
"Not all address objects are valid. Since data_store.sqlite.skip_broken=false this causes a panic. Remove or repair the broken objects manually or set data_store.sqlite.skip_broken=false as a temporary solution to ignore the error. If you need help feel free to open up an issue on GitHub."
);
panic!();
}
}
Ok(())
}
// Logs an operation to an address object // Logs an operation to an address object
async fn log_object_operation( async fn log_object_operation(
tx: &mut Transaction<'_, Sqlite>, tx: &mut Transaction<'_, Sqlite>,
@@ -134,7 +172,7 @@ impl SqliteAddressbookStore {
if let Err(err) = self.sender.try_send(CollectionOperation { topic, data }) { if let Err(err) = self.sender.try_send(CollectionOperation { topic, data }) {
error_span!( error_span!(
"Error trying to send addressbook update notification:", "Error trying to send addressbook update notification:",
err = format!("{err:?}"), err = format!("{err}"),
); );
} }
} }
@@ -353,8 +391,8 @@ impl SqliteAddressbookStore {
executor: E, executor: E,
principal: &str, principal: &str,
addressbook_id: &str, addressbook_id: &str,
) -> Result<Vec<(String, AddressObject)>, rustical_store::Error> { ) -> Result<impl Iterator<Item = (String, Result<AddressObject, ParserError>)>, Error> {
sqlx::query_as!( Ok(sqlx::query_as!(
AddressObjectRow, AddressObjectRow,
"SELECT id, vcf FROM addressobjects WHERE principal = ? AND addressbook_id = ? AND deleted_at IS NULL", "SELECT id, vcf FROM addressobjects WHERE principal = ? AND addressbook_id = ? AND deleted_at IS NULL",
principal, principal,
@@ -363,8 +401,8 @@ impl SqliteAddressbookStore {
.fetch_all(executor) .fetch_all(executor)
.await.map_err(crate::Error::from)? .await.map_err(crate::Error::from)?
.into_iter() .into_iter()
.map(std::convert::TryInto::try_into) .map(Into::into)
.collect() )
} }
async fn _get_object<'e, E: Executor<'e, Database = Sqlite>>( async fn _get_object<'e, E: Executor<'e, Database = Sqlite>>(
@@ -607,7 +645,16 @@ impl AddressbookStore for SqliteAddressbookStore {
principal: &str, principal: &str,
addressbook_id: &str, addressbook_id: &str,
) -> Result<Vec<(String, AddressObject)>, rustical_store::Error> { ) -> Result<Vec<(String, AddressObject)>, rustical_store::Error> {
Self::_get_objects(&self.db, principal, addressbook_id).await let objects = Self::_get_objects(&self.db, principal, addressbook_id).await?;
if self.skip_broken {
Ok(objects
.filter_map(|(id, res)| Some((id, res.ok()?)))
.collect())
} else {
Ok(objects
.map(|(id, res)| res.map(|obj| (id, obj)))
.collect::<Result<Vec<_>, _>>()?)
}
} }
#[instrument] #[instrument]

View File

@@ -1,9 +1,10 @@
use super::ChangeOperation; use super::ChangeOperation;
use crate::BEGIN_IMMEDIATE; use crate::BEGIN_IMMEDIATE;
use async_trait::async_trait; use async_trait::async_trait;
use caldata::parser::ParserError;
use caldata::types::CalDateTime;
use chrono::TimeDelta; use chrono::TimeDelta;
use derive_more::derive::Constructor; use derive_more::derive::Constructor;
use ical::types::CalDateTime;
use regex::Regex; use regex::Regex;
use rustical_ical::{CalendarObject, CalendarObjectType}; use rustical_ical::{CalendarObject, CalendarObjectType};
use rustical_store::calendar_store::CalendarQuery; use rustical_store::calendar_store::CalendarQuery;
@@ -13,7 +14,7 @@ use rustical_store::{CollectionOperation, CollectionOperationInfo};
use sqlx::types::chrono::NaiveDateTime; use sqlx::types::chrono::NaiveDateTime;
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction}; use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tracing::{error_span, instrument, warn}; use tracing::{error, error_span, instrument, warn};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct CalendarObjectRow { struct CalendarObjectRow {
@@ -22,6 +23,23 @@ struct CalendarObjectRow {
uid: String, uid: String,
} }
impl From<CalendarObjectRow> for (String, Result<CalendarObject, ParserError>) {
fn from(row: CalendarObjectRow) -> Self {
let result = CalendarObject::from_ics(row.ics).inspect(|object| {
if object.get_uid() != row.uid {
warn!(
"Calendar object {}.ics: UID={} and row uid={} do not match",
row.id,
object.get_uid(),
row.uid
);
}
});
(row.id, result)
}
}
impl TryFrom<CalendarObjectRow> for (String, CalendarObject) { impl TryFrom<CalendarObjectRow> for (String, CalendarObject) {
type Error = rustical_store::Error; type Error = rustical_store::Error;
@@ -92,6 +110,7 @@ impl From<CalendarRow> for Calendar {
pub struct SqliteCalendarStore { pub struct SqliteCalendarStore {
db: SqlitePool, db: SqlitePool,
sender: Sender<CollectionOperation>, sender: Sender<CollectionOperation>,
skip_broken: bool,
} }
impl SqliteCalendarStore { impl SqliteCalendarStore {
@@ -141,11 +160,40 @@ impl SqliteCalendarStore {
if let Err(err) = self.sender.try_send(CollectionOperation { topic, data }) { if let Err(err) = self.sender.try_send(CollectionOperation { topic, data }) {
error_span!( error_span!(
"Error trying to send calendar update notification:", "Error trying to send calendar update notification:",
err = format!("{err:?}"), err = format!("{err}"),
); );
} }
} }
#[allow(clippy::missing_panics_doc)]
pub async fn validate_objects(&self, principal: &str) -> Result<(), Error> {
let mut success = true;
for calendar in self.get_calendars(principal).await? {
for (object_id, res) in Self::_get_objects(&self.db, principal, &calendar.id).await? {
if let Err(err) = res {
warn!(
"Invalid calendar object found at {principal}/{cal_id}/{object_id}.ics. Error: {err}",
cal_id = calendar.id
);
success = false;
}
}
}
if !success {
if self.skip_broken {
error!(
"Not all calendar objects are valid. Since data_store.sqlite.skip_broken=true they will be hidden. You are still advised to manually remove or repair the object. If you need help feel free to open up an issue on GitHub."
);
} else {
error!(
"Not all calendar objects are valid. Since data_store.sqlite.skip_broken=false this causes a panic. Remove or repair the broken objects manually or set data_store.sqlite.skip_broken=false as a temporary solution to ignore the error. If you need help feel free to open up an issue on GitHub."
);
panic!();
}
}
Ok(())
}
/// In the past exports generated objects with invalid VERSION:4.0 /// In the past exports generated objects with invalid VERSION:4.0
/// This repair sets them to VERSION:2.0 /// This repair sets them to VERSION:2.0
#[allow(clippy::missing_panics_doc)] #[allow(clippy::missing_panics_doc)]
@@ -456,8 +504,8 @@ impl SqliteCalendarStore {
executor: E, executor: E,
principal: &str, principal: &str,
cal_id: &str, cal_id: &str,
) -> Result<Vec<(String, CalendarObject)>, Error> { ) -> Result<impl Iterator<Item = (String, Result<CalendarObject, ParserError>)>, Error> {
sqlx::query_as!( Ok(sqlx::query_as!(
CalendarObjectRow, CalendarObjectRow,
"SELECT id, uid, ics FROM calendarobjects WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL", "SELECT id, uid, ics FROM calendarobjects WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL",
principal, principal,
@@ -466,8 +514,8 @@ impl SqliteCalendarStore {
.fetch_all(executor) .fetch_all(executor)
.await.map_err(crate::Error::from)? .await.map_err(crate::Error::from)?
.into_iter() .into_iter()
.map(std::convert::TryInto::try_into) .map(Into::into)
.collect() )
} }
async fn _calendar_query<'e, E: Executor<'e, Database = Sqlite>>( async fn _calendar_query<'e, E: Executor<'e, Database = Sqlite>>(
@@ -475,14 +523,14 @@ impl SqliteCalendarStore {
principal: &str, principal: &str,
cal_id: &str, cal_id: &str,
query: CalendarQuery, query: CalendarQuery,
) -> Result<Vec<(String, CalendarObject)>, Error> { ) -> Result<impl Iterator<Item = (String, Result<CalendarObject, ParserError>)>, Error> {
// We extend our query interval by one day in each direction since we really don't want to // We extend our query interval by one day in each direction since we really don't want to
// miss any objects because of timezone differences // miss any objects because of timezone differences
// I've previously tried NaiveDate::MIN,MAX, but it seems like sqlite cannot handle these // I've previously tried NaiveDate::MIN,MAX, but it seems like sqlite cannot handle these
let start = query.time_start.map(|start| start - TimeDelta::days(1)); let start = query.time_start.map(|start| start - TimeDelta::days(1));
let end = query.time_end.map(|end| end + TimeDelta::days(1)); let end = query.time_end.map(|end| end + TimeDelta::days(1));
sqlx::query_as!( Ok(sqlx::query_as!(
CalendarObjectRow, CalendarObjectRow,
r"SELECT id, uid, ics FROM calendarobjects r"SELECT id, uid, ics FROM calendarobjects
WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL
@@ -500,8 +548,7 @@ impl SqliteCalendarStore {
.await .await
.map_err(crate::Error::from)? .map_err(crate::Error::from)?
.into_iter() .into_iter()
.map(std::convert::TryInto::try_into) .map(Into::into))
.collect()
} }
async fn _get_object<'e, E: Executor<'e, Database = Sqlite>>( async fn _get_object<'e, E: Executor<'e, Database = Sqlite>>(
@@ -641,6 +688,7 @@ impl SqliteCalendarStore {
principal: &str, principal: &str,
cal_id: &str, cal_id: &str,
synctoken: i64, synctoken: i64,
skip_broken: bool,
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> { ) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> {
struct Row { struct Row {
object_id: String, object_id: String,
@@ -670,6 +718,8 @@ impl SqliteCalendarStore {
match Self::_get_object(&mut *conn, principal, cal_id, &object_id, false).await { match Self::_get_object(&mut *conn, principal, cal_id, &object_id, false).await {
Ok(object) => objects.push((object_id, object)), Ok(object) => objects.push((object_id, object)),
Err(rustical_store::Error::NotFound) => deleted_objects.push(object_id), Err(rustical_store::Error::NotFound) => deleted_objects.push(object_id),
// Skip broken object
Err(rustical_store::Error::IcalError(_)) if skip_broken => (),
Err(err) => return Err(err), Err(err) => return Err(err),
} }
} }
@@ -820,7 +870,16 @@ impl CalendarStore for SqliteCalendarStore {
cal_id: &str, cal_id: &str,
query: CalendarQuery, query: CalendarQuery,
) -> Result<Vec<(String, CalendarObject)>, Error> { ) -> Result<Vec<(String, CalendarObject)>, Error> {
Self::_calendar_query(&self.db, principal, cal_id, query).await let objects = Self::_calendar_query(&self.db, principal, cal_id, query).await?;
if self.skip_broken {
Ok(objects
.filter_map(|(id, res)| Some((id, res.ok()?)))
.collect())
} else {
Ok(objects
.map(|(id, res)| res.map(|obj| (id, obj)))
.collect::<Result<Vec<_>, _>>()?)
}
} }
async fn calendar_metadata( async fn calendar_metadata(
@@ -851,7 +910,16 @@ impl CalendarStore for SqliteCalendarStore {
principal: &str, principal: &str,
cal_id: &str, cal_id: &str,
) -> Result<Vec<(String, CalendarObject)>, Error> { ) -> Result<Vec<(String, CalendarObject)>, Error> {
Self::_get_objects(&self.db, principal, cal_id).await let objects = Self::_get_objects(&self.db, principal, cal_id).await?;
if self.skip_broken {
Ok(objects
.filter_map(|(id, res)| Some((id, res.ok()?)))
.collect())
} else {
Ok(objects
.map(|(id, res)| res.map(|obj| (id, obj)))
.collect::<Result<Vec<_>, _>>()?)
}
} }
#[instrument] #[instrument]
@@ -974,7 +1042,7 @@ impl CalendarStore for SqliteCalendarStore {
cal_id: &str, cal_id: &str,
synctoken: i64, synctoken: i64,
) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> { ) -> Result<(Vec<(String, CalendarObject)>, Vec<String>, i64), Error> {
Self::_sync_changes(&self.db, principal, cal_id, synctoken).await Self::_sync_changes(&self.db, principal, cal_id, synctoken, self.skip_broken).await
} }
fn is_read_only(&self, _cal_id: &str) -> bool { fn is_read_only(&self, _cal_id: &str) -> bool {

View File

@@ -18,7 +18,7 @@ impl From<sqlx::Error> for Error {
sqlx::Error::RowNotFound => Self::StoreError(rustical_store::Error::NotFound), sqlx::Error::RowNotFound => Self::StoreError(rustical_store::Error::NotFound),
sqlx::Error::Database(err) => { sqlx::Error::Database(err) => {
if err.is_unique_violation() { if err.is_unique_violation() {
warn!("{err:?}"); warn!("{err}");
Self::StoreError(rustical_store::Error::AlreadyExists) Self::StoreError(rustical_store::Error::AlreadyExists)
} else { } else {
Self::SqlxError(sqlx::Error::Database(err)) Self::SqlxError(sqlx::Error::Database(err))

View File

@@ -37,11 +37,11 @@ impl SqliteStore {
} }
pub async fn create_db_pool(db_url: &str, migrate: bool) -> Result<Pool<Sqlite>, sqlx::Error> { pub async fn create_db_pool(db_url: &str, migrate: bool) -> Result<Pool<Sqlite>, sqlx::Error> {
let options: SqliteConnectOptions = db_url.parse()?;
let db = SqlitePool::connect_with( let db = SqlitePool::connect_with(
SqliteConnectOptions::new() options
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.synchronous(sqlx::sqlite::SqliteSynchronous::Normal)
.filename(db_url)
.create_if_missing(true), .create_if_missing(true),
) )
.await?; .await?;

View File

@@ -1,6 +1,6 @@
use crate::{ use crate::{
SqliteStore, addressbook_store::SqliteAddressbookStore, calendar_store::SqliteCalendarStore, SqliteStore, addressbook_store::SqliteAddressbookStore, calendar_store::SqliteCalendarStore,
principal_store::SqlitePrincipalStore, create_db_pool, principal_store::SqlitePrincipalStore,
}; };
use rstest::fixture; use rstest::fixture;
use rustical_store::auth::{AuthenticationProvider, Principal, PrincipalType}; use rustical_store::auth::{AuthenticationProvider, Principal, PrincipalType};
@@ -9,12 +9,23 @@ use sqlx::SqlitePool;
mod addressbook_store; mod addressbook_store;
mod calendar_store; mod calendar_store;
async fn get_test_db() -> SqlitePool { #[derive(Debug, Clone)]
let db = SqlitePool::connect("sqlite::memory:").await.unwrap(); pub struct TestStoreContext {
sqlx::migrate!("./migrations").run(&db).await.unwrap(); pub db: SqlitePool,
pub addr_store: SqliteAddressbookStore,
pub cal_store: SqliteCalendarStore,
pub principal_store: SqlitePrincipalStore,
pub sub_store: SqliteStore,
}
#[fixture]
pub async fn test_store_context() -> TestStoreContext {
let (send_addr, _recv) = tokio::sync::mpsc::channel(1);
let (send_cal, _recv) = tokio::sync::mpsc::channel(1);
let db = create_db_pool(":memory:", true).await.unwrap();
// Populate with test data
let principal_store = SqlitePrincipalStore::new(db.clone()); let principal_store = SqlitePrincipalStore::new(db.clone());
// Populate with test data
principal_store principal_store
.insert_principal( .insert_principal(
Principal { Principal {
@@ -33,28 +44,11 @@ async fn get_test_db() -> SqlitePool {
.await .await
.unwrap(); .unwrap();
db
}
#[derive(Debug, Clone)]
pub struct TestStoreContext {
pub db: SqlitePool,
pub addr_store: SqliteAddressbookStore,
pub cal_store: SqliteCalendarStore,
pub principal_store: SqlitePrincipalStore,
pub sub_store: SqliteStore,
}
#[fixture]
pub async fn test_store_context() -> TestStoreContext {
let (send_addr, _recv) = tokio::sync::mpsc::channel(1);
let (send_cal, _recv) = tokio::sync::mpsc::channel(1);
let db = get_test_db().await;
TestStoreContext { TestStoreContext {
db: db.clone(), db: db.clone(),
addr_store: SqliteAddressbookStore::new(db.clone(), send_addr), addr_store: SqliteAddressbookStore::new(db.clone(), send_addr, false),
cal_store: SqliteCalendarStore::new(db.clone(), send_cal), cal_store: SqliteCalendarStore::new(db.clone(), send_cal, false),
principal_store: SqlitePrincipalStore::new(db.clone()), principal_store,
sub_store: SqliteStore::new(db), sub_store: SqliteStore::new(db),
} }
} }

View File

@@ -1,6 +1,6 @@
use quick_xml::name::Namespace; use quick_xml::name::Namespace;
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct NamespaceOwned(pub Vec<u8>); pub struct NamespaceOwned(pub Vec<u8>);
impl<'a> From<Namespace<'a>> for NamespaceOwned { impl<'a> From<Namespace<'a>> for NamespaceOwned {

View File

@@ -1,18 +1,21 @@
use std::io::BufRead; use std::io::BufRead;
use quick_xml::events::BytesStart; use quick_xml::{events::BytesStart, name::ResolveResult};
use crate::{XmlDeserialize, XmlError}; use crate::{NamespaceOwned, XmlDeserialize, XmlError};
// TODO: actually implement
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Unparsed(String); pub struct Unparsed(pub Option<NamespaceOwned>, pub String);
impl Unparsed { impl Unparsed {
#[must_use] #[must_use]
pub fn tag_name(&self) -> String { pub const fn ns(&self) -> Option<&NamespaceOwned> {
// TODO: respect namespace? self.0.as_ref()
self.0.clone() }
#[must_use]
pub const fn tag_name(&self) -> &str {
self.1.as_str()
} }
} }
@@ -27,7 +30,12 @@ impl XmlDeserialize for Unparsed {
let mut buf = vec![]; let mut buf = vec![];
reader.read_to_end_into(start.name(), &mut buf)?; reader.read_to_end_into(start.name(), &mut buf)?;
} }
let tag_name = String::from_utf8_lossy(start.local_name().as_ref()).to_string(); let (ns, tag_name) = reader.resolver().resolve_element(start.name());
Ok(Self(tag_name)) let ns: Option<NamespaceOwned> = match ns {
ResolveResult::Bound(ns) => Some(ns.into()),
ResolveResult::Unbound | ResolveResult::Unknown(_) => None,
};
let tag_name = String::from_utf8_lossy(tag_name.as_ref()).to_string();
Ok(Self(ns, tag_name))
} }
} }

View File

@@ -48,3 +48,26 @@ Since the app tokens are random they use the faster `pbkdf2` algorithm.
```sh ```sh
cargo install --locked --git https://github.com/lennart-k/rustical cargo install --locked --git https://github.com/lennart-k/rustical
``` ```
## NixOS (community-maintained by [@PopeRigby](https://github.com/PopeRigby))
!!! warning
The NixOS package is not maintained by myself but since I appreciate [@PopeRigby](https://github.com/PopeRigby)'s work on it I want to mention it.
Since rustical's development is still quite active I **strongly** recommend installing from the `nixpkgs-unstable` branch.
In the `nixpkgs-unstable` you'll find a `rustical` package you can install.
There's also a service that has not been merged yet. If you know how to add modules from PRs in Nix
you can already install it <https://github.com/NixOS/nixpkgs/pull/424188>
and then setup rustical as a service:
```nix title="In your configuration.nix"
services.rustical = {
enable = true;
package = inputs.rustical.legacyPackages.${pkgs.stdenv.hostPlatform.system}.rustical;
settings = {
# Settings the same as in config.toml but in Nix syntax
# http.port = 3002;
};
};
```

View File

@@ -9,7 +9,7 @@ use axum_extra::TypedHeader;
use headers::{HeaderMapExt, UserAgent}; use headers::{HeaderMapExt, UserAgent};
use http::header::CONNECTION; use http::header::CONNECTION;
use http::{HeaderValue, StatusCode}; use http::{HeaderValue, StatusCode};
use rustical_caldav::caldav_router; use rustical_caldav::{CalDavConfig, caldav_router};
use rustical_carddav::carddav_router; use rustical_carddav::carddav_router;
use rustical_frontend::nextcloud_login::nextcloud_login_router; use rustical_frontend::nextcloud_login::nextcloud_login_router;
use rustical_frontend::{FrontendConfig, frontend_router}; use rustical_frontend::{FrontendConfig, frontend_router};
@@ -32,7 +32,8 @@ use tracing::field::display;
#[allow( #[allow(
clippy::too_many_arguments, clippy::too_many_arguments,
clippy::too_many_lines, clippy::too_many_lines,
clippy::cognitive_complexity clippy::cognitive_complexity,
clippy::missing_panics_doc
)] )]
pub fn make_app< pub fn make_app<
AS: AddressbookStore + PrefixedCalendarStore, AS: AddressbookStore + PrefixedCalendarStore,
@@ -45,6 +46,7 @@ pub fn make_app<
auth_provider: Arc<impl AuthenticationProvider>, auth_provider: Arc<impl AuthenticationProvider>,
frontend_config: FrontendConfig, frontend_config: FrontendConfig,
oidc_config: Option<OidcConfig>, oidc_config: Option<OidcConfig>,
caldav_config: CalDavConfig,
nextcloud_login_config: &NextcloudLoginConfig, nextcloud_login_config: &NextcloudLoginConfig,
dav_push_enabled: bool, dav_push_enabled: bool,
session_cookie_samesite_strict: bool, session_cookie_samesite_strict: bool,
@@ -54,6 +56,8 @@ pub fn make_app<
let combined_cal_store = let combined_cal_store =
Arc::new(CombinedCalendarStore::new(cal_store).with_store(birthday_store)); Arc::new(CombinedCalendarStore::new(cal_store).with_store(birthday_store));
let caldav_config = Arc::new(caldav_config);
let mut router = Router::new() let mut router = Router::new()
// endpoint to be used by healthcheck to see if rustical is online // endpoint to be used by healthcheck to see if rustical is online
.route("/ping", axum::routing::get(async || "Pong!")) .route("/ping", axum::routing::get(async || "Pong!"))
@@ -63,6 +67,7 @@ pub fn make_app<
combined_cal_store.clone(), combined_cal_store.clone(),
subscription_store.clone(), subscription_store.clone(),
false, false,
caldav_config.clone(),
)) ))
.merge(caldav_router( .merge(caldav_router(
"/caldav-compat", "/caldav-compat",
@@ -70,6 +75,7 @@ pub fn make_app<
combined_cal_store.clone(), combined_cal_store.clone(),
subscription_store.clone(), subscription_store.clone(),
true, true,
caldav_config,
)) ))
.route( .route(
"/.well-known/caldav", "/.well-known/caldav",
@@ -104,9 +110,9 @@ pub fn make_app<
options(async || { options(async || {
let mut resp = Response::builder().status(StatusCode::OK); let mut resp = Response::builder().status(StatusCode::OK);
resp.headers_mut() resp.headers_mut()
.unwrap() .expect("this always works")
.insert("DAV", HeaderValue::from_static("1")); .insert("DAV", HeaderValue::from_static("1"));
resp.body(Body::empty()).unwrap() resp.body(Body::empty()).expect("empty body always works")
}), }),
); );

View File

@@ -2,11 +2,12 @@ use crate::config::HttpConfig;
use clap::Parser; use clap::Parser;
use http::Method; use http::Method;
#[derive(Parser, Debug)] #[derive(Parser, Debug, Default)]
pub struct HealthArgs {} pub struct HealthArgs {}
/// Healthcheck for running rustical instance /// Healthcheck for running rustical instance
/// Currently just pings to see if it's reachable via HTTP /// Currently just pings to see if it's reachable via HTTP
#[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
pub async fn cmd_health(http_config: HttpConfig, _health_args: HealthArgs) -> anyhow::Result<()> { pub async fn cmd_health(http_config: HttpConfig, _health_args: HealthArgs) -> anyhow::Result<()> {
let client = reqwest::ClientBuilder::new().build().unwrap(); let client = reqwest::ClientBuilder::new().build().unwrap();

View File

@@ -33,7 +33,8 @@ pub struct MembershipArgs {
command: MembershipCommand, command: MembershipCommand,
} }
pub async fn handle_membership_command( #[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
pub async fn cmd_membership(
user_store: &impl AuthenticationProvider, user_store: &impl AuthenticationProvider,
MembershipArgs { command }: MembershipArgs, MembershipArgs { command }: MembershipArgs,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {

View File

@@ -3,21 +3,28 @@ use crate::config::{
SqliteDataStoreConfig, TracingConfig, SqliteDataStoreConfig, TracingConfig,
}; };
use clap::Parser; use clap::Parser;
use rustical_caldav::CalDavConfig;
use rustical_frontend::FrontendConfig; use rustical_frontend::FrontendConfig;
pub mod health; mod health;
pub mod membership; pub mod membership;
pub mod principals; pub mod principals;
pub use health::{HealthArgs, cmd_health};
pub use principals::{PrincipalsArgs, cmd_principals};
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
pub struct GenConfigArgs {} pub struct GenConfigArgs {}
#[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
pub fn cmd_gen_config(_args: GenConfigArgs) -> anyhow::Result<()> { pub fn cmd_gen_config(_args: GenConfigArgs) -> anyhow::Result<()> {
let config = Config { let config = Config {
http: HttpConfig::default(), http: HttpConfig::default(),
caldav: CalDavConfig::default(),
data_store: DataStoreConfig::Sqlite(SqliteDataStoreConfig { data_store: DataStoreConfig::Sqlite(SqliteDataStoreConfig {
db_url: "/var/lib/rustical/db.sqlite3".to_owned(), db_url: "/var/lib/rustical/db.sqlite3".to_owned(),
run_repairs: true, run_repairs: true,
skip_broken: true,
}), }),
tracing: TracingConfig::default(), tracing: TracingConfig::default(),
frontend: FrontendConfig { frontend: FrontendConfig {

View File

@@ -1,56 +1,49 @@
use super::membership::{MembershipArgs, handle_membership_command}; use super::membership::MembershipArgs;
use crate::{config::Config, get_data_stores}; use crate::{config::Config, get_data_stores, membership::cmd_membership};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use figment::{
Figment,
providers::{Env, Format, Toml},
};
use password_hash::{PasswordHasher, SaltString, rand_core::OsRng}; use password_hash::{PasswordHasher, SaltString, rand_core::OsRng};
use rustical_store::auth::{AuthenticationProvider, Principal, PrincipalType}; use rustical_store::auth::{AuthenticationProvider, Principal, PrincipalType};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
pub struct PrincipalsArgs { pub struct PrincipalsArgs {
#[arg(short, long, env, default_value = "/etc/rustical/config.toml")]
config_file: String,
#[command(subcommand)] #[command(subcommand)]
command: Command, pub command: PrincipalsCommand,
} }
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
struct CreateArgs { pub struct CreateArgs {
id: String, pub id: String,
#[arg(value_enum, short, long)] #[arg(value_enum, short, long)]
principal_type: Option<PrincipalType>, pub principal_type: Option<PrincipalType>,
#[arg(short, long)] #[arg(short, long)]
name: Option<String>, pub name: Option<String>,
#[arg(long, help = "Ask for password input")] #[arg(long, help = "Ask for password input")]
password: bool, pub password: bool,
} }
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
struct RemoveArgs { pub struct RemoveArgs {
id: String, pub id: String,
} }
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
struct EditArgs { pub struct EditArgs {
id: String, pub id: String,
#[arg(long, help = "Ask for password input")] #[arg(long, help = "Ask for password input")]
password: bool, pub password: bool,
#[arg( #[arg(
long, long,
help = "Remove password (If you only want to use OIDC for example)" help = "Remove password (If you only want to use OIDC for example)"
)] )]
remove_password: bool, pub remove_password: bool,
#[arg(short, long, help = "Change principal displayname")] #[arg(short, long, help = "Change principal displayname")]
name: Option<String>, pub name: Option<String>,
#[arg(value_enum, short, long, help = "Change the principal type")] #[arg(value_enum, short, long, help = "Change the principal type")]
principal_type: Option<PrincipalType>, pub principal_type: Option<PrincipalType>,
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
enum Command { pub enum PrincipalsCommand {
List, List,
Create(CreateArgs), Create(CreateArgs),
Remove(RemoveArgs), Remove(RemoveArgs),
@@ -58,16 +51,12 @@ enum Command {
Membership(MembershipArgs), Membership(MembershipArgs),
} }
pub async fn cmd_principals(args: PrincipalsArgs) -> anyhow::Result<()> { #[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
let config: Config = Figment::new() pub async fn cmd_principals(args: PrincipalsArgs, config: Config) -> anyhow::Result<()> {
.merge(Toml::file(&args.config_file))
.merge(Env::prefixed("RUSTICAL_").split("__"))
.extract()?;
let (_, _, _, principal_store, _) = get_data_stores(true, &config.data_store).await?; let (_, _, _, principal_store, _) = get_data_stores(true, &config.data_store).await?;
match args.command { match args.command {
Command::List => { PrincipalsCommand::List => {
for principal in principal_store.get_principals().await? { for principal in principal_store.get_principals().await? {
println!( println!(
"{} (displayname={}) [{}]", "{} (displayname={}) [{}]",
@@ -77,7 +66,7 @@ pub async fn cmd_principals(args: PrincipalsArgs) -> anyhow::Result<()> {
); );
} }
} }
Command::Create(CreateArgs { PrincipalsCommand::Create(CreateArgs {
id, id,
principal_type, principal_type,
name, name,
@@ -111,11 +100,11 @@ pub async fn cmd_principals(args: PrincipalsArgs) -> anyhow::Result<()> {
.await?; .await?;
println!("Principal created"); println!("Principal created");
} }
Command::Remove(RemoveArgs { id }) => { PrincipalsCommand::Remove(RemoveArgs { id }) => {
principal_store.remove_principal(&id).await?; principal_store.remove_principal(&id).await?;
println!("Principal {id} removed"); println!("Principal {id} removed");
} }
Command::Edit(EditArgs { PrincipalsCommand::Edit(EditArgs {
id, id,
remove_password, remove_password,
password, password,
@@ -151,8 +140,8 @@ pub async fn cmd_principals(args: PrincipalsArgs) -> anyhow::Result<()> {
principal_store.insert_principal(principal, true).await?; principal_store.insert_principal(principal, true).await?;
println!("Principal {id} updated"); println!("Principal {id} updated");
} }
Command::Membership(args) => { PrincipalsCommand::Membership(args) => {
handle_membership_command(principal_store.as_ref(), args).await?; cmd_membership(principal_store.as_ref(), args).await?;
} }
} }
Ok(()) Ok(())

View File

@@ -1,3 +1,4 @@
use rustical_caldav::CalDavConfig;
use rustical_frontend::FrontendConfig; use rustical_frontend::FrontendConfig;
use rustical_oidc::OidcConfig; use rustical_oidc::OidcConfig;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -28,6 +29,8 @@ pub struct SqliteDataStoreConfig {
pub db_url: String, pub db_url: String,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub run_repairs: bool, pub run_repairs: bool,
#[serde(default = "default_true")]
pub skip_broken: bool,
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
@@ -95,4 +98,6 @@ pub struct Config {
pub dav_push: DavPushConfig, pub dav_push: DavPushConfig,
#[serde(default)] #[serde(default)]
pub nextcloud_login: NextcloudLoginConfig, pub nextcloud_login: NextcloudLoginConfig,
#[serde(default)]
pub caldav: CalDavConfig,
} }

View File

@@ -1,5 +0,0 @@
---
source: src/integration_tests/caldav/calendar.rs
expression: body
---

View File

@@ -1,5 +0,0 @@
---
source: src/integration_tests/caldav/calendar.rs
expression: body
---

View File

@@ -1,5 +0,0 @@
---
source: src/integration_tests/caldav/calendar_import.rs
expression: body
---

View File

@@ -1,5 +0,0 @@
---
source: src/integration_tests/caldav/calendar_import.rs
expression: body
---

View File

@@ -1,107 +0,0 @@
---
source: src/integration_tests/caldav/calendar_import.rs
expression: body
---
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:RustiCal
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060104T140000
DURATION:PT1H
RECURRENCE-ID;TZID=US/Eastern:20060104T120000
SUMMARY:Event #2 bis
UID:[UID]
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20060206T001102Z
DTSTART;TZID=US/Eastern:20060102T100000
DURATION:PT1H
SUMMARY:Event #1
Description:Go Steelers!
UID:[UID]
END:VEVENT
BEGIN:VEVENT
DTSTAMP:20060206T001121Z
DTSTART;TZID=US/Eastern:20060102T120000
DURATION:PT1H
RRULE:FREQ=DAILY;COUNT=5
SUMMARY:Event #2
UID:[UID]
END:VEVENT
BEGIN:VEVENT
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com
DTSTAMP:20060206T001220Z
DTSTART;TZID=US/Eastern:20060104T100000
DURATION:PT1H
LAST-MODIFIED:20060206T001330Z
ORGANIZER:mailto:cyrus@example.com
SEQUENCE:1
STATUS:TENTATIVE
SUMMARY:Event #3
UID:[UID]
END:VEVENT
BEGIN:VTODO
DTSTAMP:20060205T235335Z
DUE;VALUE=DATE:20060104
STATUS:NEEDS-ACTION
SUMMARY:Task #1
UID:[UID]
BEGIN:VALARM
ACTION:AUDIO
TRIGGER;RELATED=START:-PT10M
END:VALARM
END:VTODO
BEGIN:VTODO
DTSTAMP:20060205T235300Z
DUE;VALUE=DATE:20060106
LAST-MODIFIED:20060205T235308Z
SEQUENCE:1
STATUS:NEEDS-ACTION
SUMMARY:Task #2
UID:[UID]
BEGIN:VALARM
ACTION:AUDIO
TRIGGER;RELATED=START:-PT10M
END:VALARM
END:VTODO
BEGIN:VTODO
COMPLETED:20051223T122322Z
DTSTAMP:20060205T235400Z
DUE;VALUE=DATE:20051225
LAST-MODIFIED:20060205T235308Z
SEQUENCE:1
STATUS:COMPLETED
SUMMARY:Task #3
UID:[UID]
END:VTODO
BEGIN:VTODO
DTSTAMP:20060205T235600Z
DUE;VALUE=DATE:20060101
LAST-MODIFIED:20060205T235308Z
SEQUENCE:1
STATUS:CANCELLED
SUMMARY:Task #4
UID:[UID]
END:VTODO
END:VCALENDAR

View File

@@ -1,5 +0,0 @@
---
source: src/integration_tests/caldav/calendar_import.rs
expression: body
---

View File

@@ -1,5 +0,0 @@
---
source: src/integration_tests/carddav/addressbook.rs
expression: body
---

View File

@@ -1,5 +0,0 @@
---
source: src/integration_tests/carddav/addressbook.rs
expression: body
---

View File

@@ -1,5 +0,0 @@
---
source: src/integration_tests/carddav/addressbook.rs
expression: body
---

View File

@@ -1,13 +0,0 @@
---
source: src/integration_tests/carddav/addressbook_import.rs
expression: body
---
BEGIN:VCARD
VERSION:4.0
FN:Simon Perreault
N:Perreault;Simon;;;ing. jr,M.Sc.
BDAY:--0203
GENDER:M
EMAIL;TYPE=work:simon.perreault@viagenie.ca
UID:[UID]
END:VCARD

View File

@@ -1,5 +0,0 @@
---
source: src/integration_tests/carddav/addressbook_import.rs
expression: body
---

View File

@@ -1,5 +0,0 @@
---
source: src/integration_tests/carddav/mod.rs
expression: body
---

View File

@@ -1,5 +0,0 @@
---
source: src/integration_tests/carddav/mod.rs
expression: body
---

165
src/lib.rs Normal file
View File

@@ -0,0 +1,165 @@
#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
use crate::config::Config;
use anyhow::Result;
use app::make_app;
use axum::ServiceExt;
use axum::extract::Request;
use clap::{Parser, Subcommand};
use config::{DataStoreConfig, SqliteDataStoreConfig};
use rustical_dav_push::DavPushController;
use rustical_store::auth::AuthenticationProvider;
use rustical_store::{
AddressbookStore, CalendarStore, CollectionOperation, PrefixedCalendarStore, SubscriptionStore,
};
use rustical_store_sqlite::addressbook_store::SqliteAddressbookStore;
use rustical_store_sqlite::calendar_store::SqliteCalendarStore;
use rustical_store_sqlite::principal_store::SqlitePrincipalStore;
use rustical_store_sqlite::{SqliteStore, create_db_pool};
use setup_tracing::setup_tracing;
use std::sync::Arc;
use tokio::sync::Notify;
use tokio::sync::mpsc::Receiver;
use tower::Layer;
use tower_http::normalize_path::NormalizePathLayer;
use tracing::{info, warn};
pub mod app;
mod commands;
pub use commands::*;
pub mod config;
mod setup_tracing;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct Args {
#[arg(short, long, env, default_value = "/etc/rustical/config.toml")]
pub config_file: String,
#[arg(long, env, help = "Do no run database migrations (only for sql store)")]
pub no_migrations: bool,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Subcommand)]
pub enum Command {
GenConfig(commands::GenConfigArgs),
Principals(PrincipalsArgs),
#[command(
about = "Healthcheck for running instance (Used for HEALTHCHECK in Docker container)"
)]
Health(HealthArgs),
}
#[allow(clippy::missing_errors_doc)]
pub async fn get_data_stores(
migrate: bool,
config: &DataStoreConfig,
) -> Result<(
Arc<impl AddressbookStore + PrefixedCalendarStore>,
Arc<impl CalendarStore>,
Arc<impl SubscriptionStore>,
Arc<impl AuthenticationProvider>,
Receiver<CollectionOperation>,
)> {
Ok(match &config {
DataStoreConfig::Sqlite(SqliteDataStoreConfig {
db_url,
run_repairs,
skip_broken,
}) => {
let db = create_db_pool(db_url, migrate).await?;
// Channel to watch for changes (for DAV Push)
let (send, recv) = tokio::sync::mpsc::channel(1000);
let addressbook_store = Arc::new(SqliteAddressbookStore::new(
db.clone(),
send.clone(),
*skip_broken,
));
let cal_store = Arc::new(SqliteCalendarStore::new(db.clone(), send, *skip_broken));
if *run_repairs {
info!("Running repair tasks");
addressbook_store.repair_orphans().await?;
cal_store.repair_invalid_version_4_0().await?;
cal_store.repair_orphans().await?;
}
let subscription_store = Arc::new(SqliteStore::new(db.clone()));
let principal_store = Arc::new(SqlitePrincipalStore::new(db));
// Validate all calendar objects
for principal in principal_store.get_principals().await? {
cal_store.validate_objects(&principal.id).await?;
addressbook_store.validate_objects(&principal.id).await?;
}
(
addressbook_store,
cal_store,
subscription_store,
principal_store,
recv,
)
}
})
}
#[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
pub async fn cmd_default(
args: Args,
config: Config,
start_notifier: Option<Arc<Notify>>,
tracing: bool,
) -> Result<()> {
if tracing {
setup_tracing(&config.tracing);
}
let (addr_store, cal_store, subscription_store, principal_store, update_recv) =
get_data_stores(!args.no_migrations, &config.data_store).await?;
let mut tasks = vec![];
if config.dav_push.enabled {
let dav_push_controller = DavPushController::new(
config.dav_push.allowed_push_servers,
subscription_store.clone(),
);
tasks.push(tokio::spawn(async move {
dav_push_controller.notifier(update_recv).await;
}));
}
let app = make_app(
addr_store.clone(),
cal_store.clone(),
subscription_store.clone(),
principal_store.clone(),
config.frontend.clone(),
config.oidc.clone(),
config.caldav,
&config.nextcloud_login,
config.dav_push.enabled,
config.http.session_cookie_samesite_strict,
config.http.payload_limit_mb,
);
let app = ServiceExt::<Request>::into_make_service(
NormalizePathLayer::trim_trailing_slash().layer(app),
);
let address = format!("{}:{}", config.http.host, config.http.port);
let listener = tokio::net::TcpListener::bind(&address).await?;
tasks.push(tokio::spawn(async move {
info!("RustiCal serving on http://{address}");
if let Some(start_notifier) = start_notifier {
start_notifier.notify_waiters();
}
axum::serve(listener, app).await.unwrap();
}));
for task in tasks {
task.await?;
}
Ok(())
}

View File

@@ -1,103 +1,12 @@
#![warn(clippy::all, clippy::pedantic, clippy::nursery)] #![warn(clippy::all, clippy::pedantic, clippy::nursery)]
use crate::commands::health::{HealthArgs, cmd_health};
use crate::config::Config;
use anyhow::Result; use anyhow::Result;
use app::make_app; use clap::Parser;
use axum::ServiceExt;
use axum::extract::Request;
use clap::{Parser, Subcommand};
use commands::cmd_gen_config;
use commands::principals::{PrincipalsArgs, cmd_principals};
use config::{DataStoreConfig, SqliteDataStoreConfig};
use figment::Figment; use figment::Figment;
use figment::providers::{Env, Format, Toml}; use figment::providers::{Env, Format, Toml};
use rustical_dav_push::DavPushController; use rustical::config::Config;
use rustical_store::auth::AuthenticationProvider; use rustical::{Args, Command};
use rustical_store::{ use rustical::{cmd_default, cmd_gen_config, cmd_health, cmd_principals};
AddressbookStore, CalendarStore, CollectionOperation, PrefixedCalendarStore, SubscriptionStore, use tracing::warn;
};
use rustical_store_sqlite::addressbook_store::SqliteAddressbookStore;
use rustical_store_sqlite::calendar_store::SqliteCalendarStore;
use rustical_store_sqlite::principal_store::SqlitePrincipalStore;
use rustical_store_sqlite::{SqliteStore, create_db_pool};
use setup_tracing::setup_tracing;
use std::sync::Arc;
use tokio::sync::mpsc::Receiver;
use tower::Layer;
use tower_http::normalize_path::NormalizePathLayer;
use tracing::{info, warn};
mod app;
mod commands;
mod config;
#[cfg(test)]
pub mod integration_tests;
mod setup_tracing;
mod migration_0_12;
use migration_0_12::{validate_address_objects_0_12, validate_calendar_objects_0_12};
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(short, long, env, default_value = "/etc/rustical/config.toml")]
config_file: String,
#[arg(long, env, help = "Do no run database migrations (only for sql store)")]
no_migrations: bool,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
GenConfig(commands::GenConfigArgs),
Principals(PrincipalsArgs),
#[command(
about = "Healthcheck for running instance (Used for HEALTHCHECK in Docker container)"
)]
Health(HealthArgs),
}
async fn get_data_stores(
migrate: bool,
config: &DataStoreConfig,
) -> Result<(
Arc<impl AddressbookStore + PrefixedCalendarStore>,
Arc<impl CalendarStore>,
Arc<impl SubscriptionStore>,
Arc<impl AuthenticationProvider>,
Receiver<CollectionOperation>,
)> {
Ok(match &config {
DataStoreConfig::Sqlite(SqliteDataStoreConfig {
db_url,
run_repairs,
}) => {
let db = create_db_pool(db_url, migrate).await?;
// Channel to watch for changes (for DAV Push)
let (send, recv) = tokio::sync::mpsc::channel(1000);
let addressbook_store = Arc::new(SqliteAddressbookStore::new(db.clone(), send.clone()));
let cal_store = Arc::new(SqliteCalendarStore::new(db.clone(), send));
if *run_repairs {
info!("Running repair tasks");
addressbook_store.repair_orphans().await?;
cal_store.repair_invalid_version_4_0().await?;
cal_store.repair_orphans().await?;
}
let subscription_store = Arc::new(SqliteStore::new(db.clone()));
let principal_store = Arc::new(SqlitePrincipalStore::new(db));
(
addressbook_store,
cal_store,
subscription_store,
principal_store,
recv,
)
}
})
}
#[tokio::main(flavor = "multi_thread")] #[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<()> { async fn main() -> Result<()> {
@@ -111,67 +20,17 @@ async fn main() -> Result<()> {
}; };
match args.command { match args.command {
Some(Command::GenConfig(gen_config_args)) => cmd_gen_config(gen_config_args)?, Some(Command::GenConfig(gen_config_args)) => cmd_gen_config(gen_config_args),
Some(Command::Principals(principals_args)) => cmd_principals(principals_args).await?, Some(Command::Principals(principals_args)) => {
cmd_principals(principals_args, parse_config()?).await
}
Some(Command::Health(health_args)) => { Some(Command::Health(health_args)) => {
let config: Config = parse_config()?; let config: Config = parse_config()?;
cmd_health(config.http, health_args).await?; cmd_health(config.http, health_args).await
} }
None => { None => {
let config: Config = parse_config()?; let config: Config = parse_config()?;
cmd_default(args, config, None, true).await
setup_tracing(&config.tracing);
let (addr_store, cal_store, subscription_store, principal_store, update_recv) =
get_data_stores(!args.no_migrations, &config.data_store).await?;
warn!(
"Validating calendar data against the next-version ical parser.
In the next major release these will be rejected and cause errors.
If any errors occur, please open an issue so they can be fixed before the next major release."
);
validate_calendar_objects_0_12(principal_store.as_ref(), cal_store.as_ref()).await?;
validate_address_objects_0_12(principal_store.as_ref(), addr_store.as_ref()).await?;
let mut tasks = vec![];
if config.dav_push.enabled {
let dav_push_controller = DavPushController::new(
config.dav_push.allowed_push_servers,
subscription_store.clone(),
);
tasks.push(tokio::spawn(async move {
dav_push_controller.notifier(update_recv).await;
}));
}
let app = make_app(
addr_store.clone(),
cal_store.clone(),
subscription_store.clone(),
principal_store.clone(),
config.frontend.clone(),
config.oidc.clone(),
&config.nextcloud_login,
config.dav_push.enabled,
config.http.session_cookie_samesite_strict,
config.http.payload_limit_mb,
);
let app = ServiceExt::<Request>::into_make_service(
NormalizePathLayer::trim_trailing_slash().layer(app),
);
let address = format!("{}:{}", config.http.host, config.http.port);
let listener = tokio::net::TcpListener::bind(&address).await?;
tasks.push(tokio::spawn(async move {
info!("RustiCal serving on http://{address}");
axum::serve(listener, app).await.unwrap();
}));
for task in tasks {
task.await?;
} }
} }
} }
Ok(())
}

View File

@@ -1,76 +0,0 @@
use ical::parser::{ical::IcalObjectParser, vcard::VcardParser};
use rustical_store::{AddressbookStore, CalendarStore, auth::AuthenticationProvider};
use tracing::{error, info};
pub async fn validate_calendar_objects_0_12(
principal_store: &impl AuthenticationProvider,
cal_store: &impl CalendarStore,
) -> Result<(), rustical_store::Error> {
let mut success = true;
for principal in principal_store.get_principals().await? {
for calendar in cal_store.get_calendars(&principal.id).await? {
for (object_id, object) in cal_store
.get_objects(&calendar.principal, &calendar.id)
.await?
{
if let Err(err) =
IcalObjectParser::from_slice(object.get_ics().as_bytes()).expect_one()
{
success = false;
error!(
"An error occured parsing a calendar object: principal={principal}, calendar={calendar}, object_id={object_id}: {err}",
principal = principal.id,
calendar = calendar.id,
);
println!("{}", object.get_ics());
}
}
}
}
if success {
info!("Your calendar data seems to be valid in the next major version.");
} else {
error!(
"Not all calendar objects will be successfully parsed in the next major version (v0.12).
This will not cause issues in this version, but please comment under the tracking issue on GitHub:
https://github.com/lennart-k/rustical/issues/165"
);
}
Ok(())
}
pub async fn validate_address_objects_0_12(
principal_store: &impl AuthenticationProvider,
addr_store: &impl AddressbookStore,
) -> Result<(), rustical_store::Error> {
let mut success = true;
for principal in principal_store.get_principals().await? {
for addressbook in addr_store.get_addressbooks(&principal.id).await? {
for (object_id, object) in addr_store
.get_objects(&addressbook.principal, &addressbook.id)
.await?
{
if let Err(err) = VcardParser::from_slice(object.get_vcf().as_bytes()).expect_one()
{
success = false;
error!(
"An error occured parsing an address object: principal={principal}, addressbook={addressbook}, object_id={object_id}: {err}",
principal = principal.id,
addressbook = addressbook.id,
);
println!("{}", object.get_vcf());
}
}
}
}
if success {
info!("Your addressbook data seems to be valid in the next major version.");
} else {
error!(
"Not all address objects will be successfully parsed in the next major version (v0.12).
This will not cause issues in this version, but please comment under the tracking issue on GitHub:
https://github.com/lennart-k/rustical/issues/165"
);
}
Ok(())
}

84
tests/common/mod.rs Normal file
View File

@@ -0,0 +1,84 @@
use rustical::{
Args, cmd_default,
config::{Config, DataStoreConfig, HttpConfig, SqliteDataStoreConfig},
};
use std::{
collections::HashSet,
net::{Ipv4Addr, SocketAddrV4, TcpListener},
sync::{Arc, Mutex, OnceLock},
thread::{self, JoinHandle},
};
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
// When running multiple integration tests we need to make sure that they don't get the same port
static BOUND_PORTS: OnceLock<Mutex<HashSet<u16>>> = OnceLock::new();
pub fn find_free_port() -> Option<u16> {
let bound_ports = BOUND_PORTS.get_or_init(Mutex::default);
let mut bound_ports_write = bound_ports.lock().unwrap();
let mut port = 15000;
// Frees the socket on drop such that this function returns a free port
while TcpListener::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port)).is_err()
|| bound_ports_write.contains(&port)
{
port += 1;
if port >= 16000 {
return None;
}
}
bound_ports_write.insert(port);
Some(port)
}
pub fn rustical_process(
db_url: Option<String>,
) -> (CancellationToken, u16, JoinHandle<()>, Arc<Notify>) {
let port = find_free_port().unwrap();
let token = CancellationToken::new();
let cloned_token = token.clone();
let start_notify = Arc::new(Notify::new());
let cloned_start_notify = start_notify.clone();
let main_process = thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let fut = async {
cmd_default(
Args {
config_file: "asldajldakjsdkj".to_owned(),
no_migrations: false,
command: None,
},
Config {
data_store: DataStoreConfig::Sqlite(SqliteDataStoreConfig {
db_url: db_url.unwrap_or(":memory:".to_owned()),
run_repairs: true,
skip_broken: false,
}),
http: HttpConfig {
host: "127.0.0.1".to_owned(),
port,
..Default::default()
},
frontend: Default::default(),
oidc: None,
tracing: Default::default(),
dav_push: Default::default(),
nextcloud_login: Default::default(),
caldav: Default::default(),
},
Some(cloned_start_notify),
false,
)
.await
};
rt.block_on(async {
tokio::select! {
_ = cloned_token.cancelled() => {},
_ = fut => {}
}
});
});
(token, port, main_process, start_notify)
}

137
tests/http_integration.rs Normal file
View File

@@ -0,0 +1,137 @@
// This integration test checks whether the HTTP server works by actually running rustical in a new
// thread.
use common::rustical_process;
use http::{Method, StatusCode};
use rustical::{
PrincipalsArgs, cmd_health, cmd_principals,
config::{Config, DataStoreConfig, HttpConfig, SqliteDataStoreConfig},
principals::{CreateArgs, PrincipalsCommand},
};
use rustical_store::auth::{AuthenticationProvider, PrincipalType};
use rustical_store_sqlite::{create_db_pool, principal_store::SqlitePrincipalStore};
use std::time::Duration;
mod common;
pub async fn test_runner<O, F>(db_path: Option<String>, inner: F)
where
O: IntoFuture<Output = ()>,
// <O as IntoFuture>::IntoFuture: UnwindSafe,
F: FnOnce(u16) -> O,
{
// Start RustiCal process
let (token, port, main_process, start_notify) = rustical_process(db_path);
// Wait for RustiCal server to listen
tokio::time::timeout(Duration::new(2, 0), start_notify.notified())
.await
.unwrap();
// We use catch_unwind to make sure we'll always correctly stop RustiCal
// Otherwise, our process would just run indefinitely
inner(port).into_future().await;
// Signal RustiCal to stop
token.cancel();
main_process.join().unwrap();
}
#[tokio::test]
async fn test_ping() {
test_runner(None, async |port| {
let origin = format!("http://localhost:{port}");
let resp = reqwest::get(origin.clone() + "/ping").await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Ensure that path normalisation works as intended
let resp = reqwest::get(origin + "/ping/").await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
cmd_health(
HttpConfig {
host: "localhost".to_owned(),
port,
..Default::default()
},
Default::default(),
)
.await
.unwrap();
})
.await
}
// When setting a use password from the CLI we effectively have two processes accessing the same
// database: The server and the CLI.
// This test ensures that the server correctly picks up the changes made by the CLI.
#[tokio::test]
async fn test_initial_setup() {
let db_tempfile = tempfile::NamedTempFile::with_suffix(".rustical-test.sqlite3").unwrap();
let db_path = db_tempfile.path().to_string_lossy().into_owned();
test_runner(Some(db_path.clone()), async |port| {
let origin = format!("http://localhost:{port}");
// Create principal
cmd_principals(
PrincipalsArgs {
command: PrincipalsCommand::Create(CreateArgs {
id: "user".to_owned(),
name: Some("Test User".to_owned()),
password: false,
principal_type: Some(PrincipalType::Individual),
}),
},
Config {
data_store: DataStoreConfig::Sqlite(SqliteDataStoreConfig {
db_url: db_path.clone(),
run_repairs: true,
skip_broken: false,
}),
http: Default::default(),
frontend: Default::default(),
oidc: None,
tracing: Default::default(),
dav_push: Default::default(),
nextcloud_login: Default::default(),
caldav: Default::default(),
},
)
.await
.unwrap();
// Bodge to set password without using command (since that reads stdin)
let db = create_db_pool(&db_path, false).await.unwrap();
let principal_store = SqlitePrincipalStore::new(db);
let app_token = "token";
principal_store
.add_app_token("user", "Test Token".to_owned(), app_token.to_owned())
.await
.unwrap();
let url = origin.clone() + "/caldav/principal/user";
let resp = reqwest::Client::new()
.request(Method::from_bytes(b"PROPFIND").unwrap(), &url)
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let resp = reqwest::Client::new()
.request(Method::from_bytes(b"PROPFIND").unwrap(), &url)
.basic_auth("user", Some("token"))
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::MULTI_STATUS);
principal_store.remove_principal("user").await.unwrap();
let resp = reqwest::Client::new()
.request(Method::from_bytes(b"PROPFIND").unwrap(), &url)
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
})
.await;
}

View File

@@ -1,4 +1,4 @@
use crate::integration_tests::{ResponseExtractString, get_app}; use super::{ResponseExtractString, get_app};
use axum::body::Body; use axum::body::Body;
use axum::extract::Request; use axum::extract::Request;
use headers::{Authorization, HeaderMapExt}; use headers::{Authorization, HeaderMapExt};

View File

@@ -1,4 +1,4 @@
use crate::integration_tests::{ResponseExtractString, get_app}; use super::{ResponseExtractString, get_app};
use axum::body::Body; use axum::body::Body;
use axum::extract::Request; use axum::extract::Request;
use headers::{Authorization, HeaderMapExt}; use headers::{Authorization, HeaderMapExt};

View File

@@ -1,3 +1,4 @@
use super::{ResponseExtractString, calendar::mkcalendar_template, get_app};
use axum::body::Body; use axum::body::Body;
use headers::{Authorization, HeaderMapExt}; use headers::{Authorization, HeaderMapExt};
use http::{Request, StatusCode}; use http::{Request, StatusCode};
@@ -6,10 +7,6 @@ use rustical_store::CalendarMetadata;
use rustical_store_sqlite::tests::{TestStoreContext, test_store_context}; use rustical_store_sqlite::tests::{TestStoreContext, test_store_context};
use tower::ServiceExt; use tower::ServiceExt;
use crate::integration_tests::{
ResponseExtractString, caldav::calendar::mkcalendar_template, get_app,
};
#[rstest] #[rstest]
#[tokio::test] #[tokio::test]
async fn test_put_invalid( async fn test_put_invalid(
@@ -66,7 +63,7 @@ END:VCALENDAR";
.typed_insert(Authorization::basic("user", "pass")); .typed_insert(Authorization::basic("user", "pass"));
let response = app.clone().oneshot(request).await.unwrap(); let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED); assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = response.extract_string().await; let body = response.extract_string().await;
insta::assert_snapshot!(body, @r#" insta::assert_snapshot!(body, @r#"
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>

View File

@@ -1,4 +1,4 @@
use crate::integration_tests::{ResponseExtractString, get_app}; use super::{ResponseExtractString, get_app};
use axum::body::Body; use axum::body::Body;
use axum::extract::Request; use axum::extract::Request;
use headers::{Authorization, HeaderMapExt}; use headers::{Authorization, HeaderMapExt};

View File

@@ -1,4 +1,4 @@
use crate::integration_tests::{ResponseExtractString, get_app}; use super::{ResponseExtractString, get_app};
use axum::body::Body; use axum::body::Body;
use axum::extract::Request; use axum::extract::Request;
use headers::{Authorization, HeaderMapExt}; use headers::{Authorization, HeaderMapExt};

View File

@@ -0,0 +1,5 @@
---
source: tests/integration_tests/caldav/calendar.rs
expression: body
---

View File

@@ -1,5 +1,5 @@
--- ---
source: src/integration_tests/caldav/calendar.rs source: tests/integration_tests/caldav/calendar.rs
expression: body expression: body
--- ---
BEGIN:VCALENDAR BEGIN:VCALENDAR

View File

@@ -0,0 +1,5 @@
---
source: tests/integration_tests/caldav/calendar.rs
expression: body
---

View File

@@ -1,5 +1,5 @@
--- ---
source: src/integration_tests/caldav/calendar.rs source: tests/integration_tests/caldav/calendar.rs
expression: body expression: body
--- ---
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
@@ -8,7 +8,7 @@ expression: body
<href>/caldav/principal/user/calendar/qwue23489.ics</href> <href>/caldav/principal/user/calendar/qwue23489.ics</href>
<propstat> <propstat>
<prop> <prop>
<getetag>&quot;aea50382a7775bb9742bfec277382e3a260b6066f503b5f5ae34548d7215ee46&quot;</getetag> <getetag>&quot;f781224669f0db2674e9e45a9be2b01774c02136e3fb72792ef217bccf49fafa&quot;</getetag>
</prop> </prop>
<status>HTTP/1.1 200 OK</status> <status>HTTP/1.1 200 OK</status>
</propstat> </propstat>

View File

@@ -1,5 +1,5 @@
--- ---
source: src/integration_tests/caldav/calendar.rs source: tests/integration_tests/caldav/calendar.rs
expression: body expression: body
--- ---
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
@@ -14,9 +14,10 @@ expression: body
PRODID:-//github.com/lennart-k/vzic-rs//RustiCal Calendar server//EN PRODID:-//github.com/lennart-k/vzic-rs//RustiCal Calendar server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VTIMEZONE BEGIN:VTIMEZONE
TZID:America/New_York TZID:US/Eastern
LAST-MODIFIED:20250723T190331Z TZID-ALIAS-OF:America/New_York
X-LIC-LOCATION:America/New_York LAST-MODIFIED:20260124T185655Z
X-LIC-LOCATION:US/Eastern
X-PROLEPTIC-TZNAME:LMT X-PROLEPTIC-TZNAME:LMT
BEGIN:STANDARD BEGIN:STANDARD
TZNAME:EST TZNAME:EST

View File

@@ -1,5 +1,5 @@
--- ---
source: src/integration_tests/caldav/calendar.rs source: tests/integration_tests/caldav/calendar.rs
expression: body expression: body
--- ---
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>

View File

@@ -1,5 +1,5 @@
--- ---
source: src/integration_tests/caldav/calendar_import.rs source: tests/integration_tests/caldav/calendar_import.rs
expression: body expression: body
--- ---
BEGIN:VCALENDAR BEGIN:VCALENDAR

View File

@@ -0,0 +1,5 @@
---
source: tests/integration_tests/caldav/calendar_import.rs
expression: body
---

View File

@@ -1,5 +1,5 @@
--- ---
source: src/integration_tests/caldav/calendar_import.rs source: tests/integration_tests/caldav/calendar_import.rs
expression: body expression: body
--- ---
BEGIN:VCALENDAR BEGIN:VCALENDAR

View File

@@ -0,0 +1,5 @@
---
source: tests/integration_tests/caldav/calendar_import.rs
expression: body
---

View File

@@ -1,5 +1,5 @@
--- ---
source: src/integration_tests/caldav/calendar_report.rs source: tests/integration_tests/caldav/calendar_report.rs
expression: body expression: body
--- ---
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>

View File

@@ -1,5 +1,5 @@
--- ---
source: src/integration_tests/caldav/calendar_report.rs source: tests/integration_tests/caldav/calendar_report.rs
expression: body expression: body
--- ---
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>

View File

@@ -1,5 +1,5 @@
--- ---
source: src/integration_tests/caldav/calendar_report.rs source: tests/integration_tests/caldav/calendar_report.rs
expression: body expression: body
--- ---
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>

View File

@@ -1,5 +1,5 @@
--- ---
source: src/integration_tests/caldav/mod.rs source: tests/integration_tests/caldav/mod.rs
expression: body expression: body
--- ---
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>

View File

@@ -1,5 +1,5 @@
--- ---
source: src/integration_tests/caldav/mod.rs source: tests/integration_tests/caldav/mod.rs
expression: body expression: body
--- ---
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>

View File

@@ -1,5 +1,5 @@
--- ---
source: src/integration_tests/caldav/mod.rs source: tests/integration_tests/caldav/mod.rs
expression: body expression: body
--- ---
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>

View File

@@ -1,4 +1,4 @@
use crate::integration_tests::{ResponseExtractString, get_app}; use super::{ResponseExtractString, get_app};
use axum::body::Body; use axum::body::Body;
use axum::extract::Request; use axum::extract::Request;
use headers::{Authorization, HeaderMapExt}; use headers::{Authorization, HeaderMapExt};

View File

@@ -1,4 +1,4 @@
use crate::integration_tests::{ResponseExtractString, get_app}; use super::{ResponseExtractString, get_app};
use axum::body::Body; use axum::body::Body;
use axum::extract::Request; use axum::extract::Request;
use headers::{Authorization, HeaderMapExt}; use headers::{Authorization, HeaderMapExt};
@@ -27,11 +27,10 @@ async fn test_import(
.body(Body::from( .body(Body::from(
r"BEGIN:VCARD r"BEGIN:VCARD
VERSION:4.0 VERSION:4.0
FN:Simon Perreault FN:John Doe
N:Perreault;Simon;;;ing. jr,M.Sc. N:Doe;John;;;,
BDAY:--0203 BDAY:--0203
GENDER:M GENDER:M
EMAIL;TYPE=work:simon.perreault@viagenie.ca
END:VCARD", END:VCARD",
)) ))
.unwrap() .unwrap()

View File

@@ -1,4 +1,4 @@
use crate::integration_tests::{ResponseExtractString, get_app}; use super::{ResponseExtractString, get_app};
use axum::body::Body; use axum::body::Body;
use axum::extract::Request; use axum::extract::Request;
use headers::{Authorization, HeaderMapExt}; use headers::{Authorization, HeaderMapExt};

View File

@@ -0,0 +1,5 @@
---
source: tests/integration_tests/carddav/addressbook.rs
expression: body
---

View File

@@ -0,0 +1,5 @@
---
source: tests/integration_tests/carddav/addressbook.rs
expression: body
---

View File

@@ -0,0 +1,5 @@
---
source: tests/integration_tests/carddav/addressbook.rs
expression: body
---

View File

@@ -1,5 +1,5 @@
--- ---
source: src/integration_tests/carddav/addressbook.rs source: tests/integration_tests/carddav/addressbook.rs
expression: body expression: body
--- ---
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>

View File

@@ -1,5 +1,5 @@
--- ---
source: src/integration_tests/carddav/addressbook.rs source: tests/integration_tests/carddav/addressbook.rs
expression: body expression: body
--- ---
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>

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