Compare commits

...

40 Commits

Author SHA1 Message Date
Lennart
0415664ff3 calendar_store: Fix deleted objects being returned 2025-06-15 16:31:07 +02:00
Lennart
677e0082fa multistatus response: Set No-Cache 2025-06-15 13:16:37 +02:00
Lennart
a387885b0a Remove calendar-proxy-write from caldav principal 2025-06-15 11:44:44 +02:00
Lennart
990b953055 Fix typo on store preventing us from deleting calendar objects 2025-06-15 10:37:51 +02:00
Lennart
36b47a645d Fix missing ece backend, finally managed to statically link openssl 2025-06-14 22:26:01 +02:00
Lennart
aa02d11f58 Increase version number to 0.3.0 2025-06-14 20:33:25 +02:00
Lennart
1c31323512 Remove optional dependencies to remove openssl dependency 2025-06-14 20:32:10 +02:00
Lennart
03ae492483 Implement DAV Push 2025-06-14 20:24:50 +02:00
Lennart
0c48507f0c dav: Fix Destination header percent decoding 2025-06-14 16:49:34 +02:00
Lennart
829d4a4385 dav: MOVE/COPY remove origin from Destination header 2025-06-14 15:46:39 +02:00
Lennart
4fe28c5b0f dav: Make MethodFunction public 2025-06-14 15:24:23 +02:00
Lennart
529f36ad99 dav: Convert is_collection const to function which will make filesystem access easier 2025-06-14 15:21:10 +02:00
Lennart
ca5891314c Forgot to commit Cargo.lock 2025-06-14 14:58:33 +02:00
Lennart
e653c68cae Set log level for 404 2025-06-14 14:57:42 +02:00
Lennart
26941c621b Update version to v0.2.2 2025-06-14 14:44:47 +02:00
Lennart
86ab6ef75e dav: Add interface for copy and move 2025-06-14 14:44:10 +02:00
Lennart
0669d4e683 fix dumb mistake 2025-06-13 18:27:16 +02:00
Lennart
0c432d70f9 frontend: Introduce Web Components for forms 2025-06-13 18:24:04 +02:00
Lennart
54997ef865 MKCOL: Set empty displayname to None 2025-06-13 18:23:32 +02:00
Lennart
1a1deeb5a2 mkcalendar: Support subscription url 2025-06-13 18:06:38 +02:00
Lennart
87899738f6 Add dev feature to serve static files from source 2025-06-13 14:57:53 +02:00
Lennart
ab90e5129c Update README.md 2025-06-12 21:06:34 +02:00
Lennart
a9cb397f57 Update README.md 2025-06-12 21:05:37 +02:00
Lennart
35e78bfb44 Update .sqlx files 2025-06-12 21:03:37 +02:00
Lennart
b6ef2b4c05 Update documentation given the changes to memberships 2025-06-12 20:59:50 +02:00
Lennart
32bc8c707d Add group-membership to both caldav and carddav and fix addressbook-home-set for shared principals 2025-06-12 20:55:22 +02:00
Lennart
1757bbee13 carddav: Remove members from addressbook-home-set 2025-06-12 20:12:17 +02:00
Lennart
4dbc316e64 Remove member principals from calendar-home-set 2025-06-12 20:10:14 +02:00
Lennart
4705170dbc Update .sqlx files 2025-06-12 20:05:51 +02:00
Lennart
0e2f08d7f2 caldav: Add some access control-related properties and advertise calendar-proxy 2025-06-12 19:51:02 +02:00
Lennart
feb8b3ff09 Add member search to user store 2025-06-12 19:50:32 +02:00
Lennart
41d5c72e4e Fix and simplify support-report-set 2025-06-12 17:39:42 +02:00
Lennart
89adbcf13f xml: Fix default namespace prefixing for enum variants 2025-06-12 17:38:56 +02:00
Lennart
5a3a2c0909 Fix TagList not writing the <prop> wrapper 2025-06-12 16:18:33 +02:00
Lennart
3e8fffa316 Fix xml PropName such that the rename attribute also propagates to the prop name 2025-06-12 16:07:32 +02:00
Lennart
40e7bc0f66 Fix tests 2025-06-12 15:33:49 +02:00
Lennart
f857d68760 principal: Implement principal-collection-set 2025-06-12 15:31:34 +02:00
Lennart
9e5eaa5e1c Fix bug where principal collections would return information about the requesting user instead of the principal resource 2025-06-12 15:23:02 +02:00
Lennart
7c73223877 dav: Implement some principal props for WebDAV ACL 2025-06-12 15:00:54 +02:00
Lennart K
0c1c04d1cd dav: Move displayname to common properties 2025-06-12 14:39:16 +02:00
95 changed files with 6111 additions and 801 deletions

1
.gitattributes vendored
View File

@@ -1,2 +1,3 @@
# Otherwise GitHub thinks this is an HTML project
crates/frontend/public/assets/licenses.html linguist-detectable=false
crates/frontend/public/assets/js/* linguist-detectable=false

4
.gitignore vendored
View File

@@ -12,3 +12,7 @@ principals.toml
.env
site
# Frontend
**/node_modules
**/.vite

View File

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

View File

