Compare commits

...

16 Commits

Author SHA1 Message Date
Lennart
455b4c405f version 0.4.13 2025-07-10 21:39:28 +02:00
Lennart
2774d092ac propfind: Implement <include/>
Implements #95
2025-07-10 15:45:54 +02:00
Lennart
32b616fd75 xml serialize_to_string: Enable indentation 2025-07-10 15:45:07 +02:00
Lennart K
b02f7c427a minor refactoring 2025-07-10 10:51:59 +02:00
Lennart
eae8e7d768 version 0.4.12 2025-07-07 21:18:46 +02:00
Lennart
105718a4ca frontend: Add xml escaping to collection creation forms 2025-07-07 21:18:16 +02:00
Lennart
0e68f1bdce frontend: refactor collection list to allow for dialogs 2025-07-07 11:22:20 +02:00
Lennart
aa744fcea2 version 0.4.11 2025-07-05 10:41:46 +02:00
Lennart
4a51a669cd frontend: stylesheet 2025-07-05 10:41:20 +02:00
Lennart
07fca05e50 Make hash for app tokens less expensive (they are random anyway) 2025-07-05 10:26:06 +02:00
Lennart
509cc8d7a1 docs: Add documentation to setup some clients (more to follow) 2025-07-05 10:22:32 +02:00
Lennart
4134ab0520 frontend: Add user to global scope and make principal inputs dropdowns for collection creation 2025-07-05 10:04:42 +02:00
Lennart
d8803a38a2 frontend: create-calendar-form put subscription url behind checkbox 2025-07-05 09:10:26 +02:00
Lennart
b5bff08b08 version 0.4.10 2025-07-05 08:50:00 +02:00
Lennart
3ca02d9792 dav: Implement HEAD method 2025-07-05 08:47:22 +02:00
Lennart
ee2cc2174c frontend: Slight stylesheet change 2025-07-05 08:47:09 +02:00
38 changed files with 342 additions and 146 deletions

22
Cargo.lock generated
View File

@@ -2999,7 +2999,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical" name = "rustical"
version = "0.4.9" version = "0.4.13"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -3042,7 +3042,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_caldav" name = "rustical_caldav"
version = "0.4.9" version = "0.4.13"
dependencies = [ dependencies = [
"async-std", "async-std",
"async-trait", "async-trait",
@@ -3080,7 +3080,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_carddav" name = "rustical_carddav"
version = "0.4.9" version = "0.4.13"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3112,7 +3112,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_dav" name = "rustical_dav"
version = "0.4.9" version = "0.4.13"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3137,7 +3137,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_dav_push" name = "rustical_dav_push"
version = "0.4.9" version = "0.4.13"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3163,7 +3163,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_frontend" name = "rustical_frontend"
version = "0.4.9" version = "0.4.13"
dependencies = [ dependencies = [
"askama", "askama",
"askama_web", "askama_web",
@@ -3196,7 +3196,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_ical" name = "rustical_ical"
version = "0.4.9" version = "0.4.13"
dependencies = [ dependencies = [
"axum", "axum",
"chrono", "chrono",
@@ -3214,7 +3214,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_oidc" name = "rustical_oidc"
version = "0.4.9" version = "0.4.13"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -3229,7 +3229,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_store" name = "rustical_store"
version = "0.4.9" version = "0.4.13"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -3263,7 +3263,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_store_sqlite" name = "rustical_store_sqlite"
version = "0.4.9" version = "0.4.13"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
@@ -3284,7 +3284,7 @@ dependencies = [
[[package]] [[package]]
name = "rustical_xml" name = "rustical_xml"
version = "0.4.9" version = "0.4.13"
dependencies = [ dependencies = [
"quick-xml", "quick-xml",
"thiserror 2.0.12", "thiserror 2.0.12",

View File

@@ -2,7 +2,7 @@
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.4.9" version = "0.4.13"
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"

View File

@@ -4,7 +4,7 @@ use axum::body::Body;
use axum::extract::State; use axum::extract::State;
use axum::{extract::Path, response::Response}; use axum::{extract::Path, response::Response};
use headers::{ContentType, HeaderMapExt}; use headers::{ContentType, HeaderMapExt};
use http::{HeaderValue, StatusCode, header}; use http::{HeaderValue, Method, StatusCode, header};
use ical::generator::{Emitter, IcalCalendarBuilder}; use ical::generator::{Emitter, IcalCalendarBuilder};
use ical::property::Property; use ical::property::Property;
use percent_encoding::{CONTROLS, utf8_percent_encode}; use percent_encoding::{CONTROLS, utf8_percent_encode};
@@ -19,6 +19,7 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
Path((principal, calendar_id)): Path<(String, String)>, Path((principal, calendar_id)): Path<(String, String)>,
State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>, State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
user: Principal, user: Principal,
method: Method,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(crate::Error::Unauthorized); return Err(crate::Error::Unauthorized);
@@ -96,5 +97,9 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
)) ))
.unwrap(), .unwrap(),
); );
Ok(resp.body(Body::new(ical_calendar.generate())).unwrap()) if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(ical_calendar.generate())).unwrap())
}
} }

View File

