Compare commits

...

32 Commits

Author SHA1 Message Date
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
Lennart
72961f44e0 Update Docker workflow to hopefully tag releases 2025-06-11 22:09:29 +02:00
Lennart
49ac6abf35 Update .gitattributes 2025-06-11 21:45:06 +02:00
Lennart
c855e3d6b6 Random preparation for release 2025-06-11 21:35:46 +02:00
Lennart
6ecdc6125e Iterate on documentation 2025-06-11 21:13:06 +02:00
Lennart
4eb35d6c0d caldav: Merge calendar store and birthday store into combined store 2025-06-11 19:57:04 +02:00
Lennart
bd0684dcbc Implement workaround to allow GNOME Accounts setup 2025-06-11 15:37:59 +02:00
Lennart
dac49f853a Update .sqlx files 2025-06-11 00:58:49 +02:00
Lennart
f1c61ecefa Fix insert_calendar: subscription_url not saved 2025-06-11 00:55:13 +02:00
86 changed files with 6029 additions and 2074 deletions

3
.gitattributes vendored Normal file
View File

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

View File

@@ -3,6 +3,9 @@ name: Docker
on: on:
push: push:
branches: ["main"] branches: ["main"]
release:
types: ["published"]
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
@@ -42,7 +45,6 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# As long as we don't have releases everything on the main branch shall be tagged as latest # As long as we don't have releases everything on the main branch shall be tagged as latest
# TODO: Before first release correctly configure this
tags: | tags: |
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch type=ref,event=branch

4
.gitignore vendored
View File

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

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

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

View File

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

39
Cargo.lock generated
View File