@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT principal FROM memberships WHERE member_of = ?",
"describe": {
"columns": [
{
"name": "principal",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "3b00b59f047e534a7f7f654984dc880f4aa9281aae5974722d2f22ec6d15cb32"
}

View File

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

161
Cargo.lock generated
View File

@@ -759,6 +759,23 @@ dependencies = [
"spki",
]
[[package]]
name = "ece"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2ea1d2f2cc974957a4e2575d8e5bb494549bab66338d6320c2789abcfff5746"
dependencies = [
"base64 0.21.7",
"byteorder",
"hex",
"hkdf",
"lazy_static",
"once_cell",
"openssl",
"sha2",
"thiserror 1.0.69",
]
[[package]]
name = "ed25519"
version = "2.2.3"
@@ -903,6 +920,21 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.1"
@@ -1222,6 +1254,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1616,6 +1654,18 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "matchit-serde"
version = "0.1.0"
source = "git+https://github.com/lennart-k/matchit-serde?rev=f0591d13#f0591d139ea1c88fa4ee397f3fcb4225fad4c6dc"
dependencies = [
"derive_more",
"matchit",
"percent-encoding",
"serde",
"thiserror 2.0.12",
]
[[package]]
name = "md-5"
version = "0.10.6"
@@ -1803,6 +1853,54 @@ dependencies = [
"url",
]
[[package]]
name = "openssl"
version = "0.10.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-src"
version = "300.5.0+3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
[[package]]
name = "opentelemetry"
version = "0.30.0"
@@ -2638,7 +2736,7 @@ dependencies = [
[[package]]
name = "rustical"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"anyhow",
"argon2",
@@ -2681,7 +2779,7 @@ dependencies = [
[[package]]
name = "rustical_caldav"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"async-trait",
"axum",
@@ -2703,6 +2801,8 @@ dependencies = [
"rustical_xml",
"serde",
"sha2",
"strum",
"strum_macros",
"thiserror 2.0.12",
"tokio",
"tower",
@@ -2714,7 +2814,7 @@ dependencies = [
[[package]]
name = "rustical_carddav"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"async-trait",
"axum",
@@ -2733,6 +2833,8 @@ dependencies = [
"rustical_store",
"rustical_xml",
"serde",
"strum",
"strum_macros",
"thiserror 2.0.12",
"tokio",
"tower",
@@ -2744,7 +2846,7 @@ dependencies = [
[[package]]
name = "rustical_dav"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"async-trait",
"axum",
@@ -2755,9 +2857,12 @@ dependencies = [
"http",
"itertools 0.14.0",
"log",
"matchit",
"matchit-serde",
"quick-xml",
"rustical_xml",
"serde",
"strum",
"thiserror 2.0.12",
"tokio",
"tower",
@@ -2766,15 +2871,21 @@ dependencies = [
[[package]]
name = "rustical_dav_push"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"derive_more",
"ece",
"futures-util",
"http",
"itertools 0.14.0",
"log",
"openssl",
"p256",
"quick-xml",
"rand 0.9.1",
"reqwest",
"rustical_dav",
"rustical_store",
@@ -2787,7 +2898,7 @@ dependencies = [
[[package]]
name = "rustical_frontend"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"askama",
"askama_web",
@@ -2811,6 +2922,7 @@ dependencies = [
"thiserror 2.0.12",
"tokio",
"tower",
"tower-http",
"tower-sessions",
"tracing",
"url",
@@ -2819,7 +2931,7 @@ dependencies = [
[[package]]
name = "rustical_ical"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"axum",
"chrono",
@@ -2837,7 +2949,7 @@ dependencies = [
[[package]]
name = "rustical_oidc"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"async-trait",
"axum",
@@ -2852,7 +2964,7 @@ dependencies = [
[[package]]
name = "rustical_store"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"anyhow",
"async-trait",
@@ -2886,7 +2998,7 @@ dependencies = [
[[package]]
name = "rustical_store_sqlite"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"async-trait",
"chrono",
@@ -2906,7 +3018,7 @@ dependencies = [
[[package]]
name = "rustical_xml"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"quick-xml",
"thiserror 2.0.12",
@@ -3435,6 +3547,25 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32"
[[package]]
name = "strum_macros"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -3752,12 +3883,20 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",

View File

@@ -2,7 +2,7 @@
members = ["crates/*"]
[workspace.package]
version = "0.1.0"
version = "0.3.0"
edition = "2024"
description = "A CalDAV server"
repository = "https://github.com/lennart-k/rustical"
@@ -20,6 +20,7 @@ publish = false
[features]
debug = ["opentelemetry"]
frontend-dev = ["rustical_frontend/dev"]
opentelemetry = [
"dep:opentelemetry",
"dep:opentelemetry-otlp",
@@ -33,6 +34,7 @@ opentelemetry = [
debug = 0
[workspace.dependencies]
matchit = "0.8"
uuid = { version = "1.11", features = ["v4", "fast-rng"] }
async-trait = "0.1"
axum = "0.8"
@@ -132,6 +134,12 @@ reqwest = { version = "0.12", features = [
], default-features = false }
openidconnect = "4.0"
clap = { version = "4.5", features = ["derive", "env"] }
matchit-serde = { git = "https://github.com/lennart-k/matchit-serde", rev = "f0591d13" }
ece = { version = "2.3", default-features = false, features = [
"backend-openssl",
] }
openssl = { version = "0.10", features = ["vendored"] }
p256 = { version = "0.13", features = ["ecdh"] }
[dependencies]
rustical_store = { workspace = true }

View File

@@ -16,7 +16,7 @@ RUN case $TARGETPLATFORM in \
*) echo "Unsupported platform ${TARGETPLATFORM}"; exit 1;; \
esac
RUN apk add --no-cache musl-dev llvm19 clang \
RUN apk add --no-cache musl-dev llvm19 clang perl pkgconf make \
&& rustup target add "$(cat /tmp/rust_target)" \
&& cargo install cargo-chef --locked \
&& rm -rf "$CARGO_HOME/registry"

View File

@@ -6,13 +6,13 @@ a CalDAV/CardDAV server
RustiCal is **not production-ready!**
While I've started migrating to RustiCal and becoming more confident,
please know that bugs and rough edges will still occur.
Concretely, if you are using Apple Calendar you will want to stay away from assigning groups to users.
If you still want to play around with it in its current state, absolutely feel free to do so and to open up an issue if something is not working. :)
## Features
- easy to backup, everything saved in one SQLite database
- ~~[WebDAV Push](https://github.com/bitfireAT/webdav-push/) support, so near-instant synchronisation to DAVx5~~ (currently broken)
- also export feature in the frontend
- [WebDAV Push](https://github.com/bitfireAT/webdav-push/) support, so near-instant synchronisation to DAVx5
- lightweight (the container image contains only one binary)
- adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks)
- deleted calendars are recoverable
@@ -29,4 +29,4 @@ a CalDAV/CardDAV server
- DAVx5,
- GNOME Accounts, GNOME Calendar, GNOME Contacts
- Evolution
- Apple Calendar (known issue: If a user is member of multiple groups then Apple Calendar just randomly selects a calendar home)
- Apple Calendar

View File

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

View File

@@ -35,3 +35,5 @@ rustical_ical.workspace = true
http.workspace = true
headers.workspace = true
tower-http.workspace = true
strum.workspace = true
strum_macros.workspace = true

View File

@@ -4,6 +4,7 @@ use crate::calendar::prop::SupportedCalendarComponentSet;
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use http::{Method, StatusCode};
use rustical_dav::xml::HrefElement;
use rustical_ical::CalendarObjectType;
use rustical_store::auth::User;
use rustical_store::{Calendar, CalendarStore, SubscriptionStore};
@@ -29,6 +30,8 @@ pub struct MkcolCalendarProp {
resourcetype: Option<Unparsed>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
supported_calendar_component_set: Option<SupportedCalendarComponentSet>,
#[xml(ns = "rustical_dav::namespace::NS_CALENDARSERVER")]
source: Option<HrefElement>,
// Ignore that property, we don't support it but also don't want to throw an error
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
#[allow(dead_code)]
@@ -69,12 +72,16 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
return Err(Error::Unauthorized);
}
let request = match method.as_str() {
let mut request = match method.as_str() {
"MKCALENDAR" => MkcalendarRequest::parse_str(&body)?.set.prop,
"MKCOL" => MkcolRequest::parse_str(&body)?.set.prop,
_ => unreachable!("We never call with another method"),
};
if let Some("") = request.displayname.as_deref() {
request.displayname = None
}
let calendar = Calendar {
id: cal_id.to_owned(),
principal: principal.to_owned(),
@@ -86,7 +93,7 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
description: request.calendar_description,
deleted_at: None,
synctoken: 0,
subscription_url: None,
subscription_url: request.source.map(|href| href.href),
push_topic: uuid::Uuid::new_v4().to_string(),
components: request
.supported_calendar_component_set

View File

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

View File

@@ -1,8 +1,9 @@
use crate::Error;
use crate::calendar::resource::{CalendarResource, CalendarResourceService};
use crate::calendar::CalendarResourceService;
use crate::calendar::resource::CalendarResource;
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use http::{HeaderMap, StatusCode, header};
use http::{HeaderMap, HeaderValue, StatusCode, header};
use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource;
use rustical_dav_push::register::PushRegister;
@@ -73,20 +74,17 @@ pub async fn route_post<C: CalendarStore, S: SubscriptionStore>(
.upsert_subscription(subscription)
.await?;
// let location = req
// .resource_map()
// .url_for(&req, "subscription", &[sub_id])
// .unwrap();
//
let location = "asd";
// TODO: make nicer
let location = format!("/push_subscription/{sub_id}");
Ok((
StatusCode::CREATED,
HeaderMap::from_iter([(header::LOCATION, location)]),
HeaderMap::from_iter([
(header::LOCATION, HeaderValue::from_str(&location).unwrap()),
(
header::EXPIRES,
HeaderValue::from_str(&expires.to_rfc2822()).unwrap(),
),
]),
)
.into_response());
Ok(HttpResponse::Created()
.append_header((header::LOCATION, location.to_string()))
.append_header((header::EXPIRES, expires.to_rfc2822()))
.finish())
.into_response())
}

View File

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

View File

@@ -149,7 +149,7 @@ mod tests {
use super::*;
use crate::calendar_object::{CalendarData, CalendarObjectPropName, ExpandElement};
use calendar_query::{CompFilterElement, FilterElement, TimeRangeElement};
use rustical_dav::xml::PropElement;
use rustical_dav::{extensions::CommonPropertiesPropName, xml::PropElement};
use rustical_ical::UtcDateTime;
use rustical_xml::{NamespaceOwned, ValueDeserialize};
@@ -160,7 +160,6 @@ mod tests {
<calendar-multiget xmlns="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">
<D:prop>
<D:getetag/>
<D:displayname/>
<calendar-data>
<expand start="20250426T220000Z" end="20250503T220000Z"/>
</calendar-data>
@@ -180,7 +179,7 @@ mod tests {
end: <UtcDateTime as ValueDeserialize>::deserialize("20250503T220000Z").unwrap(),
}), limit_recurrence_set: None, limit_freebusy_set: None }
)),
], vec![(Some(NamespaceOwned(Vec::from("DAV:"))), "displayname".to_string())])),
], vec![])),
href: vec![
"/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b".to_owned()
]
@@ -253,6 +252,7 @@ mod tests {
<D:prop>
<D:getetag/>
<D:displayname/>
<D:invalid-prop/>
</D:prop>
<D:href>/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b</D:href>
</calendar-multiget>
@@ -263,7 +263,8 @@ mod tests {
ReportRequest::CalendarMultiget(CalendarMultigetRequest {
prop: rustical_dav::xml::PropfindType::Prop(PropElement(vec![
CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::Getetag),
], vec![(Some(NamespaceOwned(Vec::from("DAV:"))), "displayname".to_string())])),
CalendarObjectPropWrapperName::Common(CommonPropertiesPropName::Displayname),
], vec![(Some(NamespaceOwned(Vec::from("DAV:"))), "invalid-prop".to_string())])),
href: vec![
"/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b".to_owned()
]

View File

@@ -1,6 +1,7 @@
use derive_more::derive::{From, Into};
use rustical_ical::CalendarObjectType;
use rustical_xml::{XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray;
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, From, Into)]
pub struct SupportedCalendarComponent {
@@ -58,39 +59,12 @@ pub struct SupportedCalendarData {
calendar_data: CalendarData,
}
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
#[derive(Debug, Clone, XmlSerialize, PartialEq, VariantArray)]
pub enum ReportMethod {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarQuery,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarMultiget,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
SyncCollection,
}
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub struct ReportWrapper {
report: ReportMethod,
}
// RFC 3253 section-3.1.5
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub struct SupportedReportSet {
#[xml(flatten)]
supported_report: Vec<ReportWrapper>,
}
impl Default for SupportedReportSet {
fn default() -> Self {
Self {
supported_report: vec![
ReportWrapper {
report: ReportMethod::CalendarQuery,
},
ReportWrapper {
report: ReportMethod::CalendarMultiget,
},
ReportWrapper {
report: ReportMethod::SyncCollection,
},
],
}
}
}

View File

@@ -1,5 +1,6 @@
use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData, SupportedReportSet};
use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData};
use crate::Error;
use crate::calendar::prop::ReportMethod;
use chrono::{DateTime, Utc};
use derive_more::derive::{From, Into};
use rustical_dav::extensions::{
@@ -7,8 +8,8 @@ use rustical_dav::extensions::{
};
use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner};
use rustical_dav_push::DavPushExtension;
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner, SupportedReportSet};
use rustical_dav_push::{DavPushExtension, DavPushExtensionProp};
use rustical_ical::CalDateTime;
use rustical_store::Calendar;
use rustical_store::auth::User;
@@ -19,10 +20,6 @@ use std::str::FromStr;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "CalendarPropName")]
pub enum CalendarProp {
// WebDAV (RFC 2518)
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
Displayname(Option<String>),
// CalDAV (RFC 4791)
#[xml(ns = "rustical_dav::namespace::NS_ICAL")]
CalendarColor(Option<String>),
@@ -44,8 +41,8 @@ pub enum CalendarProp {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
MaxResourceSize(i64),
#[xml(skip_deserializing)]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
SupportedReportSet(SupportedReportSet),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
SupportedReportSet(SupportedReportSet<ReportMethod>),
#[xml(ns = "rustical_dav::namespace::NS_CALENDARSERVER")]
Source(Option<HrefElement>),
#[xml(skip_deserializing)]
@@ -61,7 +58,7 @@ pub enum CalendarProp {
pub enum CalendarPropWrapper {
Calendar(CalendarProp),
SyncToken(SyncTokenExtensionProp),
// DavPush(DavPushExtensionProp),
DavPush(DavPushExtensionProp),
Common(CommonPropertiesProp),
}
@@ -100,7 +97,9 @@ impl Resource for CalendarResource {
type Error = Error;
type Principal = User;
const IS_COLLECTION: bool = true;
fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype {
if self.cal.subscription_url.is_none() {
@@ -127,9 +126,6 @@ impl Resource for CalendarResource {
) -> Result<Self::Prop, Self::Error> {
Ok(match prop {
CalendarPropWrapperName::Calendar(prop) => CalendarPropWrapper::Calendar(match prop {
CalendarPropName::Displayname => {
CalendarProp::Displayname(self.cal.displayname.clone())
}
CalendarPropName::CalendarColor => {
CalendarProp::CalendarColor(self.cal.color.clone())
}
@@ -157,7 +153,7 @@ impl Resource for CalendarResource {
}
CalendarPropName::MaxResourceSize => CalendarProp::MaxResourceSize(10000000),
CalendarPropName::SupportedReportSet => {
CalendarProp::SupportedReportSet(SupportedReportSet::default())
CalendarProp::SupportedReportSet(SupportedReportSet::all())
}
CalendarPropName::Source => CalendarProp::Source(
self.cal.subscription_url.to_owned().map(HrefElement::from),
@@ -172,9 +168,9 @@ impl Resource for CalendarResource {
CalendarPropWrapperName::SyncToken(prop) => {
CalendarPropWrapper::SyncToken(SyncTokenExtension::get_prop(self, prop)?)
}
// CalendarPropWrapperName::DavPush(prop) => {
// CalendarPropWrapper::DavPush(DavPushExtension::get_prop(self, prop)?)
// }
CalendarPropWrapperName::DavPush(prop) => {
CalendarPropWrapper::DavPush(DavPushExtension::get_prop(self, prop)?)
}
CalendarPropWrapperName::Common(prop) => CalendarPropWrapper::Common(
CommonPropertiesExtension::get_prop(self, puri, user, prop)?,
),
@@ -187,10 +183,6 @@ impl Resource for CalendarResource {
}
match prop {
CalendarPropWrapper::Calendar(prop) => match prop {
CalendarProp::Displayname(displayname) => {
self.cal.displayname = displayname;
Ok(())
}
CalendarProp::CalendarColor(color) => {
self.cal.color = color;
Ok(())
@@ -236,7 +228,7 @@ impl Resource for CalendarResource {
CalendarProp::MaxDateTime(_) => Err(rustical_dav::Error::PropReadOnly),
},
CalendarPropWrapper::SyncToken(prop) => SyncTokenExtension::set_prop(self, prop),
// CalendarPropWrapper::DavPush(prop) => DavPushExtension::set_prop(self, prop),
CalendarPropWrapper::DavPush(prop) => DavPushExtension::set_prop(self, prop),
CalendarPropWrapper::Common(prop) => CommonPropertiesExtension::set_prop(self, prop),
}
}
@@ -247,10 +239,6 @@ impl Resource for CalendarResource {
}
match prop {
CalendarPropWrapperName::Calendar(prop) => match prop {
CalendarPropName::Displayname => {
self.cal.displayname = None;
Ok(())
}
CalendarPropName::CalendarColor => {
self.cal.color = None;
Ok(())
@@ -284,13 +272,21 @@ impl Resource for CalendarResource {
CalendarPropName::MaxDateTime => Err(rustical_dav::Error::PropReadOnly),
},
CalendarPropWrapperName::SyncToken(prop) => SyncTokenExtension::remove_prop(self, prop),
// CalendarPropWrapperName::DavPush(prop) => DavPushExtension::remove_prop(self, prop),
CalendarPropWrapperName::DavPush(prop) => DavPushExtension::remove_prop(self, prop),
CalendarPropWrapperName::Common(prop) => {
CommonPropertiesExtension::remove_prop(self, prop)
}
}
}
fn get_displayname(&self) -> Option<&str> {
self.cal.displayname.as_deref()
}
fn set_displayname(&mut self, name: Option<String>) -> Result<(), rustical_dav::Error> {
self.cal.displayname = name;
Ok(())
}
fn get_owner(&self) -> Option<&str> {
Some(&self.cal.principal)
}

View File

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

View File

@@ -33,7 +33,7 @@ pub async fn get_event<C: CalendarStore>(
}
let event = cal_store
.get_object(&principal, &calendar_id, &object_id)
.get_object(&principal, &calendar_id, &object_id, false)
.await?;
let mut resp = Response::builder().status(StatusCode::OK);

View File

@@ -27,7 +27,9 @@ impl Resource for CalendarObjectResource {
type Error = Error;
type Principal = User;
const IS_COLLECTION: bool = false;
fn is_collection(&self) -> bool {
false
}
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[])
@@ -66,6 +68,11 @@ impl Resource for CalendarObjectResource {
})
}
fn get_displayname(&self) -> Option<&str> {
// TODO: Extract summary from object
None
}
fn get_owner(&self) -> Option<&str> {
Some(&self.principal)
}

View File

@@ -61,7 +61,7 @@ impl<C: CalendarStore> ResourceService for CalendarObjectResourceService<C> {
) -> Result<Self::Resource, Self::Error> {
let object = self
.cal_store
.get_object(principal, calendar_id, object_id)
.get_object(principal, calendar_id, object_id, false)
.await?;
Ok(CalendarObjectResource {
object,

View File

@@ -14,7 +14,6 @@ pub mod calendar;
pub mod calendar_object;
pub mod error;
pub mod principal;
// mod subscription;
pub use error::Error;
@@ -22,8 +21,11 @@ pub use error::Error;
pub struct CalDavPrincipalUri(&'static str);
impl PrincipalUri for CalDavPrincipalUri {
fn principal_collection(&self) -> String {
format!("{}/principal/", self.0)
}
fn principal_uri(&self, principal: &str) -> String {
format!("{}/principal/{}/", self.0, principal)
format!("{}{}/", self.principal_collection(), principal)
}
}

View File

@@ -2,7 +2,9 @@ use crate::Error;
use rustical_dav::extensions::CommonPropertiesExtension;
use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{Resourcetype, ResourcetypeInner};
use rustical_dav::xml::{
GroupMemberSet, GroupMembership, Resourcetype, ResourcetypeInner, SupportedReportSet,
};
use rustical_store::auth::User;
mod service;
@@ -13,6 +15,7 @@ pub use prop::*;
#[derive(Clone)]
pub struct PrincipalResource {
principal: User,
members: Vec<String>,
}
impl ResourceName for PrincipalResource {
@@ -26,12 +29,19 @@ impl Resource for PrincipalResource {
type Error = Error;
type Principal = User;
const IS_COLLECTION: bool = true;
fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "collection"),
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "principal"),
// https://github.com/apple/ccs-calendarserver/blob/13c706b985fb728b9aab42dc0fef85aae21921c3/doc/Extensions/caldav-proxy.txt
// ResourcetypeInner(
// Some(rustical_dav::namespace::NS_CALENDARSERVER),
// "calendar-proxy-write",
// ),
])
}
@@ -43,32 +53,45 @@ impl Resource for PrincipalResource {
) -> Result<Self::Prop, Self::Error> {
let principal_url = puri.principal_uri(&self.principal.id);
let home_set = CalendarHomeSet(
user.memberships()
.into_iter()
.map(|principal| puri.principal_uri(principal).into())
.collect(),
);
Ok(match prop {
PrincipalPropWrapperName::Principal(prop) => {
PrincipalPropWrapper::Principal(match prop {
PrincipalPropName::CalendarUserType => {
PrincipalProp::CalendarUserType(self.principal.principal_type.to_owned())
}
PrincipalPropName::Displayname => PrincipalProp::Displayname(
self.principal
.displayname
.to_owned()
.unwrap_or(self.principal.id.to_owned()),
),
PrincipalPropName::PrincipalUrl => {
PrincipalProp::PrincipalUrl(principal_url.into())
}
PrincipalPropName::CalendarHomeSet => PrincipalProp::CalendarHomeSet(home_set),
PrincipalPropName::CalendarHomeSet => {
PrincipalProp::CalendarHomeSet(principal_url.into())
}
PrincipalPropName::CalendarUserAddressSet => {
PrincipalProp::CalendarUserAddressSet(principal_url.into())
}
PrincipalPropName::GroupMemberSet => {
PrincipalProp::GroupMemberSet(GroupMemberSet(
self.members
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect(),
))
}
PrincipalPropName::GroupMembership => {
PrincipalProp::GroupMembership(GroupMembership(
self.principal
.memberships_without_self()
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect(),
))
}
PrincipalPropName::AlternateUriSet => PrincipalProp::AlternateUriSet,
// PrincipalPropName::PrincipalCollectionSet => {
// PrincipalProp::PrincipalCollectionSet(puri.principal_collection().into())
// }
PrincipalPropName::SupportedReportSet => {
PrincipalProp::SupportedReportSet(SupportedReportSet::all())
}
})
}
PrincipalPropWrapperName::Common(prop) => PrincipalPropWrapper::Common(
@@ -77,6 +100,15 @@ impl Resource for PrincipalResource {
})
}
fn get_displayname(&self) -> Option<&str> {
Some(
self.principal
.displayname
.as_ref()
.unwrap_or(&self.principal.id),
)
}
fn get_owner(&self) -> Option<&str> {
Some(&self.principal.id)
}

View File

@@ -1,13 +1,14 @@
use rustical_dav::{extensions::CommonPropertiesProp, xml::HrefElement};
use rustical_dav::{
extensions::CommonPropertiesProp,
xml::{GroupMemberSet, GroupMembership, HrefElement, SupportedReportSet},
};
use rustical_store::auth::user::PrincipalType;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropName")]
pub enum PrincipalProp {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
Displayname(String),
// Scheduling Extensions to CalDAV (RFC 6638)
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)]
CalendarUserType(PrincipalType),
@@ -17,10 +18,20 @@ pub enum PrincipalProp {
// WebDAV Access Control (RFC 3744)
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = b"principal-URL")]
PrincipalUrl(HrefElement),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
GroupMembership(GroupMembership),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
GroupMemberSet(GroupMemberSet),
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = b"alternate-URI-set")]
AlternateUriSet,
// #[xml(ns = "rustical_dav::namespace::NS_DAV")]
// PrincipalCollectionSet(HrefElement),
#[xml(ns = "rustical_dav::namespace::NS_DAV", skip_deserializing)]
SupportedReportSet(SupportedReportSet<ReportMethod>),
// CalDAV (RFC 4791)
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarHomeSet(CalendarHomeSet),
CalendarHomeSet(HrefElement),
}
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
@@ -30,5 +41,9 @@ pub enum PrincipalPropWrapper {
Common(CommonPropertiesProp),
}
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct CalendarHomeSet(#[xml(ty = "untagged", flatten)] pub(super) Vec<HrefElement>);
#[derive(XmlSerialize, PartialEq, Clone, VariantArray)]
pub enum ReportMethod {
// We don't actually support principal-match
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
PrincipalMatch,
}

View File

@@ -43,7 +43,7 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
type Principal = User;
type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access";
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy";
async fn get_resource(
&self,
@@ -54,7 +54,10 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
.get_principal(principal)
.await?
.ok_or(crate::Error::NotFound)?;
Ok(PrincipalResource { principal: user })
Ok(PrincipalResource {
members: self.auth_provider.list_members(&user.id).await?,
principal: user,
})
}
async fn get_members(

View File

@@ -1,33 +0,0 @@
use std::sync::Arc;
use actix_web::{
HttpResponse,
web::{self, Data, Path},
};
use rustical_dav::xml::multistatus::PropstatElement;
use rustical_store::SubscriptionStore;
use rustical_xml::{XmlRootTag, XmlSerialize};
use crate::calendar::resource::CalendarProp;
async fn handle_delete<S: SubscriptionStore>(
store: Data<S>,
path: Path<String>,
) -> Result<HttpResponse, rustical_store::Error> {
let id = path.into_inner();
store.delete_subscription(&id).await?;
Ok(HttpResponse::NoContent().body("Unregistered"))
}
pub fn subscription_resource<S: SubscriptionStore>(sub_store: Arc<S>) -> actix_web::Resource {
web::resource("/subscription/{id}")
.app_data(Data::from(sub_store))
.name("subscription")
.delete(handle_delete::<S>)
}
#[derive(XmlSerialize, XmlRootTag)]
#[xml(root = b"push-message", ns = "rustical_dav::namespace::NS_DAVPUSH")]
pub struct PushMessage {
propstat: PropstatElement<CalendarProp>,
}

View File

@@ -32,3 +32,5 @@ http.workspace = true
tower-http.workspace = true
percent-encoding.workspace = true
ical.workspace = true
strum.workspace = true
strum_macros.workspace = true

View File

@@ -32,7 +32,9 @@ impl Resource for AddressObjectResource {
type Error = Error;
type Principal = User;
const IS_COLLECTION: bool = false;
fn is_collection(&self) -> bool {
false
}
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[])
@@ -64,6 +66,10 @@ impl Resource for AddressObjectResource {
})
}
fn get_displayname(&self) -> Option<&str> {
self.object.get_full_name()
}
fn get_owner(&self) -> Option<&str> {
Some(&self.principal)
}

View File

@@ -52,8 +52,10 @@ pub async fn route_mkcol<AS: AddressbookStore, S: SubscriptionStore>(
return Err(Error::Unauthorized);
}
let request = MkcolRequest::parse_str(&body)?;
let request = request.set.prop;
let mut request = MkcolRequest::parse_str(&body)?.set.prop;
if let Some("") = request.displayname.as_deref() {
request.displayname = None
}
let addressbook = Addressbook {
id: addressbook_id.to_owned(),

View File

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

View File

@@ -1,33 +1,40 @@
use crate::Error;
use crate::addressbook::resource::AddressbookResourceService;
use actix_web::http::header;
use actix_web::web::{Data, Path};
use actix_web::{HttpRequest, HttpResponse};
use crate::addressbook::AddressbookResourceService;
use crate::addressbook::resource::AddressbookResource;
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use http::{HeaderMap, HeaderValue, StatusCode, header};
use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource;
use rustical_dav_push::register::PushRegister;
use rustical_store::auth::User;
use rustical_store::{AddressbookStore, Subscription, SubscriptionStore};
use rustical_xml::XmlDocument;
use tracing::instrument;
use tracing_actix_web::RootSpan;
#[instrument(parent = root_span.id(), skip(resource_service, root_span, req))]
pub async fn route_post<A: AddressbookStore, S: SubscriptionStore>(
path: Path<(String, String)>,
body: String,
#[instrument(skip(resource_service))]
pub async fn route_post<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addr_id)): Path<(String, String)>,
user: User,
resource_service: Data<AddressbookResourceService<A, S>>,
root_span: RootSpan,
req: HttpRequest,
) -> Result<HttpResponse, Error> {
let (principal, addressbook_id) = path.into_inner();
State(resource_service): State<AddressbookResourceService<AS, S>>,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
}
let addressbook = resource_service
.addr_store
.get_addressbook(&principal, &addressbook_id, false)
.get_addressbook(&principal, &addr_id, false)
.await?;
let addressbook_resource = AddressbookResource(addressbook);
if !addressbook_resource
.get_user_privileges(&user)?
.has(&UserPrivilege::Read)
{
return Err(Error::Unauthorized);
}
let request = PushRegister::parse_str(&body)?;
let sub_id = uuid::Uuid::new_v4().to_string();
@@ -44,7 +51,7 @@ pub async fn route_post<A: AddressbookStore, S: SubscriptionStore>(
.web_push_subscription
.push_resource
.to_owned(),
topic: addressbook.push_topic,
topic: addressbook_resource.0.push_topic,
expiration: expires.naive_local(),
public_key: request
.subscription
@@ -63,13 +70,17 @@ pub async fn route_post<A: AddressbookStore, S: SubscriptionStore>(
.upsert_subscription(subscription)
.await?;
let location = req
.resource_map()
.url_for(&req, "subscription", &[sub_id])
.unwrap();
Ok(HttpResponse::Created()
.append_header((header::LOCATION, location.to_string()))
.append_header((header::EXPIRES, expires.to_rfc2822()))
.finish())
// TODO: make nicer
let location = format!("/push_subscription/{sub_id}");
Ok((
StatusCode::CREATED,
HeaderMap::from_iter([
(header::LOCATION, HeaderValue::from_str(&location).unwrap()),
(
header::EXPIRES,
HeaderValue::from_str(&expires.to_rfc2822()).unwrap(),
),
]),
)
.into_response())
}

View File

@@ -1,21 +1,21 @@
use rustical_dav::extensions::{CommonPropertiesProp, SyncTokenExtensionProp};
use rustical_dav::{
extensions::{CommonPropertiesProp, SyncTokenExtensionProp},
xml::SupportedReportSet,
};
use rustical_dav_push::DavPushExtensionProp;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "AddressbookPropName")]
pub enum AddressbookProp {
// WebDAV (RFC 2518)
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
Displayname(Option<String>),
// CardDAV (RFC 6352)
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookDescription(Option<String>),
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", skip_deserializing)]
SupportedAddressData(SupportedAddressData),
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", skip_deserializing)]
SupportedReportSet(SupportedReportSet),
#[xml(ns = "rustical_dav::namespace::NS_DAV", skip_deserializing)]
SupportedReportSet(SupportedReportSet<ReportMethod>),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
MaxResourceSize(i64),
}
@@ -60,37 +60,10 @@ impl Default for SupportedAddressData {
}
}
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
#[derive(Debug, Clone, XmlSerialize, PartialEq, VariantArray)]
pub enum ReportMethod {
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookMultiget,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
SyncCollection,
}
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub struct SupportedReportWrapper {
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
report: ReportMethod,
}
// RFC 3253 section-3.1.5
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub struct SupportedReportSet {
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", flatten)]
supported_report: &'static [SupportedReportWrapper],
}
impl Default for SupportedReportSet {
fn default() -> Self {
Self {
supported_report: &[
SupportedReportWrapper {
report: ReportMethod::AddressbookMultiget,
},
SupportedReportWrapper {
report: ReportMethod::SyncCollection,
},
],
}
}
}

View File

@@ -1,4 +1,4 @@
use super::prop::{SupportedAddressData, SupportedReportSet};
use super::prop::SupportedAddressData;
use crate::Error;
use crate::addressbook::prop::{
AddressbookProp, AddressbookPropName, AddressbookPropWrapper, AddressbookPropWrapperName,
@@ -7,7 +7,7 @@ use derive_more::derive::{From, Into};
use rustical_dav::extensions::{CommonPropertiesExtension, SyncTokenExtension};
use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{Resourcetype, ResourcetypeInner};
use rustical_dav::xml::{Resourcetype, ResourcetypeInner, SupportedReportSet};
use rustical_dav_push::DavPushExtension;
use rustical_store::Addressbook;
use rustical_store::auth::User;
@@ -38,7 +38,9 @@ impl Resource for AddressbookResource {
type Error = Error;
type Principal = User;
const IS_COLLECTION: bool = true;
fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[
@@ -56,14 +58,11 @@ impl Resource for AddressbookResource {
Ok(match prop {
AddressbookPropWrapperName::Addressbook(prop) => {
AddressbookPropWrapper::Addressbook(match prop {
AddressbookPropName::Displayname => {
AddressbookProp::Displayname(self.0.displayname.clone())
}
AddressbookPropName::MaxResourceSize => {
AddressbookProp::MaxResourceSize(10000000)
}
AddressbookPropName::SupportedReportSet => {
AddressbookProp::SupportedReportSet(SupportedReportSet::default())
AddressbookProp::SupportedReportSet(SupportedReportSet::all())
}
AddressbookPropName::AddressbookDescription => {
AddressbookProp::AddressbookDescription(self.0.description.to_owned())
@@ -89,10 +88,6 @@ impl Resource for AddressbookResource {
fn set_prop(&mut self, prop: Self::Prop) -> Result<(), rustical_dav::Error> {
match prop {
AddressbookPropWrapper::Addressbook(prop) => match prop {
AddressbookProp::Displayname(displayname) => {
self.0.displayname = displayname;
Ok(())
}
AddressbookProp::AddressbookDescription(description) => {
self.0.description = description;
Ok(())
@@ -113,10 +108,6 @@ impl Resource for AddressbookResource {
) -> Result<(), rustical_dav::Error> {
match prop {
AddressbookPropWrapperName::Addressbook(prop) => match prop {
AddressbookPropName::Displayname => {
self.0.displayname = None;
Ok(())
}
AddressbookPropName::AddressbookDescription => {
self.0.description = None;
Ok(())
@@ -135,6 +126,14 @@ impl Resource for AddressbookResource {
}
}
fn get_displayname(&self) -> Option<&str> {
self.0.displayname.as_deref()
}
fn set_displayname(&mut self, name: Option<String>) -> Result<(), rustical_dav::Error> {
self.0.displayname = name;
Ok(())
}
fn get_owner(&self) -> Option<&str> {
Some(&self.0.principal)
}

View File

@@ -3,6 +3,7 @@ use super::methods::report::route_report_addressbook;
use crate::address_object::AddressObjectResourceService;
use crate::address_object::resource::AddressObjectResource;
use crate::addressbook::methods::get::route_get;
use crate::addressbook::methods::post::route_post;
use crate::addressbook::methods::put::route_put;
use crate::addressbook::resource::AddressbookResource;
use crate::{CardDavPrincipalUri, Error};
@@ -53,7 +54,7 @@ impl<AS: AddressbookStore, S: SubscriptionStore> ResourceService
type Principal = User;
type PrincipalUri = CardDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, addressbook";
const DAV_HEADER: &str = "1, 3, access-control, addressbook, webdav-push";
async fn get_resource(
&self,
@@ -130,6 +131,13 @@ impl<AS: AddressbookStore, S: SubscriptionStore> AxumMethods for AddressbookReso
})
}
fn post() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| {
let mut service = Handler::with_state(route_post::<AS, S>, state);
Box::pin(Service::call(&mut service, req))
})
}
fn put() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| {
let mut service = Handler::with_state(route_put::<AS, S>, state);

View File

@@ -22,8 +22,11 @@ pub mod principal;
pub struct CardDavPrincipalUri(&'static str);
impl PrincipalUri for CardDavPrincipalUri {
fn principal_collection(&self) -> String {
format!("{}/principal/", self.0)
}
fn principal_uri(&self, principal: &str) -> String {
format!("{}/principal/{}/", self.0, principal)
format!("{}{}/", self.principal_collection(), principal)
}
}

View File

@@ -2,7 +2,9 @@ use crate::Error;
use rustical_dav::extensions::CommonPropertiesExtension;
use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner};
use rustical_dav::xml::{
GroupMemberSet, GroupMembership, HrefElement, Resourcetype, ResourcetypeInner,
};
use rustical_store::auth::User;
mod service;
@@ -13,6 +15,7 @@ pub use prop::*;
#[derive(Debug, Clone)]
pub struct PrincipalResource {
principal: User,
members: Vec<String>,
}
impl ResourceName for PrincipalResource {
@@ -26,7 +29,9 @@ impl Resource for PrincipalResource {
type Error = Error;
type Principal = User;
const IS_COLLECTION: bool = true;
fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[
@@ -41,30 +46,37 @@ impl Resource for PrincipalResource {
user: &User,
prop: &PrincipalPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
let principal_href = HrefElement::new(puri.principal_uri(&user.id));
let home_set = AddressbookHomeSet(
user.memberships()
.into_iter()
.map(|principal| puri.principal_uri(principal))
.map(HrefElement::new)
.collect(),
);
let principal_href = HrefElement::new(puri.principal_uri(&self.principal.id));
Ok(match prop {
PrincipalPropWrapperName::Principal(prop) => {
PrincipalPropWrapper::Principal(match prop {
PrincipalPropName::Displayname => PrincipalProp::Displayname(
self.principal
.displayname
.to_owned()
.unwrap_or(self.principal.id.to_owned()),
),
PrincipalPropName::PrincipalUrl => PrincipalProp::PrincipalUrl(principal_href),
PrincipalPropName::AddressbookHomeSet => {
PrincipalProp::AddressbookHomeSet(home_set)
PrincipalProp::AddressbookHomeSet(principal_href)
}
PrincipalPropName::PrincipalAddress => PrincipalProp::PrincipalAddress(None),
PrincipalPropName::GroupMembership => {
PrincipalProp::GroupMembership(GroupMembership(
self.principal
.memberships_without_self()
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect(),
))
}
PrincipalPropName::GroupMemberSet => {
PrincipalProp::GroupMemberSet(GroupMemberSet(
self.members
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect(),
))
}
PrincipalPropName::AlternateUriSet => PrincipalProp::AlternateUriSet,
PrincipalPropName::PrincipalCollectionSet => {
PrincipalProp::PrincipalCollectionSet(puri.principal_collection().into())
}
})
}
@@ -74,6 +86,15 @@ impl Resource for PrincipalResource {
})
}
fn get_displayname(&self) -> Option<&str> {
Some(
self.principal
.displayname
.as_ref()
.unwrap_or(&self.principal.id),
)
}
fn get_owner(&self) -> Option<&str> {
Some(&self.principal.id)
}

View File

@@ -1,23 +1,28 @@
use rustical_dav::{extensions::CommonPropertiesProp, xml::HrefElement};
use rustical_dav::{
extensions::CommonPropertiesProp,
xml::{GroupMemberSet, GroupMembership, HrefElement},
};
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct AddressbookHomeSet(#[xml(ty = "untagged", flatten)] pub(super) Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropName")]
pub enum PrincipalProp {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
Displayname(String),
// WebDAV Access Control (RFC 3744)
#[xml(rename = b"principal-URL")]
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
PrincipalUrl(HrefElement),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
GroupMembership(GroupMembership),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
GroupMemberSet(GroupMemberSet),
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = b"alternate-URI-set")]
AlternateUriSet,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
PrincipalCollectionSet(HrefElement),
// CardDAV (RFC 6352)
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookHomeSet(AddressbookHomeSet),
AddressbookHomeSet(HrefElement),
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
PrincipalAddress(Option<HrefElement>),
}

View File

@@ -65,7 +65,10 @@ impl<A: AddressbookStore, AP: AuthenticationProvider, S: SubscriptionStore> Reso
.get_principal(principal)
.await?
.ok_or(crate::Error::NotFound)?;
Ok(PrincipalResource { principal: user })
Ok(PrincipalResource {
members: self.auth_provider.list_members(&user.id).await?,
principal: user,
})
}
async fn get_members(

View File

@@ -25,3 +25,6 @@ tracing.workspace = true
tokio.workspace = true
http.workspace = true
headers.workspace = true
strum.workspace = true
matchit.workspace = true
matchit-serde.workspace = true

View File

@@ -28,6 +28,9 @@ pub enum Error {
#[error("Precondition Failed")]
PreconditionFailed,
#[error("Forbidden")]
Forbidden,
}
impl Error {
@@ -49,6 +52,7 @@ impl Error {
Error::PropReadOnly => StatusCode::CONFLICT,
Error::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
Self::IOError(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Forbidden => StatusCode::FORBIDDEN,
}
}
}

View File

@@ -13,6 +13,8 @@ pub enum CommonPropertiesProp {
#[xml(skip_deserializing)]
#[xml(ns = "crate::namespace::NS_DAV")]
Resourcetype(Resourcetype),
#[xml(ns = "crate::namespace::NS_DAV")]
Displayname(Option<String>),
// WebDAV Current Principal Extension (RFC 5397)
#[xml(ns = "crate::namespace::NS_DAV")]
@@ -37,6 +39,9 @@ pub trait CommonPropertiesExtension: Resource {
CommonPropertiesPropName::Resourcetype => {
CommonPropertiesProp::Resourcetype(self.get_resourcetype())
}
CommonPropertiesPropName::Displayname => {
CommonPropertiesProp::Displayname(self.get_displayname().map(|s| s.to_string()))
}
CommonPropertiesPropName::CurrentUserPrincipal => {
CommonPropertiesProp::CurrentUserPrincipal(
principal_uri.principal_uri(principal.get_id()).into(),
@@ -52,12 +57,18 @@ pub trait CommonPropertiesExtension: Resource {
})
}
fn set_prop(&self, _prop: CommonPropertiesProp) -> Result<(), crate::Error> {
Err(crate::Error::PropReadOnly)
fn set_prop(&mut self, prop: CommonPropertiesProp) -> Result<(), crate::Error> {
match prop {
CommonPropertiesProp::Displayname(name) => self.set_displayname(name),
_ => Err(crate::Error::PropReadOnly),
}
}
fn remove_prop(&self, _prop: &CommonPropertiesPropName) -> Result<(), crate::Error> {
Err(crate::Error::PropReadOnly)
fn remove_prop(&mut self, prop: &CommonPropertiesPropName) -> Result<(), crate::Error> {
match prop {
CommonPropertiesPropName::Displayname => self.set_displayname(None),
_ => Err(crate::Error::PropReadOnly),
}
}
}

View File

@@ -1,25 +1,54 @@
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use tracing::instrument;
use crate::{
header::{Depth, Overwrite},
resource::ResourceService,
};
use axum::{
extract::{MatchedPath, Path, State},
response::{IntoResponse, Response},
};
use http::{HeaderMap, StatusCode, Uri};
use matchit_serde::ParamsDeserializer;
use serde::Deserialize;
use tracing::instrument;
#[instrument(skip(_path, _resource_service,))]
#[instrument(skip(path, resource_service,))]
pub(crate) async fn axum_route_copy<R: ResourceService>(
Path(_path): Path<R::PathComponents>,
State(_resource_service): State<R>,
Path(path): Path<R::PathComponents>,
State(resource_service): State<R>,
depth: Option<Depth>,
principal: R::Principal,
overwrite: Overwrite,
matched_path: MatchedPath,
header_map: HeaderMap,
) -> Result<Response, R::Error> {
// TODO: Actually implement, but to be WebDAV-compliant we must at least support this route but
// can return a 403 error
let _depth = depth.unwrap_or(Depth::Infinity);
Ok(StatusCode::FORBIDDEN.into_response())
let destination = header_map
.get("Destination")
.ok_or(crate::Error::Forbidden)?
.to_str()
.map_err(|_| crate::Error::Forbidden)?;
let destination_uri: Uri = destination.parse().map_err(|_| crate::Error::Forbidden)?;
// TODO: Check that host also matches
let destination = destination_uri.path();
let mut router = matchit::Router::new();
router.insert(matched_path.as_str(), ()).unwrap();
if let Ok(matchit::Match { params, .. }) = router.at(destination) {
let params =
matchit_serde::Params::try_from(&params).map_err(|_| crate::Error::Forbidden)?;
let dest_path = R::PathComponents::deserialize(&ParamsDeserializer::new(params))
.map_err(|_| crate::Error::Forbidden)?;
if resource_service
.copy_resource(&path, &dest_path, &principal, overwrite.is_true())
.await?
{
// Overwritten
Ok(StatusCode::NO_CONTENT.into_response())
} else {
// Not overwritten
Ok(StatusCode::CREATED.into_response())
}
} else {
Ok(StatusCode::FORBIDDEN.into_response())
}
}

View File

@@ -1,25 +1,54 @@
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use tracing::instrument;
use crate::{
header::{Depth, Overwrite},
resource::ResourceService,
};
use axum::{
extract::{MatchedPath, Path, State},
response::{IntoResponse, Response},
};
use http::{HeaderMap, StatusCode, Uri};
use matchit_serde::ParamsDeserializer;
use serde::Deserialize;
use tracing::instrument;
#[instrument(skip(_path, _resource_service,))]
#[instrument(skip(path, resource_service,))]
pub(crate) async fn axum_route_move<R: ResourceService>(
Path(_path): Path<R::PathComponents>,
State(_resource_service): State<R>,
Path(path): Path<R::PathComponents>,
State(resource_service): State<R>,
depth: Option<Depth>,
principal: R::Principal,
overwrite: Overwrite,
matched_path: MatchedPath,
header_map: HeaderMap,
) -> Result<Response, R::Error> {
// TODO: Actually implement, but to be WebDAV-compliant we must at least support this route but
// can return a 403 error
let _depth = depth.unwrap_or(Depth::Infinity);
Ok(StatusCode::FORBIDDEN.into_response())
let destination = header_map
.get("Destination")
.ok_or(crate::Error::Forbidden)?
.to_str()
.map_err(|_| crate::Error::Forbidden)?;
let destination_uri: Uri = destination.parse().map_err(|_| crate::Error::Forbidden)?;
// TODO: Check that host also matches
let destination = destination_uri.path();
let mut router = matchit::Router::new();
router.insert(matched_path.as_str(), ()).unwrap();
if let Ok(matchit::Match { params, .. }) = router.at(destination) {
let params =
matchit_serde::Params::try_from(&params).map_err(|_| crate::Error::Forbidden)?;
let dest_path = R::PathComponents::deserialize(&ParamsDeserializer::new(params))
.map_err(|_| crate::Error::Forbidden)?;
if resource_service
.copy_resource(&path, &dest_path, &principal, overwrite.is_true())
.await?
{
// Overwritten
Ok(StatusCode::NO_CONTENT.into_response())
} else {
// Not overwritten
Ok(StatusCode::CREATED.into_response())
}
} else {
Ok(StatusCode::FORBIDDEN.into_response())
}
}

View File

@@ -18,7 +18,7 @@ mod methods;
mod principal_uri;
mod resource_service;
pub use axum_methods::AxumMethods;
pub use axum_methods::{AxumMethods, MethodFunction};
pub use axum_service::AxumService;
pub use principal_uri::PrincipalUri;
@@ -37,7 +37,7 @@ pub trait Resource: Clone + Send + 'static {
type Error: From<crate::Error>;
type Principal: Principal;
const IS_COLLECTION: bool;
fn is_collection(&self) -> bool;
fn get_resourcetype(&self) -> Resourcetype;
@@ -60,6 +60,11 @@ pub trait Resource: Clone + Send + 'static {
Err(crate::Error::PropReadOnly)
}
fn get_displayname(&self) -> Option<&str>;
fn set_displayname(&mut self, _name: Option<String>) -> Result<(), crate::Error> {
Err(crate::Error::PropReadOnly)
}
fn get_owner(&self) -> Option<&str> {
None
}
@@ -106,7 +111,7 @@ pub trait Resource: Clone + Send + 'static {
) -> Result<ResponseElement<Self::Prop>, Self::Error> {
// Collections have a trailing slash
let mut path = path.to_string();
if Self::IS_COLLECTION && !path.ends_with('/') {
if self.is_collection() && !path.ends_with('/') {
path.push('/');
}

View File

@@ -1,3 +1,4 @@
pub trait PrincipalUri: 'static + Clone + Send + Sync {
fn principal_collection(&self) -> String;
fn principal_uri(&self, principal: &str) -> String;
}

View File

@@ -9,7 +9,13 @@ use serde::Deserialize;
#[async_trait]
pub trait ResourceService: Clone + Sized + Send + Sync + AxumMethods + 'static {
type PathComponents: for<'de> Deserialize<'de> + Sized + Send + Sync + Clone + 'static; // defines how the resource URI maps to parameters, i.e. /{principal}/{calendar} -> (String, String)
type PathComponents: std::fmt::Debug
+ for<'de> Deserialize<'de>
+ Sized
+ Send
+ Sync
+ Clone
+ 'static; // defines how the resource URI maps to parameters, i.e. /{principal}/{calendar} -> (String, String)
type MemberType: Resource<Error = Self::Error, Principal = Self::Principal>
+ super::ResourceName;
type Resource: Resource<Error = Self::Error, Principal = Self::Principal>;
@@ -47,6 +53,28 @@ pub trait ResourceService: Clone + Sized + Send + Sync + AxumMethods + 'static {
Err(crate::Error::Unauthorized.into())
}
// Returns whether an existing resource was overwritten
async fn copy_resource(
&self,
_path: &Self::PathComponents,
_destination: &Self::PathComponents,
_user: &Self::Principal,
_overwrite: bool,
) -> Result<bool, Self::Error> {
Err(crate::Error::Forbidden.into())
}
// Returns whether an existing resource was overwritten
async fn move_resource(
&self,
_path: &Self::PathComponents,
_destination: &Self::PathComponents,
_user: &Self::Principal,
_overwrite: bool,
) -> Result<bool, Self::Error> {
Err(crate::Error::Forbidden.into())
}
fn axum_service(self) -> AxumService<Self>
where
Self: AxumMethods,

View File

@@ -24,7 +24,9 @@ impl<PR: Resource, P: Principal> Resource for RootResource<PR, P> {
type Error = PR::Error;
type Principal = P;
const IS_COLLECTION: bool = true;
fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[ResourcetypeInner(
@@ -33,6 +35,10 @@ impl<PR: Resource, P: Principal> Resource for RootResource<PR, P> {
)])
}
fn get_displayname(&self) -> Option<&str> {
Some("RustiCal DAV root")
}
fn get_prop(
&self,
principal_uri: &impl PrincipalUri,

View File

@@ -0,0 +1,8 @@
use crate::xml::HrefElement;
use rustical_xml::{XmlDeserialize, XmlSerialize};
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct GroupMembership(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct GroupMemberSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);

View File

@@ -11,3 +11,7 @@ pub use tag_list::TagList;
mod error;
pub mod sync_collection;
pub use error::ErrorElement;
mod report_set;
pub use report_set::SupportedReportSet;
mod group;
pub use group::*;

View File

@@ -1,4 +1,5 @@
use crate::xml::TagList;
use headers::{CacheControl, ContentType, HeaderMapExt};
use http::StatusCode;
use quick_xml::name::Namespace;
use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot};
@@ -109,7 +110,6 @@ impl<T1: XmlSerialize, T2: XmlSerialize> axum::response::IntoResponse
{
fn into_response(self) -> axum::response::Response {
use axum::body::Body;
use http::header;
let mut output: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into();
let mut writer = quick_xml::Writer::new_with_indent(&mut output, b' ', 4);
@@ -118,9 +118,9 @@ impl<T1: XmlSerialize, T2: XmlSerialize> axum::response::IntoResponse
}
let mut resp = axum::response::Response::builder().status(StatusCode::MULTI_STATUS);
resp.headers_mut()
.unwrap()
.insert(header::CONTENT_TYPE, "application/xml".try_into().unwrap());
let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ContentType::xml());
hdrs.typed_insert(CacheControl::new().with_no_cache());
resp.body(Body::from(output)).unwrap()
}
}

View File

@@ -0,0 +1,34 @@
use rustical_xml::XmlSerialize;
use strum::VariantArray;
// RFC 3253 section-3.1.5
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub struct SupportedReportSet<T: XmlSerialize + 'static> {
#[xml(flatten)]
#[xml(ns = "crate::namespace::NS_DAV")]
supported_report: Vec<ReportWrapper<T>>,
}
impl<T: XmlSerialize + Clone + 'static> SupportedReportSet<T> {
pub fn new(methods: Vec<T>) -> Self {
Self {
supported_report: methods
.into_iter()
.map(|method| ReportWrapper { report: method })
.collect(),
}
}
pub fn all() -> Self
where
T: VariantArray,
{
Self::new(T::VARIANTS.to_vec())
}
}
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub struct ReportWrapper<T: XmlSerialize> {
#[xml(ns = "crate::namespace::NS_DAV")]
report: T,
}

View File

@@ -1,5 +1,8 @@
use derive_more::derive::From;
use quick_xml::name::Namespace;
use quick_xml::{
events::{BytesEnd, BytesStart, Event},
name::Namespace,
};
use rustical_xml::{NamespaceOwned, XmlSerialize};
use std::collections::HashMap;
@@ -9,11 +12,37 @@ pub struct TagList(Vec<(Option<NamespaceOwned>, String)>);
impl XmlSerialize for TagList {
fn serialize<W: std::io::Write>(
&self,
_ns: Option<Namespace>,
_tag: Option<&[u8]>,
_namespaces: &HashMap<Namespace, &[u8]>,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut quick_xml::Writer<W>,
) -> std::io::Result<()> {
let prefix = ns
.map(|ns| namespaces.get(&ns))
.unwrap_or(None)
.map(|prefix| {
if !prefix.is_empty() {
[*prefix, b":"].concat()
} else {
Vec::new()
}
});
let has_prefix = prefix.is_some();
let tagname = tag.map(|tag| [&prefix.unwrap_or_default(), tag].concat());
let qname = tagname
.as_ref()
.map(|tagname| ::quick_xml::name::QName(tagname));
if let Some(qname) = &qname {
let mut bytes_start = BytesStart::from(qname.to_owned());
if !has_prefix {
if let Some(ns) = &ns {
bytes_start.push_attribute((b"xmlns".as_ref(), ns.as_ref()));
}
}
writer.write_event(Event::Start(bytes_start))?;
}
for (ns, tag) in &self.0 {
let mut el = writer.create_element(tag);
if let Some(ns) = ns {
@@ -21,6 +50,10 @@ impl XmlSerialize for TagList {
}
el.write_empty()?;
}
if let Some(qname) = &qname {
writer.write_event(Event::End(BytesEnd::from(qname.to_owned())))?;
}
Ok(())
}

View File

@@ -23,3 +23,9 @@ tokio.workspace = true
rustical_dav.workspace = true
rustical_store.workspace = true
http.workspace = true
base64.workspace = true
p256.workspace = true
rand.workspace = true
ece.workspace = true
axum.workspace = true
openssl.workspace = true

View File

@@ -0,0 +1,23 @@
use axum::{
Router,
extract::{Path, State},
response::{IntoResponse, Response},
routing::delete,
};
use http::StatusCode;
use rustical_store::SubscriptionStore;
use std::sync::Arc;
async fn handle_delete<S: SubscriptionStore>(
State(store): State<Arc<S>>,
Path(id): Path<String>,
) -> Result<Response, rustical_store::Error> {
store.delete_subscription(&id).await?;
Ok((StatusCode::NO_CONTENT, "Unregistered").into_response())
}
pub fn subscription_service<S: SubscriptionStore>(sub_store: Arc<S>) -> Router {
Router::new()
.route("/push_subscription/{id}", delete(handle_delete::<S>))
.with_state(sub_store)
}

View File

@@ -1,14 +1,41 @@
mod extension;
pub mod notifier;
mod prop;
pub mod register;
use base64::Engine;
use derive_more::Constructor;
pub use extension::*;
use http::{HeaderValue, Method, header};
pub use prop::*;
use rustical_store::{CollectionOperation, SubscriptionStore};
use std::sync::Arc;
use reqwest::{Body, Url};
use rustical_store::{
CollectionOperation, CollectionOperationInfo, Subscription, SubscriptionStore,
};
use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot};
use std::{collections::HashMap, sync::Arc, time::Duration};
use tokio::sync::mpsc::Receiver;
use tracing::error;
use tracing::{error, warn};
mod endpoints;
pub use endpoints::subscription_service;
#[derive(XmlSerialize, Debug)]
pub struct ContentUpdate {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
sync_token: Option<String>,
}
#[derive(XmlSerialize, XmlRootTag, Debug)]
#[xml(root = b"push-message", ns = "rustical_dav::namespace::NS_DAVPUSH")]
#[xml(ns_prefix(
rustical_dav::namespace::NS_DAVPUSH = b"",
rustical_dav::namespace::NS_DAV = b"D",
))]
struct PushMessage {
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
topic: String,
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
content_update: Option<ContentUpdate>,
}
#[derive(Debug, Constructor)]
pub struct DavPushController<S: SubscriptionStore> {
@@ -18,14 +45,176 @@ pub struct DavPushController<S: SubscriptionStore> {
impl<S: SubscriptionStore> DavPushController<S> {
pub async fn notifier(&self, mut recv: Receiver<CollectionOperation>) {
while let Some(message) = recv.recv().await {
let subscribers = match self.sub_store.get_subscriptions(&message.topic).await {
Ok(subs) => subs,
Err(err) => {
error!("{err}");
continue;
loop {
// Make sure we don't flood the subscribers
tokio::time::sleep(Duration::from_secs(10)).await;
let mut messages = vec![];
recv.recv_many(&mut messages, 100).await;
// Right now we just have to show the latest content update by topic
// This might become more complicated in the future depending on what kind of updates
// we add
let mut latest_messages = HashMap::new();
for message in messages {
if matches!(message.data, CollectionOperationInfo::Content { .. }) {
latest_messages.insert(message.topic.to_string(), message);
}
};
}
let messages = latest_messages.into_values();
for message in messages {
self.send_message(message).await;
}
}
}
async fn send_message(&self, message: CollectionOperation) {
let subscriptions = match self.sub_store.get_subscriptions(&message.topic).await {
Ok(subs) => subs,
Err(err) => {
error!("{err}");
return;
}
};
if subscriptions.is_empty() {
return;
}
if matches!(message.data, CollectionOperationInfo::Delete) {
// Collection has been deleted, but we cannot handle that
return;
}
let content_update = if let CollectionOperationInfo::Content { sync_token } = message.data {
Some(ContentUpdate {
sync_token: Some(sync_token),
})
} else {
None
};
let push_message = PushMessage {
topic: message.topic,
content_update,
};
let mut output: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into();
let mut writer = quick_xml::Writer::new_with_indent(&mut output, b' ', 4);
if let Err(err) = push_message.serialize_root(&mut writer) {
error!("Could not serialize push message: {}", err);
return;
}
let payload = String::from_utf8(output).unwrap();
for subsciption in subscriptions {
if let Some(allowed_push_servers) = &self.allowed_push_servers {
if let Ok(url) = Url::parse(&subsciption.push_resource) {
let origin = url.origin().unicode_serialization();
if !allowed_push_servers.contains(&origin) {
warn!(
"Deleting subscription {} on topic {} because the endpoint is not in the list of allowed push servers",
subsciption.id, subsciption.topic
);
self.try_delete_subscription(&subsciption.id).await;
}
} else {
warn!(
"Deleting subscription {} on topic {} because of invalid URL",
subsciption.id, subsciption.topic
);
self.try_delete_subscription(&subsciption.id).await;
};
}
if let Err(err) = self.send_payload(&payload, &subsciption).await {
error!("An error occured sending out a push notification: {err}");
if err.is_permament_error() {
warn!(
"Deleting subscription {} on topic {}",
subsciption.id, subsciption.topic
);
self.try_delete_subscription(&subsciption.id).await;
}
}
}
}
async fn try_delete_subscription(&self, sub_id: &str) {
if let Err(err) = self.sub_store.delete_subscription(sub_id).await {
error!("Error deleting subsciption: {err}");
}
}
async fn send_payload(
&self,
payload: &str,
subsciption: &Subscription,
) -> Result<(), NotifierError> {
if subsciption.public_key_type != "p256dh" {
return Err(NotifierError::InvalidPublicKeyType(
subsciption.public_key_type.to_string(),
));
}
let endpoint = subsciption.push_resource.parse().map_err(|_| {
NotifierError::InvalidEndpointUrl(subsciption.push_resource.to_string())
})?;
let ua_public = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&subsciption.public_key)
.map_err(|_| NotifierError::InvalidKeyEncoding)?;
let auth_secret = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&subsciption.auth_secret)
.map_err(|_| NotifierError::InvalidKeyEncoding)?;
let client = reqwest::ClientBuilder::new()
.build()
.map_err(NotifierError::from)?;
let payload = ece::encrypt(&ua_public, &auth_secret, payload.as_bytes())?;
let mut request = reqwest::Request::new(Method::POST, endpoint);
*request.body_mut() = Some(Body::from(payload));
let hdrs = request.headers_mut();
hdrs.insert(
header::CONTENT_ENCODING,
HeaderValue::from_static("aes128gcm"),
);
hdrs.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
);
client.execute(request).await?;
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
enum NotifierError {
#[error("Invalid public key type: {0}")]
InvalidPublicKeyType(String),
#[error("Invalid endpoint URL: {0}")]
InvalidEndpointUrl(String),
#[error("Invalid key encoding")]
InvalidKeyEncoding,
#[error(transparent)]
EceError(#[from] ece::Error),
#[error(transparent)]
ReqwestError(#[from] reqwest::Error),
}
impl NotifierError {
// Decide whether the error should cause the subscription to be removed
pub fn is_permament_error(&self) -> bool {
match self {
Self::InvalidPublicKeyType(_)
| Self::InvalidEndpointUrl(_)
| Self::InvalidKeyEncoding => true,
Self::EceError(err) => matches!(
err,
ece::Error::InvalidAuthSecret | ece::Error::InvalidKeyLength
),
Self::ReqwestError(_) => false,
}
}
}

View File

@@ -1,147 +0,0 @@
use http::StatusCode;
use reqwest::{
Method, Request,
header::{self, HeaderName, HeaderValue},
};
use rustical_dav::xml::multistatus::PropstatElement;
use rustical_store::{CollectionOperation, CollectionOperationType, SubscriptionStore};
use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot};
use std::{str::FromStr, sync::Arc};
use tokio::sync::mpsc::Receiver;
use tracing::{error, info, warn};
// use web_push::{SubscriptionInfo, WebPushMessage, WebPushMessageBuilder};
#[derive(XmlSerialize, Debug)]
struct PushMessageProp {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
topic: String,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
sync_token: Option<String>,
}
#[derive(XmlSerialize, XmlRootTag, Debug)]
#[xml(root = b"push-message", ns = "rustical_dav::namespace::NS_DAVPUSH")]
#[xml(ns_prefix(
rustical_dav::namespace::NS_DAVPUSH = b"",
rustical_dav::namespace::NS_DAV = b"D",
))]
struct PushMessage {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
propstat: PropstatElement<PushMessageProp>,
}
// pub fn build_request(message: WebPushMessage) -> Request {
// // A little janky :)
// let url = reqwest::Url::from_str(&message.endpoint.to_string()).unwrap();
// let mut builder = Request::new(Method::POST, url);
//
// if let Some(topic) = message.topic {
// builder
// .headers_mut()
// .insert("Topic", HeaderValue::from_str(topic.as_str()).unwrap());
// }
//
// if let Some(payload) = message.payload {
// builder.headers_mut().insert(
// header::CONTENT_ENCODING,
// HeaderValue::from_static(payload.content_encoding.to_str()),
// );
// builder.headers_mut().insert(
// header::CONTENT_TYPE,
// HeaderValue::from_static("application/octet-stream"),
// );
//
// for (k, v) in payload.crypto_headers.into_iter() {
// let v: &str = v.as_ref();
// builder.headers_mut().insert(
// HeaderName::from_static(k),
// HeaderValue::from_str(&v).unwrap(),
// );
// }
//
// *builder.body_mut() = Some(reqwest::Body::from(payload.content));
// }
// builder
// }
pub async fn push_notifier(
allowed_push_servers: Option<Vec<String>>,
mut recv: Receiver<CollectionOperation>,
sub_store: Arc<impl SubscriptionStore>,
) {
let client = reqwest::Client::new();
while let Some(message) = recv.recv().await {
let subscribers = match sub_store.get_subscriptions(&message.topic).await {
Ok(subs) => subs,
Err(err) => {
error!("{err}");
continue;
}
};
let status = match message.r#type {
CollectionOperationType::Object => StatusCode::OK,
CollectionOperationType::Delete => StatusCode::NOT_FOUND,
};
let push_message = PushMessage {
propstat: PropstatElement {
prop: PushMessageProp {
topic: message.topic,
sync_token: message.sync_token,
},
status,
},
};
let mut output: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into();
let mut writer = quick_xml::Writer::new_with_indent(&mut output, b' ', 4);
if let Err(err) = push_message.serialize_root(&mut writer) {
error!("Could not serialize push message: {}", err);
continue;
}
let payload = String::from_utf8(output).unwrap();
// for subscriber in subscribers {
// let push_resource = subscriber.push_resource;
//
// let sub_info = SubscriptionInfo {
// endpoint: push_resource.to_owned(),
// keys: web_push::SubscriptionKeys {
// p256dh: subscriber.public_key,
// auth: subscriber.auth_secret,
// },
// };
// let mut builder = WebPushMessageBuilder::new(&sub_info);
// builder.set_payload(web_push::ContentEncoding::Aes128Gcm, payload.as_bytes());
// let push_message = builder.build().unwrap();
// let request = build_request(push_message);
//
// let allowed = if let Some(allowed_push_servers) = &allowed_push_servers {
// if let Ok(resource_url) = reqwest::Url::parse(&push_resource) {
// let origin = resource_url.origin().ascii_serialization();
// allowed_push_servers
// .iter()
// .any(|allowed_push_server| allowed_push_server == &origin)
// } else {
// warn!("Invalid push url: {push_resource}");
// false
// }
// } else {
// true
// };
//
// if allowed {
// info!("Sending a push message to {}: {}", push_resource, payload);
// if let Err(err) = client.execute(request).await {
// error!("{err}");
// }
// } else {
// warn!(
// "Not sending a push notification to {} since it's not allowed in dav_push::allowed_push_servers",
// push_resource
// );
// }
// }
}
}

View File

@@ -7,6 +7,10 @@ repository.workspace = true
license.workspace = true
publish = false
[features]
default = []
dev = ["tower-http/fs"]
[dependencies]
tower.workspace = true
http.workspace = true
@@ -34,3 +38,4 @@ axum-extra.workspace = true
headers.workspace = true
tower-sessions.workspace = true
percent-encoding.workspace = true
tower-http = { workspace = true, optional = true }

View File

@@ -0,0 +1,19 @@
{
"tasks": {
"dev": "deno run -A --node-modules-dir npm:vite build --emptyOutDir --watch",
"build": "deno run -A --node-modules-dir npm:vite build --emptyOutDir"
},
"compilerOptions": {
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
]
},
"imports": {
"@deno/vite-plugin": "npm:@deno/vite-plugin@^1.0.4",
"lit": "npm:lit@^3.2.1",
"vite": "npm:vite@^6.1.1",
"webdav": "npm:webdav@^5.8.0"
}
}

436
crates/frontend/js-components/deno.lock generated Normal file
View File

@@ -0,0 +1,436 @@
{
"version": "4",
"specifiers": {
"npm:@deno/vite-plugin@^1.0.4": "1.0.4_vite@6.3.5__picomatch@4.0.2",
"npm:lit@^3.2.1": "3.3.0",
"npm:vite@*": "6.3.5_picomatch@4.0.2",
"npm:vite@^6.1.1": "6.3.5_picomatch@4.0.2",
"npm:webdav@^5.8.0": "5.8.0"
},
"npm": {
"@buttercup/fetch@0.2.1": {
"integrity": "sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==",
"dependencies": [
"node-fetch"
]
},
"@deno/vite-plugin@1.0.4_vite@6.3.5__picomatch@4.0.2": {
"integrity": "sha512-xg8YT8Wn2sGXSnJgiGTpBGX1Dov0c6fd1rAp8VsfrCUtyBRRWzwVMAnd3fQ4yq8h7LSVvJUxEFN4U421k/DQLA==",
"dependencies": [
"vite"
]
},
"@esbuild/aix-ppc64@0.25.5": {
"integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="
},
"@esbuild/android-arm64@0.25.5": {
"integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg=="
},
"@esbuild/android-arm@0.25.5": {
"integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="
},
"@esbuild/android-x64@0.25.5": {
"integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw=="
},
"@esbuild/darwin-arm64@0.25.5": {
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ=="
},
"@esbuild/darwin-x64@0.25.5": {
"integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ=="
},
"@esbuild/freebsd-arm64@0.25.5": {
"integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw=="
},
"@esbuild/freebsd-x64@0.25.5": {
"integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw=="
},
"@esbuild/linux-arm64@0.25.5": {
"integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg=="
},
"@esbuild/linux-arm@0.25.5": {
"integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw=="
},
"@esbuild/linux-ia32@0.25.5": {
"integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA=="
},
"@esbuild/linux-loong64@0.25.5": {
"integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg=="
},
"@esbuild/linux-mips64el@0.25.5": {
"integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg=="
},
"@esbuild/linux-ppc64@0.25.5": {
"integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ=="
},
"@esbuild/linux-riscv64@0.25.5": {
"integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA=="
},
"@esbuild/linux-s390x@0.25.5": {
"integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ=="
},
"@esbuild/linux-x64@0.25.5": {
"integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="
},
"@esbuild/netbsd-arm64@0.25.5": {
"integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw=="
},
"@esbuild/netbsd-x64@0.25.5": {
"integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ=="
},
"@esbuild/openbsd-arm64@0.25.5": {
"integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw=="
},
"@esbuild/openbsd-x64@0.25.5": {
"integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg=="
},
"@esbuild/sunos-x64@0.25.5": {
"integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA=="
},
"@esbuild/win32-arm64@0.25.5": {
"integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw=="
},
"@esbuild/win32-ia32@0.25.5": {
"integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ=="
},
"@esbuild/win32-x64@0.25.5": {
"integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g=="
},
"@lit-labs/ssr-dom-shim@1.3.0": {
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ=="
},
"@lit/reactive-element@2.1.0": {
"integrity": "sha512-L2qyoZSQClcBmq0qajBVbhYEcG6iK0XfLn66ifLe/RfC0/ihpc+pl0Wdn8bJ8o+hj38cG0fGXRgSS20MuXn7qA==",
"dependencies": [
"@lit-labs/ssr-dom-shim"
]
},
"@rollup/rollup-android-arm-eabi@4.43.0": {
"integrity": "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw=="
},
"@rollup/rollup-android-arm64@4.43.0": {
"integrity": "sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA=="
},
"@rollup/rollup-darwin-arm64@4.43.0": {
"integrity": "sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A=="
},
"@rollup/rollup-darwin-x64@4.43.0": {
"integrity": "sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg=="
},
"@rollup/rollup-freebsd-arm64@4.43.0": {
"integrity": "sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ=="
},
"@rollup/rollup-freebsd-x64@4.43.0": {
"integrity": "sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg=="
},
"@rollup/rollup-linux-arm-gnueabihf@4.43.0": {
"integrity": "sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw=="
},
"@rollup/rollup-linux-arm-musleabihf@4.43.0": {
"integrity": "sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw=="
},
"@rollup/rollup-linux-arm64-gnu@4.43.0": {
"integrity": "sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA=="
},
"@rollup/rollup-linux-arm64-musl@4.43.0": {
"integrity": "sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA=="
},
"@rollup/rollup-linux-loongarch64-gnu@4.43.0": {
"integrity": "sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg=="
},
"@rollup/rollup-linux-powerpc64le-gnu@4.43.0": {
"integrity": "sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw=="
},
"@rollup/rollup-linux-riscv64-gnu@4.43.0": {
"integrity": "sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g=="
},
"@rollup/rollup-linux-riscv64-musl@4.43.0": {
"integrity": "sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q=="
},
"@rollup/rollup-linux-s390x-gnu@4.43.0": {
"integrity": "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw=="
},
"@rollup/rollup-linux-x64-gnu@4.43.0": {
"integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ=="
},
"@rollup/rollup-linux-x64-musl@4.43.0": {
"integrity": "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ=="
},
"@rollup/rollup-win32-arm64-msvc@4.43.0": {
"integrity": "sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw=="
},
"@rollup/rollup-win32-ia32-msvc@4.43.0": {
"integrity": "sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw=="
},
"@rollup/rollup-win32-x64-msvc@4.43.0": {
"integrity": "sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw=="
},
"@types/estree@1.0.7": {
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="
},
"@types/trusted-types@2.0.7": {
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
"balanced-match@1.0.2": {
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"base-64@1.0.0": {
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="
},
"brace-expansion@2.0.2": {
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dependencies": [
"balanced-match"
]
},
"byte-length@1.0.2": {
"integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q=="
},
"charenc@0.0.2": {
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="
},
"crypt@0.0.2": {
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="
},
"data-uri-to-buffer@4.0.1": {
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="
},
"entities@6.0.1": {
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="
},
"esbuild@0.25.5": {
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
"dependencies": [
"@esbuild/aix-ppc64",
"@esbuild/android-arm",
"@esbuild/android-arm64",
"@esbuild/android-x64",
"@esbuild/darwin-arm64",
"@esbuild/darwin-x64",
"@esbuild/freebsd-arm64",
"@esbuild/freebsd-x64",
"@esbuild/linux-arm",
"@esbuild/linux-arm64",
"@esbuild/linux-ia32",
"@esbuild/linux-loong64",
"@esbuild/linux-mips64el",
"@esbuild/linux-ppc64",
"@esbuild/linux-riscv64",
"@esbuild/linux-s390x",
"@esbuild/linux-x64",
"@esbuild/netbsd-arm64",
"@esbuild/netbsd-x64",
"@esbuild/openbsd-arm64",
"@esbuild/openbsd-x64",
"@esbuild/sunos-x64",
"@esbuild/win32-arm64",
"@esbuild/win32-ia32",
"@esbuild/win32-x64"
]
},
"fast-xml-parser@4.5.3": {
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
"dependencies": [
"strnum"
]
},
"fdir@6.4.6_picomatch@4.0.2": {
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dependencies": [
"picomatch"
]
},
"fetch-blob@3.2.0": {
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"dependencies": [
"node-domexception",
"web-streams-polyfill"
]
},
"formdata-polyfill@4.0.10": {
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"dependencies": [
"fetch-blob"
]
},
"fsevents@2.3.3": {
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="
},
"hot-patcher@2.0.1": {
"integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q=="
},
"is-buffer@1.1.6": {
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
"layerr@3.0.0": {
"integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="
},
"lit-element@4.2.0": {
"integrity": "sha512-MGrXJVAI5x+Bfth/pU9Kst1iWID6GHDLEzFEnyULB/sFiRLgkd8NPK/PeeXxktA3T6EIIaq8U3KcbTU5XFcP2Q==",
"dependencies": [
"@lit-labs/ssr-dom-shim",
"@lit/reactive-element",
"lit-html"
]
},
"lit-html@3.3.0": {
"integrity": "sha512-RHoswrFAxY2d8Cf2mm4OZ1DgzCoBKUKSPvA1fhtSELxUERq2aQQ2h05pO9j81gS1o7RIRJ+CePLogfyahwmynw==",
"dependencies": [
"@types/trusted-types"
]
},
"lit@3.3.0": {
"integrity": "sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==",
"dependencies": [
"@lit/reactive-element",
"lit-element",
"lit-html"
]
},
"md5@2.3.0": {
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"dependencies": [
"charenc",
"crypt",
"is-buffer"
]
},
"minimatch@9.0.5": {
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dependencies": [
"brace-expansion"
]
},
"nanoid@3.3.11": {
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="
},
"nested-property@4.0.0": {
"integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA=="
},
"node-domexception@1.0.0": {
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="
},
"node-fetch@3.3.2": {
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"dependencies": [
"data-uri-to-buffer",
"fetch-blob",
"formdata-polyfill"
]
},
"path-posix@1.0.0": {
"integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA=="
},
"picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"picomatch@4.0.2": {
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="
},
"postcss@8.5.5": {
"integrity": "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==",
"dependencies": [
"nanoid",
"picocolors",
"source-map-js"
]
},
"querystringify@2.2.0": {
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
"requires-port@1.0.0": {
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"rollup@4.43.0": {
"integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==",
"dependencies": [
"@rollup/rollup-android-arm-eabi",
"@rollup/rollup-android-arm64",
"@rollup/rollup-darwin-arm64",
"@rollup/rollup-darwin-x64",
"@rollup/rollup-freebsd-arm64",
"@rollup/rollup-freebsd-x64",
"@rollup/rollup-linux-arm-gnueabihf",
"@rollup/rollup-linux-arm-musleabihf",
"@rollup/rollup-linux-arm64-gnu",
"@rollup/rollup-linux-arm64-musl",
"@rollup/rollup-linux-loongarch64-gnu",
"@rollup/rollup-linux-powerpc64le-gnu",
"@rollup/rollup-linux-riscv64-gnu",
"@rollup/rollup-linux-riscv64-musl",
"@rollup/rollup-linux-s390x-gnu",
"@rollup/rollup-linux-x64-gnu",
"@rollup/rollup-linux-x64-musl",
"@rollup/rollup-win32-arm64-msvc",
"@rollup/rollup-win32-ia32-msvc",
"@rollup/rollup-win32-x64-msvc",
"@types/estree",
"fsevents"
]
},
"source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
},
"strnum@1.1.2": {
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="
},
"tinyglobby@0.2.14_picomatch@4.0.2": {
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dependencies": [
"fdir",
"picomatch"
]
},
"url-join@5.0.0": {
"integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="
},
"url-parse@1.5.10": {
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dependencies": [
"querystringify",
"requires-port"
]
},
"vite@6.3.5_picomatch@4.0.2": {
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dependencies": [
"esbuild",
"fdir",
"fsevents",
"picomatch",
"postcss",
"rollup",
"tinyglobby"
]
},
"web-streams-polyfill@3.3.3": {
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="
},
"webdav@5.8.0": {
"integrity": "sha512-iuFG7NamJ41Oshg4930iQgfIpRrUiatPWIekeznYgEf2EOraTRcDPTjy7gIOMtkdpKTaqPk1E68NO5PAGtJahA==",
"dependencies": [
"@buttercup/fetch",
"base-64",
"byte-length",
"entities",
"fast-xml-parser",
"hot-patcher",
"layerr",
"md5",
"minimatch",
"nested-property",
"node-fetch",
"path-posix",
"url-join",
"url-parse"
]
}
},
"workspace": {
"dependencies": [
"npm:@deno/vite-plugin@^1.0.4",
"npm:lit@^3.2.1",
"npm:vite@^6.1.1",
"npm:webdav@^5.8.0"
]
}
}

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Lit</title>
<link rel="stylesheet" href="./src/index.css" />
<script type="module" src="/src/my-element.ts"></script>
</head>
<body>
<my-element>
<h1>Vite + Lit</h1>
</my-element>
</body>
</html>

View File

@@ -0,0 +1,87 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { createClient } from "webdav";
@customElement("create-addressbook-form")
export class CreateAddressbookForm extends LitElement {
constructor() {
super()
}
protected override createRenderRoot() {
return this
}
client = createClient("/carddav")
@property()
user: String = ''
@property()
id: String = ''
@property()
displayname: String = ''
@property()
description: String = ''
override render() {
return html`
<section>
<h3>Create calendar</h3>
<form @submit=${this.submit}>
<label>
id
<input type="text" name="id" @change=${e => this.id = e.target.value} />
</label>
<br>
<label>
Displayname
<input type="text" name="displayname" value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" @change=${e => this.description = e.target.value} />
</label>
<br>
<button type="submit">Create</button>
</form>
</section>
`
}
async submit(e: SubmitEvent) {
console.log(this.displayname)
e.preventDefault()
if (!this.id) {
alert("Empty id")
return
}
if (!this.displayname) {
alert("Empty displayname")
return
}
// TODO: Escape user input: There's not really a security risk here but would be nicer
await this.client.createDirectory(`/principal/${this.user}/${this.id}`, {
data: `
<mkcol xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
<set>
<prop>
<displayname>${this.displayname}</displayname>
${this.description ? `<CARD:addressbook-description>${this.description}</CARD:addressbook-description>` : ''}
</prop>
</set>
</mkcol>
`
})
window.location.reload()
return null
}
}
declare global {
interface HTMLElementTagNameMap {
'create-addressbook-form': CreateAddressbookForm
}
}

View File

@@ -0,0 +1,118 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { createClient } from "webdav";
@customElement("create-calendar-form")
export class CreateCalendarForm extends LitElement {
constructor() {
super()
}
protected override createRenderRoot() {
return this
}
client = createClient("/caldav")
@property()
user: String = ''
@property()
id: String = ''
@property()
displayname: String = ''
@property()
description: String = ''
@property()
color: String = ''
@property()
subscriptionUrl: String = ''
@property()
components: Set<"VEVENT" | "VTODO" | "VJOURNAL"> = new Set()
override render() {
return html`
<section>
<h3>Create calendar</h3>
<form @submit=${this.submit}>
<label>
id
<input type="text" name="id" @change=${e => this.id = e.target.value} />
</label>
<br>
<label>
Displayname
<input type="text" name="displayname" value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" @change=${e => this.description = e.target.value} />
</label>
<br>
<label>
Color
<input type="color" name="color" @change=${e => this.color = e.target.value} />
</label>
<br>
<label>
Subscription URL
<input type="text" name="subscription_url" @change=${e => this.subscriptionUrl = e.target.value} />
</label>
<br>
${["VEVENT", "VTODO", "VJOURNAL"].map(comp => html`
<label>
Support ${comp}
<input type="checkbox" value=${comp} @change=${e => e.target.checked ? this.components.add(e.target.value) : this.components.delete(e.target.value)} />
</label>
`)}
<br>
<button type="submit">Create</button>
</form>
</section>
`
}
async submit(e: SubmitEvent) {
console.log(this.displayname)
e.preventDefault()
if (!this.id) {
alert("Empty id")
return
}
if (!this.displayname) {
alert("Empty displayname")
return
}
if (!this.components.size) {
alert("No calendar components selected")
return
}
await this.client.createDirectory(`/principal/${this.user}/${this.id}`, {
data: `
<mkcol xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<set>
<prop>
<displayname>${this.displayname}</displayname>
${this.description ? `<CAL:calendar-description>${this.description}</CAL:calendar-description>` : ''}
${this.color ? `<ICAL:calendar-color>${this.color}</ICAL:calendar-color>` : ''}
${this.subscriptionUrl ? `<CS:source>${this.subscriptionUrl}</CS:source>` : ''}
<CAL:supported-calendar-component-set>
${Array.from(this.components.keys()).map(comp => `<CAL:comp name="${comp}" />`).join('\n')}
</CAL:supported-calendar-component-set>
</prop>
</set>
</mkcol>
`
})
window.location.reload()
return null
}
}
declare global {
interface HTMLElementTagNameMap {
'create-calendar-form': CreateCalendarForm
}
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,12 @@
{
"module": "nodenext",
"moduleResolution": "nodenext",
"compilerOptions": {
"target": "es2020",
"experimentalDecorators": true,
"useDefineForClassFields": false
},
"include": [
"lib/**/*.ts"
]
}

View File

@@ -0,0 +1,29 @@
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: {
// include: ["lit"]
},
build: {
copyPublicDir: false,
lib: {
entry: 'lib/index.ts',
formats: ['es'],
},
rollupOptions: {
input: [
"lib/create-calendar-form.ts",
"lib/create-addressbook-form.ts",
],
output: {
dir: "../public/assets/js/",
format: "es",
manualChunks: {
lit: ["lit"],
webdav: ["webdav"],
}
}
},
},
})

View File

@@ -0,0 +1,81 @@
import { i as d, x as m } from "./lit-Dq9MfRDi.mjs";
import { n, t as c } from "./property-DwhV4xIV.mjs";
import { a as u } from "./webdav-Bz4I5vNH.mjs";
var h = Object.defineProperty, y = Object.getOwnPropertyDescriptor, r = (e, a, o, s) => {
for (var t = s > 1 ? void 0 : s ? y(a, o) : a, p = e.length - 1, l; p >= 0; p--)
(l = e[p]) && (t = (s ? l(a, o, t) : l(t)) || t);
return s && t && h(a, o, t), t;
};
let i = class extends d {
constructor() {
super(), this.client = u("/carddav"), this.user = "", this.id = "", this.displayname = "", this.description = "";
}
createRenderRoot() {
return this;
}
render() {
return m`
<section>
<h3>Create calendar</h3>
<form @submit=${this.submit}>
<label>
id
<input type="text" name="id" @change=${(e) => this.id = e.target.value} />
</label>
<br>
<label>
Displayname
<input type="text" name="displayname" value=${this.displayname} @change=${(e) => this.displayname = e.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" @change=${(e) => this.description = e.target.value} />
</label>
<br>
<button type="submit">Create</button>
</form>
</section>
`;
}
async submit(e) {
if (console.log(this.displayname), e.preventDefault(), !this.id) {
alert("Empty id");
return;
}
if (!this.displayname) {
alert("Empty displayname");
return;
}
return await this.client.createDirectory(`/principal/${this.user}/${this.id}`, {
data: `
<mkcol xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
<set>
<prop>
<displayname>${this.displayname}</displayname>
${this.description ? `<CARD:addressbook-description>${this.description}</CARD:addressbook-description>` : ""}
</prop>
</set>
</mkcol>
`
}), window.location.reload(), null;
}
};
r([
n()
], i.prototype, "user", 2);
r([
n()
], i.prototype, "id", 2);
r([
n()
], i.prototype, "displayname", 2);
r([
n()
], i.prototype, "description", 2);
i = r([
c("create-addressbook-form")
], i);
export {
i as CreateAddressbookForm
};

View File

@@ -0,0 +1,117 @@
import { i as m, x as c } from "./lit-Dq9MfRDi.mjs";
import { n as s, t as d } from "./property-DwhV4xIV.mjs";
import { a as u } from "./webdav-Bz4I5vNH.mjs";
var h = Object.defineProperty, b = Object.getOwnPropertyDescriptor, a = (e, t, o, n) => {
for (var i = n > 1 ? void 0 : n ? b(t, o) : t, l = e.length - 1, p; l >= 0; l--)
(p = e[l]) && (i = (n ? p(t, o, i) : p(i)) || i);
return n && i && h(t, o, i), i;
};
let r = class extends m {
constructor() {
super(), this.client = u("/caldav"), this.user = "", this.id = "", this.displayname = "", this.description = "", this.color = "", this.subscriptionUrl = "", this.components = /* @__PURE__ */ new Set();
}
createRenderRoot() {
return this;
}
render() {
return c`
<section>
<h3>Create calendar</h3>
<form @submit=${this.submit}>
<label>
id
<input type="text" name="id" @change=${(e) => this.id = e.target.value} />
</label>
<br>
<label>
Displayname
<input type="text" name="displayname" value=${this.displayname} @change=${(e) => this.displayname = e.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" @change=${(e) => this.description = e.target.value} />
</label>
<br>
<label>
Color
<input type="color" name="color" @change=${(e) => this.color = e.target.value} />
</label>
<br>
<label>
Subscription URL
<input type="text" name="subscription_url" @change=${(e) => this.subscriptionUrl = e.target.value} />
</label>
<br>
${["VEVENT", "VTODO", "VJOURNAL"].map((e) => c`
<label>
Support ${e}
<input type="checkbox" value=${e} @change=${(t) => t.target.checked ? this.components.add(t.target.value) : this.components.delete(t.target.value)} />
</label>
`)}
<br>
<button type="submit">Create</button>
</form>
</section>
`;
}
async submit(e) {
if (console.log(this.displayname), e.preventDefault(), !this.id) {
alert("Empty id");
return;
}
if (!this.displayname) {
alert("Empty displayname");
return;
}
if (!this.components.size) {
alert("No calendar components selected");
return;
}
return await this.client.createDirectory(`/principal/${this.user}/${this.id}`, {
data: `
<mkcol xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<set>
<prop>
<displayname>${this.displayname}</displayname>
${this.description ? `<CAL:calendar-description>${this.description}</CAL:calendar-description>` : ""}
${this.color ? `<ICAL:calendar-color>${this.color}</ICAL:calendar-color>` : ""}
${this.subscriptionUrl ? `<CS:source>${this.subscriptionUrl}</CS:source>` : ""}
<CAL:supported-calendar-component-set>
${Array.from(this.components.keys()).map((t) => `<CAL:comp name="${t}" />`).join(`
`)}
</CAL:supported-calendar-component-set>
</prop>
</set>
</mkcol>
`
}), window.location.reload(), null;
}
};
a([
s()
], r.prototype, "user", 2);
a([
s()
], r.prototype, "id", 2);
a([
s()
], r.prototype, "displayname", 2);
a([
s()
], r.prototype, "description", 2);
a([
s()
], r.prototype, "color", 2);
a([
s()
], r.prototype, "subscriptionUrl", 2);
a([
s()
], r.prototype, "components", 2);
r = a([
d("create-calendar-form")
], r);
export {
r as CreateCalendarForm
};

View File

@@ -0,0 +1,550 @@
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const M = globalThis, B = M.ShadowRoot && (M.ShadyCSS === void 0 || M.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, tt = Symbol(), W = /* @__PURE__ */ new WeakMap();
let ot = class {
constructor(t, e, s) {
if (this._$cssResult$ = !0, s !== tt) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");
this.cssText = t, this.t = e;
}
get styleSheet() {
let t = this.o;
const e = this.t;
if (B && t === void 0) {
const s = e !== void 0 && e.length === 1;
s && (t = W.get(e)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), s && W.set(e, t));
}
return t;
}
toString() {
return this.cssText;
}
};
const ht = (r) => new ot(typeof r == "string" ? r : r + "", void 0, tt), at = (r, t) => {
if (B) r.adoptedStyleSheets = t.map((e) => e instanceof CSSStyleSheet ? e : e.styleSheet);
else for (const e of t) {
const s = document.createElement("style"), i = M.litNonce;
i !== void 0 && s.setAttribute("nonce", i), s.textContent = e.cssText, r.appendChild(s);
}
}, V = B ? (r) => r : (r) => r instanceof CSSStyleSheet ? ((t) => {
let e = "";
for (const s of t.cssRules) e += s.cssText;
return ht(e);
})(r) : r;
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const { is: lt, defineProperty: ct, getOwnPropertyDescriptor: dt, getOwnPropertyNames: pt, getOwnPropertySymbols: ut, getPrototypeOf: $t } = Object, f = globalThis, q = f.trustedTypes, _t = q ? q.emptyScript : "", k = f.reactiveElementPolyfillSupport, w = (r, t) => r, j = { toAttribute(r, t) {
switch (t) {
case Boolean:
r = r ? _t : null;
break;
case Object:
case Array:
r = r == null ? r : JSON.stringify(r);
}
return r;
}, fromAttribute(r, t) {
let e = r;
switch (t) {
case Boolean:
e = r !== null;
break;
case Number:
e = r === null ? null : Number(r);
break;
case Object:
case Array:
try {
e = JSON.parse(r);
} catch {
e = null;
}
}
return e;
} }, et = (r, t) => !lt(r, t), J = { attribute: !0, type: String, converter: j, reflect: !1, useDefault: !1, hasChanged: et };
Symbol.metadata ?? (Symbol.metadata = Symbol("metadata")), f.litPropertyMetadata ?? (f.litPropertyMetadata = /* @__PURE__ */ new WeakMap());
let v = class extends HTMLElement {
static addInitializer(t) {
this._$Ei(), (this.l ?? (this.l = [])).push(t);
}
static get observedAttributes() {
return this.finalize(), this._$Eh && [...this._$Eh.keys()];
}
static createProperty(t, e = J) {
if (e.state && (e.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(t) && ((e = Object.create(e)).wrapped = !0), this.elementProperties.set(t, e), !e.noAccessor) {
const s = Symbol(), i = this.getPropertyDescriptor(t, s, e);
i !== void 0 && ct(this.prototype, t, i);
}
}
static getPropertyDescriptor(t, e, s) {
const { get: i, set: n } = dt(this.prototype, t) ?? { get() {
return this[e];
}, set(o) {
this[e] = o;
} };
return { get: i, set(o) {
const a = i == null ? void 0 : i.call(this);
n == null || n.call(this, o), this.requestUpdate(t, a, s);
}, configurable: !0, enumerable: !0 };
}
static getPropertyOptions(t) {
return this.elementProperties.get(t) ?? J;
}
static _$Ei() {
if (this.hasOwnProperty(w("elementProperties"))) return;
const t = $t(this);
t.finalize(), t.l !== void 0 && (this.l = [...t.l]), this.elementProperties = new Map(t.elementProperties);
}
static finalize() {
if (this.hasOwnProperty(w("finalized"))) return;
if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(w("properties"))) {
const e = this.properties, s = [...pt(e), ...ut(e)];
for (const i of s) this.createProperty(i, e[i]);
}
const t = this[Symbol.metadata];
if (t !== null) {
const e = litPropertyMetadata.get(t);
if (e !== void 0) for (const [s, i] of e) this.elementProperties.set(s, i);
}
this._$Eh = /* @__PURE__ */ new Map();
for (const [e, s] of this.elementProperties) {
const i = this._$Eu(e, s);
i !== void 0 && this._$Eh.set(i, e);
}
this.elementStyles = this.finalizeStyles(this.styles);
}
static finalizeStyles(t) {
const e = [];
if (Array.isArray(t)) {
const s = new Set(t.flat(1 / 0).reverse());
for (const i of s) e.unshift(V(i));
} else t !== void 0 && e.push(V(t));
return e;
}
static _$Eu(t, e) {
const s = e.attribute;
return s === !1 ? void 0 : typeof s == "string" ? s : typeof t == "string" ? t.toLowerCase() : void 0;
}
constructor() {
super(), this._$Ep = void 0, this.isUpdatePending = !1, this.hasUpdated = !1, this._$Em = null, this._$Ev();
}
_$Ev() {
var t;
this._$ES = new Promise((e) => this.enableUpdating = e), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), (t = this.constructor.l) == null || t.forEach((e) => e(this));
}
addController(t) {
var e;
(this._$EO ?? (this._$EO = /* @__PURE__ */ new Set())).add(t), this.renderRoot !== void 0 && this.isConnected && ((e = t.hostConnected) == null || e.call(t));
}
removeController(t) {
var e;
(e = this._$EO) == null || e.delete(t);
}
_$E_() {
const t = /* @__PURE__ */ new Map(), e = this.constructor.elementProperties;
for (const s of e.keys()) this.hasOwnProperty(s) && (t.set(s, this[s]), delete this[s]);
t.size > 0 && (this._$Ep = t);
}
createRenderRoot() {
const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);
return at(t, this.constructor.elementStyles), t;
}
connectedCallback() {
var t;
this.renderRoot ?? (this.renderRoot = this.createRenderRoot()), this.enableUpdating(!0), (t = this._$EO) == null || t.forEach((e) => {
var s;
return (s = e.hostConnected) == null ? void 0 : s.call(e);
});
}
enableUpdating(t) {
}
disconnectedCallback() {
var t;
(t = this._$EO) == null || t.forEach((e) => {
var s;
return (s = e.hostDisconnected) == null ? void 0 : s.call(e);
});
}
attributeChangedCallback(t, e, s) {
this._$AK(t, s);
}
_$ET(t, e) {
var n;
const s = this.constructor.elementProperties.get(t), i = this.constructor._$Eu(t, s);
if (i !== void 0 && s.reflect === !0) {
const o = (((n = s.converter) == null ? void 0 : n.toAttribute) !== void 0 ? s.converter : j).toAttribute(e, s.type);
this._$Em = t, o == null ? this.removeAttribute(i) : this.setAttribute(i, o), this._$Em = null;
}
}
_$AK(t, e) {
var n, o;
const s = this.constructor, i = s._$Eh.get(t);
if (i !== void 0 && this._$Em !== i) {
const a = s.getPropertyOptions(i), h = typeof a.converter == "function" ? { fromAttribute: a.converter } : ((n = a.converter) == null ? void 0 : n.fromAttribute) !== void 0 ? a.converter : j;
this._$Em = i, this[i] = h.fromAttribute(e, a.type) ?? ((o = this._$Ej) == null ? void 0 : o.get(i)) ?? null, this._$Em = null;
}
}
requestUpdate(t, e, s) {
var i;
if (t !== void 0) {
const n = this.constructor, o = this[t];
if (s ?? (s = n.getPropertyOptions(t)), !((s.hasChanged ?? et)(o, e) || s.useDefault && s.reflect && o === ((i = this._$Ej) == null ? void 0 : i.get(t)) && !this.hasAttribute(n._$Eu(t, s)))) return;
this.C(t, e, s);
}
this.isUpdatePending === !1 && (this._$ES = this._$EP());
}
C(t, e, { useDefault: s, reflect: i, wrapped: n }, o) {
s && !(this._$Ej ?? (this._$Ej = /* @__PURE__ */ new Map())).has(t) && (this._$Ej.set(t, o ?? e ?? this[t]), n !== !0 || o !== void 0) || (this._$AL.has(t) || (this.hasUpdated || s || (e = void 0), this._$AL.set(t, e)), i === !0 && this._$Em !== t && (this._$Eq ?? (this._$Eq = /* @__PURE__ */ new Set())).add(t));
}
async _$EP() {
this.isUpdatePending = !0;
try {
await this._$ES;
} catch (e) {
Promise.reject(e);
}
const t = this.scheduleUpdate();
return t != null && await t, !this.isUpdatePending;
}
scheduleUpdate() {
return this.performUpdate();
}
performUpdate() {
var s;
if (!this.isUpdatePending) return;
if (!this.hasUpdated) {
if (this.renderRoot ?? (this.renderRoot = this.createRenderRoot()), this._$Ep) {
for (const [n, o] of this._$Ep) this[n] = o;
this._$Ep = void 0;
}
const i = this.constructor.elementProperties;
if (i.size > 0) for (const [n, o] of i) {
const { wrapped: a } = o, h = this[n];
a !== !0 || this._$AL.has(n) || h === void 0 || this.C(n, void 0, o, h);
}
}
let t = !1;
const e = this._$AL;
try {
t = this.shouldUpdate(e), t ? (this.willUpdate(e), (s = this._$EO) == null || s.forEach((i) => {
var n;
return (n = i.hostUpdate) == null ? void 0 : n.call(i);
}), this.update(e)) : this._$EM();
} catch (i) {
throw t = !1, this._$EM(), i;
}
t && this._$AE(e);
}
willUpdate(t) {
}
_$AE(t) {
var e;
(e = this._$EO) == null || e.forEach((s) => {
var i;
return (i = s.hostUpdated) == null ? void 0 : i.call(s);
}), this.hasUpdated || (this.hasUpdated = !0, this.firstUpdated(t)), this.updated(t);
}
_$EM() {
this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = !1;
}
get updateComplete() {
return this.getUpdateComplete();
}
getUpdateComplete() {
return this._$ES;
}
shouldUpdate(t) {
return !0;
}
update(t) {
this._$Eq && (this._$Eq = this._$Eq.forEach((e) => this._$ET(e, this[e]))), this._$EM();
}
updated(t) {
}
firstUpdated(t) {
}
};
v.elementStyles = [], v.shadowRootOptions = { mode: "open" }, v[w("elementProperties")] = /* @__PURE__ */ new Map(), v[w("finalized")] = /* @__PURE__ */ new Map(), k == null || k({ ReactiveElement: v }), (f.reactiveElementVersions ?? (f.reactiveElementVersions = [])).push("2.1.0");
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const C = globalThis, N = C.trustedTypes, K = N ? N.createPolicy("lit-html", { createHTML: (r) => r }) : void 0, st = "$lit$", _ = `lit$${Math.random().toFixed(9).slice(2)}$`, it = "?" + _, ft = `<${it}>`, g = document, P = () => g.createComment(""), x = (r) => r === null || typeof r != "object" && typeof r != "function", I = Array.isArray, At = (r) => I(r) || typeof (r == null ? void 0 : r[Symbol.iterator]) == "function", D = `[
\f\r]`, b = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, Z = /-->/g, F = />/g, A = RegExp(`>|${D}(?:([^\\s"'>=/]+)(${D}*=${D}*(?:[^
\f\r"'\`<>=]|("|')|))|$)`, "g"), G = /'/g, Q = /"/g, rt = /^(?:script|style|textarea|title)$/i, mt = (r) => (t, ...e) => ({ _$litType$: r, strings: t, values: e }), xt = mt(1), E = Symbol.for("lit-noChange"), d = Symbol.for("lit-nothing"), X = /* @__PURE__ */ new WeakMap(), m = g.createTreeWalker(g, 129);
function nt(r, t) {
if (!I(r) || !r.hasOwnProperty("raw")) throw Error("invalid template strings array");
return K !== void 0 ? K.createHTML(t) : t;
}
const yt = (r, t) => {
const e = r.length - 1, s = [];
let i, n = t === 2 ? "<svg>" : t === 3 ? "<math>" : "", o = b;
for (let a = 0; a < e; a++) {
const h = r[a];
let c, p, l = -1, u = 0;
for (; u < h.length && (o.lastIndex = u, p = o.exec(h), p !== null); ) u = o.lastIndex, o === b ? p[1] === "!--" ? o = Z : p[1] !== void 0 ? o = F : p[2] !== void 0 ? (rt.test(p[2]) && (i = RegExp("</" + p[2], "g")), o = A) : p[3] !== void 0 && (o = A) : o === A ? p[0] === ">" ? (o = i ?? b, l = -1) : p[1] === void 0 ? l = -2 : (l = o.lastIndex - p[2].length, c = p[1], o = p[3] === void 0 ? A : p[3] === '"' ? Q : G) : o === Q || o === G ? o = A : o === Z || o === F ? o = b : (o = A, i = void 0);
const $ = o === A && r[a + 1].startsWith("/>") ? " " : "";
n += o === b ? h + ft : l >= 0 ? (s.push(c), h.slice(0, l) + st + h.slice(l) + _ + $) : h + _ + (l === -2 ? a : $);
}
return [nt(r, n + (r[e] || "<?>") + (t === 2 ? "</svg>" : t === 3 ? "</math>" : "")), s];
};
class U {
constructor({ strings: t, _$litType$: e }, s) {
let i;
this.parts = [];
let n = 0, o = 0;
const a = t.length - 1, h = this.parts, [c, p] = yt(t, e);
if (this.el = U.createElement(c, s), m.currentNode = this.el.content, e === 2 || e === 3) {
const l = this.el.content.firstChild;
l.replaceWith(...l.childNodes);
}
for (; (i = m.nextNode()) !== null && h.length < a; ) {
if (i.nodeType === 1) {
if (i.hasAttributes()) for (const l of i.getAttributeNames()) if (l.endsWith(st)) {
const u = p[o++], $ = i.getAttribute(l).split(_), H = /([.?@])?(.*)/.exec(u);
h.push({ type: 1, index: n, name: H[2], strings: $, ctor: H[1] === "." ? vt : H[1] === "?" ? Et : H[1] === "@" ? St : R }), i.removeAttribute(l);
} else l.startsWith(_) && (h.push({ type: 6, index: n }), i.removeAttribute(l));
if (rt.test(i.tagName)) {
const l = i.textContent.split(_), u = l.length - 1;
if (u > 0) {
i.textContent = N ? N.emptyScript : "";
for (let $ = 0; $ < u; $++) i.append(l[$], P()), m.nextNode(), h.push({ type: 2, index: ++n });
i.append(l[u], P());
}
}
} else if (i.nodeType === 8) if (i.data === it) h.push({ type: 2, index: n });
else {
let l = -1;
for (; (l = i.data.indexOf(_, l + 1)) !== -1; ) h.push({ type: 7, index: n }), l += _.length - 1;
}
n++;
}
}
static createElement(t, e) {
const s = g.createElement("template");
return s.innerHTML = t, s;
}
}
function S(r, t, e = r, s) {
var o, a;
if (t === E) return t;
let i = s !== void 0 ? (o = e._$Co) == null ? void 0 : o[s] : e._$Cl;
const n = x(t) ? void 0 : t._$litDirective$;
return (i == null ? void 0 : i.constructor) !== n && ((a = i == null ? void 0 : i._$AO) == null || a.call(i, !1), n === void 0 ? i = void 0 : (i = new n(r), i._$AT(r, e, s)), s !== void 0 ? (e._$Co ?? (e._$Co = []))[s] = i : e._$Cl = i), i !== void 0 && (t = S(r, i._$AS(r, t.values), i, s)), t;
}
class gt {
constructor(t, e) {
this._$AV = [], this._$AN = void 0, this._$AD = t, this._$AM = e;
}
get parentNode() {
return this._$AM.parentNode;
}
get _$AU() {
return this._$AM._$AU;
}
u(t) {
const { el: { content: e }, parts: s } = this._$AD, i = ((t == null ? void 0 : t.creationScope) ?? g).importNode(e, !0);
m.currentNode = i;
let n = m.nextNode(), o = 0, a = 0, h = s[0];
for (; h !== void 0; ) {
if (o === h.index) {
let c;
h.type === 2 ? c = new O(n, n.nextSibling, this, t) : h.type === 1 ? c = new h.ctor(n, h.name, h.strings, this, t) : h.type === 6 && (c = new bt(n, this, t)), this._$AV.push(c), h = s[++a];
}
o !== (h == null ? void 0 : h.index) && (n = m.nextNode(), o++);
}
return m.currentNode = g, i;
}
p(t) {
let e = 0;
for (const s of this._$AV) s !== void 0 && (s.strings !== void 0 ? (s._$AI(t, s, e), e += s.strings.length - 2) : s._$AI(t[e])), e++;
}
}
class O {
get _$AU() {
var t;
return ((t = this._$AM) == null ? void 0 : t._$AU) ?? this._$Cv;
}
constructor(t, e, s, i) {
this.type = 2, this._$AH = d, this._$AN = void 0, this._$AA = t, this._$AB = e, this._$AM = s, this.options = i, this._$Cv = (i == null ? void 0 : i.isConnected) ?? !0;
}
get parentNode() {
let t = this._$AA.parentNode;
const e = this._$AM;
return e !== void 0 && (t == null ? void 0 : t.nodeType) === 11 && (t = e.parentNode), t;
}
get startNode() {
return this._$AA;
}
get endNode() {
return this._$AB;
}
_$AI(t, e = this) {
t = S(this, t, e), x(t) ? t === d || t == null || t === "" ? (this._$AH !== d && this._$AR(), this._$AH = d) : t !== this._$AH && t !== E && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : At(t) ? this.k(t) : this._(t);
}
O(t) {
return this._$AA.parentNode.insertBefore(t, this._$AB);
}
T(t) {
this._$AH !== t && (this._$AR(), this._$AH = this.O(t));
}
_(t) {
this._$AH !== d && x(this._$AH) ? this._$AA.nextSibling.data = t : this.T(g.createTextNode(t)), this._$AH = t;
}
$(t) {
var n;
const { values: e, _$litType$: s } = t, i = typeof s == "number" ? this._$AC(t) : (s.el === void 0 && (s.el = U.createElement(nt(s.h, s.h[0]), this.options)), s);
if (((n = this._$AH) == null ? void 0 : n._$AD) === i) this._$AH.p(e);
else {
const o = new gt(i, this), a = o.u(this.options);
o.p(e), this.T(a), this._$AH = o;
}
}
_$AC(t) {
let e = X.get(t.strings);
return e === void 0 && X.set(t.strings, e = new U(t)), e;
}
k(t) {
I(this._$AH) || (this._$AH = [], this._$AR());
const e = this._$AH;
let s, i = 0;
for (const n of t) i === e.length ? e.push(s = new O(this.O(P()), this.O(P()), this, this.options)) : s = e[i], s._$AI(n), i++;
i < e.length && (this._$AR(s && s._$AB.nextSibling, i), e.length = i);
}
_$AR(t = this._$AA.nextSibling, e) {
var s;
for ((s = this._$AP) == null ? void 0 : s.call(this, !1, !0, e); t && t !== this._$AB; ) {
const i = t.nextSibling;
t.remove(), t = i;
}
}
setConnected(t) {
var e;
this._$AM === void 0 && (this._$Cv = t, (e = this._$AP) == null || e.call(this, t));
}
}
class R {
get tagName() {
return this.element.tagName;
}
get _$AU() {
return this._$AM._$AU;
}
constructor(t, e, s, i, n) {
this.type = 1, this._$AH = d, this._$AN = void 0, this.element = t, this.name = e, this._$AM = i, this.options = n, s.length > 2 || s[0] !== "" || s[1] !== "" ? (this._$AH = Array(s.length - 1).fill(new String()), this.strings = s) : this._$AH = d;
}
_$AI(t, e = this, s, i) {
const n = this.strings;
let o = !1;
if (n === void 0) t = S(this, t, e, 0), o = !x(t) || t !== this._$AH && t !== E, o && (this._$AH = t);
else {
const a = t;
let h, c;
for (t = n[0], h = 0; h < n.length - 1; h++) c = S(this, a[s + h], e, h), c === E && (c = this._$AH[h]), o || (o = !x(c) || c !== this._$AH[h]), c === d ? t = d : t !== d && (t += (c ?? "") + n[h + 1]), this._$AH[h] = c;
}
o && !i && this.j(t);
}
j(t) {
t === d ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? "");
}
}
class vt extends R {
constructor() {
super(...arguments), this.type = 3;
}
j(t) {
this.element[this.name] = t === d ? void 0 : t;
}
}
class Et extends R {
constructor() {
super(...arguments), this.type = 4;
}
j(t) {
this.element.toggleAttribute(this.name, !!t && t !== d);
}
}
class St extends R {
constructor(t, e, s, i, n) {
super(t, e, s, i, n), this.type = 5;
}
_$AI(t, e = this) {
if ((t = S(this, t, e, 0) ?? d) === E) return;
const s = this._$AH, i = t === d && s !== d || t.capture !== s.capture || t.once !== s.once || t.passive !== s.passive, n = t !== d && (s === d || i);
i && this.element.removeEventListener(this.name, this, s), n && this.element.addEventListener(this.name, this, t), this._$AH = t;
}
handleEvent(t) {
var e;
typeof this._$AH == "function" ? this._$AH.call(((e = this.options) == null ? void 0 : e.host) ?? this.element, t) : this._$AH.handleEvent(t);
}
}
class bt {
constructor(t, e, s) {
this.element = t, this.type = 6, this._$AN = void 0, this._$AM = e, this.options = s;
}
get _$AU() {
return this._$AM._$AU;
}
_$AI(t) {
S(this, t);
}
}
const L = C.litHtmlPolyfillSupport;
L == null || L(U, O), (C.litHtmlVersions ?? (C.litHtmlVersions = [])).push("3.3.0");
const wt = (r, t, e) => {
const s = (e == null ? void 0 : e.renderBefore) ?? t;
let i = s._$litPart$;
if (i === void 0) {
const n = (e == null ? void 0 : e.renderBefore) ?? null;
s._$litPart$ = i = new O(t.insertBefore(P(), n), n, void 0, e ?? {});
}
return i._$AI(r), i;
};
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const y = globalThis;
class T extends v {
constructor() {
super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0;
}
createRenderRoot() {
var e;
const t = super.createRenderRoot();
return (e = this.renderOptions).renderBefore ?? (e.renderBefore = t.firstChild), t;
}
update(t) {
const e = this.render();
this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = wt(e, this.renderRoot, this.renderOptions);
}
connectedCallback() {
var t;
super.connectedCallback(), (t = this._$Do) == null || t.setConnected(!0);
}
disconnectedCallback() {
var t;
super.disconnectedCallback(), (t = this._$Do) == null || t.setConnected(!1);
}
render() {
return E;
}
}
var Y;
T._$litElement$ = !0, T.finalized = !0, (Y = y.litElementHydrateSupport) == null || Y.call(y, { LitElement: T });
const z = y.litElementPolyfillSupport;
z == null || z({ LitElement: T });
(y.litElementVersions ?? (y.litElementVersions = [])).push("4.2.0");
export {
et as f,
T as i,
j as u,
xt as x
};

View File

@@ -0,0 +1,47 @@
import { f as d, u as l } from "./lit-Dq9MfRDi.mjs";
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const f = (t) => (r, e) => {
e !== void 0 ? e.addInitializer(() => {
customElements.define(t, r);
}) : customElements.define(t, r);
};
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const p = { attribute: !0, type: String, converter: l, reflect: !1, hasChanged: d }, u = (t = p, r, e) => {
const { kind: i, metadata: a } = e;
let n = globalThis.litPropertyMetadata.get(a);
if (n === void 0 && globalThis.litPropertyMetadata.set(a, n = /* @__PURE__ */ new Map()), i === "setter" && ((t = Object.create(t)).wrapped = !0), n.set(e.name, t), i === "accessor") {
const { name: o } = e;
return { set(s) {
const c = r.get.call(this);
r.set.call(this, s), this.requestUpdate(o, c, t);
}, init(s) {
return s !== void 0 && this.C(o, void 0, t, s), s;
} };
}
if (i === "setter") {
const { name: o } = e;
return function(s) {
const c = this[o];
r.call(this, s), this.requestUpdate(o, c, t);
};
}
throw Error("Unsupported decorator location: " + i);
};
function m(t) {
return (r, e) => typeof e == "object" ? u(t, r, e) : ((i, a, n) => {
const o = a.hasOwnProperty(n);
return a.constructor.createProperty(n, i), o ? Object.getOwnPropertyDescriptor(a, n) : void 0;
})(t, r, e);
}
export {
m as n,
f as t
};

File diff suppressed because it is too large Load Diff

View File

@@ -44,13 +44,14 @@
<h2>Overview of licenses:</h2>
<ul class="licenses-overview">
<li><a href="#Apache-2.0">Apache License 2.0</a> (273)</li>
<li><a href="#MIT">MIT License</a> (80)</li>
<li><a href="#Apache-2.0">Apache License 2.0</a> (278)</li>
<li><a href="#MIT">MIT License</a> (83)</li>
<li><a href="#Unicode-3.0">Unicode License v3</a> (19)</li>
<li><a href="#AGPL-3.0">GNU Affero General Public License v3.0</a> (12)</li>
<li><a href="#BSD-3-Clause">BSD 3-Clause &quot;New&quot; or &quot;Revised&quot; License</a> (5)</li>
<li><a href="#ISC">ISC License</a> (3)</li>
<li><a href="#CDLA-Permissive-2.0">Community Data License Agreement Permissive 2.0</a> (1)</li>
<li><a href="#MPL-2.0">Mozilla Public License 2.0</a> (1)</li>
<li><a href="#Zlib">zlib License</a> (1)</li>
</ul>
@@ -60,7 +61,7 @@
<h3 id="AGPL-3.0">GNU Affero General Public License v3.0</h3>
<h4>Used by:</h4>
<ul class="license-used-by">
<li><a href=" https://github.com/lennart-k/rustical ">rustical 0.1.0</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical 0.2.2</a></li>
</ul>
<pre class="license-text"> GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
@@ -729,16 +730,16 @@ For more information on this, and how to apply and follow the GNU AGPL, see
<h3 id="AGPL-3.0">GNU Affero General Public License v3.0</h3>
<h4>Used by:</h4>
<ul class="license-used-by">
<li><a href=" https://github.com/lennart-k/rustical ">rustical_caldav 0.1.0</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_carddav 0.1.0</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_dav 0.1.0</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_dav_push 0.1.0</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_frontend 0.1.0</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_ical 0.1.0</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_oidc 0.1.0</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_store 0.1.0</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_store_sqlite 0.1.0</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_xml 0.1.0</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_caldav 0.2.2</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_carddav 0.2.2</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_dav 0.2.2</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_dav_push 0.2.2</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_frontend 0.2.2</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_ical 0.2.2</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_oidc 0.2.2</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_store 0.2.2</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_store_sqlite 0.2.2</a></li>
<li><a href=" https://github.com/lennart-k/rustical ">rustical_xml 0.2.2</a></li>
<li><a href=" https://crates.io/crates/xml_derive ">xml_derive 0.1.0</a></li>
</ul>
<pre class="license-text">GNU AFFERO GENERAL PUBLIC LICENSE
@@ -4342,9 +4343,13 @@ Software.
<li><a href=" https://github.com/rust-cli/anstyle.git ">anstyle 1.0.10</a></li>
<li><a href=" https://github.com/clap-rs/clap ">clap 4.5.39</a></li>
<li><a href=" https://github.com/rust-cli/anstyle.git ">colorchoice 1.0.3</a></li>
<li><a href=" https://github.com/sfackler/foreign-types ">foreign-types-shared 0.1.1</a></li>
<li><a href=" https://github.com/sfackler/foreign-types ">foreign-types 0.3.2</a></li>
<li><a href=" https://github.com/KokaKiwi/rust-hex ">hex 0.4.3</a></li>
<li><a href=" https://github.com/polyfill-rs/is_terminal_polyfill ">is_terminal_polyfill 1.70.1</a></li>
<li><a href=" https://github.com/polyfill-rs/once_cell_polyfill ">once_cell_polyfill 1.70.1</a></li>
<li><a href=" https://crates.io/crates/openssl-macros ">openssl-macros 0.1.1</a></li>
<li><a href=" https://github.com/sfackler/rust-openssl ">openssl 0.10.73</a></li>
<li><a href=" https://github.com/toml-rs/toml ">serde_spanned 0.6.8</a></li>
<li><a href=" https://github.com/toml-rs/toml ">toml 0.8.22</a></li>
<li><a href=" https://github.com/toml-rs/toml ">toml_datetime 0.6.9</a></li>
@@ -11662,6 +11667,29 @@ You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an &quot;AS IS&quot; BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
</pre>
</li>
<li class="license">
<h3 id="Apache-2.0">Apache License 2.0</h3>
<h4>Used by:</h4>
<ul class="license-used-by">
<li><a href=" https://github.com/sfackler/rust-openssl ">openssl 0.10.73</a></li>
</ul>
<pre class="license-text">Copyright 2011-2017 Google Inc.
2013 Jack Lloyd
2013-2014 Steven Fackler
Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an &quot;AS IS&quot; BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -12128,6 +12156,39 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.</pre>
</li>
<li class="license">
<h3 id="MIT">MIT License</h3>
<h4>Used by:</h4>
<ul class="license-used-by">
<li><a href=" https://github.com/sfackler/rust-openssl ">openssl-sys 0.9.109</a></li>
</ul>
<pre class="license-text">Copyright (c) 2014 Alex Crichton
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the &quot;Software&quot;), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
</pre>
</li>
<li class="license">
<h3 id="MIT">MIT License</h3>
@@ -12908,6 +12969,36 @@ furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
</pre>
</li>
<li class="license">
<h3 id="MIT">MIT License</h3>
<h4>Used by:</h4>
<ul class="license-used-by">
<li><a href=" https://github.com/Peternator7/strum ">strum 0.27.1</a></li>
<li><a href=" https://github.com/Peternator7/strum ">strum_macros 0.27.1</a></li>
</ul>
<pre class="license-text">MIT License
Copyright (c) 2019 Peter Glotfelty
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the &quot;Software&quot;), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
@@ -13526,6 +13617,387 @@ THE SOFTWARE.
<pre class="license-text">This project is dual-licensed under the Unlicense and MIT licenses.
You may use this code under the terms of either license.
</pre>
</li>
<li class="license">
<h3 id="MPL-2.0">Mozilla Public License 2.0</h3>
<h4>Used by:</h4>
<ul class="license-used-by">
<li><a href=" https://github.com/mozilla/rust-ece ">ece 2.3.1</a></li>
</ul>
<pre class="license-text">Mozilla Public License Version 2.0
&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;&#x3D;
1. Definitions
--------------
1.1. &quot;Contributor&quot;
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. &quot;Contributor Version&quot;
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor&#x27;s Contribution.
1.3. &quot;Contribution&quot;
means Covered Software of a particular Contributor.
1.4. &quot;Covered Software&quot;
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. &quot;Incompatible With Secondary Licenses&quot;
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. &quot;Executable Form&quot;
means any form of the work other than Source Code Form.
1.7. &quot;Larger Work&quot;
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. &quot;License&quot;
means this document.
1.9. &quot;Licensable&quot;
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. &quot;Modifications&quot;
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. &quot;Patent Claims&quot; of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. &quot;Secondary License&quot;
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. &quot;Source Code Form&quot;
means the form of the work preferred for making modifications.
1.14. &quot;You&quot; (or &quot;Your&quot;)
means an individual or a legal entity exercising rights under this
License. For legal entities, &quot;You&quot; includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, &quot;control&quot; means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party&#x27;s
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients&#x27; rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients&#x27; rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an &quot;as is&quot; *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party&#x27;s negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party&#x27;s ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - &quot;Incompatible With Secondary Licenses&quot; Notice
---------------------------------------------------------
This Source Code Form is &quot;Incompatible With Secondary Licenses&quot;, as
defined by the Mozilla Public License, v. 2.0.
</pre>
</li>
<li class="license">

View File

@@ -1,5 +1,10 @@
{% extends "layouts/default.html" %}
{% block imports %}
<script type="module" src="/frontend/assets/js/create-calendar-form.mjs" async></script>
<script type="module" src="/frontend/assets/js/create-addressbook-form.mjs" async></script>
{% endblock %}
{% block content %}
<div id="page-user">
@@ -9,7 +14,6 @@
<h2>Profile</h2>
<h3>Groups</h3>
<ul>
{% for group in user.memberships() %}
<li>{{ group }}</li>
@@ -117,51 +121,7 @@
{% endfor %}
</ul>
{% endif %}
<section>
<h3>Create calendar</h3>
<form action="/frontend/user/{{ user.id }}/calendar" method="POST">
<label>
href
<input type="text" name="id" />
</label>
<br>
<label>
Displayname
<input type="text" name="displayname" />
</label>
<br>
<label>
Description
<input type="text" name="description" />
</label>
<br>
<label>
Color
<input type="color" name="color" />
</label>
<br>
<label>
Subscription URL
<input type="text" name="subscription_url" />
</label>
<br>
<label>
Support VEVENT
<input type="checkbox" name="comp_event" checked />
</label>
<label>
Support VTODO
<input type="checkbox" name="comp_todo" checked />
</label>
<label>
Support VJOURNAL
<input type="checkbox" name="comp_journal" />
</label>
<br>
<button type="submit">Create</button>
</form>
</section>
<create-calendar-form user="{{ user.id }}"></create-calendar-form>
</section>
@@ -207,27 +167,7 @@
</ul>
{% endif %}
<section>
<h3>Create addressbook</h3>
<form action="/frontend/user/{{ user.id }}/addressbook" method="POST">
<label>
href
<input type="text" name="id" />
</label>
<br>
<label>
Displayname
<input type="text" name="displayname" />
</label>
<br>
<label>
Description
<input type="text" name="description" />
</label>
<br>
<button type="submit">Create</button>
</form>
</section>
<create-addressbook-form user="{{ user.id }}"></create-addressbook-form>
</section>
{% endblock %}

View File

@@ -11,11 +11,11 @@ use rust_embed::RustEmbed;
use std::{convert::Infallible, marker::PhantomData, str::FromStr};
use tower::Service;
#[derive(Clone, RustEmbed)]
#[derive(Clone, RustEmbed, Default)]
#[folder = "public/assets"]
pub struct Assets;
#[derive(Clone)]
#[derive(Clone, Default)]
pub struct EmbedService<E>
where
E: 'static + RustEmbed,
@@ -23,17 +23,6 @@ where
_embed: PhantomData<E>,
}
impl<E> EmbedService<E>
where
E: 'static + RustEmbed,
{
pub fn new() -> Self {
Self {
_embed: PhantomData,
}
}
}
impl<E> Service<Request> for EmbedService<E>
where
E: 'static + RustEmbed,

View File

@@ -25,21 +25,15 @@ mod routes;
pub use config::FrontendConfig;
use oidc_user_store::OidcUserStore;
use crate::{
assets::{Assets, EmbedService},
routes::{
addressbook::{
route_addressbook, route_addressbook_restore, route_create_addressbook,
route_delete_addressbook,
},
app_token::{route_delete_app_token, route_post_app_token},
calendar::{
route_calendar, route_calendar_restore, route_create_calendar, route_delete_calendar,
},
login::{route_get_login, route_post_login, route_post_logout},
user::{route_get_home, route_root, route_user_named},
},
use crate::routes::{
addressbook::{route_addressbook, route_addressbook_restore, route_delete_addressbook},
app_token::{route_delete_app_token, route_post_app_token},
calendar::{route_calendar, route_calendar_restore, route_delete_calendar},
login::{route_get_login, route_post_login, route_post_logout},
user::{route_get_home, route_root, route_user_named},
};
#[cfg(not(feature = "dev"))]
use assets::{Assets, EmbedService};
pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: AddressbookStore>(
prefix: &'static str,
@@ -62,7 +56,6 @@ pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: Addres
post(route_delete_app_token::<AP>),
)
// Calendar
.route("/user/{user}/calendar", post(route_create_calendar::<CS>))
.route(
"/user/{user}/calendar/{calendar}",
get(route_calendar::<CS>),
@@ -76,10 +69,6 @@ pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: Addres
post(route_calendar_restore::<CS>),
)
// Addressbook
.route(
"/user/{user}/addressbook",
post(route_create_addressbook::<AS>),
)
.route(
"/user/{user}/addressbook/{addressbook}",
get(route_addressbook::<AS>),
@@ -93,8 +82,15 @@ pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: Addres
post(route_addressbook_restore::<AS>),
)
.route("/login", get(route_get_login).post(route_post_login::<AP>))
.route("/logout", post(route_post_logout))
.route_service("/assets/{*file}", EmbedService::<Assets>::new());
.route("/logout", post(route_post_logout));
#[cfg(not(feature = "dev"))]
let mut router = router.route_service("/assets/{*file}", EmbedService::<Assets>::default());
#[cfg(feature = "dev")]
let mut router = router.nest_service(
"/assets",
tower_http::services::ServeDir::new(concat!(env!("CARGO_MANIFEST_DIR"), "/public/assets")),
);
if let Some(oidc_config) = oidc_config.clone() {
router = router

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use askama::Template;
use askama_web::WebTemplate;
use axum::{
Extension, Form,
Extension,
extract::Path,
response::{IntoResponse, Redirect, Response},
};
@@ -11,7 +11,6 @@ use axum_extra::TypedHeader;
use headers::Referer;
use http::StatusCode;
use rustical_store::{Addressbook, AddressbookStore, auth::User};
use serde::{Deserialize, Deserializer};
#[derive(Template, WebTemplate)]
#[template(path = "pages/addressbook.html")]
@@ -33,53 +32,6 @@ pub async fn route_addressbook<AS: AddressbookStore>(
.into_response())
}
fn empty_to_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let val: Option<String> = Deserialize::deserialize(deserializer)?;
Ok(val.filter(|val| !val.is_empty()))
}
#[derive(Deserialize, Clone)]
pub struct PutAddressbookForm {
id: String,
#[serde(deserialize_with = "empty_to_none")]
displayname: Option<String>,
#[serde(deserialize_with = "empty_to_none")]
description: Option<String>,
}
pub async fn route_create_addressbook<AS: AddressbookStore>(
Path(owner): Path<String>,
Extension(store): Extension<Arc<AS>>,
user: User,
Form(PutAddressbookForm {
id,
displayname,
description,
}): Form<PutAddressbookForm>,
) -> Result<Response, rustical_store::Error> {
if !user.is_principal(&owner) {
return Ok(StatusCode::UNAUTHORIZED.into_response());
}
assert!(!id.is_empty());
let addressbook = Addressbook {
id: id.to_owned(),
displayname,
description,
principal: user.id.to_owned(),
synctoken: 0,
deleted_at: None,
push_topic: uuid::Uuid::new_v4().to_string(),
};
store.insert_addressbook(addressbook).await?;
Ok(Redirect::to(&format!("/frontend/user/{}/addressbook/{}", user.id, id)).into_response())
}
pub async fn route_addressbook_restore<AS: AddressbookStore>(
Path((owner, addressbook_id)): Path<(String, String)>,
Extension(store): Extension<Arc<AS>>,

View File

@@ -3,16 +3,14 @@ use std::sync::Arc;
use askama::Template;
use askama_web::WebTemplate;
use axum::{
Extension, Form,
Extension,
extract::Path,
response::{IntoResponse, Redirect, Response},
};
use axum_extra::TypedHeader;
use headers::Referer;
use http::StatusCode;
use rustical_ical::CalendarObjectType;
use rustical_store::{Calendar, CalendarStore, auth::User};
use serde::{Deserialize, Deserializer};
#[derive(Template, WebTemplate)]
#[template(path = "pages/calendar.html")]
@@ -34,82 +32,6 @@ pub async fn route_calendar<C: CalendarStore>(
.into_response())
}
fn empty_to_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let val: Option<String> = Deserialize::deserialize(deserializer)?;
Ok(val.filter(|val| !val.is_empty()))
}
#[derive(Deserialize, Clone)]
pub struct PutCalendarForm {
id: String,
#[serde(deserialize_with = "empty_to_none")]
displayname: Option<String>,
#[serde(deserialize_with = "empty_to_none")]
description: Option<String>,
#[serde(deserialize_with = "empty_to_none")]
color: Option<String>,
#[serde(deserialize_with = "empty_to_none")]
subscription_url: Option<String>,
comp_event: Option<String>,
comp_todo: Option<String>,
comp_journal: Option<String>,
}
pub async fn route_create_calendar<C: CalendarStore>(
Path(owner): Path<String>,
Extension(store): Extension<Arc<C>>,
user: User,
Form(PutCalendarForm {
id,
displayname,
description,
color,
subscription_url,
comp_event,
comp_todo,
comp_journal,
}): Form<PutCalendarForm>,
) -> Result<Response, rustical_store::Error> {
if !user.is_principal(&owner) {
return Ok(StatusCode::UNAUTHORIZED.into_response());
}
assert!(!id.is_empty());
let mut comps = vec![];
if comp_event.is_some() {
comps.push(CalendarObjectType::Event);
}
if comp_todo.is_some() {
comps.push(CalendarObjectType::Todo);
}
if comp_journal.is_some() {
comps.push(CalendarObjectType::Journal);
}
let cal = Calendar {
id: id.to_owned(),
displayname,
description,
color,
subscription_url,
principal: user.id.to_owned(),
components: comps,
order: 0,
timezone_id: None,
timezone: None,
synctoken: 0,
deleted_at: None,
push_topic: uuid::Uuid::new_v4().to_string(),
};
store.insert_calendar(cal).await?;
Ok(Redirect::to(&format!("/frontend/user/{}/calendar/{}", user.id, id)).into_response())
}
pub async fn route_calendar_restore<CS: CalendarStore>(
Path((owner, cal_id)): Path<(String, String)>,
Extension(store): Extension<Arc<CS>>,

View File

@@ -72,9 +72,9 @@ impl AddressObject {
CalDateTime::parse_prop(prop, &HashMap::default()).ok()
}
pub fn get_full_name(&self) -> Option<&String> {
pub fn get_full_name(&self) -> Option<&str> {
let prop = self.vcard.get_property("FN")?;
prop.value.as_ref()
prop.value.as_deref()
}
pub fn get_anniversary_object(&self) -> Result<Option<CalendarObject>, Error> {

View File

@@ -24,6 +24,7 @@ pub trait AuthenticationProvider: Send + Sync + 'static {
async fn add_membership(&self, principal: &str, member_of: &str) -> Result<(), Error>;
async fn remove_membership(&self, principal: &str, member_of: &str) -> Result<(), Error>;
async fn list_members(&self, principal: &str) -> Result<Vec<String>, Error>;
}
pub use middleware::AuthenticationMiddleware;

View File

@@ -108,6 +108,10 @@ impl User {
memberships.push(self.id.as_str());
memberships
}
pub fn memberships_without_self(&self) -> Vec<&str> {
self.memberships.iter().map(String::as_str).collect()
}
}
impl rustical_dav::Principal for User {

View File

@@ -58,6 +58,7 @@ pub trait CalendarStore: Send + Sync + 'static {
principal: &str,
cal_id: &str,
object_id: &str,
show_deleted: bool,
) -> Result<CalendarObject, Error>;
async fn put_object(
&self,

View File

@@ -80,11 +80,13 @@ impl<CS: CalendarStore, BS: CalendarStore> CalendarStore for CombinedCalendarSto
use_trashbin: bool,
) -> Result<(), Error> {
if cal_id.starts_with(BIRTHDAYS_PREFIX) {
Err(Error::ReadOnly)
} else {
self.birthday_store
.delete_object(principal, cal_id, object_id, use_trashbin)
.await
} else {
self.cal_store
.delete_object(principal, cal_id, object_id, use_trashbin)
.await
}
}
@@ -94,14 +96,15 @@ impl<CS: CalendarStore, BS: CalendarStore> CalendarStore for CombinedCalendarSto
principal: &str,
cal_id: &str,
object_id: &str,
show_deleted: bool,
) -> Result<CalendarObject, Error> {
if cal_id.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store
.get_object(principal, cal_id, object_id)
.get_object(principal, cal_id, object_id, show_deleted)
.await
} else {
self.cal_store
.get_object(principal, cal_id, object_id)
.get_object(principal, cal_id, object_id, show_deleted)
.await
}
}
@@ -237,4 +240,3 @@ impl<CS: CalendarStore, BS: CalendarStore> CalendarStore for CombinedCalendarSto
}
}
}

View File

@@ -126,13 +126,14 @@ impl<AS: AddressbookStore> CalendarStore for ContactBirthdayStore<AS> {
principal: &str,
cal_id: &str,
object_id: &str,
show_deleted: bool,
) -> Result<CalendarObject, Error> {
let cal_id = cal_id
.strip_prefix(BIRTHDAYS_PREFIX)
.ok_or(Error::NotFound)?;
let (addressobject_id, date_type) = object_id.rsplit_once("-").ok_or(Error::NotFound)?;
self.0
.get_object(principal, cal_id, addressobject_id, false)
.get_object(principal, cal_id, addressobject_id, show_deleted)
.await?
.get_significant_dates()?
.remove(date_type)

View File

@@ -22,22 +22,15 @@ pub use addressbook::Addressbook;
pub use calendar::Calendar;
#[derive(Debug, Clone)]
pub enum CollectionOperationType {
pub enum CollectionOperationInfo {
// Sync-Token increased
Object,
Content { sync_token: String },
// Collection deleted
Delete,
}
#[derive(Debug, Clone)]
pub enum CollectionOperationDomain {
Calendar,
Addressbook,
}
#[derive(Debug, Clone)]
pub struct CollectionOperation {
pub r#type: CollectionOperationType,
pub domain: CollectionOperationDomain,
pub topic: String,
pub sync_token: Option<String>,
pub data: CollectionOperationInfo,
}

View File

@@ -3,8 +3,8 @@ use async_trait::async_trait;
use derive_more::derive::Constructor;
use rustical_ical::AddressObject;
use rustical_store::{
Addressbook, AddressbookStore, CollectionOperation, CollectionOperationDomain,
CollectionOperationType, Error, synctoken::format_synctoken,
Addressbook, AddressbookStore, CollectionOperation, CollectionOperationInfo, Error,
synctoken::format_synctoken,
};
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender;
@@ -250,7 +250,7 @@ impl SqliteAddressbookStore {
) -> Result<AddressObject, rustical_store::Error> {
Ok(sqlx::query_as!(
AddressObjectRow,
"SELECT id, vcf FROM addressobjects WHERE (principal, addressbook_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) or ?)",
"SELECT id, vcf FROM addressobjects WHERE (principal, addressbook_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) OR ?)",
principal,
addressbook_id,
object_id,
@@ -413,10 +413,8 @@ impl AddressbookStore for SqliteAddressbookStore {
if let Some(addressbook) = addressbook {
if let Err(err) = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Delete,
domain: CollectionOperationDomain::Addressbook,
data: CollectionOperationInfo::Delete,
topic: addressbook.push_topic,
sync_token: None,
}) {
error!("Push notification about deleted addressbook failed: {err}");
};
@@ -485,7 +483,7 @@ impl AddressbookStore for SqliteAddressbookStore {
)
.await?;
let synctoken = log_object_operation(
let sync_token = log_object_operation(
&mut tx,
&principal,
&addressbook_id,
@@ -498,13 +496,11 @@ impl AddressbookStore for SqliteAddressbookStore {
tx.commit().await.map_err(crate::Error::from)?;
if let Err(err) = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Object,
domain: CollectionOperationDomain::Addressbook,
data: CollectionOperationInfo::Content { sync_token },
topic: self
.get_addressbook(&principal, &addressbook_id, false)
.await?
.push_topic,
sync_token: Some(synctoken),
}) {
error!("Push notification about deleted addressbook failed: {err}");
};
@@ -524,7 +520,7 @@ impl AddressbookStore for SqliteAddressbookStore {
Self::_delete_object(&mut *tx, principal, addressbook_id, object_id, use_trashbin).await?;
let synctoken = log_object_operation(
let sync_token = log_object_operation(
&mut tx,
principal,
addressbook_id,
@@ -536,16 +532,15 @@ impl AddressbookStore for SqliteAddressbookStore {
tx.commit().await.map_err(crate::Error::from)?;
// TODO: Watch for errors here?
let _ = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Object,
domain: CollectionOperationDomain::Addressbook,
if let Err(err) = self.sender.try_send(CollectionOperation {
data: CollectionOperationInfo::Content { sync_token },
topic: self
.get_addressbook(principal, addressbook_id, false)
.await?
.push_topic,
sync_token: Some(synctoken),
});
}) {
error!("Push notification about deleted addressbook failed: {err}");
};
Ok(())
}
@@ -560,7 +555,7 @@ impl AddressbookStore for SqliteAddressbookStore {
Self::_restore_object(&mut *tx, principal, addressbook_id, object_id).await?;
let synctoken = log_object_operation(
let sync_token = log_object_operation(
&mut tx,
principal,
addressbook_id,
@@ -571,16 +566,15 @@ impl AddressbookStore for SqliteAddressbookStore {
.map_err(crate::Error::from)?;
tx.commit().await.map_err(crate::Error::from)?;
// TODO: Watch for errors here?
let _ = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Object,
domain: CollectionOperationDomain::Addressbook,
if let Err(err) = self.sender.try_send(CollectionOperation {
data: CollectionOperationInfo::Content { sync_token },
topic: self
.get_addressbook(principal, addressbook_id, false)
.await?
.push_topic,
sync_token: Some(synctoken),
});
}) {
error!("Push notification about deleted addressbook failed: {err}");
};
Ok(())
}

View File

@@ -6,7 +6,7 @@ use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectType};
use rustical_store::calendar_store::CalendarQuery;
use rustical_store::synctoken::format_synctoken;
use rustical_store::{Calendar, CalendarStore, Error};
use rustical_store::{CollectionOperation, CollectionOperationType};
use rustical_store::{CollectionOperation, CollectionOperationInfo};
use sqlx::types::chrono::NaiveDateTime;
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender;
@@ -296,13 +296,15 @@ impl SqliteCalendarStore {
principal: &str,
cal_id: &str,
object_id: &str,
show_deleted: bool,
) -> Result<CalendarObject, Error> {
sqlx::query_as!(
CalendarObjectRow,
"SELECT id, ics FROM calendarobjects WHERE (principal, cal_id, id) = (?, ?, ?)",
"SELECT id, ics FROM calendarobjects WHERE (principal, cal_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) OR ?)",
principal,
cal_id,
object_id
object_id,
show_deleted
)
.fetch_one(executor)
.await
@@ -454,7 +456,7 @@ impl SqliteCalendarStore {
.unwrap_or(0);
for Row { object_id, .. } in changes {
match Self::_get_object(&mut *conn, principal, cal_id, &object_id).await {
match Self::_get_object(&mut *conn, principal, cal_id, &object_id, false).await {
Ok(object) => objects.push(object),
Err(rustical_store::Error::NotFound) => deleted_objects.push(object_id),
Err(err) => return Err(err),
@@ -518,10 +520,8 @@ impl CalendarStore for SqliteCalendarStore {
if let Some(cal) = cal {
if let Err(err) = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Delete,
domain: rustical_store::CollectionOperationDomain::Calendar,
data: CollectionOperationInfo::Delete,
topic: cal.push_topic,
sync_token: None,
}) {
error!("Push notification about deleted calendar failed: {err}");
};
@@ -559,8 +559,9 @@ impl CalendarStore for SqliteCalendarStore {
principal: &str,
cal_id: &str,
object_id: &str,
show_deleted: bool,
) -> Result<CalendarObject, Error> {
Self::_get_object(&self.db, principal, cal_id, object_id).await
Self::_get_object(&self.db, principal, cal_id, object_id, show_deleted).await
}
#[instrument]
@@ -585,7 +586,7 @@ impl CalendarStore for SqliteCalendarStore {
)
.await?;
let synctoken = log_object_operation(
let sync_token = log_object_operation(
&mut tx,
&principal,
&cal_id,
@@ -597,10 +598,8 @@ impl CalendarStore for SqliteCalendarStore {
tx.commit().await.map_err(crate::Error::from)?;
if let Err(err) = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Object,
domain: rustical_store::CollectionOperationDomain::Calendar,
data: CollectionOperationInfo::Content { sync_token },
topic: self.get_calendar(&principal, &cal_id).await?.push_topic,
sync_token: Some(synctoken),
}) {
error!("Push notification about deleted calendar failed: {err}");
};
@@ -619,15 +618,13 @@ impl CalendarStore for SqliteCalendarStore {
Self::_delete_object(&mut *tx, principal, cal_id, id, use_trashbin).await?;
let synctoken =
let sync_token =
log_object_operation(&mut tx, principal, cal_id, id, ChangeOperation::Delete).await?;
tx.commit().await.map_err(crate::Error::from)?;
if let Err(err) = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Object,
domain: rustical_store::CollectionOperationDomain::Calendar,
data: CollectionOperationInfo::Content { sync_token },
topic: self.get_calendar(principal, cal_id).await?.push_topic,
sync_token: Some(synctoken),
}) {
error!("Push notification about deleted calendar failed: {err}");
};
@@ -645,16 +642,14 @@ impl CalendarStore for SqliteCalendarStore {
Self::_restore_object(&mut *tx, principal, cal_id, object_id).await?;
let synctoken =
let sync_token =
log_object_operation(&mut tx, principal, cal_id, object_id, ChangeOperation::Add)
.await?;
tx.commit().await.map_err(crate::Error::from)?;
if let Err(err) = self.sender.try_send(CollectionOperation {
r#type: CollectionOperationType::Object,
domain: rustical_store::CollectionOperationDomain::Calendar,
data: CollectionOperationInfo::Content { sync_token },
topic: self.get_calendar(principal, cal_id).await?.push_topic,
sync_token: Some(synctoken),
}) {
error!("Push notification about deleted calendar failed: {err}");
};

View File

@@ -249,4 +249,18 @@ impl AuthenticationProvider for SqlitePrincipalStore {
.map_err(crate::Error::from)?;
Ok(())
}
#[instrument]
async fn list_members(&self, principal: &str) -> Result<Vec<String>, Error> {
Ok(sqlx::query!(
r#"SELECT principal FROM memberships WHERE member_of = ?"#,
principal
)
.fetch_all(&self.db)
.await
.map_err(crate::Error::from)?
.into_iter()
.map(|record| record.principal)
.collect())
}
}

View File

@@ -92,10 +92,17 @@ impl Enum {
let prop_name_variants = tagged_variants.iter().map(|variant| {
let ident = &variant.variant.ident;
let xml_name = variant.xml_name();
if let Some(proptype) = &variant.attrs.prop {
quote! {#ident(#proptype)}
quote! {
#[xml(rename = #xml_name)]
#ident(#proptype)
}
} else {
quote! {#ident}
quote! {
#[xml(rename = #xml_name)]
#ident
}
}
});

View File

@@ -25,7 +25,11 @@ impl Enum {
let prefix = ns
.map(|ns| namespaces.get(&ns))
.unwrap_or(None)
.map(|prefix| [*prefix, b":"].concat());
.map(|prefix| if !prefix.is_empty() {
[*prefix, b":"].concat()
} else {
vec![]
});
let has_prefix = prefix.is_some();
let tagname = tag.map(|tag| [&prefix.unwrap_or_default(), tag].concat());
let qname = tagname.as_ref().map(|tagname| ::quick_xml::name::QName(tagname));

View File

@@ -4,15 +4,14 @@ a CalDAV/CardDAV server
!!! warning
RustiCal is **not production-ready!**
While I've started migrating to RustiCal and becoming more confident,
please know that bugs and rough edges will still occur.
Concretely, if you are using Apple Calendar you will want to stay away from assigning groups to users.
While I've started migrating to RustiCal and becoming more confident, please know that bugs and rough edges will still occur.
If you still want to play around with it in its current state, absolutely feel free to do so and to open up an issue if something is not working. :)
## Features
- easy to backup, everything saved in one SQLite database
- also export feature in the frontend
- [WebDAV Push](https://github.com/bitfireAT/webdav-push/) support, so near-instant synchronisation to DAVx5
- lightweight (the container image contains only one binary)
- adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks)
- deleted calendars are recoverable
@@ -25,4 +24,4 @@ If you still want to play around with it in its current state, absolutely feel f
- DAVx5,
- GNOME Accounts, GNOME Calendar, GNOME Contacts
- Evolution
- Apple Calendar (known issue: If a user is member of multiple groups then Apple Calendar just randomly selects a calendar home)
- Apple Calendar

View File

@@ -29,8 +29,7 @@ docker run --rm -it -v YOUR_DATA_DIR:/var/lib/rustical/ ghcr.io/lennart-k/rustic
This is also the place to set up **groups**.
Groups and rooms are also just principals and you can specify them as such using the `--principal-type` parameter.
To assign a user to a group you can use the `rustical membership` command. Being a member to a principal means that you can completely act on their behalf and see their collections.
**Note:** Apple Calendar doesn't play well with the current membership implementation so you might not want to set up groups at the moment.
**Note:** Many clients don't support autodiscovery of principals a user is a member of. In that case you'd have to set up multiple CalDAV profiles in your client with the respective principal URLs.
## Password vs app tokens

View File

@@ -34,6 +34,7 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
frontend_config: FrontendConfig,
oidc_config: Option<OidcConfig>,
nextcloud_login_config: NextcloudLoginConfig,
dav_push_enabled: bool,
) -> Router<()> {
let combined_cal_store = Arc::new(CombinedCalendarStore::new(
cal_store.clone(),
@@ -90,6 +91,13 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
nextcloud_login_router(auth_provider.clone()),
);
}
if dav_push_enabled {
router = router.merge(rustical_dav_push::subscription_service(
subscription_store.clone(),
));
}
router
.layer(
SessionManagerLayer::new(session_store)
@@ -126,12 +134,18 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
if response.status().is_server_error() {
tracing::error!("server error");
} else if response.status().is_client_error() {
if response.status() == StatusCode::UNAUTHORIZED {
// The iOS client always tries an unauthenticated request first so
// logging 401's as errors would clog up our logs
tracing::debug!("unauthorized");
} else {
tracing::error!("client error");
match response.status() {
StatusCode::UNAUTHORIZED => {
// The iOS client always tries an unauthenticated request first so
// logging 401's as errors would clog up our logs
tracing::debug!("unauthorized");
}
StatusCode::NOT_FOUND => {
tracing::warn!("client error");
}
_ => {
tracing::error!("client error");
}
}
};
})

View File

@@ -37,9 +37,14 @@ pub struct TracingConfig {
pub opentelemetry: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct DavPushConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
// Allowed Push servers, accepts any by default

View File

@@ -117,6 +117,7 @@ async fn main() -> Result<()> {
config.frontend.clone(),
config.oidc.clone(),
config.nextcloud_login.clone(),
config.dav_push.enabled,
);
let app = ServiceExt::<Request>::into_make_service(
NormalizePathLayer::trim_trailing_slash().layer(app),