@@ -67,7 +67,7 @@ fn objects_response(
object, object,
principal: principal.to_owned(), principal: principal.to_owned(),
} }
.propfind(&path, prop, puri, user)?, .propfind(&path, prop, None, puri, user)?,
); );
} }

View File

@@ -39,7 +39,7 @@ pub async fn handle_sync_collection<C: CalendarStore>(
object, object,
principal: principal.to_owned(), principal: principal.to_owned(),
} }
.propfind(&path, &sync_collection.prop, puri, user)?, .propfind(&path, &sync_collection.prop, None, puri, user)?,
); );
} }

View File

@@ -6,7 +6,7 @@ use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
use headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch}; use headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch};
use http::{HeaderMap, StatusCode}; use http::{HeaderMap, Method, StatusCode};
use rustical_ical::CalendarObject; use rustical_ical::CalendarObject;
use rustical_store::CalendarStore; use rustical_store::CalendarStore;
use rustical_store::auth::Principal; use rustical_store::auth::Principal;
@@ -22,6 +22,7 @@ pub async fn get_event<C: CalendarStore>(
}): Path<CalendarObjectPathComponents>, }): Path<CalendarObjectPathComponents>,
State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>, State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>,
user: Principal, user: Principal,
method: Method,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(crate::Error::Unauthorized); return Err(crate::Error::Unauthorized);
@@ -42,7 +43,11 @@ pub async fn get_event<C: CalendarStore>(
let hdrs = resp.headers_mut().unwrap(); let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ETag::from_str(&event.get_etag()).unwrap()); hdrs.typed_insert(ETag::from_str(&event.get_etag()).unwrap());
hdrs.typed_insert(ContentType::from_str("text/calendar").unwrap()); hdrs.typed_insert(ContentType::from_str("text/calendar").unwrap());
Ok(resp.body(Body::new(event.get_ics().to_owned())).unwrap()) if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(event.get_ics().to_owned())).unwrap())
}
} }
#[instrument(skip(cal_store))] #[instrument(skip(cal_store))]

View File

@@ -69,7 +69,6 @@ impl Resource for CalendarObjectResource {
} }
fn get_displayname(&self) -> Option<&str> { fn get_displayname(&self) -> Option<&str> {
// TODO: Extract summary from object
None None
} }

View File

@@ -7,6 +7,7 @@ use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
use axum_extra::headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch}; use axum_extra::headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch};
use http::Method;
use http::{HeaderMap, StatusCode}; use http::{HeaderMap, StatusCode};
use rustical_dav::privileges::UserPrivilege; use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource; use rustical_dav::resource::Resource;
@@ -25,6 +26,7 @@ pub async fn get_object<AS: AddressbookStore>(
}): Path<AddressObjectPathComponents>, }): Path<AddressObjectPathComponents>,
State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>, State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>,
user: Principal, user: Principal,
method: Method,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
@@ -49,7 +51,11 @@ pub async fn get_object<AS: AddressbookStore>(
let hdrs = resp.headers_mut().unwrap(); let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ETag::from_str(&object.get_etag()).unwrap()); hdrs.typed_insert(ETag::from_str(&object.get_etag()).unwrap());
hdrs.typed_insert(ContentType::from_str("text/vcard").unwrap()); hdrs.typed_insert(ContentType::from_str("text/vcard").unwrap());
Ok(resp.body(Body::new(object.get_vcf().to_owned())).unwrap()) if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(object.get_vcf().to_owned())).unwrap())
}
} }
#[instrument(skip(addr_store, body))] #[instrument(skip(addr_store, body))]

View File

@@ -5,7 +5,7 @@ use axum::body::Body;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::response::Response; use axum::response::Response;
use axum_extra::headers::{ContentType, HeaderMapExt}; use axum_extra::headers::{ContentType, HeaderMapExt};
use http::{HeaderValue, StatusCode, header}; use http::{HeaderValue, Method, StatusCode, header};
use percent_encoding::{CONTROLS, utf8_percent_encode}; use percent_encoding::{CONTROLS, utf8_percent_encode};
use rustical_dav::privileges::UserPrivilege; use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource; use rustical_dav::resource::Resource;
@@ -20,6 +20,7 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>, Path((principal, addressbook_id)): Path<(String, String)>,
State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>, State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,
user: Principal, user: Principal,
method: Method,
) -> Result<Response, Error> { ) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
@@ -46,7 +47,7 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
let mut resp = Response::builder().status(StatusCode::OK); let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap(); let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ContentType::from_str("text/vcard").unwrap()); hdrs.typed_insert(ContentType::from_str("text/vcard").unwrap());
let filename = format!("{}_{}.vcf", principal, addressbook_id); let filename = format!("{principal}_{addressbook_id}.vcf");
let filename = utf8_percent_encode(&filename, CONTROLS); let filename = utf8_percent_encode(&filename, CONTROLS);
hdrs.insert( hdrs.insert(
header::CONTENT_DISPOSITION, header::CONTENT_DISPOSITION,
@@ -55,5 +56,9 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
)) ))
.unwrap(), .unwrap(),
); );
Ok(resp.body(Body::new(vcf)).unwrap()) if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(vcf)).unwrap())
}
} }