@@ -1222,6 +1222,12 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -2703,6 +2709,8 @@ dependencies = [
"rustical_xml", "rustical_xml",
"serde", "serde",
"sha2", "sha2",
"strum",
"strum_macros",
"thiserror 2.0.12", "thiserror 2.0.12",
"tokio", "tokio",
"tower", "tower",
@@ -2733,6 +2741,8 @@ dependencies = [
"rustical_store", "rustical_store",
"rustical_xml", "rustical_xml",
"serde", "serde",
"strum",
"strum_macros",
"thiserror 2.0.12", "thiserror 2.0.12",
"tokio", "tokio",
"tower", "tower",
@@ -2758,6 +2768,7 @@ dependencies = [
"quick-xml", "quick-xml",
"rustical_xml", "rustical_xml",
"serde", "serde",
"strum",
"thiserror 2.0.12", "thiserror 2.0.12",
"tokio", "tokio",
"tower", "tower",
@@ -2811,6 +2822,7 @@ dependencies = [
"thiserror 2.0.12", "thiserror 2.0.12",
"tokio", "tokio",
"tower", "tower",
"tower-http",
"tower-sessions", "tower-sessions",
"tracing", "tracing",
"url", "url",
@@ -3435,6 +3447,25 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 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]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -3752,12 +3783,20 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
"http-range-header",
"httpdate",
"iri-string", "iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",

View File

@@ -6,6 +6,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
description = "A CalDAV server" description = "A CalDAV server"
repository = "https://github.com/lennart-k/rustical" repository = "https://github.com/lennart-k/rustical"
license = "AGPL-3.0-or-later"
[package] [package]
name = "rustical" name = "rustical"
@@ -13,11 +14,13 @@ version.workspace = true
edition.workspace = true edition.workspace = true
description.workspace = true description.workspace = true
repository.workspace = true repository.workspace = true
license.workspace = true
resolver = "2" resolver = "2"
publish = false publish = false
[features] [features]
debug = ["opentelemetry"] debug = ["opentelemetry"]
frontend-dev = ["rustical_frontend/dev"]
opentelemetry = [ opentelemetry = [
"dep:opentelemetry", "dep:opentelemetry",
"dep:opentelemetry-otlp", "dep:opentelemetry-otlp",
@@ -26,6 +29,7 @@ opentelemetry = [
"dep:tracing-opentelemetry", "dep:tracing-opentelemetry",
] ]
[profile.dev] [profile.dev]
debug = 0 debug = 0

View File

@@ -3,10 +3,10 @@
a CalDAV/CardDAV server a CalDAV/CardDAV server
> [!WARNING] > [!WARNING]
> RustiCal is **not production-ready!** RustiCal is **not production-ready!**
> I'm just starting to use it myself so I cannot guarantee that everything will be working smoothly just yet. While I've started migrating to RustiCal and becoming more confident,
> I hope there won't be any manual migrations anymore but if you want to be an early adopter some SQL knowledge might be useful just in case. 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. :) 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 ## Features
@@ -22,3 +22,10 @@ a CalDAV/CardDAV server
## Getting Started ## Getting Started
- Check out the [documentation](https://lennart-k.github.io/rustical/installation/) - Check out the [documentation](https://lennart-k.github.io/rustical/installation/)
## Tested Clients
- DAVx5,
- GNOME Accounts, GNOME Calendar, GNOME Contacts
- Evolution
- Apple Calendar

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
use derive_more::derive::{From, Into}; use derive_more::derive::{From, Into};
use rustical_ical::CalendarObjectType; use rustical_ical::CalendarObjectType;
use rustical_xml::{XmlDeserialize, XmlSerialize}; use rustical_xml::{XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray;
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, From, Into)] #[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, From, Into)]
pub struct SupportedCalendarComponent { pub struct SupportedCalendarComponent {
@@ -58,39 +59,12 @@ pub struct SupportedCalendarData {
calendar_data: CalendarData, calendar_data: CalendarData,
} }
#[derive(Debug, Clone, XmlSerialize, PartialEq)] #[derive(Debug, Clone, XmlSerialize, PartialEq, VariantArray)]
pub enum ReportMethod { pub enum ReportMethod {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarQuery, CalendarQuery,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarMultiget, CalendarMultiget,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
SyncCollection, 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::Error;
use crate::calendar::prop::ReportMethod;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use derive_more::derive::{From, Into}; use derive_more::derive::{From, Into};
use rustical_dav::extensions::{ use rustical_dav::extensions::{
@@ -7,7 +8,7 @@ use rustical_dav::extensions::{
}; };
use rustical_dav::privileges::UserPrivilegeSet; use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName}; use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner}; use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner, SupportedReportSet};
use rustical_dav_push::DavPushExtension; use rustical_dav_push::DavPushExtension;
use rustical_ical::CalDateTime; use rustical_ical::CalDateTime;
use rustical_store::Calendar; use rustical_store::Calendar;
@@ -19,10 +20,6 @@ use std::str::FromStr;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "CalendarPropName")] #[xml(unit_variants_ident = "CalendarPropName")]
pub enum CalendarProp { pub enum CalendarProp {
// WebDAV (RFC 2518)
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
Displayname(Option<String>),
// CalDAV (RFC 4791) // CalDAV (RFC 4791)
#[xml(ns = "rustical_dav::namespace::NS_ICAL")] #[xml(ns = "rustical_dav::namespace::NS_ICAL")]
CalendarColor(Option<String>), CalendarColor(Option<String>),
@@ -44,8 +41,8 @@ pub enum CalendarProp {
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
MaxResourceSize(i64), MaxResourceSize(i64),
#[xml(skip_deserializing)] #[xml(skip_deserializing)]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
SupportedReportSet(SupportedReportSet), SupportedReportSet(SupportedReportSet<ReportMethod>),
#[xml(ns = "rustical_dav::namespace::NS_CALENDARSERVER")] #[xml(ns = "rustical_dav::namespace::NS_CALENDARSERVER")]
Source(Option<HrefElement>), Source(Option<HrefElement>),
#[xml(skip_deserializing)] #[xml(skip_deserializing)]
@@ -127,9 +124,6 @@ impl Resource for CalendarResource {
) -> Result<Self::Prop, Self::Error> { ) -> Result<Self::Prop, Self::Error> {
Ok(match prop { Ok(match prop {
CalendarPropWrapperName::Calendar(prop) => CalendarPropWrapper::Calendar(match prop { CalendarPropWrapperName::Calendar(prop) => CalendarPropWrapper::Calendar(match prop {
CalendarPropName::Displayname => {
CalendarProp::Displayname(self.cal.displayname.clone())
}
CalendarPropName::CalendarColor => { CalendarPropName::CalendarColor => {
CalendarProp::CalendarColor(self.cal.color.clone()) CalendarProp::CalendarColor(self.cal.color.clone())
} }
@@ -157,7 +151,7 @@ impl Resource for CalendarResource {
} }
CalendarPropName::MaxResourceSize => CalendarProp::MaxResourceSize(10000000), CalendarPropName::MaxResourceSize => CalendarProp::MaxResourceSize(10000000),
CalendarPropName::SupportedReportSet => { CalendarPropName::SupportedReportSet => {
CalendarProp::SupportedReportSet(SupportedReportSet::default()) CalendarProp::SupportedReportSet(SupportedReportSet::all())
} }
CalendarPropName::Source => CalendarProp::Source( CalendarPropName::Source => CalendarProp::Source(
self.cal.subscription_url.to_owned().map(HrefElement::from), self.cal.subscription_url.to_owned().map(HrefElement::from),
@@ -187,10 +181,6 @@ impl Resource for CalendarResource {
} }
match prop { match prop {
CalendarPropWrapper::Calendar(prop) => match prop { CalendarPropWrapper::Calendar(prop) => match prop {
CalendarProp::Displayname(displayname) => {
self.cal.displayname = displayname;
Ok(())
}
CalendarProp::CalendarColor(color) => { CalendarProp::CalendarColor(color) => {
self.cal.color = color; self.cal.color = color;
Ok(()) Ok(())
@@ -247,10 +237,6 @@ impl Resource for CalendarResource {
} }
match prop { match prop {
CalendarPropWrapperName::Calendar(prop) => match prop { CalendarPropWrapperName::Calendar(prop) => match prop {
CalendarPropName::Displayname => {
self.cal.displayname = None;
Ok(())
}
CalendarPropName::CalendarColor => { CalendarPropName::CalendarColor => {
self.cal.color = None; self.cal.color = None;
Ok(()) Ok(())
@@ -291,6 +277,14 @@ impl Resource for CalendarResource {
} }
} }
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> { fn get_owner(&self) -> Option<&str> {
Some(&self.cal.principal) Some(&self.cal.principal)
} }

View File

@@ -50,7 +50,7 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
type Principal = User; type Principal = User;
type PrincipalUri = CalDavPrincipalUri; type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access"; const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy";
async fn get_resource( async fn get_resource(
&self, &self,
@@ -59,7 +59,7 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
let calendar = self.cal_store.get_calendar(principal, cal_id).await?; let calendar = self.cal_store.get_calendar(principal, cal_id).await?;
Ok(CalendarResource { Ok(CalendarResource {
cal: calendar, cal: calendar,
read_only: self.cal_store.is_read_only(), read_only: self.cal_store.is_read_only(cal_id),
}) })
} }

View File

@@ -66,6 +66,11 @@ impl Resource for CalendarObjectResource {
}) })
} }
fn get_displayname(&self) -> Option<&str> {
// TODO: Extract summary from object
None
}
fn get_owner(&self) -> Option<&str> { fn get_owner(&self) -> Option<&str> {
Some(&self.principal) Some(&self.principal)
} }

View File

@@ -1,64 +0,0 @@
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_store::auth::User;
mod service;
pub use service::*;
mod prop;
pub use prop::*;
#[derive(Clone)]
pub struct CalendarSetResource {
pub(crate) principal: String,
pub(crate) read_only: bool,
pub(crate) name: &'static str,
}
impl ResourceName for CalendarSetResource {
fn get_name(&self) -> String {
self.name.to_owned()
}
}
impl Resource for CalendarSetResource {
type Prop = PrincipalPropWrapper;
type Error = Error;
type Principal = User;
const IS_COLLECTION: bool = true;
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[ResourcetypeInner(
Some(rustical_dav::namespace::NS_DAV),
"collection",
)])
}
fn get_prop(
&self,
puri: &impl PrincipalUri,
user: &User,
prop: &PrincipalPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
Ok(match prop {
PrincipalPropWrapperName::Common(prop) => PrincipalPropWrapper::Common(
<Self as CommonPropertiesExtension>::get_prop(self, puri, user, prop)?,
),
})
}
fn get_owner(&self) -> Option<&str> {
Some(&self.principal)
}
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> {
Ok(if self.read_only {
UserPrivilegeSet::owner_read(user.is_principal(&self.principal))
} else {
UserPrivilegeSet::owner_only(user.is_principal(&self.principal))
})
}
}

View File

@@ -1,8 +0,0 @@
use rustical_dav::extensions::CommonPropertiesProp;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)]
pub enum PrincipalPropWrapper {
Common(CommonPropertiesProp),
}

View File

@@ -1,84 +0,0 @@
use crate::calendar::CalendarResourceService;
use crate::calendar::resource::CalendarResource;
use crate::calendar_set::CalendarSetResource;
use crate::{CalDavPrincipalUri, Error};
use async_trait::async_trait;
use axum::Router;
use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::auth::User;
use rustical_store::{CalendarStore, SubscriptionStore};
use std::sync::Arc;
pub struct CalendarSetResourceService<C: CalendarStore, S: SubscriptionStore> {
name: &'static str,
cal_store: Arc<C>,
sub_store: Arc<S>,
}
impl<C: CalendarStore, S: SubscriptionStore> Clone for CalendarSetResourceService<C, S> {
fn clone(&self) -> Self {
Self {
name: self.name,
cal_store: self.cal_store.clone(),
sub_store: self.sub_store.clone(),
}
}
}
impl<C: CalendarStore, S: SubscriptionStore> CalendarSetResourceService<C, S> {
pub fn new(name: &'static str, cal_store: Arc<C>, sub_store: Arc<S>) -> Self {
Self {
name,
cal_store,
sub_store,
}
}
}
#[async_trait]
impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarSetResourceService<C, S> {
type PathComponents = (String,);
type MemberType = CalendarResource;
type Resource = CalendarSetResource;
type Error = Error;
type Principal = User;
type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, extended-mkcol, calendar-access";
async fn get_resource(
&self,
(principal,): &Self::PathComponents,
) -> Result<Self::Resource, Self::Error> {
Ok(CalendarSetResource {
principal: principal.to_owned(),
read_only: self.cal_store.is_read_only(),
name: self.name,
})
}
async fn get_members(
&self,
(principal,): &Self::PathComponents,
) -> Result<Vec<Self::MemberType>, Self::Error> {
let calendars = self.cal_store.get_calendars(principal).await?;
Ok(calendars
.into_iter()
.map(|cal| CalendarResource {
cal,
read_only: self.cal_store.is_read_only(),
})
.collect())
}
fn axum_router<State: Send + Sync + Clone + 'static>(self) -> axum::Router<State> {
Router::new()
.nest(
"/{calendar_id}",
CalendarResourceService::new(self.cal_store.clone(), self.sub_store.clone())
.axum_router(),
)
.route_service("/", self.axum_service())
}
}
impl<C: CalendarStore, S: SubscriptionStore> AxumMethods for CalendarSetResourceService<C, S> {}

View File

@@ -7,12 +7,11 @@ use rustical_dav::resource::{PrincipalUri, ResourceService};
use rustical_dav::resources::RootResourceService; use rustical_dav::resources::RootResourceService;
use rustical_store::auth::middleware::AuthenticationLayer; use rustical_store::auth::middleware::AuthenticationLayer;
use rustical_store::auth::{AuthenticationProvider, User}; use rustical_store::auth::{AuthenticationProvider, User};
use rustical_store::{AddressbookStore, CalendarStore, ContactBirthdayStore, SubscriptionStore}; use rustical_store::{CalendarStore, SubscriptionStore};
use std::sync::Arc; use std::sync::Arc;
pub mod calendar; pub mod calendar;
pub mod calendar_object; pub mod calendar_object;
pub mod calendar_set;
pub mod error; pub mod error;
pub mod principal; pub mod principal;
// mod subscription; // mod subscription;
@@ -23,28 +22,23 @@ pub use error::Error;
pub struct CalDavPrincipalUri(&'static str); pub struct CalDavPrincipalUri(&'static str);
impl PrincipalUri for CalDavPrincipalUri { impl PrincipalUri for CalDavPrincipalUri {
fn principal_collection(&self) -> String {
format!("{}/principal/", self.0)
}
fn principal_uri(&self, principal: &str) -> String { fn principal_uri(&self, principal: &str) -> String {
format!("{}/principal/{}/", self.0, principal) format!("{}{}/", self.principal_collection(), principal)
} }
} }
pub fn caldav_router< pub fn caldav_router<AP: AuthenticationProvider, C: CalendarStore, S: SubscriptionStore>(
AP: AuthenticationProvider,
AS: AddressbookStore,
C: CalendarStore,
S: SubscriptionStore,
>(
prefix: &'static str, prefix: &'static str,
auth_provider: Arc<AP>, auth_provider: Arc<AP>,
store: Arc<C>, store: Arc<C>,
addr_store: Arc<AS>,
subscription_store: Arc<S>, subscription_store: Arc<S>,
) -> Router { ) -> Router {
let birthday_store = Arc::new(ContactBirthdayStore::new(addr_store));
let principal_service = PrincipalResourceService { let principal_service = PrincipalResourceService {
auth_provider: auth_provider.clone(), auth_provider: auth_provider.clone(),
sub_store: subscription_store.clone(), sub_store: subscription_store.clone(),
birthday_store: birthday_store.clone(),
cal_store: store.clone(), cal_store: store.clone(),
}; };

View File

@@ -2,7 +2,9 @@ use crate::Error;
use rustical_dav::extensions::CommonPropertiesExtension; use rustical_dav::extensions::CommonPropertiesExtension;
use rustical_dav::privileges::UserPrivilegeSet; use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName}; use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner}; use rustical_dav::xml::{
GroupMemberSet, GroupMembership, Resourcetype, ResourcetypeInner, SupportedReportSet,
};
use rustical_store::auth::User; use rustical_store::auth::User;
mod service; mod service;
@@ -13,7 +15,7 @@ pub use prop::*;
#[derive(Clone)] #[derive(Clone)]
pub struct PrincipalResource { pub struct PrincipalResource {
principal: User, principal: User,
home_set: &'static [&'static str], members: Vec<String>,
} }
impl ResourceName for PrincipalResource { impl ResourceName for PrincipalResource {
@@ -33,6 +35,11 @@ impl Resource for PrincipalResource {
Resourcetype(&[ Resourcetype(&[
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "collection"), ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "collection"),
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "principal"), ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "principal"),
// https://github.com/apple/ccs-calendarserver/blob/13c706b985fb728b9aab42dc0fef85aae21921c3/doc/Extensions/caldav-proxy.txt
ResourcetypeInner(
Some(rustical_dav::namespace::NS_CALENDARSERVER),
"calendar-proxy-write",
),
]) ])
} }
@@ -44,37 +51,45 @@ impl Resource for PrincipalResource {
) -> Result<Self::Prop, Self::Error> { ) -> Result<Self::Prop, Self::Error> {
let principal_url = puri.principal_uri(&self.principal.id); let principal_url = puri.principal_uri(&self.principal.id);
let home_set = CalendarHomeSet(
user.memberships()
.into_iter()
.map(|principal| puri.principal_uri(principal))
.flat_map(|principal_url| {
self.home_set.iter().map(move |&home_name| {
HrefElement::new(format!("{}{}/", &principal_url, home_name))
})
})
.collect(),
);
Ok(match prop { Ok(match prop {
PrincipalPropWrapperName::Principal(prop) => { PrincipalPropWrapperName::Principal(prop) => {
PrincipalPropWrapper::Principal(match prop { PrincipalPropWrapper::Principal(match prop {
PrincipalPropName::CalendarUserType => { PrincipalPropName::CalendarUserType => {
PrincipalProp::CalendarUserType(self.principal.principal_type.to_owned()) 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 => { PrincipalPropName::PrincipalUrl => {
PrincipalProp::PrincipalUrl(principal_url.into()) PrincipalProp::PrincipalUrl(principal_url.into())
} }
PrincipalPropName::CalendarHomeSet => PrincipalProp::CalendarHomeSet(home_set), PrincipalPropName::CalendarHomeSet => {
PrincipalProp::CalendarHomeSet(principal_url.into())
}
PrincipalPropName::CalendarUserAddressSet => { PrincipalPropName::CalendarUserAddressSet => {
PrincipalProp::CalendarUserAddressSet(principal_url.into()) 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( PrincipalPropWrapperName::Common(prop) => PrincipalPropWrapper::Common(
@@ -83,6 +98,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> { fn get_owner(&self) -> Option<&str> {
Some(&self.principal.id) 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_store::auth::user::PrincipalType;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropName")] #[xml(unit_variants_ident = "PrincipalPropName")]
pub enum PrincipalProp { pub enum PrincipalProp {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
Displayname(String),
// Scheduling Extensions to CalDAV (RFC 6638) // Scheduling Extensions to CalDAV (RFC 6638)
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)] #[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)]
CalendarUserType(PrincipalType), CalendarUserType(PrincipalType),
@@ -17,10 +18,20 @@ pub enum PrincipalProp {
// WebDAV Access Control (RFC 3744) // WebDAV Access Control (RFC 3744)
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = b"principal-URL")] #[xml(ns = "rustical_dav::namespace::NS_DAV", rename = b"principal-URL")]
PrincipalUrl(HrefElement), 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) // CalDAV (RFC 4791)
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarHomeSet(CalendarHomeSet), CalendarHomeSet(HrefElement),
} }
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
@@ -30,5 +41,9 @@ pub enum PrincipalPropWrapper {
Common(CommonPropertiesProp), Common(CommonPropertiesProp),
} }
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)] #[derive(XmlSerialize, PartialEq, Clone, VariantArray)]
pub struct CalendarHomeSet(#[xml(ty = "untagged", flatten)] pub(super) Vec<HrefElement>); pub enum ReportMethod {
// We don't actually support principal-match
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
PrincipalMatch,
}

View File

@@ -1,4 +1,5 @@
use crate::calendar_set::{CalendarSetResource, CalendarSetResourceService}; use crate::calendar::CalendarResourceService;
use crate::calendar::resource::CalendarResource;
use crate::principal::PrincipalResource; use crate::principal::PrincipalResource;
use crate::{CalDavPrincipalUri, Error}; use crate::{CalDavPrincipalUri, Error};
use async_trait::async_trait; use async_trait::async_trait;
@@ -13,39 +14,36 @@ pub struct PrincipalResourceService<
AP: AuthenticationProvider, AP: AuthenticationProvider,
S: SubscriptionStore, S: SubscriptionStore,
CS: CalendarStore, CS: CalendarStore,
BS: CalendarStore,
> { > {
pub(crate) auth_provider: Arc<AP>, pub(crate) auth_provider: Arc<AP>,
pub(crate) sub_store: Arc<S>, pub(crate) sub_store: Arc<S>,
pub(crate) cal_store: Arc<CS>, pub(crate) cal_store: Arc<CS>,
pub(crate) birthday_store: Arc<BS>,
} }
impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore, BS: CalendarStore> Clone impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Clone
for PrincipalResourceService<AP, S, CS, BS> for PrincipalResourceService<AP, S, CS>
{ {
fn clone(&self) -> Self { fn clone(&self) -> Self {
Self { Self {
auth_provider: self.auth_provider.clone(), auth_provider: self.auth_provider.clone(),
sub_store: self.sub_store.clone(), sub_store: self.sub_store.clone(),
cal_store: self.cal_store.clone(), cal_store: self.cal_store.clone(),
birthday_store: self.birthday_store.clone(),
} }
} }
} }
#[async_trait] #[async_trait]
impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore, BS: CalendarStore> impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> ResourceService
ResourceService for PrincipalResourceService<AP, S, CS, BS> for PrincipalResourceService<AP, S, CS>
{ {
type PathComponents = (String,); type PathComponents = (String,);
type MemberType = CalendarSetResource; type MemberType = CalendarResource;
type Resource = PrincipalResource; type Resource = PrincipalResource;
type Error = Error; type Error = Error;
type Principal = User; type Principal = User;
type PrincipalUri = CalDavPrincipalUri; type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access"; const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy";
async fn get_resource( async fn get_resource(
&self, &self,
@@ -57,8 +55,8 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore, BS: Ca
.await? .await?
.ok_or(crate::Error::NotFound)?; .ok_or(crate::Error::NotFound)?;
Ok(PrincipalResource { Ok(PrincipalResource {
members: self.auth_provider.list_members(&user.id).await?,
principal: user, principal: user,
home_set: &["calendar", "birthdays"],
}) })
} }
@@ -66,45 +64,29 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore, BS: Ca
&self, &self,
(principal,): &Self::PathComponents, (principal,): &Self::PathComponents,
) -> Result<Vec<Self::MemberType>, Self::Error> { ) -> Result<Vec<Self::MemberType>, Self::Error> {
Ok(vec![ let calendars = self.cal_store.get_calendars(principal).await?;
CalendarSetResource {
name: "calendar", Ok(calendars
principal: principal.to_owned(), .into_iter()
read_only: false, .map(|cal| CalendarResource {
}, read_only: self.cal_store.is_read_only(&cal.id),
CalendarSetResource { cal,
name: "birthdays", })
principal: principal.to_owned(), .collect())
read_only: true,
},
])
} }
fn axum_router<State: Send + Sync + Clone + 'static>(self) -> axum::Router<State> { fn axum_router<State: Send + Sync + Clone + 'static>(self) -> axum::Router<State> {
Router::new() Router::new()
.nest( .nest(
"/calendar", "/{calendar_id}",
CalendarSetResourceService::new( CalendarResourceService::new(self.cal_store.clone(), self.sub_store.clone())
"calendar", .axum_router(),
self.cal_store.clone(),
self.sub_store.clone(),
)
.axum_router(),
)
.nest(
"/birthdays",
CalendarSetResourceService::new(
"birthdays",
self.birthday_store.clone(),
self.sub_store.clone(),
)
.axum_router(),
) )
.route_service("/", self.axum_service()) .route_service("/", self.axum_service())
} }
} }
impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore, BS: CalendarStore> impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> AxumMethods
AxumMethods for PrincipalResourceService<AP, S, CS, BS> for PrincipalResourceService<AP, S, CS>
{ {
} }

View File

@@ -4,6 +4,7 @@ version.workspace = true
edition.workspace = true edition.workspace = true
description.workspace = true description.workspace = true
repository.workspace = true repository.workspace = true
license.workspace = true
publish = false publish = false
[dependencies] [dependencies]
@@ -31,3 +32,5 @@ http.workspace = true
tower-http.workspace = true tower-http.workspace = true
percent-encoding.workspace = true percent-encoding.workspace = true
ical.workspace = true ical.workspace = true
strum.workspace = true
strum_macros.workspace = true

View File

@@ -64,6 +64,10 @@ impl Resource for AddressObjectResource {
}) })
} }
fn get_displayname(&self) -> Option<&str> {
self.object.get_full_name()
}
fn get_owner(&self) -> Option<&str> { fn get_owner(&self) -> Option<&str> {
Some(&self.principal) Some(&self.principal)
} }

View File

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

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_dav_push::DavPushExtensionProp;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "AddressbookPropName")] #[xml(unit_variants_ident = "AddressbookPropName")]
pub enum AddressbookProp { pub enum AddressbookProp {
// WebDAV (RFC 2518)
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
Displayname(Option<String>),
// CardDAV (RFC 6352) // CardDAV (RFC 6352)
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookDescription(Option<String>), AddressbookDescription(Option<String>),
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", skip_deserializing)] #[xml(ns = "rustical_dav::namespace::NS_CARDDAV", skip_deserializing)]
SupportedAddressData(SupportedAddressData), SupportedAddressData(SupportedAddressData),
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", skip_deserializing)] #[xml(ns = "rustical_dav::namespace::NS_DAV", skip_deserializing)]
SupportedReportSet(SupportedReportSet), SupportedReportSet(SupportedReportSet<ReportMethod>),
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
MaxResourceSize(i64), MaxResourceSize(i64),
} }
@@ -60,37 +60,10 @@ impl Default for SupportedAddressData {
} }
} }
#[derive(Debug, Clone, XmlSerialize, PartialEq)] #[derive(Debug, Clone, XmlSerialize, PartialEq, VariantArray)]
pub enum ReportMethod { pub enum ReportMethod {
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookMultiget, AddressbookMultiget,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
SyncCollection, 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::Error;
use crate::addressbook::prop::{ use crate::addressbook::prop::{
AddressbookProp, AddressbookPropName, AddressbookPropWrapper, AddressbookPropWrapperName, AddressbookProp, AddressbookPropName, AddressbookPropWrapper, AddressbookPropWrapperName,
@@ -7,7 +7,7 @@ use derive_more::derive::{From, Into};
use rustical_dav::extensions::{CommonPropertiesExtension, SyncTokenExtension}; use rustical_dav::extensions::{CommonPropertiesExtension, SyncTokenExtension};
use rustical_dav::privileges::UserPrivilegeSet; use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName}; 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_dav_push::DavPushExtension;
use rustical_store::Addressbook; use rustical_store::Addressbook;
use rustical_store::auth::User; use rustical_store::auth::User;
@@ -56,14 +56,11 @@ impl Resource for AddressbookResource {
Ok(match prop { Ok(match prop {
AddressbookPropWrapperName::Addressbook(prop) => { AddressbookPropWrapperName::Addressbook(prop) => {
AddressbookPropWrapper::Addressbook(match prop { AddressbookPropWrapper::Addressbook(match prop {
AddressbookPropName::Displayname => {
AddressbookProp::Displayname(self.0.displayname.clone())
}
AddressbookPropName::MaxResourceSize => { AddressbookPropName::MaxResourceSize => {
AddressbookProp::MaxResourceSize(10000000) AddressbookProp::MaxResourceSize(10000000)
} }
AddressbookPropName::SupportedReportSet => { AddressbookPropName::SupportedReportSet => {
AddressbookProp::SupportedReportSet(SupportedReportSet::default()) AddressbookProp::SupportedReportSet(SupportedReportSet::all())
} }
AddressbookPropName::AddressbookDescription => { AddressbookPropName::AddressbookDescription => {
AddressbookProp::AddressbookDescription(self.0.description.to_owned()) AddressbookProp::AddressbookDescription(self.0.description.to_owned())
@@ -89,10 +86,6 @@ impl Resource for AddressbookResource {
fn set_prop(&mut self, prop: Self::Prop) -> Result<(), rustical_dav::Error> { fn set_prop(&mut self, prop: Self::Prop) -> Result<(), rustical_dav::Error> {
match prop { match prop {
AddressbookPropWrapper::Addressbook(prop) => match prop { AddressbookPropWrapper::Addressbook(prop) => match prop {
AddressbookProp::Displayname(displayname) => {
self.0.displayname = displayname;
Ok(())
}
AddressbookProp::AddressbookDescription(description) => { AddressbookProp::AddressbookDescription(description) => {
self.0.description = description; self.0.description = description;
Ok(()) Ok(())
@@ -113,10 +106,6 @@ impl Resource for AddressbookResource {
) -> Result<(), rustical_dav::Error> { ) -> Result<(), rustical_dav::Error> {
match prop { match prop {
AddressbookPropWrapperName::Addressbook(prop) => match prop { AddressbookPropWrapperName::Addressbook(prop) => match prop {
AddressbookPropName::Displayname => {
self.0.displayname = None;
Ok(())
}
AddressbookPropName::AddressbookDescription => { AddressbookPropName::AddressbookDescription => {
self.0.description = None; self.0.description = None;
Ok(()) Ok(())
@@ -135,6 +124,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> { fn get_owner(&self) -> Option<&str> {
Some(&self.0.principal) Some(&self.0.principal)
} }

View File

@@ -22,8 +22,11 @@ pub mod principal;
pub struct CardDavPrincipalUri(&'static str); pub struct CardDavPrincipalUri(&'static str);
impl PrincipalUri for CardDavPrincipalUri { impl PrincipalUri for CardDavPrincipalUri {
fn principal_collection(&self) -> String {
format!("{}/principal/", self.0)
}
fn principal_uri(&self, principal: &str) -> String { 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::extensions::CommonPropertiesExtension;
use rustical_dav::privileges::UserPrivilegeSet; use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName}; 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; use rustical_store::auth::User;
mod service; mod service;
@@ -13,6 +15,7 @@ pub use prop::*;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PrincipalResource { pub struct PrincipalResource {
principal: User, principal: User,
members: Vec<String>,
} }
impl ResourceName for PrincipalResource { impl ResourceName for PrincipalResource {
@@ -41,30 +44,37 @@ impl Resource for PrincipalResource {
user: &User, user: &User,
prop: &PrincipalPropWrapperName, prop: &PrincipalPropWrapperName,
) -> Result<Self::Prop, Self::Error> { ) -> Result<Self::Prop, Self::Error> {
let principal_href = HrefElement::new(puri.principal_uri(&user.id)); let principal_href = HrefElement::new(puri.principal_uri(&self.principal.id));
let home_set = AddressbookHomeSet(
user.memberships()
.into_iter()
.map(|principal| puri.principal_uri(principal))
.map(HrefElement::new)
.collect(),
);
Ok(match prop { Ok(match prop {
PrincipalPropWrapperName::Principal(prop) => { PrincipalPropWrapperName::Principal(prop) => {
PrincipalPropWrapper::Principal(match 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::PrincipalUrl => PrincipalProp::PrincipalUrl(principal_href),
PrincipalPropName::AddressbookHomeSet => { PrincipalPropName::AddressbookHomeSet => {
PrincipalProp::AddressbookHomeSet(home_set) PrincipalProp::AddressbookHomeSet(principal_href)
} }
PrincipalPropName::PrincipalAddress => PrincipalProp::PrincipalAddress(None), 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 +84,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> { fn get_owner(&self) -> Option<&str> {
Some(&self.principal.id) 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}; 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)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropName")] #[xml(unit_variants_ident = "PrincipalPropName")]
pub enum PrincipalProp { pub enum PrincipalProp {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
Displayname(String),
// WebDAV Access Control (RFC 3744) // WebDAV Access Control (RFC 3744)
#[xml(rename = b"principal-URL")] #[xml(rename = b"principal-URL")]
#[xml(ns = "rustical_dav::namespace::NS_DAV")] #[xml(ns = "rustical_dav::namespace::NS_DAV")]
PrincipalUrl(HrefElement), PrincipalUrl(HrefElement),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
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) // CardDAV (RFC 6352)
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookHomeSet(AddressbookHomeSet), AddressbookHomeSet(HrefElement),
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")] #[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
PrincipalAddress(Option<HrefElement>), PrincipalAddress(Option<HrefElement>),
} }

View File

@@ -65,7 +65,10 @@ impl<A: AddressbookStore, AP: AuthenticationProvider, S: SubscriptionStore> Reso
.get_principal(principal) .get_principal(principal)
.await? .await?
.ok_or(crate::Error::NotFound)?; .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( async fn get_members(

View File

@@ -4,6 +4,7 @@ version.workspace = true
edition.workspace = true edition.workspace = true
description.workspace = true description.workspace = true
repository.workspace = true repository.workspace = true
license.workspace = true
publish = false publish = false
[dependencies] [dependencies]
@@ -24,3 +25,4 @@ tracing.workspace = true
tokio.workspace = true tokio.workspace = true
http.workspace = true http.workspace = true
headers.workspace = true headers.workspace = true
strum.workspace = true

View File

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

View File

@@ -114,6 +114,9 @@ where
} }
async fn route_options<RS: ResourceService + AxumMethods>() -> Response<Body> { async fn route_options<RS: ResourceService + AxumMethods>() -> Response<Body> {
// Semantically NO_CONTENT would also make sense,
// but GNOME Accounts only works when returning OK
// https://gitlab.gnome.org/GNOME/gnome-online-accounts/-/blob/master/src/goabackend/goadavclient.c#L289
let mut resp = Response::builder().status(StatusCode::OK); let mut resp = Response::builder().status(StatusCode::OK);
let headers = resp.headers_mut().unwrap(); let headers = resp.headers_mut().unwrap();
headers.insert("DAV", HeaderValue::from_static(RS::DAV_HEADER)); headers.insert("DAV", HeaderValue::from_static(RS::DAV_HEADER));

View File

@@ -60,6 +60,11 @@ pub trait Resource: Clone + Send + 'static {
Err(crate::Error::PropReadOnly) 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> { fn get_owner(&self) -> Option<&str> {
None None
} }

View File

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

View File

@@ -33,6 +33,10 @@ impl<PR: Resource, P: Principal> Resource for RootResource<PR, P> {
)]) )])
} }
fn get_displayname(&self) -> Option<&str> {
Some("RustiCal DAV root")
}
fn get_prop( fn get_prop(
&self, &self,
principal_uri: &impl PrincipalUri, 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; mod error;
pub mod sync_collection; pub mod sync_collection;
pub use error::ErrorElement; pub use error::ErrorElement;
mod report_set;
pub use report_set::SupportedReportSet;
mod group;
pub use group::*;

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 derive_more::derive::From;
use quick_xml::name::Namespace; use quick_xml::{
events::{BytesEnd, BytesStart, Event},
name::Namespace,
};
use rustical_xml::{NamespaceOwned, XmlSerialize}; use rustical_xml::{NamespaceOwned, XmlSerialize};
use std::collections::HashMap; use std::collections::HashMap;
@@ -9,11 +12,37 @@ pub struct TagList(Vec<(Option<NamespaceOwned>, String)>);
impl XmlSerialize for TagList { impl XmlSerialize for TagList {
fn serialize<W: std::io::Write>( fn serialize<W: std::io::Write>(
&self, &self,
_ns: Option<Namespace>, ns: Option<Namespace>,
_tag: Option<&[u8]>, tag: Option<&[u8]>,
_namespaces: &HashMap<Namespace, &[u8]>, namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut quick_xml::Writer<W>, writer: &mut quick_xml::Writer<W>,
) -> std::io::Result<()> { ) -> 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 { for (ns, tag) in &self.0 {
let mut el = writer.create_element(tag); let mut el = writer.create_element(tag);
if let Some(ns) = ns { if let Some(ns) = ns {
@@ -21,6 +50,10 @@ impl XmlSerialize for TagList {
} }
el.write_empty()?; el.write_empty()?;
} }
if let Some(qname) = &qname {
writer.write_event(Event::End(BytesEnd::from(qname.to_owned())))?;
}
Ok(()) Ok(())
} }

View File

@@ -4,6 +4,7 @@ version.workspace = true
edition.workspace = true edition.workspace = true
description.workspace = true description.workspace = true
repository.workspace = true repository.workspace = true
license.workspace = true
publish = false publish = false
[dependencies] [dependencies]

View File

@@ -4,8 +4,13 @@ version.workspace = true
edition.workspace = true edition.workspace = true
description.workspace = true description.workspace = true
repository.workspace = true repository.workspace = true
license.workspace = true
publish = false publish = false
[features]
default = []
dev = ["tower-http/fs"]
[dependencies] [dependencies]
tower.workspace = true tower.workspace = true
http.workspace = true http.workspace = true
@@ -33,3 +38,4 @@ axum-extra.workspace = true
headers.workspace = true headers.workspace = true
tower-sessions.workspace = true tower-sessions.workspace = true
percent-encoding.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

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,10 @@
{% extends "layouts/default.html" %} {% 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 %} {% block content %}
<div id="page-user"> <div id="page-user">
@@ -9,7 +14,6 @@
<h2>Profile</h2> <h2>Profile</h2>
<h3>Groups</h3> <h3>Groups</h3>
<ul> <ul>
{% for group in user.memberships() %} {% for group in user.memberships() %}
<li>{{ group }}</li> <li>{{ group }}</li>
@@ -117,51 +121,7 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
<create-calendar-form user="{{ user.id }}"></create-calendar-form>
<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>
</section> </section>
@@ -207,27 +167,7 @@
</ul> </ul>
{% endif %} {% endif %}
<section> <create-addressbook-form user="{{ user.id }}"></create-addressbook-form>
<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>
</section> </section>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use askama::Template; use askama::Template;
use askama_web::WebTemplate; use askama_web::WebTemplate;
use axum::{ use axum::{
Extension, Form, Extension,
extract::Path, extract::Path,
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
@@ -11,7 +11,6 @@ use axum_extra::TypedHeader;
use headers::Referer; use headers::Referer;
use http::StatusCode; use http::StatusCode;
use rustical_store::{Addressbook, AddressbookStore, auth::User}; use rustical_store::{Addressbook, AddressbookStore, auth::User};
use serde::{Deserialize, Deserializer};
#[derive(Template, WebTemplate)] #[derive(Template, WebTemplate)]
#[template(path = "pages/addressbook.html")] #[template(path = "pages/addressbook.html")]
@@ -33,53 +32,6 @@ pub async fn route_addressbook<AS: AddressbookStore>(
.into_response()) .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>( pub async fn route_addressbook_restore<AS: AddressbookStore>(
Path((owner, addressbook_id)): Path<(String, String)>, Path((owner, addressbook_id)): Path<(String, String)>,
Extension(store): Extension<Arc<AS>>, Extension(store): Extension<Arc<AS>>,

View File

@@ -3,16 +3,14 @@ use std::sync::Arc;
use askama::Template; use askama::Template;
use askama_web::WebTemplate; use askama_web::WebTemplate;
use axum::{ use axum::{
Extension, Form, Extension,
extract::Path, extract::Path,
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
use headers::Referer; use headers::Referer;
use http::StatusCode; use http::StatusCode;
use rustical_ical::CalendarObjectType;
use rustical_store::{Calendar, CalendarStore, auth::User}; use rustical_store::{Calendar, CalendarStore, auth::User};
use serde::{Deserialize, Deserializer};
#[derive(Template, WebTemplate)] #[derive(Template, WebTemplate)]
#[template(path = "pages/calendar.html")] #[template(path = "pages/calendar.html")]
@@ -34,82 +32,6 @@ pub async fn route_calendar<C: CalendarStore>(
.into_response()) .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>( pub async fn route_calendar_restore<CS: CalendarStore>(
Path((owner, cal_id)): Path<(String, String)>, Path((owner, cal_id)): Path<(String, String)>,
Extension(store): Extension<Arc<CS>>, Extension(store): Extension<Arc<CS>>,

View File

@@ -4,6 +4,8 @@ version.workspace = true
edition.workspace = true edition.workspace = true
description.workspace = true description.workspace = true
repository.workspace = true repository.workspace = true
license.workspace = true
publish = false
[dependencies] [dependencies]

View File

@@ -72,9 +72,9 @@ impl AddressObject {
CalDateTime::parse_prop(prop, &HashMap::default()).ok() 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")?; 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> { pub fn get_anniversary_object(&self) -> Result<Option<CalendarObject>, Error> {

View File

@@ -4,6 +4,8 @@ version.workspace = true
edition.workspace = true edition.workspace = true
description.workspace = true description.workspace = true
repository.workspace = true repository.workspace = true
license.workspace = true
publish = false
[dependencies] [dependencies]
openidconnect.workspace = true openidconnect.workspace = true

View File

@@ -4,6 +4,7 @@ version.workspace = true
edition.workspace = true edition.workspace = true
description.workspace = true description.workspace = true
repository.workspace = true repository.workspace = true
license.workspace = true
publish = false publish = false
[dependencies] [dependencies]

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 add_membership(&self, principal: &str, member_of: &str) -> Result<(), Error>;
async fn remove_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; pub use middleware::AuthenticationMiddleware;

View File

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

View File

@@ -80,5 +80,5 @@ pub trait CalendarStore: Send + Sync + 'static {
object_id: &str, object_id: &str,
) -> Result<(), Error>; ) -> Result<(), Error>;
fn is_read_only(&self) -> bool; fn is_read_only(&self, cal_id: &str) -> bool;
} }

View File

@@ -0,0 +1,240 @@
use std::sync::Arc;
use async_trait::async_trait;
use derive_more::Constructor;
use rustical_ical::CalendarObject;
use crate::{
Calendar, CalendarStore, Error, calendar_store::CalendarQuery,
contact_birthday_store::BIRTHDAYS_PREFIX,
};
#[derive(Debug, Constructor)]
pub struct CombinedCalendarStore<CS: CalendarStore, BS: CalendarStore> {
cal_store: Arc<CS>,
birthday_store: Arc<BS>,
}
impl<CS: CalendarStore, BS: CalendarStore> Clone for CombinedCalendarStore<CS, BS> {
fn clone(&self) -> Self {
Self {
cal_store: self.cal_store.clone(),
birthday_store: self.birthday_store.clone(),
}
}
}
#[async_trait]
impl<CS: CalendarStore, BS: CalendarStore> CalendarStore for CombinedCalendarStore<CS, BS> {
#[inline]
async fn get_calendar(&self, principal: &str, id: &str) -> Result<Calendar, Error> {
if id.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store.get_calendar(principal, id).await
} else {
self.cal_store.get_calendar(principal, id).await
}
}
#[inline]
async fn update_calendar(
&self,
principal: String,
id: String,
calendar: Calendar,
) -> Result<(), crate::Error> {
if id.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store
.update_calendar(principal, id, calendar)
.await
} else {
self.cal_store
.update_calendar(principal, id, calendar)
.await
}
}
#[inline]
async fn insert_calendar(&self, calendar: Calendar) -> Result<(), Error> {
if calendar.id.starts_with(BIRTHDAYS_PREFIX) {
Err(Error::ReadOnly)
} else {
self.cal_store.insert_calendar(calendar).await
}
}
#[inline]
async fn get_calendars(&self, principal: &str) -> Result<Vec<Calendar>, Error> {
Ok([
self.cal_store.get_calendars(principal).await?,
self.birthday_store.get_calendars(principal).await?,
]
.concat())
}
#[inline]
async fn delete_object(
&self,
principal: &str,
cal_id: &str,
object_id: &str,
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
}
}
#[inline]
async fn get_object(
&self,
principal: &str,
cal_id: &str,
object_id: &str,
) -> Result<CalendarObject, Error> {
if cal_id.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store
.get_object(principal, cal_id, object_id)
.await
} else {
self.cal_store
.get_object(principal, cal_id, object_id)
.await
}
}
#[inline]
async fn sync_changes(
&self,
principal: &str,
cal_id: &str,
synctoken: i64,
) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error> {
if cal_id.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store
.sync_changes(principal, cal_id, synctoken)
.await
} else {
self.cal_store
.sync_changes(principal, cal_id, synctoken)
.await
}
}
#[inline]
async fn get_objects(
&self,
principal: &str,
cal_id: &str,
) -> Result<Vec<CalendarObject>, Error> {
if cal_id.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store.get_objects(principal, cal_id).await
} else {
self.cal_store.get_objects(principal, cal_id).await
}
}
#[inline]
async fn calendar_query(
&self,
principal: &str,
cal_id: &str,
query: CalendarQuery,
) -> Result<Vec<CalendarObject>, Error> {
if cal_id.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store
.calendar_query(principal, cal_id, query)
.await
} else {
self.cal_store
.calendar_query(principal, cal_id, query)
.await
}
}
#[inline]
async fn restore_calendar(&self, principal: &str, name: &str) -> Result<(), Error> {
if name.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store.restore_calendar(principal, name).await
} else {
self.cal_store.restore_calendar(principal, name).await
}
}
#[inline]
async fn delete_calendar(
&self,
principal: &str,
name: &str,
use_trashbin: bool,
) -> Result<(), Error> {
if name.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store
.delete_calendar(principal, name, use_trashbin)
.await
} else {
self.cal_store
.delete_calendar(principal, name, use_trashbin)
.await
}
}
#[inline]
async fn get_deleted_calendars(&self, principal: &str) -> Result<Vec<Calendar>, Error> {
Ok([
self.birthday_store.get_deleted_calendars(principal).await?,
self.cal_store.get_deleted_calendars(principal).await?,
]
.concat())
}
#[inline]
async fn restore_object(
&self,
principal: &str,
cal_id: &str,
object_id: &str,
) -> Result<(), Error> {
if cal_id.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store
.restore_object(principal, cal_id, object_id)
.await
} else {
self.cal_store
.restore_object(principal, cal_id, object_id)
.await
}
}
#[inline]
async fn put_object(
&self,
principal: String,
cal_id: String,
object: CalendarObject,
overwrite: bool,
) -> Result<(), Error> {
if cal_id.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store
.put_object(principal, cal_id, object, overwrite)
.await
} else {
self.cal_store
.put_object(principal, cal_id, object, overwrite)
.await
}
}
#[inline]
fn is_read_only(&self, cal_id: &str) -> bool {
if cal_id.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store.is_read_only(cal_id)
} else {
self.cal_store.is_read_only(cal_id)
}
}
}

View File

@@ -5,13 +5,15 @@ use rustical_ical::{AddressObject, CalendarObject, CalendarObjectType};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
pub(crate) const BIRTHDAYS_PREFIX: &str = "_birthdays_";
#[derive(Constructor, Clone)] #[derive(Constructor, Clone)]
pub struct ContactBirthdayStore<AS: AddressbookStore>(Arc<AS>); pub struct ContactBirthdayStore<AS: AddressbookStore>(Arc<AS>);
fn birthday_calendar(addressbook: Addressbook) -> Calendar { fn birthday_calendar(addressbook: Addressbook) -> Calendar {
Calendar { Calendar {
principal: addressbook.principal, principal: addressbook.principal,
id: addressbook.id, id: format!("{}{}", BIRTHDAYS_PREFIX, addressbook.id),
displayname: addressbook displayname: addressbook
.displayname .displayname
.map(|name| format!("{} birthdays", name)), .map(|name| format!("{} birthdays", name)),
@@ -33,9 +35,11 @@ fn birthday_calendar(addressbook: Addressbook) -> Calendar {
} }
} }
/// Objects are all prefixed with BIRTHDAYS_PREFIX
#[async_trait] #[async_trait]
impl<AS: AddressbookStore> CalendarStore for ContactBirthdayStore<AS> { impl<AS: AddressbookStore> CalendarStore for ContactBirthdayStore<AS> {
async fn get_calendar(&self, principal: &str, id: &str) -> Result<Calendar, Error> { async fn get_calendar(&self, principal: &str, id: &str) -> Result<Calendar, Error> {
let id = id.strip_prefix(BIRTHDAYS_PREFIX).ok_or(Error::NotFound)?;
let addressbook = self.0.get_addressbook(principal, id, false).await?; let addressbook = self.0.get_addressbook(principal, id, false).await?;
Ok(birthday_calendar(addressbook)) Ok(birthday_calendar(addressbook))
} }
@@ -80,6 +84,9 @@ impl<AS: AddressbookStore> CalendarStore for ContactBirthdayStore<AS> {
cal_id: &str, cal_id: &str,
synctoken: i64, synctoken: i64,
) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error> { ) -> Result<(Vec<CalendarObject>, Vec<String>, i64), Error> {
let cal_id = cal_id
.strip_prefix(BIRTHDAYS_PREFIX)
.ok_or(Error::NotFound)?;
let (objects, deleted_objects, new_synctoken) = let (objects, deleted_objects, new_synctoken) =
self.0.sync_changes(principal, cal_id, synctoken).await?; self.0.sync_changes(principal, cal_id, synctoken).await?;
let objects: Result<Vec<Option<CalendarObject>>, rustical_ical::Error> = objects let objects: Result<Vec<Option<CalendarObject>>, rustical_ical::Error> = objects
@@ -96,6 +103,9 @@ impl<AS: AddressbookStore> CalendarStore for ContactBirthdayStore<AS> {
principal: &str, principal: &str,
cal_id: &str, cal_id: &str,
) -> Result<Vec<CalendarObject>, Error> { ) -> Result<Vec<CalendarObject>, Error> {
let cal_id = cal_id
.strip_prefix(BIRTHDAYS_PREFIX)
.ok_or(Error::NotFound)?;
let objects: Result<Vec<HashMap<&'static str, CalendarObject>>, rustical_ical::Error> = let objects: Result<Vec<HashMap<&'static str, CalendarObject>>, rustical_ical::Error> =
self.0 self.0
.get_objects(principal, cal_id) .get_objects(principal, cal_id)
@@ -117,6 +127,9 @@ impl<AS: AddressbookStore> CalendarStore for ContactBirthdayStore<AS> {
cal_id: &str, cal_id: &str,
object_id: &str, object_id: &str,
) -> Result<CalendarObject, Error> { ) -> 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)?; let (addressobject_id, date_type) = object_id.rsplit_once("-").ok_or(Error::NotFound)?;
self.0 self.0
.get_object(principal, cal_id, addressobject_id, false) .get_object(principal, cal_id, addressobject_id, false)
@@ -155,7 +168,7 @@ impl<AS: AddressbookStore> CalendarStore for ContactBirthdayStore<AS> {
Err(Error::ReadOnly) Err(Error::ReadOnly)
} }
fn is_read_only(&self) -> bool { fn is_read_only(&self, _cal_id: &str) -> bool {
true true
} }
} }

View File

@@ -5,6 +5,7 @@ pub mod error;
pub use error::Error; pub use error::Error;
pub mod auth; pub mod auth;
mod calendar; mod calendar;
mod combined_calendar_store;
mod contact_birthday_store; mod contact_birthday_store;
mod secret; mod secret;
mod subscription_store; mod subscription_store;
@@ -12,6 +13,7 @@ pub mod synctoken;
pub use addressbook_store::AddressbookStore; pub use addressbook_store::AddressbookStore;
pub use calendar_store::CalendarStore; pub use calendar_store::CalendarStore;
pub use combined_calendar_store::CombinedCalendarStore;
pub use contact_birthday_store::ContactBirthdayStore; pub use contact_birthday_store::ContactBirthdayStore;
pub use secret::Secret; pub use secret::Secret;
pub use subscription_store::*; pub use subscription_store::*;

View File

@@ -4,6 +4,7 @@ version.workspace = true
edition.workspace = true edition.workspace = true
description.workspace = true description.workspace = true
repository.workspace = true repository.workspace = true
license.workspace = true
publish = false publish = false
[dependencies] [dependencies]

View File

@@ -144,14 +144,15 @@ impl SqliteCalendarStore {
let comp_journal = calendar.components.contains(&CalendarObjectType::Journal); let comp_journal = calendar.components.contains(&CalendarObjectType::Journal);
sqlx::query!( sqlx::query!(
r#"INSERT INTO calendars (principal, id, displayname, description, "order", color, timezone, timezone_id, push_topic, comp_event, comp_todo, comp_journal) r#"INSERT INTO calendars (principal, id, displayname, description, "order", color, subscription_url, timezone, timezone_id, push_topic, comp_event, comp_todo, comp_journal)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
calendar.principal, calendar.principal,
calendar.id, calendar.id,
calendar.displayname, calendar.displayname,
calendar.description, calendar.description,
calendar.order, calendar.order,
calendar.color, calendar.color,
calendar.subscription_url,
calendar.timezone, calendar.timezone,
calendar.timezone_id, calendar.timezone_id,
calendar.push_topic, calendar.push_topic,
@@ -670,7 +671,7 @@ impl CalendarStore for SqliteCalendarStore {
Self::_sync_changes(&self.db, principal, cal_id, synctoken).await Self::_sync_changes(&self.db, principal, cal_id, synctoken).await
} }
fn is_read_only(&self) -> bool { fn is_read_only(&self, _cal_id: &str) -> bool {
false false
} }
} }

View File

@@ -249,4 +249,18 @@ impl AuthenticationProvider for SqlitePrincipalStore {
.map_err(crate::Error::from)?; .map_err(crate::Error::from)?;
Ok(()) 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

@@ -4,6 +4,7 @@ version.workspace = true
edition.workspace = true edition.workspace = true
description.workspace = true description.workspace = true
repository.workspace = true repository.workspace = true
license.workspace = true
publish = false publish = false
[dependencies] [dependencies]

View File

@@ -2,6 +2,7 @@
name = "xml_derive" name = "xml_derive"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
license.workspace = true
[lib] [lib]
proc-macro = true proc-macro = true

View File

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

View File

@@ -25,7 +25,11 @@ impl Enum {
let prefix = ns let prefix = ns
.map(|ns| namespaces.get(&ns)) .map(|ns| namespaces.get(&ns))
.unwrap_or(None) .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 has_prefix = prefix.is_some();
let tagname = tag.map(|tag| [&prefix.unwrap_or_default(), tag].concat()); let tagname = tag.map(|tag| [&prefix.unwrap_or_default(), tag].concat());
let qname = tagname.as_ref().map(|tagname| ::quick_xml::name::QName(tagname)); let qname = tagname.as_ref().map(|tagname| ::quick_xml::name::QName(tagname));

View File

@@ -4,17 +4,23 @@ a CalDAV/CardDAV server
!!! warning !!! warning
RustiCal is **not production-ready!** RustiCal is **not production-ready!**
I'm just starting to use it myself so I cannot guarantee that everything will be working smoothly just yet. While I've started migrating to RustiCal and becoming more confident, please know that bugs and rough edges will still occur.
I hope there won't be any manual migrations anymore but if you want to be an early adopter some SQL knowledge might be useful just in case.
If you still want to play around with it in its current state, absolutely feel free to do so and to open up an issue if something is not working. :) If you still want to 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 ## Features
- easy to backup, everything saved in one SQLite database - 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
- lightweight (the container image contains only one binary) - lightweight (the container image contains only one binary)
- adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks) - adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks)
- deleted calendars are recoverable - deleted calendars are recoverable
- Nextcloud login flow (In DAVx5 you can login through the Nextcloud flow and automatically generate an app token) - Nextcloud login flow (In DAVx5 you can login through the Nextcloud flow and automatically generate an app token)
- Apple configuration profiles (skip copy-pasting passwords and instead generate the configuration in the frontend) - Apple configuration profiles (skip copy-pasting passwords and instead generate the configuration in the frontend)
- [OpenID Connect](setup/oidc.md) support (with option to disable password login) - [OpenID Connect](setup/oidc.md) support (with option to disable password login)
## Tested Clients
- DAVx5,
- GNOME Accounts, GNOME Calendar, GNOME Contacts
- Evolution
- Apple Calendar

View File

@@ -2,11 +2,13 @@
## Docker ## Docker
You can start a RustiCal container using the following command:
```sh ```sh
docker run \ docker run \
-p 4000:4000 \ -p 4000:4000 \
-v YOUR_DATA_DIR:/var/lib/rustical/ \ -v YOUR_DATA_DIR:/var/lib/rustical/ \
-v YOUR_CONFIG_TOML:/etc/rustical/config.toml \ # (1)! -v OPTIONAL_YOUR_CONFIG_TOML:/etc/rustical/config.toml \ # (1)!
-e RUSTICAL__CONFIG_OPTION="asd" \ # (2)! -e RUSTICAL__CONFIG_OPTION="asd" \ # (2)!
ghcr.io/lennart-k/rustical ghcr.io/lennart-k/rustical
``` ```
@@ -14,21 +16,43 @@ docker run \
1. Mount config file 1. Mount config file
2. Alternatively specify configuration using environment variables 2. Alternatively specify configuration using environment variables
## User management
In case you already have an OIDC server set up, see [here](setup/oidc.md) how to set up OIDC login and maybe skip this section.
Otherwise you will have to use the `rustical principals` command.
In Docker you can run this with
```sh
docker run --rm -it -v YOUR_DATA_DIR:/var/lib/rustical/ ghcr.io/lennart-k/rustical rustical principals
```
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:** 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
The password is optional (if you have configured OpenID Connect) and is only used to log in to the frontend.
Since it's sensitive information, a secure but slow hash algorithm (`argon2`) is chosen.
App tokens are used by your CalDAV/CardDAV client (which can be managed through the frontend).
I recommend to generate random app tokens for each CalDAV/CardDAV client.
Since the app tokens are random they use the faster `pbkdf2` algorithm.
## Configuration ## Configuration
RustiCal can be configured using either a `config.toml` file or environent variables. While RustiCal (apart from user management) will work without any configuration you should still know how to configure it. :)
To see all configuration options available you can browse the [Cargo docs](/rustical/_crate/rustical/config/struct.Config.html). You can either mount a `config.toml` file or use environment variables.
### TOML To see the options you can generate a default configuration using
You can generate a default `config.toml` configuration using
```sh title="Generate default config.toml" ```sh title="Generate default config.toml"
rustical gen-config rustical gen-config
``` ```
You'll have to set your database path to something like `/var/lib/rustical/db.sqlite3`. To see all configuration options available you can browse the [Cargo docs](/rustical/_crate/rustical/config/struct.Config.html).
### Environment variables ### Environment variables
@@ -48,38 +72,6 @@ Every variable is
- Dots become `__` - Dots become `__`
- Arrays are JSON-encoded - Arrays are JSON-encoded
## Users and groups
Next, you will want to set up your principals.
Using the `rustical principals` command you can manage principals and passwords.
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.
You can also completely skip this and instead configure OpenID Connect.
In that case your user will automatically be created when logging in through the frontend.
## Password vs app tokens
The password is optional (if you have configured OpenID Connect) and is only used to log in to the frontend.
Since it's sensitive information,
the secure but slow hash algorithm `argon2` is chosen.
App tokens are used by your CalDAV/CardDAV client (which can be managed through the frontend).
I recommend to generate random app tokens for each CalDAV/CardDAV client.
Since the app tokens are random they use the faster `pbkdf2` algorithm.
## WebDAV Push
RustiCal supports [WebDAV Push](https://github.com/bitfireAT/webdav-push/) which can notify compatible clients like DAVx5 about changed calendar/addressbook objects.
Since push messages are currently not encrypted you might potentially want to ensure that users only subscribe through your push server (e.g. [ntfy.sh](https://ntfy.sh/)), you can configure it the following:
```toml
[dav_push]
# Must strictly be the URL origin (so no trailing slashes)
allowed_push_servers = ["https://your-instance-ntfy.sh"]
```
## Manual ## Manual
```sh ```sh

View File

@@ -13,7 +13,10 @@ client_secret = "secret..."
claim_userid = "preferred_username" # (1)! claim_userid = "preferred_username" # (1)!
scopes = ["openid", "profile", "groups"] scopes = ["openid", "profile", "groups"]
require_group = "app/rustical" # (2)! require_group = "app/rustical" # (2)!
allow_sign_up = false allow_sign_up = true
[frontend]
allow_password_login = false # optional
``` ```
1. Can be either `preferred_username` or `sub` 1. Can be either `preferred_username` or `sub`
@@ -32,3 +35,17 @@ identity_providers:
redirect_uris: redirect_uris:
- https://rustical.example.com/frontend/login/oidc/callback - https://rustical.example.com/frontend/login/oidc/callback
``` ```
### With environment variables
```sh
RUSTICAL_OIDC__NAME: "Authelia"
RUSTICAL_OIDC__ISSUER: "https://auth.example.com"
RUSTICAL_OIDC__CLIENT_ID: "rustical"
RUSTICAL_OIDC__CLIENT_SECRET: "secret..."
RUSTICAL_OIDC__CLAIM_USERID: "preferred_username"
RUSTICAL_OIDC__SCOPES: '["openid", "profile", "groups"]'
RUSTICAL_OIDC__REQUIRE_GROUP: "app:rustical"
RUSTICAL_OIDC__ALLOW_SIGN_UP: "true"
RUSTICAL_FRONTEND__ALLOW_PASSWORD_LOGIN: "false"
```

View File

@@ -51,6 +51,7 @@ markdown_extensions:
- pymdownx.superfences - pymdownx.superfences
- admonition - admonition
- attr_list - attr_list
- pymdownx.tabbed
- pymdownx.emoji: - pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg emoji_generator: !!python/name:material.extensions.emoji.to_svg

View File

@@ -1,16 +1,20 @@
use crate::config::NextcloudLoginConfig; use crate::config::NextcloudLoginConfig;
use axum::Router; use axum::Router;
use axum::body::Body;
use axum::extract::Request; use axum::extract::Request;
use axum::response::Response; use axum::response::Response;
use axum::routing::options;
use headers::{HeaderMapExt, UserAgent}; use headers::{HeaderMapExt, UserAgent};
use http::StatusCode; use http::{HeaderValue, StatusCode};
use rustical_caldav::caldav_router; use rustical_caldav::caldav_router;
use rustical_carddav::carddav_router; use rustical_carddav::carddav_router;
use rustical_frontend::nextcloud_login::nextcloud_login_router; use rustical_frontend::nextcloud_login::nextcloud_login_router;
use rustical_frontend::{FrontendConfig, frontend_router}; use rustical_frontend::{FrontendConfig, frontend_router};
use rustical_oidc::OidcConfig; use rustical_oidc::OidcConfig;
use rustical_store::auth::AuthenticationProvider; use rustical_store::auth::AuthenticationProvider;
use rustical_store::{AddressbookStore, CalendarStore, SubscriptionStore}; use rustical_store::{
AddressbookStore, CalendarStore, CombinedCalendarStore, ContactBirthdayStore, SubscriptionStore,
};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tower_http::catch_panic::CatchPanicLayer; use tower_http::catch_panic::CatchPanicLayer;
@@ -31,12 +35,16 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
oidc_config: Option<OidcConfig>, oidc_config: Option<OidcConfig>,
nextcloud_login_config: NextcloudLoginConfig, nextcloud_login_config: NextcloudLoginConfig,
) -> Router<()> { ) -> Router<()> {
let combined_cal_store = Arc::new(CombinedCalendarStore::new(
cal_store.clone(),
ContactBirthdayStore::new(addr_store.clone()).into(),
));
let mut router = Router::new() let mut router = Router::new()
.merge(caldav_router( .merge(caldav_router(
"/caldav", "/caldav",
auth_provider.clone(), auth_provider.clone(),
cal_store.clone(), combined_cal_store.clone(),
addr_store.clone(),
subscription_store.clone(), subscription_store.clone(),
)) ))
.merge(carddav_router( .merge(carddav_router(
@@ -46,12 +54,30 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
subscription_store.clone(), subscription_store.clone(),
)); ));
// GNOME Accounts needs to discover a WebDAV Files endpoint to complete the setup
// It looks at / as well as /remote.php/dav (Nextcloud)
// This is not nice but we offer this as a sacrificial route to ensure the CalDAV/CardDAV setup
// works.
// See:
// https://github.com/GNOME/gnome-online-accounts/blob/master/src/goabackend/goadavclient.c
// https://github.com/GNOME/gnome-online-accounts/blob/master/src/goabackend/goawebdavprovider.c
router = router.route(
"/remote.php/dav",
options(async || {
let mut resp = Response::builder().status(StatusCode::OK);
resp.headers_mut()
.unwrap()
.insert("DAV", HeaderValue::from_static("1"));
resp.body(Body::empty()).unwrap()
}),
);
let session_store = MemoryStore::default(); let session_store = MemoryStore::default();
if frontend_config.enabled { if frontend_config.enabled {
router = router.merge(frontend_router( router = router.merge(frontend_router(
"/frontend", "/frontend",
auth_provider.clone(), auth_provider.clone(),
cal_store.clone(), combined_cal_store.clone(),
addr_store.clone(), addr_store.clone(),
frontend_config, frontend_config,
oidc_config, oidc_config,