View File

@@ -88,15 +88,8 @@ pub async fn route_mkcol<AS: AddressbookStore, S: SubscriptionStore>(
} }
} }
match addr_store.insert_addressbook(addressbook).await { addr_store.insert_addressbook(addressbook).await?;
// TODO: The spec says we should return a mkcol-response. Ok(StatusCode::CREATED.into_response())
// However, it works without one but breaks on iPadOS when using an empty one :)
Ok(()) => Ok(StatusCode::CREATED.into_response()),
Err(err) => {
dbg!(err.to_string());
Err(err.into())
}
}
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -81,7 +81,7 @@ pub async fn handle_addressbook_multiget<AS: AddressbookStore>(
object, object,
principal: principal.to_owned(), principal: principal.to_owned(),
} }
.propfind(&path, prop, puri, user)?, .propfind(&path, prop, None, puri, user)?,
); );
} }

View File

@@ -39,7 +39,7 @@ pub async fn handle_sync_collection<AS: AddressbookStore>(
object, object,
principal: principal.to_owned(), principal: principal.to_owned(),
} }
.propfind(&path, &sync_collection.prop, puri, user)?, .propfind(&path, &sync_collection.prop, None, puri, user)?,
); );
} }

View File

@@ -18,11 +18,6 @@ pub trait AxumMethods: Sized + Send + Sync + 'static {
None None
} }
#[inline]
fn head() -> Option<MethodFunction<Self>> {
None
}
#[inline] #[inline]
fn post() -> Option<MethodFunction<Self>> { fn post() -> Option<MethodFunction<Self>> {
None None
@@ -58,8 +53,6 @@ pub trait AxumMethods: Sized + Send + Sync + 'static {
} }
if Self::get().is_some() { if Self::get().is_some() {
allow.push(Method::GET); allow.push(Method::GET);
}
if Self::head().is_some() {
allow.push(Method::HEAD); allow.push(Method::HEAD);
} }
if Self::post().is_some() { if Self::post().is_some() {

View File

@@ -72,16 +72,11 @@ where
return svc(self.resource_service.clone(), req); return svc(self.resource_service.clone(), req);
} }
} }
"GET" => { "GET" | "HEAD" => {
if let Some(svc) = RS::get() { if let Some(svc) = RS::get() {
return svc(self.resource_service.clone(), req); return svc(self.resource_service.clone(), req);
} }
} }
"HEAD" => {
if let Some(svc) = RS::head() {
return svc(self.resource_service.clone(), req);
}
}
"POST" => { "POST" => {
if let Some(svc) = RS::post() { if let Some(svc) = RS::post() {
return svc(self.resource_service.clone(), req); return svc(self.resource_service.clone(), req);

View File

@@ -64,6 +64,7 @@ pub(crate) async fn route_propfind<R: ResourceService>(
} else { } else {
PropfindElement { PropfindElement {
prop: PropfindType::Allprop, prop: PropfindType::Allprop,
include: None,
} }
}; };
let propfind_member: PropfindElement<<<R::MemberType as Resource>::Prop as PropName>::Names> = let propfind_member: PropfindElement<<<R::MemberType as Resource>::Prop as PropName>::Names> =
@@ -72,6 +73,7 @@ pub(crate) async fn route_propfind<R: ResourceService>(
} else { } else {
PropfindElement { PropfindElement {
prop: PropfindType::Allprop, prop: PropfindType::Allprop,
include: None,
} }
}; };
@@ -82,13 +84,20 @@ pub(crate) async fn route_propfind<R: ResourceService>(
member_responses.push(member.propfind( member_responses.push(member.propfind(
&format!("{}/{}", path.trim_end_matches('/'), member.get_name()), &format!("{}/{}", path.trim_end_matches('/'), member.get_name()),
&propfind_member.prop, &propfind_member.prop,
propfind_member.include.as_ref(),
puri, puri,
principal, principal,
)?); )?);
} }
} }
let response = resource.propfind(path, &propfind_self.prop, puri, principal)?; let response = resource.propfind(
path,
&propfind_self.prop,
propfind_self.include.as_ref(),
puri,
principal,
)?;
Ok(MultistatusElement { Ok(MultistatusElement {
responses: vec![response], responses: vec![response],

View File

@@ -106,6 +106,7 @@ pub trait Resource: Clone + Send + 'static {
&self, &self,
path: &str, path: &str,
prop: &PropfindType<<Self::Prop as PropName>::Names>, prop: &PropfindType<<Self::Prop as PropName>::Names>,
include: Option<&PropElement<<Self::Prop as PropName>::Names>>,
principal_uri: &impl PrincipalUri, principal_uri: &impl PrincipalUri,
principal: &Self::Principal, principal: &Self::Principal,
) -> Result<ResponseElement<Self::Prop>, Self::Error> { ) -> Result<ResponseElement<Self::Prop>, Self::Error> {
@@ -115,36 +116,40 @@ pub trait Resource: Clone + Send + 'static {
path.push('/'); path.push('/');
} }
// TODO: Support include element let (mut props, mut invalid_props): (HashSet<<Self::Prop as PropName>::Names>, Vec<_>) =
let (props, invalid_props): (HashSet<<Self::Prop as PropName>::Names>, Vec<_>) = match prop match prop {
{ PropfindType::Propname => {
PropfindType::Propname => { let props = Self::list_props()
let props = Self::list_props() .into_iter()
.into_iter() .map(|(ns, tag)| (ns.map(NamespaceOwned::from), tag.to_string()))
.map(|(ns, tag)| (ns.map(NamespaceOwned::from), tag.to_string())) .collect_vec();
.collect_vec();
return Ok(ResponseElement { return Ok(ResponseElement {
href: path.to_owned(), href: path.to_owned(),
propstat: vec![PropstatWrapper::TagList(PropstatElement { propstat: vec![PropstatWrapper::TagList(PropstatElement {
prop: TagList::from(props), prop: TagList::from(props),
status: StatusCode::OK, status: StatusCode::OK,
})], })],
..Default::default() ..Default::default()
}); });
} }
PropfindType::Allprop => ( PropfindType::Allprop => (
Self::list_props() Self::list_props()
.iter() .iter()
.map(|(_ns, name)| <Self::Prop as PropName>::Names::from_str(name).unwrap()) .map(|(_ns, name)| <Self::Prop as PropName>::Names::from_str(name).unwrap())
.collect(), .collect(),
vec![], vec![],
), ),
PropfindType::Prop(PropElement(valid_tags, invalid_tags)) => ( PropfindType::Prop(PropElement(valid_tags, invalid_tags)) => (
valid_tags.iter().cloned().collect(), valid_tags.iter().cloned().collect(),
invalid_tags.to_owned(), invalid_tags.to_owned(),
), ),
}; };
if let Some(PropElement(valid_tags, invalid_tags)) = include {
props.extend(valid_tags.clone());
invalid_props.extend(invalid_tags.to_owned());
}
let prop_responses = props let prop_responses = props
.into_iter() .into_iter()

View File

@@ -11,10 +11,11 @@ use rustical_xml::XmlRootTag;
pub struct PropfindElement<PN: XmlDeserialize> { pub struct PropfindElement<PN: XmlDeserialize> {
#[xml(ty = "untagged")] #[xml(ty = "untagged")]
pub prop: PropfindType<PN>, pub prop: PropfindType<PN>,
#[xml(ns = "crate::namespace::NS_DAV")]
pub include: Option<PropElement<PN>>,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
// pub struct PropElement<PN: XmlDeserialize = Propname>(#[xml(ty = "untagged", flatten)] pub Vec<PN>);
pub struct PropElement<PN: XmlDeserialize>( pub struct PropElement<PN: XmlDeserialize>(
// valid // valid
pub Vec<PN>, pub Vec<PN>,

View File

@@ -33,7 +33,13 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
out, out,
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<document><resourcetype><displayname xmlns=\"DAV:\"/><calendar-color xmlns=\"http://calendarserver.org/ns/\"/></resourcetype></document>" r#"<?xml version="1.0" encoding="utf-8"?>
<document>
<resourcetype>
<displayname xmlns="DAV:"/>
<calendar-color xmlns="http://calendarserver.org/ns/"/>
</resourcetype>
</document>"#
) )
} }
} }

View File

@@ -2,6 +2,7 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { createClient } from "webdav"; import { createClient } from "webdav";
import { escapeXml } from ".";
@customElement("create-addressbook-form") @customElement("create-addressbook-form")
export class CreateAddressbookForm extends LitElement { export class CreateAddressbookForm extends LitElement {
@@ -17,15 +18,15 @@ export class CreateAddressbookForm extends LitElement {
client = createClient("/carddav") client = createClient("/carddav")
@property() @property()
user: String = '' user: string = ''
@property() @property()
principal: String = '' principal: string = ''
@property() @property()
addr_id: String = '' addr_id: string = ''
@property() @property()
displayname: String = '' displayname: string = ''
@property() @property()
description: String = '' description: string = ''
dialog: Ref<HTMLDialogElement> = createRef() dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef() form: Ref<HTMLFormElement> = createRef()
@@ -38,7 +39,12 @@ export class CreateAddressbookForm extends LitElement {
<form @submit=${this.submit} ${ref(this.form)}> <form @submit=${this.submit} ${ref(this.form)}>
<label> <label>
principal (for group addressbooks) principal (for group addressbooks)
<input type="text" name="principal" value=${this.user} @change=${e => this.principal = e.target.value} /> <select name="principal" value=${this.user} @change=${e => this.principal = e.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map(membership => html`
<option value=${membership}>${membership}</option>
`)}
</select>
</label> </label>
<br> <br>
<label> <label>
@@ -74,14 +80,13 @@ export class CreateAddressbookForm extends LitElement {
alert("Empty displayname") alert("Empty displayname")
return return
} }
// TODO: Escape user input: There's not really a security risk here but would be nicer
await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.addr_id}`, { await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.addr_id}`, {
data: ` data: `
<mkcol xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav"> <mkcol xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
<set> <set>
<prop> <prop>
<displayname>${this.displayname}</displayname> <displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CARD:addressbook-description>${this.description}</CARD:addressbook-description>` : ''} ${this.description ? `<CARD:addressbook-description>${escapeXml(this.description)}</CARD:addressbook-description>` : ''}
</prop> </prop>
</set> </set>
</mkcol> </mkcol>

View File

@@ -2,6 +2,7 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { createClient } from "webdav"; import { createClient } from "webdav";
import { escapeXml } from ".";
@customElement("create-calendar-form") @customElement("create-calendar-form")
export class CreateCalendarForm extends LitElement { export class CreateCalendarForm extends LitElement {
@@ -16,19 +17,21 @@ export class CreateCalendarForm extends LitElement {
client = createClient("/caldav") client = createClient("/caldav")
@property() @property()
user: String = '' user: string = ''
@property() @property()
principal: String = '' principal: string = ''
@property() @property()
cal_id: String = '' cal_id: string = ''
@property() @property()
displayname: String = '' displayname: string = ''
@property() @property()
description: String = '' description: string = ''
@property() @property()
color: String = '' color: string = ''
@property() @property()
subscriptionUrl: String = '' isSubscription: boolean = false
@property()
subscriptionUrl: string = ''
@property() @property()
components: Set<"VEVENT" | "VTODO" | "VJOURNAL"> = new Set() components: Set<"VEVENT" | "VTODO" | "VJOURNAL"> = new Set()
@@ -43,8 +46,13 @@ export class CreateCalendarForm extends LitElement {
<h3>Create calendar</h3> <h3>Create calendar</h3>
<form @submit=${this.submit} ${ref(this.form)}> <form @submit=${this.submit} ${ref(this.form)}>
<label> <label>
principal (for group calendar) principal (for group calendars)
<input type="text" name="principal" value=${this.user} @change=${e => this.principal = e.target.value} /> <select name="principal" value=${this.user} @change=${e => this.principal = e.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map(membership => html`
<option value=${membership}>${membership}</option>
`)}
</select>
</label> </label>
<br> <br>
<label> <label>
@@ -67,11 +75,20 @@ export class CreateCalendarForm extends LitElement {
<input type="color" name="color" @change=${e => this.color = e.target.value} /> <input type="color" name="color" @change=${e => this.color = e.target.value} />
</label> </label>
<br> <br>
<br>
<label> <label>
Subscription URL Calendar is subscription to external calendar
<input type="text" name="subscription_url" @change=${e => this.subscriptionUrl = e.target.value} /> <input type="checkbox" name="is_subscription" @change=${e => this.isSubscription = e.target.checked} />
</label> </label>
<br> <br>
${this.isSubscription ? html`
<label>
Subscription URL
<input type="text" name="subscription_url" @change=${e => this.subscriptionUrl = e.target.value} />
</label>
<br>
`: html``}
<br>
${["VEVENT", "VTODO", "VJOURNAL"].map(comp => html` ${["VEVENT", "VTODO", "VJOURNAL"].map(comp => html`
<label> <label>
Support ${comp} Support ${comp}
@@ -107,12 +124,12 @@ export class CreateCalendarForm extends LitElement {
<mkcol xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/"> <mkcol xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<set> <set>
<prop> <prop>
<displayname>${this.displayname}</displayname> <displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CAL:calendar-description>${this.description}</CAL:calendar-description>` : ''} ${this.description ? `<CAL:calendar-description>${escapeXml(this.description)}</CAL:calendar-description>` : ''}
${this.color ? `<ICAL:calendar-color>${this.color}</ICAL:calendar-color>` : ''} ${this.color ? `<ICAL:calendar-color>${escapeXml(this.color)}</ICAL:calendar-color>` : ''}
${this.subscriptionUrl ? `<CS:source><href>${this.subscriptionUrl}</href></CS:source>` : ''} ${(this.isSubscription && this.subscriptionUrl) ? `<CS:source><href>${escapeXml(this.subscriptionUrl)}</href></CS:source>` : ''}
<CAL:supported-calendar-component-set> <CAL:supported-calendar-component-set>
${Array.from(this.components.keys()).map(comp => `<CAL:comp name="${comp}" />`).join('\n')} ${Array.from(this.components.keys()).map(comp => `<CAL:comp name="${escapeXml(comp)}" />`).join('\n')}
</CAL:supported-calendar-component-set> </CAL:supported-calendar-component-set>
</prop> </prop>
</set> </set>

View File

@@ -1,6 +1,5 @@
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { createClient } from "webdav";
@customElement("delete-button") @customElement("delete-button")
export class DeleteButton extends LitElement { export class DeleteButton extends LitElement {

View File

@@ -0,0 +1,9 @@
interface Window {
rusticalUser: {
id: String,
displayname: String | null,
memberships: Array<String>,
principal_type: "individual" | "group" | "room" | String
}
}

View File

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

View File

@@ -1,6 +1,6 @@
import { i, x } from "./lit-z6_uA4GX.mjs"; import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs"; import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n } from "./ref-CPp9J0V5.mjs"; import { e, n, a as escapeXml } from "./index-b86iLJlP.mjs";
import { a as an } from "./webdav-D0R7xCzX.mjs"; import { a as an } from "./webdav-D0R7xCzX.mjs";
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -35,7 +35,12 @@ let CreateAddressbookForm = class extends i {
<form @submit=${this.submit} ${n(this.form)}> <form @submit=${this.submit} ${n(this.form)}>
<label> <label>
principal (for group addressbooks) principal (for group addressbooks)
<input type="text" name="principal" value=${this.user} @change=${(e2) => this.principal = e2.target.value} /> <select name="principal" value=${this.user} @change=${(e2) => this.principal = e2.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map((membership) => x`
<option value=${membership}>${membership}</option>
`)}
</select>
</label> </label>
<br> <br>
<label> <label>
@@ -79,8 +84,8 @@ let CreateAddressbookForm = class extends i {
<mkcol xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav"> <mkcol xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
<set> <set>
<prop> <prop>
<displayname>${this.displayname}</displayname> <displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CARD:addressbook-description>${this.description}</CARD:addressbook-description>` : ""} ${this.description ? `<CARD:addressbook-description>${escapeXml(this.description)}</CARD:addressbook-description>` : ""}
</prop> </prop>
</set> </set>
</mkcol> </mkcol>

View File

@@ -1,6 +1,6 @@
import { i, x } from "./lit-z6_uA4GX.mjs"; import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs"; import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n } from "./ref-CPp9J0V5.mjs"; import { e, n, a as escapeXml } from "./index-b86iLJlP.mjs";
import { a as an } from "./webdav-D0R7xCzX.mjs"; import { a as an } from "./webdav-D0R7xCzX.mjs";
var __defProp = Object.defineProperty; var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -22,6 +22,7 @@ let CreateCalendarForm = class extends i {
this.displayname = ""; this.displayname = "";
this.description = ""; this.description = "";
this.color = ""; this.color = "";
this.isSubscription = false;
this.subscriptionUrl = ""; this.subscriptionUrl = "";
this.components = /* @__PURE__ */ new Set(); this.components = /* @__PURE__ */ new Set();
this.dialog = e(); this.dialog = e();
@@ -37,8 +38,13 @@ let CreateCalendarForm = class extends i {
<h3>Create calendar</h3> <h3>Create calendar</h3>
<form @submit=${this.submit} ${n(this.form)}> <form @submit=${this.submit} ${n(this.form)}>
<label> <label>
principal (for group calendar) principal (for group calendars)
<input type="text" name="principal" value=${this.user} @change=${(e2) => this.principal = e2.target.value} /> <select name="principal" value=${this.user} @change=${(e2) => this.principal = e2.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map((membership) => x`
<option value=${membership}>${membership}</option>
`)}
</select>
</label> </label>
<br> <br>
<label> <label>
@@ -61,11 +67,20 @@ let CreateCalendarForm = class extends i {
<input type="color" name="color" @change=${(e2) => this.color = e2.target.value} /> <input type="color" name="color" @change=${(e2) => this.color = e2.target.value} />
</label> </label>
<br> <br>
<br>
<label> <label>
Subscription URL Calendar is subscription to external calendar
<input type="text" name="subscription_url" @change=${(e2) => this.subscriptionUrl = e2.target.value} /> <input type="checkbox" name="is_subscription" @change=${(e2) => this.isSubscription = e2.target.checked} />
</label> </label>
<br> <br>
${this.isSubscription ? x`
<label>
Subscription URL
<input type="text" name="subscription_url" @change=${(e2) => this.subscriptionUrl = e2.target.value} />
</label>
<br>
` : x``}
<br>
${["VEVENT", "VTODO", "VJOURNAL"].map((comp) => x` ${["VEVENT", "VTODO", "VJOURNAL"].map((comp) => x`
<label> <label>
Support ${comp} Support ${comp}
@@ -104,12 +119,12 @@ let CreateCalendarForm = class extends i {
<mkcol xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/"> <mkcol xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<set> <set>
<prop> <prop>
<displayname>${this.displayname}</displayname> <displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CAL:calendar-description>${this.description}</CAL:calendar-description>` : ""} ${this.description ? `<CAL:calendar-description>${escapeXml(this.description)}</CAL:calendar-description>` : ""}
${this.color ? `<ICAL:calendar-color>${this.color}</ICAL:calendar-color>` : ""} ${this.color ? `<ICAL:calendar-color>${escapeXml(this.color)}</ICAL:calendar-color>` : ""}
${this.subscriptionUrl ? `<CS:source><href>${this.subscriptionUrl}</href></CS:source>` : ""} ${this.isSubscription && this.subscriptionUrl ? `<CS:source><href>${escapeXml(this.subscriptionUrl)}</href></CS:source>` : ""}
<CAL:supported-calendar-component-set> <CAL:supported-calendar-component-set>
${Array.from(this.components.keys()).map((comp) => `<CAL:comp name="${comp}" />`).join("\n")} ${Array.from(this.components.keys()).map((comp) => `<CAL:comp name="${escapeXml(comp)}" />`).join("\n")}
</CAL:supported-calendar-component-set> </CAL:supported-calendar-component-set>
</prop> </prop>
</set> </set>
@@ -138,6 +153,9 @@ __decorateClass([
__decorateClass([ __decorateClass([
n$1() n$1()
], CreateCalendarForm.prototype, "color", 2); ], CreateCalendarForm.prototype, "color", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "isSubscription", 2);
__decorateClass([ __decorateClass([
n$1() n$1()
], CreateCalendarForm.prototype, "subscriptionUrl", 2); ], CreateCalendarForm.prototype, "subscriptionUrl", 2);

View File

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

View File

@@ -205,10 +205,21 @@ ul.collection-list {
li.collection-list-item { li.collection-list-item {
list-style: none; list-style: none;
display: contents; display: block;
position: relative;
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 5%);
border: 2px solid var(--border-color);
border-radius: 12px;
margin: 12px 0;
box-shadow: 4px 2px 12px -6px black;
overflow: hidden;
a { a {
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 5%); position: absolute;
inset: 2px;
}
.inner {
display: grid; display: grid;
min-height: 80px; min-height: 80px;
height: fit-content; height: fit-content;
@@ -227,11 +238,15 @@ ul.collection-list {
text-decoration: none; text-decoration: none;
padding-left: 12px; padding-left: 12px;
border: 2px solid var(--border-color); position: relative;
border-radius: 12px; z-index: 1;
margin: 12px; pointer-events: none;
box-shadow: 4px 2px 12px -6px black;
overflow: hidden; a,
button {
pointer-events: all;
cursor: pointer;
}
.title { .title {
font-weight: bold; font-weight: bold;
@@ -283,10 +298,10 @@ ul.collection-list {
display: flex; display: flex;
gap: 12px; gap: 12px;
} }
}
&:hover { &:hover {
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%); background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%);
}
} }
} }
} }
@@ -310,12 +325,20 @@ footer {
} }
input[type="text"], input[type="text"],
input[type="password"] { input[type="password"],
input[type="color"],
select {
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%); background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%);
border: 2px solid var(--border-color); border: 2px solid var(--border-color);
padding: 6px 6px; padding: 6px 6px;
color: var(--text-on-background-color); color: var(--text-on-background-color);
margin: 2px; margin: 2px;
border-radius: 8px;
&:hover,
&:focus {
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 20%);
}
} }
svg.icon { svg.icon {

View File

@@ -2,7 +2,8 @@
<ul class="collection-list"> <ul class="collection-list">
{% for (meta, addressbook) in addressbooks %} {% for (meta, addressbook) in addressbooks %}
<li class="collection-list-item"> <li class="collection-list-item">
<a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}"> <a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}"></a>
<div class="inner">
<span class="title"> <span class="title">
{%- if addressbook.principal != user.id -%}{{ addressbook.principal }}/{%- endif -%} {%- if addressbook.principal != user.id -%}{{ addressbook.principal }}/{%- endif -%}
{{ addressbook.displayname.to_owned().unwrap_or(addressbook.id.to_owned()) }} {{ addressbook.displayname.to_owned().unwrap_or(addressbook.id.to_owned()) }}
@@ -21,7 +22,7 @@
<div class="metadata"> <div class="metadata">
{{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects {{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects
</div> </div>
</a> </div>
</li> </li>
{% else %} {% else %}
You do not have any addressbooks yet You do not have any addressbooks yet
@@ -32,7 +33,8 @@
<ul class="collection-list"> <ul class="collection-list">
{% for (meta, addressbook) in deleted_addressbooks %} {% for (meta, addressbook) in deleted_addressbooks %}
<li class="collection-list-item"> <li class="collection-list-item">
<a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}"> <a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}"></a>
<div class="inner">
<span class="title"> <span class="title">
{%- if addressbook.principal != user.id -%}{{ addressbook.principal }}/{%- endif -%} {%- if addressbook.principal != user.id -%}{{ addressbook.principal }}/{%- endif -%}
{{ addressbook.displayname.to_owned().unwrap_or(addressbook.id.to_owned()) }} {{ addressbook.displayname.to_owned().unwrap_or(addressbook.id.to_owned()) }}
@@ -50,7 +52,7 @@
<div class="metadata"> <div class="metadata">
{{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects {{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects
</div> </div>
</a> </div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -3,7 +3,8 @@
{% for (meta, calendar) in calendars %} {% for (meta, calendar) in calendars %}
{% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %} {% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %}
<li class="collection-list-item" style="--color: {{ color }}"> <li class="collection-list-item" style="--color: {{ color }}">
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id }}"> <a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id }}"></a>
<div class="inner">
<span class="title"> <span class="title">
{%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%} {%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%}
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }} {{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
@@ -31,7 +32,7 @@
{{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects {{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects
</div> </div>
<div class="color-chip"></div> <div class="color-chip"></div>
</a> </div>
</li> </li>
{% else %} {% else %}
You do not have any calendars yet You do not have any calendars yet
@@ -43,7 +44,8 @@
{% for (meta, calendar) in deleted_calendars %} {% for (meta, calendar) in deleted_calendars %}
{% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %} {% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %}
<li class="collection-list-item" style="--color: {{ color }}"> <li class="collection-list-item" style="--color: {{ color }}">
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}"> <a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}"></a>
<div class="inner">
<span class="title"> <span class="title">
{%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%} {%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%}
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }} {{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
@@ -67,7 +69,7 @@
{{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects {{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects
</div> </div>
<div class="color-chip"></div> <div class="color-chip"></div>
</a> </div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -1,6 +1,10 @@
{% extends "layouts/default.html" %} {% extends "layouts/default.html" %}
{% block imports %} {% block imports %}
<template id="data-rustical-user">{{ user|json }}</template>
<script>
window.rusticalUser = JSON.parse(document.querySelector('#data-rustical-user').innerHTML)
</script>
<script type="module" src="/frontend/assets/js/create-calendar-form.mjs" async></script> <script type="module" src="/frontend/assets/js/create-calendar-form.mjs" async></script>
<script type="module" src="/frontend/assets/js/create-addressbook-form.mjs" async></script> <script type="module" src="/frontend/assets/js/create-addressbook-form.mjs" async></script>
<script type="module" src="/frontend/assets/js/delete-button.mjs" async></script> <script type="module" src="/frontend/assets/js/delete-button.mjs" async></script>

View File

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

View File

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

View File

@@ -206,7 +206,7 @@ impl AuthenticationProvider for SqlitePrincipalStore {
None, None,
None, None,
Params { Params {
rounds: 100, rounds: 10,
..Default::default() ..Default::default()
}, },
&salt, &salt,

View File

@@ -43,7 +43,7 @@ pub trait XmlSerializeRoot {
fn serialize_to_string(&self) -> std::io::Result<String> { fn serialize_to_string(&self) -> std::io::Result<String> {
let mut buf: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into(); let mut buf: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into();
let mut writer = quick_xml::Writer::new(&mut buf); let mut writer = quick_xml::Writer::new_with_indent(&mut buf, b' ', 4);
self.serialize_root(&mut writer)?; self.serialize_root(&mut writer)?;
Ok(String::from_utf8_lossy(&buf).to_string()) Ok(String::from_utf8_lossy(&buf).to_string())
} }

View File

@@ -22,6 +22,11 @@ fn test_struct_value_tagged() {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
out, out,
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<propfind><prop><test>asd</test></prop></propfind>" r#"<?xml version="1.0" encoding="utf-8"?>
<propfind>
<prop>
<test>asd</test>
</prop>
</propfind>"#
); );
} }

View File

@@ -71,7 +71,11 @@ fn test_struct_value_tagged() {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
out, out,
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<document><href>okay</href><num>123</num></document>" r#"<?xml version="1.0" encoding="utf-8"?>
<document>
<href>okay</href>
<num>123</num>
</document>"#
); );
} }
@@ -91,7 +95,8 @@ fn test_struct_value_untagged() {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
out, out,
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<document>okays</document>" r#"<?xml version="1.0" encoding="utf-8"?>
<document>okays</document>"#
); );
} }
@@ -111,7 +116,11 @@ fn test_struct_vec() {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
out, out,
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<document><href>okay</href><href>wow</href></document>" r#"<?xml version="1.0" encoding="utf-8"?>
<document>
<href>okay</href>
<href>wow</href>
</document>"#
); );
} }
@@ -141,7 +150,10 @@ fn test_struct_serialize_with() {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
out, out,
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<document><href>OKAY</href></document>" r#"<?xml version="1.0" encoding="utf-8"?>
<document>
<href>OKAY</href>
</document>"#
); );
} }

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

@@ -0,0 +1,60 @@
# Client Setup
## Common
Following resources are available.
```
/.well-known/caldav
# CalDAV root
/caldav
# Principal home
/caldav/principal/<user_id>
# Calendar home
/caldav/principal/<user_id>/<calendar_id>
/caldav/principal/<user_id>/_birthdays_<addressbook_id>
```
```
/.well-known/carddav
# CardDAV root
/carddav
# Principal home
/carddav/principal/<user_id>
# Addressbook home
/carddav/principal/<user_id>/<addressbook_id>
```
### Authentication
Authenticate with HTTP Basic authentication using your user id and a generated app token.
## DAVx5
You can set up DAVx5 through the Nextcloud login flow. Collections including group collections will automatically be discovered.
## Apple Calendar
You can download a configuration profile from the frontend in the app token section.
**Limitation**: Group collections are not automatically discovered, for these you need to set up separate CalDAV configurations using the corresponding principal homes (but your own user id).
## Evolution
Set up a collection account in the account settings.
**Limitation**: Group collections are not discovered. It seems as if currently you have to add each group collection manually.
## Home Assistant CalDAV integration
As URL specify
```
https://<your-host>/.well-known/caldav
```
For goup collections explicitly specify
```
https://<your-host>/caldav/principal/<principal>
```

View File

@@ -68,6 +68,7 @@ nav:
- Installation: - Installation:
- installation/index.md - installation/index.md
- Configuration: installation/configuration.md - Configuration: installation/configuration.md
- Client Setup: setup/client.md
- OpenID Connect: setup/oidc.md - OpenID Connect: setup/oidc.md
- Developers: - Developers:
- developers/index.md - developers/index.md