Compare commits

...

58 Commits

Author SHA1 Message Date
Lennart
72f970a857 version 0.6.3 2025-07-20 13:39:25 +02:00
Lennart
08c250657e well-known: add second apple user agent 2025-07-20 13:38:57 +02:00
Lennart
b8ef2f1ba2 version 0.6.2 2025-07-20 13:16:42 +02:00
Lennart
c8adf60f48 version 0.6.1 2025-07-20 13:13:01 +02:00
Lennart
507cb77e85 Add /.well-known/caldav exception for Apple Calendar 2025-07-20 13:10:52 +02:00
Lennart
8881ea2a05 frontend: Fix some HTML syntax errors 2025-07-19 17:50:14 +02:00
Lennart
119e17a8e1 rustical_xml: Add :: prefix to quick_xml imports 2025-07-19 16:23:43 +02:00
Lennart
8b01c5388b version 0.6.0 2025-07-18 21:09:11 +02:00
Lennart
35f423d4ca frontend: Add addressbook editing form 2025-07-18 21:08:11 +02:00
Lennart
a827b40b47 frontend: Add calendar editing form 2025-07-18 21:00:58 +02:00
Lennart
16f9ce6f38 dav: Fix proppatch supporting multiple properties in <set> and <remove> elements 2025-07-18 20:59:37 +02:00
Lennart
34839aa2ed caldav: Allow proppatch for supported-calendar-component-set 2025-07-18 20:42:11 +02:00
Lennart
2724154ed3 ical: Serialize calendar component type 2025-07-18 20:41:44 +02:00
Lennart
c490c413ec frontend: Fix layout of calendar component chips 2025-07-18 19:53:45 +02:00
Lennart
994864c6ef Update README and client documentation 2025-07-18 18:21:10 +02:00
Lennart
92fd28cdbb caldav: calendar-query fix xml 2025-07-18 17:39:57 +02:00
Lennart
d7e871f0e6 version 0.5.1 2025-07-18 15:14:47 +02:00
Lennart
a0fc073bd2 docs: Document that we expect HTTPS
fixes #75
2025-07-18 14:31:22 +02:00
Lennart
c8dffb4f9e version 0.5.0 2025-07-18 14:15:14 +02:00
Lennart
b6d1899636 carddav: Add full addressbook-home-set 2025-07-18 14:13:34 +02:00
Lennart
81f1767efa docs: Update client documentation for CalDAV 2025-07-18 14:13:11 +02:00
Lennart K
54eb9ddfcc docs: Update notes for Apple Calendar 2025-07-18 12:24:28 +02:00
Lennart K
60a0f16557 frontend: Update Apple profile for caldav-compat 2025-07-18 12:18:55 +02:00
Lennart K
e4f188d299 Update documentation for simplified calendar home set 2025-07-18 12:18:40 +02:00
Lennart K
69163404a1 caldav: Add endpoint with simplified calendar-home-set 2025-07-18 12:18:27 +02:00
Lennart K
0b7cfea79c clippy appeasement 2025-07-18 11:29:03 +02:00
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
Lennart
caf10912e5 Version 0.4.9 2025-07-04 21:53:07 +02:00
Lennart
ec89cd6fa5 fix header bar on mobile 2025-07-04 21:52:23 +02:00
Lennart K
ae20573670 frontend: Add file sizes to collections 2025-07-04 21:20:49 +02:00
Lennart K
71cee2d20c frontend: Add some iconography 2025-07-04 21:12:28 +02:00
Lennart K
83c6bf247e Add sqlx queries 2025-07-04 20:58:32 +02:00
Lennart K
6bcc03d659 frontend: Add basic information about collections 2025-07-04 20:54:37 +02:00
Lennart K
32f5c01716 frontend: checkbox alignment for create calendar form 2025-07-04 19:57:20 +02:00
Lennart K
40938cba02 Some work on the frontend 2025-07-04 19:44:17 +02:00
Lennart
a5663bf006 Remove unnecessary pwhash command 2025-07-02 23:43:18 +02:00
Lennart
26306fd661 xml: Fix writer type 2025-07-02 23:31:04 +02:00
Lennart
d8e4bd1cc4 xml: Remove generics from XmlSerialize 2025-07-02 19:02:25 +02:00
Lennart K
a18ff2b400 propfind: Add todo comment 2025-07-02 16:51:05 +02:00
Lennart K
bf13d95b97 xml: Make XmlSerialize trait more precise 2025-07-02 12:51:29 +02:00
Lennart K
ee1faa4c20 version 0.4.8 2025-07-01 14:09:58 +02:00
Lennart K
1e999ca0cc feat(frontend): Add bodged field to create group collections 2025-07-01 14:09:32 +02:00
Lennart K
f27245f996 fix(store_sqlite): Principal upsert 2025-07-01 13:49:43 +02:00
96 changed files with 1947 additions and 656 deletions

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n REPLACE INTO principals\n (id, displayname, principal_type, password_hash)\n VALUES (?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "2f043f62a7c0eae1023e319f0bc8f35dfdcf6a8247e03b1de3e2cabb2d3ab8ae"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO principals\n (id, displayname, principal_type, password_hash) VALUES (?, ?, ?, ?)\n ON CONFLICT(id) DO UPDATE SET\n (displayname, principal_type, password_hash)\n = (excluded.displayname, excluded.principal_type, excluded.password_hash)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "5c09c2a3c052188435409d4ff076575394e625dd19f00dea2d4c71a9f34a5952"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT length(vcf) AS 'length!: u64', deleted_at AS 'deleted!: bool' FROM addressobjects WHERE principal = ? AND addressbook_id = ?",
"describe": {
"columns": [
{
"name": "length!: u64",
"ordinal": 0,
"type_info": "Null"
},
{
"name": "deleted!: bool",
"ordinal": 1,
"type_info": "Datetime"
}
],
"parameters": {
"Right": 2
},
"nullable": [
null,
true
]
},
"hash": "660833e0505d3bbcd6dd736cce06b1bf14263d0e0e87b27d89d376d422e4e474"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT length(ics) AS 'length!: u64', deleted_at AS 'deleted!: bool' FROM calendarobjects WHERE principal = ? AND cal_id = ?",
"describe": {
"columns": [
{
"name": "length!: u64",
"ordinal": 0,
"type_info": "Null"
},
{
"name": "deleted!: bool",
"ordinal": 1,
"type_info": "Datetime"
}
],
"parameters": {
"Right": 2
},
"nullable": [
null,
true
]
},
"hash": "d9f14260a46a7ccd137d462c35d350a7fe338a074131776596c5d803fcda1f48"
}

22
Cargo.lock generated
View File

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

View File

@@ -2,7 +2,7 @@
members = ["crates/*"]
[workspace.package]
version = "0.4.7"
version = "0.6.3"
edition = "2024"
description = "A CalDAV server"
repository = "https://github.com/lennart-k/rustical"

View File

@@ -12,13 +12,14 @@ a CalDAV/CardDAV server
- easy to backup, everything saved in one SQLite database
- also export feature in the frontend
- [WebDAV Push](https://github.com/bitfireAT/webdav-push/) support, so near-instant synchronisation to DAVx5
- **[WebDAV Push](https://github.com/bitfireAT/webdav-push/)** support, so near-instant synchronisation to DAVx5
- lightweight (the container image contains only one binary)
- adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks)
- deleted calendars are recoverable
- 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)
- OpenID Connect support (with option to disable password login)
- **OpenID Connect** support (with option to disable password login)
- Group-based **sharing**
## Getting Started

View File

@@ -4,7 +4,7 @@ use axum::body::Body;
use axum::extract::State;
use axum::{extract::Path, response::Response};
use headers::{ContentType, HeaderMapExt};
use http::{HeaderValue, StatusCode, header};
use http::{HeaderValue, Method, StatusCode, header};
use ical::generator::{Emitter, IcalCalendarBuilder};
use ical::property::Property;
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)>,
State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
user: Principal,
method: Method,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(crate::Error::Unauthorized);
@@ -96,5 +97,9 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
))
.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

@@ -16,6 +16,7 @@ pub(crate) struct TimeRangeElement {
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
// https://www.rfc-editor.org/rfc/rfc4791#section-9.7.3
struct ParamFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
is_not_defined: Option<()>,
@@ -32,11 +33,13 @@ struct TextMatchElement {
#[xml(ty = "attr")]
collation: String,
#[xml(ty = "attr")]
negate_collation: String,
// "yes" or "no", default: "no"
negate_condition: Option<String>,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
// https://www.rfc-editor.org/rfc/rfc4791#section-9.7.2
pub(crate) struct PropFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
is_not_defined: Option<()>,
@@ -46,6 +49,9 @@ pub(crate) struct PropFilterElement {
text_match: Option<TextMatchElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
param_filter: Vec<ParamFilterElement>,
#[xml(ty = "attr")]
name: String,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
@@ -61,7 +67,7 @@ pub(crate) struct CompFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
pub(crate) comp_filter: Vec<CompFilterElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", ty = "attr")]
#[xml(ty = "attr")]
pub(crate) name: String,
}
@@ -203,3 +209,102 @@ pub async fn get_objects_calendar_query<C: CalendarStore>(
}
Ok(objects)
}
#[cfg(test)]
mod tests {
use rustical_dav::xml::PropElement;
use rustical_xml::XmlDocument;
use crate::{
calendar::methods::report::{
ReportRequest,
calendar_query::{
CalendarQueryRequest, CompFilterElement, FilterElement, ParamFilterElement,
PropFilterElement, TextMatchElement,
},
},
calendar_object::{CalendarObjectPropName, CalendarObjectPropWrapperName},
};
#[test]
fn calendar_query_7_8_7() {
const INPUT: &str = r#"
<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop xmlns:D="DAV:">
<D:getetag/>
<C:calendar-data/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:prop-filter name="ATTENDEE">
<C:text-match collation="i;ascii-casemap">mailto:lisa@example.com</C:text-match>
<C:param-filter name="PARTSTAT">
<C:text-match collation="i;ascii-casemap">NEEDS-ACTION</C:text-match>
</C:param-filter>
</C:prop-filter>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"#;
let report = ReportRequest::parse_str(INPUT).unwrap();
let calendar_query: CalendarQueryRequest =
if let ReportRequest::CalendarQuery(query) = report {
query
} else {
panic!()
};
assert_eq!(
calendar_query,
CalendarQueryRequest {
prop: rustical_dav::xml::PropfindType::Prop(PropElement(
vec![
CalendarObjectPropWrapperName::CalendarObject(
CalendarObjectPropName::Getetag,
),
CalendarObjectPropWrapperName::CalendarObject(
CalendarObjectPropName::CalendarData(Default::default())
),
],
vec![]
)),
filter: Some(FilterElement {
comp_filter: CompFilterElement {
is_not_defined: None,
time_range: None,
prop_filter: vec![],
comp_filter: vec![CompFilterElement {
prop_filter: vec![PropFilterElement {
name: "ATTENDEE".to_owned(),
text_match: Some(TextMatchElement {
collation: "i;ascii-casemap".to_owned(),
negate_condition: None
}),
is_not_defined: None,
param_filter: vec![ParamFilterElement {
is_not_defined: None,
name: "PARTSTAT".to_owned(),
text_match: Some(TextMatchElement {
collation: "i;ascii-casemap".to_owned(),
negate_condition: None
}),
}],
time_range: None
}],
comp_filter: vec![],
is_not_defined: None,
name: "VEVENT".to_owned(),
time_range: None
}],
name: "VCALENDAR".to_owned()
}
}),
timezone: None,
timezone_id: None
}
)
}
}

View File

@@ -67,7 +67,7 @@ fn objects_response(
object,
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,
principal: principal.to_owned(),
}
.propfind(&path, &sync_collection.prop, puri, user)?,
.propfind(&path, &sync_collection.prop, None, puri, user)?,
);
}

View File

@@ -34,7 +34,7 @@ pub enum CalendarProp {
CalendarTimezoneId(Option<String>),
#[xml(ns = "rustical_dav::namespace::NS_ICAL")]
CalendarOrder(Option<i64>),
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
SupportedCalendarComponentSet(SupportedCalendarComponentSet),
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)]
SupportedCalendarData(SupportedCalendarData),
@@ -201,10 +201,7 @@ impl Resource for CalendarResource {
if let Some(tzid) = &timezone_id {
// Validate timezone id
chrono_tz::Tz::from_str(tzid).map_err(|_| {
rustical_dav::Error::BadRequest(format!(
"Invalid timezone-id: {}",
tzid
))
rustical_dav::Error::BadRequest(format!("Invalid timezone-id: {tzid}"))
})?;
// TODO: Ensure that timezone is also updated (For now hope that clients play nice)
}

View File

@@ -6,7 +6,7 @@ use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use axum_extra::TypedHeader;
use headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch};
use http::{HeaderMap, StatusCode};
use http::{HeaderMap, Method, StatusCode};
use rustical_ical::CalendarObject;
use rustical_store::CalendarStore;
use rustical_store::auth::Principal;
@@ -22,6 +22,7 @@ pub async fn get_event<C: CalendarStore>(
}): Path<CalendarObjectPathComponents>,
State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>,
user: Principal,
method: Method,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(crate::Error::Unauthorized);
@@ -42,7 +43,11 @@ pub async fn get_event<C: CalendarStore>(
let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ETag::from_str(&event.get_etag()).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))]

View File

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

View File

@@ -1,5 +1,3 @@
use axum::response::Redirect;
use axum::routing::any;
use axum::{Extension, Router};
use derive_more::Constructor;
use principal::PrincipalResourceService;
@@ -14,7 +12,6 @@ pub mod calendar;
pub mod calendar_object;
pub mod error;
pub mod principal;
pub use error::Error;
#[derive(Debug, Clone, Constructor)]
@@ -34,23 +31,18 @@ pub fn caldav_router<AP: AuthenticationProvider, C: CalendarStore, S: Subscripti
auth_provider: Arc<AP>,
store: Arc<C>,
subscription_store: Arc<S>,
simplified_home_set: bool,
) -> Router {
let principal_service = PrincipalResourceService {
auth_provider: auth_provider.clone(),
sub_store: subscription_store.clone(),
cal_store: store.clone(),
};
Router::new()
.nest(
prefix,
RootResourceService::<_, Principal, CalDavPrincipalUri>::new(principal_service.clone())
.axum_router()
.layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CalDavPrincipalUri(prefix))),
)
.route(
"/.well-known/caldav",
any(async || Redirect::permanent(prefix)),
)
Router::new().nest(
prefix,
RootResourceService::<_, Principal, CalDavPrincipalUri>::new(PrincipalResourceService {
auth_provider: auth_provider.clone(),
sub_store: subscription_store.clone(),
cal_store: store.clone(),
simplified_home_set,
})
.axum_router()
.layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CalDavPrincipalUri(prefix))),
)
}

View File

@@ -18,6 +18,8 @@ pub mod tests;
pub struct PrincipalResource {
principal: Principal,
members: Vec<String>,
// If true only return the principal as the calendar home set, otherwise also groups
simplified_home_set: bool,
}
impl ResourceName for PrincipalResource {
@@ -64,9 +66,17 @@ impl Resource for PrincipalResource {
PrincipalPropName::PrincipalUrl => {
PrincipalProp::PrincipalUrl(principal_url.into())
}
PrincipalPropName::CalendarHomeSet => {
PrincipalProp::CalendarHomeSet(principal_url.into())
}
PrincipalPropName::CalendarHomeSet => PrincipalProp::CalendarHomeSet(
CalendarHomeSet(if self.simplified_home_set {
vec![principal_url.into()]
} else {
self.principal
.memberships()
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect()
}),
),
PrincipalPropName::CalendarUserAddressSet => {
PrincipalProp::CalendarUserAddressSet(principal_url.into())
}

View File

@@ -31,9 +31,12 @@ pub enum PrincipalProp {
// CalDAV (RFC 4791)
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarHomeSet(HrefElement),
CalendarHomeSet(CalendarHomeSet),
}
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct CalendarHomeSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)]
pub enum PrincipalPropWrapper {

View File

@@ -18,6 +18,8 @@ pub struct PrincipalResourceService<
pub(crate) auth_provider: Arc<AP>,
pub(crate) sub_store: Arc<S>,
pub(crate) cal_store: Arc<CS>,
// If true only return the principal as the calendar home set, otherwise also groups
pub(crate) simplified_home_set: bool,
}
impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Clone
@@ -28,6 +30,7 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Clone
auth_provider: self.auth_provider.clone(),
sub_store: self.sub_store.clone(),
cal_store: self.cal_store.clone(),
simplified_home_set: self.simplified_home_set,
}
}
}
@@ -58,6 +61,7 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
Ok(PrincipalResource {
members: self.auth_provider.list_members(&user.id).await?,
principal: user,
simplified_home_set: self.simplified_home_set,
})
}

View File

@@ -27,6 +27,7 @@ async fn test_principal_resource(
cal_store: Arc::new(cal_store.await),
sub_store: Arc::new(sub_store.await),
auth_provider: Arc::new(auth_provider.await),
simplified_home_set: false,
};
assert!(matches!(

View File

@@ -7,6 +7,7 @@ use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use axum_extra::TypedHeader;
use axum_extra::headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch};
use http::Method;
use http::{HeaderMap, StatusCode};
use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource;
@@ -25,6 +26,7 @@ pub async fn get_object<AS: AddressbookStore>(
}): Path<AddressObjectPathComponents>,
State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>,
user: Principal,
method: Method,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
@@ -49,7 +51,11 @@ pub async fn get_object<AS: AddressbookStore>(
let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ETag::from_str(&object.get_etag()).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))]

View File

@@ -5,7 +5,7 @@ use axum::body::Body;
use axum::extract::{Path, State};
use axum::response::Response;
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 rustical_dav::privileges::UserPrivilege;
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)>,
State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,
user: Principal,
method: Method,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
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 hdrs = resp.headers_mut().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);
hdrs.insert(
header::CONTENT_DISPOSITION,
@@ -55,5 +56,9 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
))
.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 {
// TODO: The spec says we should return a mkcol-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())
}
}
addr_store.insert_addressbook(addressbook).await?;
Ok(StatusCode::CREATED.into_response())
}
#[cfg(test)]

View File

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

View File

@@ -53,7 +53,13 @@ impl Resource for PrincipalResource {
PrincipalPropWrapper::Principal(match prop {
PrincipalPropName::PrincipalUrl => PrincipalProp::PrincipalUrl(principal_href),
PrincipalPropName::AddressbookHomeSet => {
PrincipalProp::AddressbookHomeSet(principal_href)
PrincipalProp::AddressbookHomeSet(AddressbookHomeSet(
self.principal
.memberships()
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect(),
))
}
PrincipalPropName::PrincipalAddress => PrincipalProp::PrincipalAddress(None),
PrincipalPropName::GroupMembership => {

View File

@@ -22,11 +22,14 @@ pub enum PrincipalProp {
// CardDAV (RFC 6352)
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookHomeSet(HrefElement),
AddressbookHomeSet(AddressbookHomeSet),
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
PrincipalAddress(Option<HrefElement>),
}
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct AddressbookHomeSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)]
pub enum PrincipalPropWrapper {

View File

@@ -16,12 +16,12 @@ pub enum UserPrivilege {
}
impl XmlSerialize for UserPrivilegeSet {
fn serialize<W: std::io::Write>(
fn serialize(
&self,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut quick_xml::Writer<W>,
writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> {
#[derive(XmlSerialize)]
pub struct FakeUserPrivilegeSet {
@@ -35,7 +35,6 @@ impl XmlSerialize for UserPrivilegeSet {
.serialize(ns, tag, namespaces, writer)
}
#[allow(refining_impl_trait)]
fn attributes<'a>(&self) -> Option<Vec<quick_xml::events::attributes::Attribute<'a>>> {
None
}

View File

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

View File

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

View File

@@ -64,6 +64,7 @@ pub(crate) async fn route_propfind<R: ResourceService>(
} else {
PropfindElement {
prop: PropfindType::Allprop,
include: None,
}
};
let propfind_member: PropfindElement<<<R::MemberType as Resource>::Prop as PropName>::Names> =
@@ -72,22 +73,31 @@ pub(crate) async fn route_propfind<R: ResourceService>(
} else {
PropfindElement {
prop: PropfindType::Allprop,
include: None,
}
};
let mut member_responses = Vec::new();
if depth != &Depth::Zero {
// TODO: authorization check for member resources
for member in resource_service.get_members(path_components).await? {
member_responses.push(member.propfind(
&format!("{}/{}", path.trim_end_matches('/'), member.get_name()),
&propfind_member.prop,
propfind_member.include.as_ref(),
puri,
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 {
responses: vec![response],

View File

@@ -26,21 +26,21 @@ enum SetPropertyPropWrapper<T: XmlDeserialize> {
// We are <prop>
#[derive(XmlDeserialize, Clone, Debug)]
struct SetPropertyPropWrapperWrapper<T: XmlDeserialize>(
#[xml(ty = "untagged")] SetPropertyPropWrapper<T>,
#[xml(ty = "untagged", flatten)] Vec<SetPropertyPropWrapper<T>>,
);
// We are <set>
#[derive(XmlDeserialize, Clone, Debug)]
struct SetPropertyElement<T: XmlDeserialize> {
#[xml(ns = "crate::namespace::NS_DAV")]
prop: T,
prop: SetPropertyPropWrapperWrapper<T>,
}
#[derive(XmlDeserialize, Clone, Debug)]
struct TagName(#[xml(ty = "tag_name")] String);
#[derive(XmlDeserialize, Clone, Debug)]
struct PropertyElement(#[xml(ty = "untagged")] TagName);
struct PropertyElement(#[xml(ty = "untagged", flatten)] Vec<TagName>);
#[derive(XmlDeserialize, Clone, Debug)]
struct RemovePropertyElement {
@@ -81,9 +81,8 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
let href = path.to_owned();
// Extract operations
let PropertyupdateElement::<SetPropertyPropWrapperWrapper<<R::Resource as Resource>::Prop>>(
operations,
) = XmlDocument::parse_str(body).map_err(Error::XmlError)?;
let PropertyupdateElement::<<R::Resource as Resource>::Prop>(operations) =
XmlDocument::parse_str(body).map_err(Error::XmlError)?;
let mut resource = resource_service
.get_resource(path_components, false)
@@ -100,59 +99,63 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
for operation in operations.into_iter() {
match operation {
Operation::Set(SetPropertyElement {
prop: SetPropertyPropWrapperWrapper(property),
prop: SetPropertyPropWrapperWrapper(properties),
}) => {
match property {
SetPropertyPropWrapper::Valid(prop) => {
let propname: <<R::Resource as Resource>::Prop as PropName>::Names =
prop.clone().into();
let (ns, propname): (Option<Namespace>, &str) = propname.into();
match resource.set_prop(prop) {
Ok(()) => {
props_ok.push((ns.map(NamespaceOwned::from), propname.to_owned()))
}
Err(Error::PropReadOnly) => props_conflict
.push((ns.map(NamespaceOwned::from), propname.to_owned())),
Err(err) => return Err(err.into()),
};
}
SetPropertyPropWrapper::Invalid(invalid) => {
let propname = invalid.tag_name();
for property in properties {
match property {
SetPropertyPropWrapper::Valid(prop) => {
let propname: <<R::Resource as Resource>::Prop as PropName>::Names =
prop.clone().into();
let (ns, propname): (Option<Namespace>, &str) = propname.into();
match resource.set_prop(prop) {
Ok(()) => props_ok
.push((ns.map(NamespaceOwned::from), propname.to_owned())),
Err(Error::PropReadOnly) => props_conflict
.push((ns.map(NamespaceOwned::from), propname.to_owned())),
Err(err) => return Err(err.into()),
};
}
SetPropertyPropWrapper::Invalid(invalid) => {
let propname = invalid.tag_name();
if let Some(full_propname) = <R::Resource as Resource>::list_props()
.into_iter()
.find_map(|(ns, tag)| {
if tag == propname.as_str() {
Some((ns.map(NamespaceOwned::from), tag.to_owned()))
} else {
None
}
})
{
// This happens in following cases:
// - read-only properties with #[serde(skip_deserializing)]
// - internal properties
props_conflict.push(full_propname)
} else {
props_not_found.push((None, propname));
if let Some(full_propname) = <R::Resource as Resource>::list_props()
.into_iter()
.find_map(|(ns, tag)| {
if tag == propname.as_str() {
Some((ns.map(NamespaceOwned::from), tag.to_owned()))
} else {
None
}
})
{
// This happens in following cases:
// - read-only properties with #[serde(skip_deserializing)]
// - internal properties
props_conflict.push(full_propname)
} else {
props_not_found.push((None, propname));
}
}
}
}
}
Operation::Remove(remove_el) => {
let propname = remove_el.prop.0.0;
match <<R::Resource as Resource>::Prop as PropName>::Names::from_str(&propname) {
Ok(prop) => match resource.remove_prop(&prop) {
Ok(()) => props_ok.push((None, propname)),
Err(Error::PropReadOnly) => props_conflict.push({
let (ns, tag) = prop.into();
(ns.map(NamespaceOwned::from), tag.to_owned())
}),
Err(err) => return Err(err.into()),
},
// I guess removing a nonexisting property should be successful :)
Err(_) => props_ok.push((None, propname)),
};
for tagname in remove_el.prop.0 {
let propname = tagname.0;
match <<R::Resource as Resource>::Prop as PropName>::Names::from_str(&propname)
{
Ok(prop) => match resource.remove_prop(&prop) {
Ok(()) => props_ok.push((None, propname)),
Err(Error::PropReadOnly) => props_conflict.push({
let (ns, tag) = prop.into();
(ns.map(NamespaceOwned::from), tag.to_owned())
}),
Err(err) => return Err(err.into()),
},
// I guess removing a nonexisting property should be successful :)
Err(_) => props_ok.push((None, propname)),
};
}
}
}
}

View File

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

View File

@@ -19,12 +19,12 @@ pub struct PropstatElement<PropType: XmlSerialize> {
pub status: StatusCode,
}
fn xml_serialize_status<W: ::std::io::Write>(
fn xml_serialize_status(
status: &StatusCode,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut quick_xml::Writer<W>,
writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> {
XmlSerialize::serialize(&format!("HTTP/1.1 {}", status), ns, tag, namespaces, writer)
}
@@ -49,12 +49,12 @@ pub struct ResponseElement<PropstatType: XmlSerialize> {
pub propstat: Vec<PropstatWrapper<PropstatType>>,
}
fn xml_serialize_optional_status<W: ::std::io::Write>(
fn xml_serialize_optional_status(
val: &Option<StatusCode>,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut quick_xml::Writer<W>,
writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> {
XmlSerialize::serialize(
&val.map(|status| format!("HTTP/1.1 {}", status)),

View File

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

View File

@@ -33,7 +33,13 @@ mod tests {
.unwrap();
assert_eq!(
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

@@ -10,12 +10,12 @@ use std::collections::HashMap;
pub struct TagList(Vec<(Option<NamespaceOwned>, String)>);
impl XmlSerialize for TagList {
fn serialize<W: std::io::Write>(
fn serialize(
&self,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut quick_xml::Writer<W>,
writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> {
let prefix = ns
.map(|ns| namespaces.get(&ns))
@@ -57,7 +57,6 @@ impl XmlSerialize for TagList {
Ok(())
}
#[allow(refining_impl_trait)]
fn attributes<'a>(&self) -> Option<Vec<quick_xml::events::attributes::Attribute<'a>>> {
None
}

View File

@@ -2,6 +2,7 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { createClient } from "webdav";
import { escapeXml } from ".";
@customElement("create-addressbook-form")
export class CreateAddressbookForm extends LitElement {
@@ -17,13 +18,15 @@ export class CreateAddressbookForm extends LitElement {
client = createClient("/carddav")
@property()
user: String = ''
user: string = ''
@property()
addr_id: String = ''
principal: string = ''
@property()
displayname: String = ''
addr_id: string = ''
@property()
description: String = ''
displayname: string = ''
@property()
description: string = ''
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
@@ -34,6 +37,16 @@ export class CreateAddressbookForm extends LitElement {
<dialog ${ref(this.dialog)}>
<h3>Create addressbook</h3>
<form @submit=${this.submit} ${ref(this.form)}>
<label>
principal (for group addressbooks)
<select name="principal" value=${this.user} @change=${e => this.principal = e.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map(membership => html`
<option value=${membership}>${membership}</option>
`)}
</select>
</label>
<br>
<label>
id
<input type="text" name="id" @change=${e => this.addr_id = e.target.value} />
@@ -67,14 +80,13 @@ export class CreateAddressbookForm extends LitElement {
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.addr_id}`, {
await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.addr_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>` : ''}
<displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CARD:addressbook-description>${escapeXml(this.description)}</CARD:addressbook-description>` : ''}
</prop>
</set>
</mkcol>

View File

@@ -2,6 +2,7 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { createClient } from "webdav";
import { escapeXml } from ".";
@customElement("create-calendar-form")
export class CreateCalendarForm extends LitElement {
@@ -16,17 +17,21 @@ export class CreateCalendarForm extends LitElement {
client = createClient("/caldav")
@property()
user: String = ''
user: string = ''
@property()
cal_id: String = ''
principal: string = ''
@property()
displayname: String = ''
cal_id: string = ''
@property()
description: String = ''
displayname: string = ''
@property()
color: String = ''
description: string = ''
@property()
subscriptionUrl: String = ''
color: string = ''
@property()
isSubscription: boolean = false
@property()
subscriptionUrl: string = ''
@property()
components: Set<"VEVENT" | "VTODO" | "VJOURNAL"> = new Set()
@@ -40,6 +45,16 @@ export class CreateCalendarForm extends LitElement {
<dialog ${ref(this.dialog)}>
<h3>Create calendar</h3>
<form @submit=${this.submit} ${ref(this.form)}>
<label>
principal (for group calendars)
<select name="principal" value=${this.user} @change=${e => this.principal = e.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map(membership => html`
<option value=${membership}>${membership}</option>
`)}
</select>
</label>
<br>
<label>
id
<input type="text" name="id" @change=${e => this.cal_id = e.target.value} />
@@ -60,16 +75,26 @@ export class CreateCalendarForm extends LitElement {
<input type="color" name="color" @change=${e => this.color = e.target.value} />
</label>
<br>
<br>
<label>
Subscription URL
<input type="text" name="subscription_url" @change=${e => this.subscriptionUrl = e.target.value} />
Calendar is subscription to external calendar
<input type="checkbox" name="is_subscription" @change=${e => this.isSubscription = e.target.checked} />
</label>
<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`
<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>
`)}
<br>
<button type="submit">Create</button>
@@ -94,17 +119,17 @@ export class CreateCalendarForm extends LitElement {
alert("No calendar components selected")
return
}
await this.client.createDirectory(`/principal/${this.user}/${this.cal_id}`, {
await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.cal_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><href>${this.subscriptionUrl}</href></CS:source>` : ''}
<displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CAL:calendar-description>${escapeXml(this.description)}</CAL:calendar-description>` : ''}
${this.color ? `<ICAL:calendar-color>${escapeXml(this.color)}</ICAL:calendar-color>` : ''}
${(this.isSubscription && this.subscriptionUrl) ? `<CS:source><href>${escapeXml(this.subscriptionUrl)}</href></CS:source>` : ''}
<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>
</prop>
</set>

View File

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

View File

@@ -0,0 +1,97 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { escapeXml } from ".";
@customElement("edit-addressbook-form")
export class EditAddressbookForm extends LitElement {
constructor() {
super()
}
protected override createRenderRoot() {
return this
}
@property()
principal: string = ''
@property()
addr_id: string = ''
@property()
displayname: string = ''
@property()
description: string = ''
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
override render() {
return html`
<button @click=${() => this.dialog.value.showModal()}>Edit addressbook</button>
<dialog ${ref(this.dialog)}>
<h3>Create addressbook</h3>
<form @submit=${this.submit} ${ref(this.form)}>
<label>
Displayname
<input type="text" name="displayname" .value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" .value=${this.description} @change=${e => this.description = e.target.value} />
</label>
<br>
<button type="submit">Submit</button>
<button type="submit" @click=${event => { event.preventDefault(); this.dialog.value.close(); this.form.value.reset() }} class="cancel">Cancel</button>
</form>
</dialog>
`
}
async submit(e: SubmitEvent) {
e.preventDefault()
if (!this.principal) {
alert("Empty principal")
return
}
if (!this.addr_id) {
alert("Empty id")
return
}
if (!this.displayname) {
alert("Empty displayname")
return
}
await fetch(`/carddav/principal/${this.principal}/${this.addr_id}`, {
method: 'PROPPATCH',
headers: {
'Content-Type': 'application/xml'
},
body: `
<propertyupdate xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
<set>
<prop>
<displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CARD:addressbook-description>${escapeXml(this.description)}</CARD:addressbook-description>` : ''}
</prop>
</set>
<remove>
<prop>
${!this.description ? '<CARD:calendar-description />' : ''}
</prop>
</remove>
</propertyupdate>
`
})
window.location.reload()
return null
}
}
declare global {
interface HTMLElementTagNameMap {
'edit-addressbook-form': EditAddressbookForm
}
}

View File

@@ -0,0 +1,128 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { escapeXml } from ".";
@customElement("edit-calendar-form")
export class EditCalendarForm extends LitElement {
constructor() {
super()
}
protected override createRenderRoot() {
return this
}
@property()
principal: string
@property()
cal_id: string
@property()
displayname: string = ''
@property()
description: string = ''
@property()
color: string = ''
@property({
converter: {
fromAttribute: (value, _type) => new Set(value ? JSON.parse(value) : []),
toAttribute: (value, _type) => JSON.stringify(value)
}
})
components: Set<"VEVENT" | "VTODO" | "VJOURNAL"> = new Set()
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
override render() {
return html`
<button @click=${() => this.dialog.value.showModal()}>Edit calendar</button>
<dialog ${ref(this.dialog)}>
<h3>Create calendar</h3>
<form @submit=${this.submit} ${ref(this.form)}>
<label>
Displayname
<input type="text" name="displayname" .value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" .value=${this.description} @change=${e => this.description = e.target.value} />
</label>
<br>
<label>
Color
<input type="color" name="color" .value=${this.color} @change=${e => this.color = e.target.value} />
</label>
<br>
${["VEVENT", "VTODO", "VJOURNAL"].map(comp => html`
<label>
Support ${comp}
<input type="checkbox" value=${comp} ?checked=${this.components.has(comp)} @change=${e => e.target.checked ? this.components.add(e.target.value) : this.components.delete(e.target.value)} />
</label>
<br>
`)}
<br>
<button type="submit">Submit</button>
<button type="submit" @click=${event => { event.preventDefault(); this.dialog.value.close(); this.form.value.reset() }} class="cancel">Cancel</button>
</form>
</dialog>
`
}
async submit(e: SubmitEvent) {
e.preventDefault()
if (!this.principal) {
alert("Empty principal")
return
}
if (!this.cal_id) {
alert("Empty id")
return
}
if (!this.displayname) {
alert("Empty displayname")
return
}
if (!this.components.size) {
alert("No calendar components selected")
return
}
await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
method: 'PROPPATCH',
headers: {
'Content-Type': 'application/xml'
},
body: `
<propertyupdate xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<set>
<prop>
<displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CAL:calendar-description>${escapeXml(this.description)}</CAL:calendar-description>` : ''}
${this.color ? `<ICAL:calendar-color>${escapeXml(this.color)}</ICAL:calendar-color>` : ''}
<CAL:supported-calendar-component-set>
${Array.from(this.components.keys()).map(comp => `<CAL:comp name="${escapeXml(comp)}" />`).join('\n')}
</CAL:supported-calendar-component-set>
</prop>
</set>
<remove>
<prop>
${!this.description ? '<CAL:calendar-description />' : ''}
${!this.color ? '<ICAL:calendar-color />' : ''}
</prop>
</remove>
</propertyupdate>
`
})
window.location.reload()
return null
}
}
declare global {
interface HTMLElementTagNameMap {
'edit-calendar-form': EditCalendarForm
}
}

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

@@ -15,7 +15,9 @@ export default defineConfig({
rollupOptions: {
input: [
"lib/create-calendar-form.ts",
"lib/edit-calendar-form.ts",
"lib/create-addressbook-form.ts",
"lib/edit-addressbook-form.ts",
"lib/delete-button.ts",
],
output: {

View File

@@ -1,6 +1,6 @@
import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n } from "./ref-CPp9J0V5.mjs";
import { e, n, a as escapeXml } from "./index-b86iLJlP.mjs";
import { a as an } from "./webdav-D0R7xCzX.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -17,6 +17,7 @@ let CreateAddressbookForm = class extends i {
super();
this.client = an("/carddav");
this.user = "";
this.principal = "";
this.addr_id = "";
this.displayname = "";
this.description = "";
@@ -32,6 +33,16 @@ let CreateAddressbookForm = class extends i {
<dialog ${n(this.dialog)}>
<h3>Create addressbook</h3>
<form @submit=${this.submit} ${n(this.form)}>
<label>
principal (for group addressbooks)
<select name="principal" value=${this.user} @change=${(e2) => this.principal = e2.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map((membership) => x`
<option value=${membership}>${membership}</option>
`)}
</select>
</label>
<br>
<label>
id
<input type="text" name="id" @change=${(e2) => this.addr_id = e2.target.value} />
@@ -68,13 +79,13 @@ let CreateAddressbookForm = class extends i {
alert("Empty displayname");
return;
}
await this.client.createDirectory(`/principal/${this.user}/${this.addr_id}`, {
await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.addr_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>` : ""}
<displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CARD:addressbook-description>${escapeXml(this.description)}</CARD:addressbook-description>` : ""}
</prop>
</set>
</mkcol>
@@ -87,6 +98,9 @@ let CreateAddressbookForm = class extends i {
__decorateClass([
n$1()
], CreateAddressbookForm.prototype, "user", 2);
__decorateClass([
n$1()
], CreateAddressbookForm.prototype, "principal", 2);
__decorateClass([
n$1()
], CreateAddressbookForm.prototype, "addr_id", 2);

View File

@@ -1,6 +1,6 @@
import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n } from "./ref-CPp9J0V5.mjs";
import { e, n, a as escapeXml } from "./index-b86iLJlP.mjs";
import { a as an } from "./webdav-D0R7xCzX.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -17,10 +17,12 @@ let CreateCalendarForm = class extends i {
super();
this.client = an("/caldav");
this.user = "";
this.principal = "";
this.cal_id = "";
this.displayname = "";
this.description = "";
this.color = "";
this.isSubscription = false;
this.subscriptionUrl = "";
this.components = /* @__PURE__ */ new Set();
this.dialog = e();
@@ -35,6 +37,16 @@ let CreateCalendarForm = class extends i {
<dialog ${n(this.dialog)}>
<h3>Create calendar</h3>
<form @submit=${this.submit} ${n(this.form)}>
<label>
principal (for group calendars)
<select name="principal" value=${this.user} @change=${(e2) => this.principal = e2.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map((membership) => x`
<option value=${membership}>${membership}</option>
`)}
</select>
</label>
<br>
<label>
id
<input type="text" name="id" @change=${(e2) => this.cal_id = e2.target.value} />
@@ -55,16 +67,26 @@ let CreateCalendarForm = class extends i {
<input type="color" name="color" @change=${(e2) => this.color = e2.target.value} />
</label>
<br>
<br>
<label>
Subscription URL
<input type="text" name="subscription_url" @change=${(e2) => this.subscriptionUrl = e2.target.value} />
Calendar is subscription to external calendar
<input type="checkbox" name="is_subscription" @change=${(e2) => this.isSubscription = e2.target.checked} />
</label>
<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`
<label>
Support ${comp}
<input type="checkbox" value=${comp} @change=${(e2) => e2.target.checked ? this.components.add(e2.target.value) : this.components.delete(e2.target.value)} />
</label>
<br>
`)}
<br>
<button type="submit">Create</button>
@@ -92,17 +114,17 @@ let CreateCalendarForm = class extends i {
alert("No calendar components selected");
return;
}
await this.client.createDirectory(`/principal/${this.user}/${this.cal_id}`, {
await this.client.createDirectory(`/principal/${this.principal || this.user}/${this.cal_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><href>${this.subscriptionUrl}</href></CS:source>` : ""}
<displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CAL:calendar-description>${escapeXml(this.description)}</CAL:calendar-description>` : ""}
${this.color ? `<ICAL:calendar-color>${escapeXml(this.color)}</ICAL:calendar-color>` : ""}
${this.isSubscription && this.subscriptionUrl ? `<CS:source><href>${escapeXml(this.subscriptionUrl)}</href></CS:source>` : ""}
<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>
</prop>
</set>
@@ -116,6 +138,9 @@ let CreateCalendarForm = class extends i {
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "user", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "principal", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "cal_id", 2);
@@ -128,6 +153,9 @@ __decorateClass([
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "color", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "isSubscription", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "subscriptionUrl", 2);

View File

@@ -0,0 +1,109 @@
import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n, a as escapeXml } from "./index-b86iLJlP.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
for (var i2 = decorators.length - 1, decorator; i2 >= 0; i2--)
if (decorator = decorators[i2])
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
if (kind && result) __defProp(target, key, result);
return result;
};
let EditAddressbookForm = class extends i {
constructor() {
super();
this.principal = "";
this.addr_id = "";
this.displayname = "";
this.description = "";
this.dialog = e();
this.form = e();
}
createRenderRoot() {
return this;
}
render() {
return x`
<button @click=${() => this.dialog.value.showModal()}>Edit addressbook</button>
<dialog ${n(this.dialog)}>
<h3>Create addressbook</h3>
<form @submit=${this.submit} ${n(this.form)}>
<label>
Displayname
<input type="text" name="displayname" .value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" .value=${this.description} @change=${(e2) => this.description = e2.target.value} />
</label>
<br>
<button type="submit">Submit</button>
<button type="submit" @click=${(event) => {
event.preventDefault();
this.dialog.value.close();
this.form.value.reset();
}} class="cancel">Cancel</button>
</form>
</dialog>
`;
}
async submit(e2) {
e2.preventDefault();
if (!this.principal) {
alert("Empty principal");
return;
}
if (!this.addr_id) {
alert("Empty id");
return;
}
if (!this.displayname) {
alert("Empty displayname");
return;
}
await fetch(`/carddav/principal/${this.principal}/${this.addr_id}`, {
method: "PROPPATCH",
headers: {
"Content-Type": "application/xml"
},
body: `
<propertyupdate xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
<set>
<prop>
<displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CARD:addressbook-description>${escapeXml(this.description)}</CARD:addressbook-description>` : ""}
</prop>
</set>
<remove>
<prop>
${!this.description ? "<CARD:calendar-description />" : ""}
</prop>
</remove>
</propertyupdate>
`
});
window.location.reload();
return null;
}
};
__decorateClass([
n$1()
], EditAddressbookForm.prototype, "principal", 2);
__decorateClass([
n$1()
], EditAddressbookForm.prototype, "addr_id", 2);
__decorateClass([
n$1()
], EditAddressbookForm.prototype, "displayname", 2);
__decorateClass([
n$1()
], EditAddressbookForm.prototype, "description", 2);
EditAddressbookForm = __decorateClass([
t("edit-addressbook-form")
], EditAddressbookForm);
export {
EditAddressbookForm
};

View File

@@ -0,0 +1,142 @@
import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n, a as escapeXml } from "./index-b86iLJlP.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
for (var i2 = decorators.length - 1, decorator; i2 >= 0; i2--)
if (decorator = decorators[i2])
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
if (kind && result) __defProp(target, key, result);
return result;
};
let EditCalendarForm = class extends i {
constructor() {
super();
this.displayname = "";
this.description = "";
this.color = "";
this.components = /* @__PURE__ */ new Set();
this.dialog = e();
this.form = e();
}
createRenderRoot() {
return this;
}
render() {
return x`
<button @click=${() => this.dialog.value.showModal()}>Edit calendar</button>
<dialog ${n(this.dialog)}>
<h3>Create calendar</h3>
<form @submit=${this.submit} ${n(this.form)}>
<label>
Displayname
<input type="text" name="displayname" .value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" .value=${this.description} @change=${(e2) => this.description = e2.target.value} />
</label>
<br>
<label>
Color
<input type="color" name="color" .value=${this.color} @change=${(e2) => this.color = e2.target.value} />
</label>
<br>
${["VEVENT", "VTODO", "VJOURNAL"].map((comp) => x`
<label>
Support ${comp}
<input type="checkbox" value=${comp} ?checked=${this.components.has(comp)} @change=${(e2) => e2.target.checked ? this.components.add(e2.target.value) : this.components.delete(e2.target.value)} />
</label>
<br>
`)}
<br>
<button type="submit">Submit</button>
<button type="submit" @click=${(event) => {
event.preventDefault();
this.dialog.value.close();
this.form.value.reset();
}} class="cancel">Cancel</button>
</form>
</dialog>
`;
}
async submit(e2) {
e2.preventDefault();
if (!this.principal) {
alert("Empty principal");
return;
}
if (!this.cal_id) {
alert("Empty id");
return;
}
if (!this.displayname) {
alert("Empty displayname");
return;
}
if (!this.components.size) {
alert("No calendar components selected");
return;
}
await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
method: "PROPPATCH",
headers: {
"Content-Type": "application/xml"
},
body: `
<propertyupdate xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<set>
<prop>
<displayname>${escapeXml(this.displayname)}</displayname>
${this.description ? `<CAL:calendar-description>${escapeXml(this.description)}</CAL:calendar-description>` : ""}
${this.color ? `<ICAL:calendar-color>${escapeXml(this.color)}</ICAL:calendar-color>` : ""}
<CAL:supported-calendar-component-set>
${Array.from(this.components.keys()).map((comp) => `<CAL:comp name="${escapeXml(comp)}" />`).join("\n")}
</CAL:supported-calendar-component-set>
</prop>
</set>
<remove>
<prop>
${!this.description ? "<CAL:calendar-description />" : ""}
${!this.color ? "<ICAL:calendar-color />" : ""}
</prop>
</remove>
</propertyupdate>
`
});
window.location.reload();
return null;
}
};
__decorateClass([
n$1()
], EditCalendarForm.prototype, "principal", 2);
__decorateClass([
n$1()
], EditCalendarForm.prototype, "cal_id", 2);
__decorateClass([
n$1()
], EditCalendarForm.prototype, "displayname", 2);
__decorateClass([
n$1()
], EditCalendarForm.prototype, "description", 2);
__decorateClass([
n$1()
], EditCalendarForm.prototype, "color", 2);
__decorateClass([
n$1({
converter: {
fromAttribute: (value, _type) => new Set(value ? JSON.parse(value) : []),
toAttribute: (value, _type) => JSON.stringify(value)
}
})
], EditCalendarForm.prototype, "components", 2);
EditCalendarForm = __decorateClass([
t("edit-calendar-form")
], EditCalendarForm);
export {
EditCalendarForm
};

View File

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

View File

@@ -1,21 +1,41 @@
:root {
--background-color: #FFF;
--background-darker: #EEE;
--text-on-background-color: #111;
--primary-color: #2F2FE1;
--primary-color-dark: color-mix(in srgb, var(--primary-color), #000000 80%);
--text-on-primary-color: #FFF;
/* --color-red: #FE2060; */
/* --color-red: #EE1D59; */
--color-red: #E31B39;
--dilute-color: black;
--border-color: black;
}
html {
@media (prefers-color-scheme: dark) {
:root {
--background-color: #222;
--background-darker: #292929;
--text-on-background-color: #CACACA;
--primary-color: color-mix(in srgb, #2F2FE1, white 15%);
--primary-color-dark: color-mix(in srgb, var(--primary-color), #000000 80%);
--text-on-primary-color: #FFF;
/* --color-red: #FE2060; */
--color-red: #EE1D59;
--dilute-color: white;
--border-color: color-mix(in srgb, var(--background-color), var(--dilute-color) 15%);
}
}
html,
dialog {
background-color: var(--background-color);
color: var(--text-on-background-color);
}
body {
/* position: relative; */
font-family: sans-serif;
font-family: 'Noto Sans', Helvetica, Arial, sans-serif;
margin: 0 auto;
max-width: 1200px;
min-height: 100%;
@@ -29,32 +49,65 @@ body {
padding: 12px;
}
a {
color: var(--text-on-background-color);
}
header {
background: var(--background-darker);
height: 60px;
min-height: 60px;
font-weight: bold;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 12px;
padding: 4px 12px;
border: 2px solid black;
border: 2px solid var(--border-color);
border-radius: 12px;
margin: 12px;
box-shadow: 4px 2px 12px -5px black;
a {
display: flex;
justify-content: space-between;
a.logo {
font-size: 2em;
text-decoration: none;
color: black;
}
nav {
display: flex;
border-radius: 12px;
background: color-mix(in srgb, var(--background-darker), var(--dilute-color) 5%);
a {
text-decoration: none;
margin: 4px 8px;
padding: 8px 12px;
border-radius: 12px;
background: color-mix(in srgb, var(--background-darker), var(--dilute-color) 2%);
&:hover {
background: color-mix(in srgb, var(--background-darker), var(--dilute-color) 20%);
}
&.active {
background: color-mix(in srgb, var(--background-darker), var(--dilute-color) 15%);
}
svg.icon {
width: 1.3em;
vertical-align: bottom;
margin-right: 6px;
}
}
}
.logout_form {
display: contents;
button {
margin-left: auto;
}
}
}
@@ -69,7 +122,7 @@ button,
.button {
border: none;
background: var(--primary-color);
padding: 10px 16px;
padding: 8px 16px;
border-radius: 8px;
color: var(--text-on-primary-color);
font-size: 0.9em;
@@ -97,7 +150,7 @@ input[type="password"] {
}
section {
border: 1px solid black;
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 4px 2px 12px -8px black;
border-collapse: collapse;
@@ -108,7 +161,7 @@ section {
}
table {
border: 1px solid black;
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 4px 2px 12px -6px black;
border-collapse: collapse;
@@ -118,7 +171,7 @@ table {
td,
th {
padding: 8px;
border: 1px solid black;
border: 1px solid var(--border-color);
width: max-content;
}
@@ -126,12 +179,8 @@ table {
height: 40px;
}
/* tr:nth-of-type(2n+1) { */
/* background: var(--background-darker); */
/* } */
tr:hover {
background: #DDD;
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%);
}
tr:first-child th {
@@ -151,88 +200,109 @@ table {
}
}
#page-user {
ul {
padding-left: 0;
ul.collection-list {
padding-left: 0;
li.collection-list-item {
list-style: none;
display: contents;
li.collection-list-item {
list-style: none;
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 {
background: #EEE;
display: grid;
min-height: 80px;
grid-template-areas:
". . color-chip"
"title comps color-chip"
"description description color-chip"
"subscription-url subscription-url color-chip"
"actions actions color-chip"
". . color-chip";
grid-template-rows: 12px auto auto auto auto 12px;
grid-template-columns: min-content auto 80px;
row-gap: 4px;
color: inherit;
text-decoration: none;
padding-left: 12px;
a {
position: absolute;
inset: 2px;
}
border: 2px solid black;
border-radius: 12px;
margin: 12px;
box-shadow: 4px 2px 12px -6px black;
overflow: hidden;
.inner {
display: grid;
min-height: 80px;
height: fit-content;
grid-template-areas:
". color-chip"
"title color-chip"
"description color-chip"
"subscription-url color-chip"
"metadata color-chip"
"actions color-chip"
". color-chip";
grid-template-rows: 12px auto auto auto auto auto 12px;
grid-template-columns: auto 80px;
row-gap: 4px;
color: inherit;
text-decoration: none;
padding-left: 12px;
.title {
font-weight: bold;
grid-area: title;
margin-right: 12px;
white-space: nowrap;
}
position: relative;
z-index: 1;
pointer-events: none;
a,
button {
pointer-events: all;
cursor: pointer;
}
.title {
font-weight: bold;
grid-area: title;
margin-right: 12px;
white-space: nowrap;
}
span {
margin: 8px initial;
}
.comps {
display: inline;
span {
margin: 8px initial;
}
.comps {
grid-area: comps;
span {
margin: 0 2px;
background: var(--primary-color);
color: var(--text-on-primary-color);
font-size: .8em;
padding: 3px 8px;
border-radius: 12px;
}
}
.description {
grid-area: description;
white-space: nowrap;
}
.subscription-url {
grid-area: subscription-url;
white-space: nowrap;
}
.color-chip {
background: var(--color);
grid-area: color-chip;
}
.actions {
grid-area: actions;
width: fit-content;
display: flex;
gap: 12px;
}
&:hover {
background: #DDD;
margin: 0 2px;
background: var(--primary-color);
color: var(--text-on-primary-color);
font-size: .8em;
padding: 3px 8px;
border-radius: 12px;
}
}
.description {
grid-area: description;
white-space: nowrap;
}
.metadata {
grid-area: metadata;
white-space: nowrap;
}
.subscription-url {
grid-area: subscription-url;
white-space: nowrap;
}
.color-chip {
background: var(--color);
grid-area: color-chip;
}
.actions {
pointer-events: all;
grid-area: actions;
width: fit-content;
display: flex;
gap: 12px;
}
}
&:hover {
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%);
}
}
}
@@ -242,7 +312,7 @@ textarea {
}
dialog {
border: 1px solid black;
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 32px;
}
@@ -252,6 +322,28 @@ footer {
justify-content: center;
margin-top: 32px;
gap: 24px;
/* position: absolute; */
bottom: 20px;
}
input[type="text"],
input[type="password"],
input[type="color"],
select {
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 10%);
border: 2px solid var(--border-color);
padding: 6px 6px;
color: var(--text-on-background-color);
margin: 2px;
border-radius: 8px;
&:hover,
&:focus {
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 20%);
}
}
svg.icon {
stroke-width: 2px;
color: var(--text-on-background-color);
stroke: var(--text-on-background-color);
}

View File

@@ -1,56 +0,0 @@
<section>
<h2>Addressbooks</h2>
<ul>
{% for addressbook in addressbooks %}
<li class="collection-list-item">
<a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}">
<span class="title">
{%- if addressbook.principal != user.id -%}{{ addressbook.principal }}/{%- endif -%}
{{ addressbook.displayname.to_owned().unwrap_or(addressbook.id.to_owned()) }}
</span>
<span class="description">
{% if let Some(description) = addressbook.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}" target="_blank"
method="GET">
<button type="submit">Download</button>
</form>
<delete-button trash
href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button>
</div>
</a>
</li>
{% else %}
You do not have any addressbooks yet
{% endfor %}
</ul>
{%if !deleted_addressbooks.is_empty() %}
<h3>Deleted Addressbooks</h3>
<ul>
{% for addressbook in deleted_addressbooks %}
<li class="collection-list-item">
<a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}">
<span class="title">
{%- if addressbook.principal != user.id -%}{{ addressbook.principal }}/{%- endif -%}
{{ addressbook.displayname.to_owned().unwrap_or(addressbook.id.to_owned()) }}
</span>
<span class="description">
{% if let Some(description) = addressbook.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}/restore"
method="POST" class="restore-form">
<button type="submit">Restore</button>
</form>
<delete-button href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button>
</div>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
<create-addressbook-form user="{{ user.id }}"></create-addressbook-form>
</section>

View File

@@ -1,72 +0,0 @@
<section>
<h2>Calendars</h2>
<ul>
{% for calendar in calendars %}
{% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %}
<li class="collection-list-item" style="--color: {{ color }}">
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id }}">
<span class="title">
{%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%}
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
</span>
<div class="comps">
{% for comp in calendar.components %}
<span>{{ comp }}</span>
{% endfor %}
</div>
<span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
</span>
{% if let Some(subscription_url) = calendar.subscription_url %}
<span class="subscription-url">{{ subscription_url }}</span>
{% endif %}
<div class="actions">
<form action="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}" target="_blank" method="GET">
<button type="submit">Download</button>
</form>
{% if !calendar.id.starts_with("_birthdays_") %}
<delete-button trash href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button>
{% endif %}
</div>
<div class="color-chip"></div>
</a>
</li>
{% else %}
You do not have any calendars yet
{% endfor %}
</ul>
{%if !deleted_calendars.is_empty() %}
<h3>Deleted Calendars</h3>
<ul>
{% for calendar in deleted_calendars %}
{% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %}
<li class="collection-list-item" style="--color: {{ color }}">
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}">
<span class="title">
{%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%}
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
</span>
<div class="comps">
{% for comp in calendar.components %}
<span>{{ comp }}</span>
{% endfor %}
</div>
<span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}/restore" method="POST"
class="restore-form">
<button type="submit">Restore</button>
</form>
<delete-button href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button>
</div>
<div class="color-chip"></div>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
<create-calendar-form user="{{ user.id }}"></create-calendar-form>
</section>

View File

@@ -1,58 +0,0 @@
<section>
<h2>Profile</h2>
{% let groups = user.memberships_without_self() %}
{% if groups.len() > 0 %}
<h3>Groups</h3>
<ul>
{% for group in groups %}
<li>{{ group }}</li>
{% endfor %}
</ul>
{% endif %}
<h3>App tokens</h3>
<table id="app-tokens">
<tr>
<th>Name</th>
<th>Created at</th>
<th></th>
</tr>
{% for app_token in app_tokens %}
<tr>
<td>{{ app_token.name }}</td>
<td>
{% if let Some(created_at) = app_token.created_at %}
{{ chrono_humanize::HumanTime::from(created_at.to_owned()) }}
{% endif %}
</td>
<td>
<form action="/frontend/user/{{ user.id }}/app_token/{{ app_token.id }}/delete" method="POST">
<button type="submit" class="delete">Delete</button>
</form>
</td>
</tr>
{% endfor %}
<tr class="generate">
<td>
<form action="/frontend/user/{{ user.id }}/app_token" method="POST" id="form_generate_app_token">
<label class="font_bold" for="generate_app_token_name">App name</label>
<input type="text" name="name" id="generate_app_token_name" />
</form>
</td>
<td></td>
<td>
<button type="submit" form="form_generate_app_token">Generate</button>
{% if is_apple %}
<button type="submit" form="form_generate_app_token" name="apple" value="true">Apple Configuration Profile
(contains token)</button>
{% endif %}
</td>
</tr>
</table>
{% if let Some(hostname) = davx5_hostname %}
<a
href="intent://{{ hostname | urlencode }}#Intent;action=android.intent.action.VIEW;component=at.bitfire.davdroid.ui.setup.LoginActivity;scheme=davx5;package=at.bitfire.davdroid;S.loginFlow=1;end">Configure
in DAVx5</a>
{% endif %}
</section>

View File

@@ -0,0 +1,68 @@
<h2>{{user.id }}'s Addressbooks</h2>
<ul class="collection-list">
{% for (meta, addressbook) in addressbooks %}
<li class="collection-list-item">
<a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}"></a>
<div class="inner">
<span class="title">
{%- if addressbook.principal != user.id -%}{{ addressbook.principal }}/{%- endif -%}
{{ addressbook.displayname.to_owned().unwrap_or(addressbook.id.to_owned()) }}
</span>
<span class="description">
{% if let Some(description) = addressbook.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}" target="_blank"
method="GET">
<button type="submit">Download</button>
</form>
<edit-addressbook-form
principal="{{ addressbook.principal }}"
addr_id="{{ addressbook.id }}"
displayname="{{ addressbook.displayname.as_deref().unwrap_or_default() }}"
description="{{ addressbook.description.as_deref().unwrap_or_default() }}"
></edit-addressbook-form>
<delete-button trash
href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button>
</div>
<div class="metadata">
{{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects
</div>
</div>
</li>
{% else %}
You do not have any addressbooks yet
{% endfor %}
</ul>
{%if !deleted_addressbooks.is_empty() %}
<h3>Deleted Addressbooks</h3>
<ul class="collection-list">
{% for (meta, addressbook) in deleted_addressbooks %}
<li class="collection-list-item">
<a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}"></a>
<div class="inner">
<span class="title">
{%- if addressbook.principal != user.id -%}{{ addressbook.principal }}/{%- endif -%}
{{ addressbook.displayname.to_owned().unwrap_or(addressbook.id.to_owned()) }}
</span>
<span class="description">
{% if let Some(description) = addressbook.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}/restore"
method="POST" class="restore-form">
<button type="submit">Restore</button>
</form>
<delete-button href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button>
</div>
<div class="metadata">
{{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects
</div>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
<create-addressbook-form user="{{ user.id }}"></create-addressbook-form>

View File

@@ -0,0 +1,86 @@
<h2>{{ user.id }}'s Calendars</h2>
<ul class="collection-list">
{% for (meta, calendar) in calendars %}
{% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %}
<li class="collection-list-item" style="--color: {{ color }}">
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id }}"></a>
<div class="inner">
<span class="title">
{%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%}
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
<div class="comps">
{% for comp in calendar.components %}
<span>{{ comp }}</span>
{% endfor %}
</div>
</span>
<span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
</span>
{% if let Some(subscription_url) = calendar.subscription_url %}
<span class="subscription-url">{{ subscription_url }}</span>
{% endif %}
<div class="actions">
<form action="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}" target="_blank" method="GET">
<button type="submit">Download</button>
</form>
{% if !calendar.id.starts_with("_birthdays_") %}
<edit-calendar-form
principal="{{ calendar.principal }}"
cal_id="{{ calendar.id }}"
displayname="{{ calendar.displayname.as_deref().unwrap_or_default() }}"
description="{{ calendar.description.as_deref().unwrap_or_default() }}"
color="{{ calendar.color.as_deref().unwrap_or_default() }}"
components="{{ calendar.components | json }}"
></edit-calendar-form>
<delete-button trash href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button>
{% endif %}
</div>
<div class="metadata">
{{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects
</div>
<div class="color-chip"></div>
</div>
</li>
{% else %}
You do not have any calendars yet
{% endfor %}
</ul>
{%if !deleted_calendars.is_empty() %}
<h3>Deleted Calendars</h3>
<ul class="collection-list">
{% for (meta, calendar) in deleted_calendars %}
{% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %}
<li class="collection-list-item" style="--color: {{ color }}">
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}"></a>
<div class="inner">
<span class="title">
{%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%}
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
<div class="comps">
{% for comp in calendar.components %}
<span>{{ comp }}</span>
{% endfor %}
</div>
</span>
<span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}/restore" method="POST"
class="restore-form">
<button type="submit">Restore</button>
</form>
<delete-button href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button>
</div>
<div class="metadata">
{{ meta.len }} ({{ meta.size | filesizeformat }}) objects, {{ meta.deleted_len }} ({{ meta.deleted_size | filesizeformat }}) deleted objects
</div>
<div class="color-chip"></div>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
<create-calendar-form user="{{ user.id }}"></create-calendar-form>

View File

@@ -0,0 +1,56 @@
<h2>{{ user.id }}'s Profile</h2>
{% let groups = user.memberships_without_self() %}
{% if groups.len() > 0 %}
<h3>Groups</h3>
<ul>
{% for group in groups %}
<li>{{ group }}</li>
{% endfor %}
</ul>
{% endif %}
<h3>App tokens</h3>
<table id="app-tokens">
<tr>
<th>Name</th>
<th>Created at</th>
<th></th>
</tr>
{% for app_token in app_tokens %}
<tr>
<td>{{ app_token.name }}</td>
<td>
{% if let Some(created_at) = app_token.created_at %}
{{ chrono_humanize::HumanTime::from(created_at.to_owned()) }}
{% endif %}
</td>
<td>
<form action="/frontend/user/{{ user.id }}/app_token/{{ app_token.id }}/delete" method="POST">
<button type="submit" class="delete">Delete</button>
</form>
</td>
</tr>
{% endfor %}
<tr class="generate">
<td>
<form action="/frontend/user/{{ user.id }}/app_token" method="POST" id="form_generate_app_token">
<label class="font_bold" for="generate_app_token_name">App name</label>
<input type="text" name="name" id="generate_app_token_name" />
</form>
</td>
<td></td>
<td>
<button type="submit" form="form_generate_app_token">Generate</button>
{% if is_apple %}
<button type="submit" form="form_generate_app_token" name="apple" value="true">Apple Configuration Profile
(contains token)</button>
{% endif %}
</td>
</tr>
</table>
{% if let Some(hostname) = davx5_hostname %}
<a
href="intent://{{ hostname | urlencode }}#Intent;action=android.intent.action.VIEW;component=at.bitfire.davdroid.ui.setup.LoginActivity;scheme=davx5;package=at.bitfire.davdroid;S.loginFlow=1;end">Configure
in DAVx5</a>
{% endif %}

View File

@@ -0,0 +1,7 @@
<!-- Adapted from https://iconoir.com/ -->
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
<path d="M15 4V2M15 4V6M15 4H10.5M3 10V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V10H3Z" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M3 10V6C3 4.89543 3.89543 4 5 4H7" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M7 2V6" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M21 10V6C21 4.89543 20.1046 4 19 4H18.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 608 B

View File

@@ -0,0 +1,7 @@
<!-- Adapted from https://iconoir.com/ -->
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
<path d="M1 20V19C1 15.134 4.13401 12 8 12V12C11.866 12 15 15.134 15 19V20" stroke-linecap="round"></path>
<path d="M13 14V14C13 11.2386 15.2386 9 18 9V9C20.7614 9 23 11.2386 23 14V14.5" stroke-linecap="round"></path>
<path d="M8 12C10.2091 12 12 10.2091 12 8C12 5.79086 10.2091 4 8 4C5.79086 4 4 5.79086 4 8C4 10.2091 5.79086 12 8 12Z" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M18 9C19.6569 9 21 7.65685 21 6C21 4.34315 19.6569 3 18 3C16.3431 3 15 4.34315 15 6C15 7.65685 16.3431 9 18 9Z" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 739 B

View File

@@ -0,0 +1,5 @@
<!-- Adapted from https://iconoir.com/ -->
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
<path d="M5 20V19C5 15.134 8.13401 12 12 12V12C15.866 12 19 15.134 19 19V20" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M12 12C14.2091 12 16 10.2091 16 8C16 5.79086 14.2091 4 12 4C9.79086 4 8 5.79086 8 8C8 10.2091 9.79086 12 12 12Z" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 476 B

View File

@@ -12,7 +12,8 @@
<body>
{% block header %}
<header>
<a href="/frontend/user">RustiCal</a>
<a class="logo" href="/frontend/user">RustiCal</a>
{% block header_center %}{% endblock %}
<form method="POST" action="/frontend/logout" class="logout_form">
<button type="submit">Log out</button>
</form>
@@ -21,9 +22,9 @@
<div id="app">
{% block content %}<p>Placeholder</p>{% endblock %}
</div>
<footer>
<a href="{{ env!("CARGO_PKG_REPOSITORY") }}" target="_blank">RustiCal {{ env!("CARGO_PKG_VERSION") }}</a>
<a href="/frontend/assets/licenses.html" target="_blank">Open Source Licenses</a>
</footer>
</body>
<footer>
<a href="{{ env!("CARGO_PKG_REPOSITORY") }}" target="_blank">RustiCal {{ env!("CARGO_PKG_VERSION") }}</a>
<a href="/frontend/assets/licenses.html" target="_blank">Open Source Licenses</a>
</footer>
</html>

View File

@@ -1,19 +1,25 @@
{% extends "layouts/default.html" %}
{% 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/edit-calendar-form.mjs" async></script>
<script type="module" src="/frontend/assets/js/create-addressbook-form.mjs" async></script>
<script type="module" src="/frontend/assets/js/edit-addressbook-form.mjs" async></script>
<script type="module" src="/frontend/assets/js/delete-button.mjs" async></script>
{% endblock %}
{% block header_center %}
<nav class="header-center">
<a href="/frontend/user/{{ user.id }}" {% if S::name() == "profile" %}class="active"{% endif %}>{% include "icons/user.svg" %}Profile</a>
<a href="/frontend/user/{{ user.id }}/calendar" {% if S::name() == "calendars" %}class="active"{% endif %}>{% include "icons/calendar.svg" %}Calendars</a>
<a href="/frontend/user/{{ user.id }}/addressbook" {% if S::name() == "addressbooks" %}class="active"{% endif %}>{% include "icons/group.svg" %}Addressbooks</a>
</nav>
{% endblock %}
{% block content %}
<div id="page-user">
<h1>Welcome {{ user.id }}!</h1>
{% include "components/profile_section.html" %}
{% include "components/calendars_section.html" %}
{% include "components/addressbooks_section.html" %}
{{ section|safe }}
{% endblock %}

View File

@@ -8,6 +8,7 @@ use axum::{
};
use headers::{ContentType, HeaderMapExt};
use http::{Method, StatusCode};
use routes::{addressbooks::route_addressbooks, calendars::route_calendars};
use rustical_oidc::{OidcConfig, OidcServiceConfig, route_get_oidc_callback, route_post_oidc};
use rustical_store::{
AddressbookStore, CalendarStore,
@@ -20,6 +21,7 @@ mod assets;
mod config;
pub mod nextcloud_login;
mod oidc_user_store;
pub(crate) mod pages;
mod routes;
pub use config::FrontendConfig;
@@ -56,6 +58,7 @@ pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: Addres
post(route_delete_app_token::<AP>),
)
// Calendar
.route("/user/{user}/calendar", get(route_calendars::<CS>))
.route(
"/user/{user}/calendar/{calendar}",
get(route_calendar::<CS>),
@@ -65,6 +68,7 @@ pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: Addres
post(route_calendar_restore::<CS>),
)
// Addressbook
.route("/user/{user}/addressbook", get(route_addressbooks::<AS>))
.route(
"/user/{user}/addressbook/{addressbook}",
get(route_addressbook::<AS>),
@@ -137,15 +141,14 @@ async fn unauthorized_handler(mut request: Request, next: Next) -> Response {
return resp
.body(Body::new(format!(
r#"<!Doctype html>
<html>
<head>
<meta http-equiv="refresh" content="1; url={login_url}" />
</head>
<body>
Unauthorized, redirecting to <a href="{login_url}">login page</a>
</body>
<html>
"#,
<html>
<head>
<meta http-equiv="refresh" content="1; url={login_url}" />
</head>
<body>
Unauthorized, redirecting to <a href="{login_url}">login page</a>
</body>
</html>"#,
)))
.unwrap();
}

View File

@@ -0,0 +1 @@
pub mod user;

View File

@@ -0,0 +1,14 @@
use askama::Template;
use askama_web::WebTemplate;
use rustical_store::auth::Principal;
pub trait Section: Template {
fn name() -> &'static str;
}
#[derive(Template, WebTemplate)]
#[template(path = "pages/user.html")]
pub struct UserPage<S: Section> {
pub user: Principal,
pub section: S,
}

View File

@@ -0,0 +1,75 @@
use std::sync::Arc;
use askama::Template;
use askama_web::WebTemplate;
use axum::{Extension, extract::Path, response::IntoResponse};
use http::StatusCode;
use rustical_store::{Addressbook, AddressbookStore, CollectionMetadata, auth::Principal};
use crate::pages::user::{Section, UserPage};
impl Section for AddressbooksSection {
fn name() -> &'static str {
"addressbooks"
}
}
#[derive(Template, WebTemplate)]
#[template(path = "components/sections/addressbooks_section.html")]
pub struct AddressbooksSection {
pub user: Principal,
pub addressbooks: Vec<(CollectionMetadata, Addressbook)>,
pub deleted_addressbooks: Vec<(CollectionMetadata, Addressbook)>,
}
pub async fn route_addressbooks<AS: AddressbookStore>(
Path(user_id): Path<String>,
Extension(addr_store): Extension<Arc<AS>>,
user: Principal,
) -> impl IntoResponse {
if user_id != user.id {
return StatusCode::UNAUTHORIZED.into_response();
}
let mut addressbooks = vec![];
for group in user.memberships() {
addressbooks.extend(addr_store.get_addressbooks(group).await.unwrap());
}
let mut deleted_addressbooks = vec![];
for group in user.memberships() {
deleted_addressbooks.extend(addr_store.get_deleted_addressbooks(group).await.unwrap());
}
let mut addressbook_infos = vec![];
for addressbook in addressbooks {
addressbook_infos.push((
addr_store
.addressbook_metadata(&addressbook.principal, &addressbook.id)
.await
.unwrap(),
addressbook,
));
}
let mut deleted_addressbook_infos = vec![];
for addressbook in deleted_addressbooks {
deleted_addressbook_infos.push((
addr_store
.addressbook_metadata(&addressbook.principal, &addressbook.id)
.await
.unwrap(),
addressbook,
));
}
UserPage {
section: AddressbooksSection {
user: user.clone(),
addressbooks: addressbook_infos,
deleted_addressbooks: deleted_addressbook_infos,
},
user,
}
.into_response()
}

View File

@@ -64,7 +64,7 @@ pub async fn route_post_app_token<AP: AuthenticationProvider>(
token_name: name,
account_description: format!("{}@{}", &user.id, &hostname),
hostname: hostname.clone(),
caldav_principal_url: format!("https://{hostname}/caldav/principal/{user_id}"),
caldav_principal_url: format!("https://{hostname}/caldav-compat/principal/{user_id}"),
carddav_principal_url: format!("https://{hostname}/carddav/principal/{user_id}"),
user: user.id.to_owned(),
token,
@@ -79,13 +79,12 @@ pub async fn route_post_app_token<AP: AuthenticationProvider>(
hdrs.typed_insert(
ContentType::from_str("application/x-apple-aspen-config; charset=utf-8").unwrap(),
);
let filename = format!("rustical-{}.mobileconfig", user_id);
let filename = format!("rustical-{user_id}.mobileconfig");
let filename = utf8_percent_encode(&filename, CONTROLS);
hdrs.insert(
header::CONTENT_DISPOSITION,
HeaderValue::from_str(&format!(
"attachement; filename*=UTF-8''{} filename={}",
filename, filename
"attachement; filename*=UTF-8''{filename} filename={filename}",
))
.unwrap(),
);

View File

@@ -0,0 +1,74 @@
use std::sync::Arc;
use crate::pages::user::{Section, UserPage};
use askama::Template;
use askama_web::WebTemplate;
use axum::{Extension, extract::Path, response::IntoResponse};
use http::StatusCode;
use rustical_store::{Calendar, CalendarStore, CollectionMetadata, auth::Principal};
impl Section for CalendarsSection {
fn name() -> &'static str {
"calendars"
}
}
#[derive(Template, WebTemplate)]
#[template(path = "components/sections/calendars_section.html")]
pub struct CalendarsSection {
pub user: Principal,
pub calendars: Vec<(CollectionMetadata, Calendar)>,
pub deleted_calendars: Vec<(CollectionMetadata, Calendar)>,
}
pub async fn route_calendars<CS: CalendarStore>(
Path(user_id): Path<String>,
Extension(cal_store): Extension<Arc<CS>>,
user: Principal,
) -> impl IntoResponse {
if user_id != user.id {
return StatusCode::UNAUTHORIZED.into_response();
}
let mut calendars = vec![];
for group in user.memberships() {
calendars.extend(cal_store.get_calendars(group).await.unwrap());
}
let mut calendar_infos = vec![];
for calendar in calendars {
calendar_infos.push((
cal_store
.calendar_metadata(&calendar.principal, &calendar.id)
.await
.unwrap(),
calendar,
));
}
let mut deleted_calendars = vec![];
for group in user.memberships() {
deleted_calendars.extend(cal_store.get_deleted_calendars(group).await.unwrap());
}
let mut deleted_calendar_infos = vec![];
for calendar in deleted_calendars {
deleted_calendar_infos.push((
cal_store
.calendar_metadata(&calendar.principal, &calendar.id)
.await
.unwrap(),
calendar,
));
}
UserPage {
section: CalendarsSection {
user: user.clone(),
calendars: calendar_infos,
deleted_calendars: deleted_calendar_infos,
},
user,
}
.into_response()
}

View File

@@ -1,5 +1,7 @@
pub mod addressbook;
pub mod addressbooks;
pub mod app_token;
pub mod calendar;
pub mod calendars;
pub mod login;
pub mod user;

View File

@@ -11,19 +11,23 @@ use axum_extra::{TypedHeader, extract::Host};
use headers::UserAgent;
use http::StatusCode;
use rustical_store::{
Addressbook, AddressbookStore, Calendar, CalendarStore,
AddressbookStore, CalendarStore,
auth::{AppToken, AuthenticationProvider, Principal},
};
use crate::pages::user::{Section, UserPage};
impl Section for ProfileSection {
fn name() -> &'static str {
"profile"
}
}
#[derive(Template, WebTemplate)]
#[template(path = "pages/user.html")]
pub struct UserPage {
#[template(path = "components/sections/profile_section.html")]
pub struct ProfileSection {
pub user: Principal,
pub app_tokens: Vec<AppToken>,
pub calendars: Vec<Calendar>,
pub deleted_calendars: Vec<Calendar>,
pub addressbooks: Vec<Addressbook>,
pub deleted_addressbooks: Vec<Addressbook>,
pub is_apple: bool,
pub davx5_hostname: Option<String>,
}
@@ -69,14 +73,13 @@ pub async fn route_user_named<
let davx5_hostname = user_agent.as_str().contains("Android").then_some(host);
UserPage {
app_tokens: auth_provider.get_app_tokens(&user.id).await.unwrap(),
calendars,
deleted_calendars,
addressbooks,
deleted_addressbooks,
section: ProfileSection {
user: user.clone(),
app_tokens: auth_provider.get_app_tokens(&user.id).await.unwrap(),
is_apple,
davx5_hostname,
},
user,
is_apple,
davx5_hostname,
}
.into_response()
}

View File

@@ -15,8 +15,11 @@ use std::{collections::HashMap, io::BufReader};
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Display)]
// specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6
pub enum CalendarObjectType {
#[serde(rename = "VEVENT")]
Event = 0,
#[serde(rename = "VTODO")]
Todo = 1,
#[serde(rename = "VJOURNAL")]
Journal = 2,
}

View File

@@ -1,4 +1,4 @@
use crate::{Error, addressbook::Addressbook};
use crate::{CollectionMetadata, Error, addressbook::Addressbook};
use async_trait::async_trait;
use rustical_ical::AddressObject;
@@ -35,6 +35,12 @@ pub trait AddressbookStore: Send + Sync + 'static {
synctoken: i64,
) -> Result<(Vec<AddressObject>, Vec<String>, i64), Error>;
async fn addressbook_metadata(
&self,
principal: &str,
addressbook_id: &str,
) -> Result<CollectionMetadata, Error>;
async fn get_objects(
&self,
principal: &str,

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
use crate::{Calendar, error::Error};
use crate::{Calendar, CollectionMetadata, error::Error};
use async_trait::async_trait;
use chrono::NaiveDate;
use rustical_ical::CalendarObject;
@@ -53,6 +53,12 @@ pub trait CalendarStore: Send + Sync + 'static {
self.get_objects(principal, cal_id).await
}
async fn calendar_metadata(
&self,
principal: &str,
cal_id: &str,
) -> Result<CollectionMetadata, Error>;
async fn get_objects(
&self,
principal: &str,

View File

@@ -135,6 +135,20 @@ impl<CS: CalendarStore, BS: CalendarStore> CalendarStore for CombinedCalendarSto
}
}
#[inline]
async fn calendar_metadata(
&self,
principal: &str,
cal_id: &str,
) -> Result<crate::CollectionMetadata, Error> {
if cal_id.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store
.calendar_metadata(principal, cal_id)
.await
} else {
self.cal_store.calendar_metadata(principal, cal_id).await
}
}
#[inline]
async fn get_objects(
&self,

View File

@@ -16,7 +16,7 @@ fn birthday_calendar(addressbook: Addressbook) -> Calendar {
id: format!("{}{}", BIRTHDAYS_PREFIX, addressbook.id),
displayname: addressbook
.displayname
.map(|name| format!("{} birthdays", name)),
.map(|name| format!("{name} birthdays")),
order: 0,
description: None,
color: None,
@@ -104,6 +104,17 @@ impl<AS: AddressbookStore> CalendarStore for ContactBirthdayStore<AS> {
Ok((objects, deleted_objects, new_synctoken))
}
async fn calendar_metadata(
&self,
principal: &str,
cal_id: &str,
) -> Result<crate::CollectionMetadata, Error> {
let cal_id = cal_id
.strip_prefix(BIRTHDAYS_PREFIX)
.ok_or(Error::NotFound)?;
self.0.addressbook_metadata(principal, cal_id).await
}
async fn get_objects(
&self,
principal: &str,

View File

@@ -37,3 +37,11 @@ pub struct CollectionOperation {
pub topic: String,
pub data: CollectionOperationInfo,
}
#[derive(Default, Debug, Clone)]
pub struct CollectionMetadata {
pub len: usize,
pub deleted_len: usize,
pub size: u64,
pub deleted_size: u64,
}

View File

@@ -1,7 +1,7 @@
const SYNC_NAMESPACE: &str = "github.com/lennart-k/rustical/ns/";
pub fn format_synctoken(synctoken: i64) -> String {
format!("{}{}", SYNC_NAMESPACE, synctoken)
format!("{SYNC_NAMESPACE}{synctoken}")
}
pub fn parse_synctoken(synctoken: &str) -> Option<i64> {

View File

@@ -3,8 +3,8 @@ use async_trait::async_trait;
use derive_more::derive::Constructor;
use rustical_ical::AddressObject;
use rustical_store::{
Addressbook, AddressbookStore, CollectionOperation, CollectionOperationInfo, Error,
synctoken::format_synctoken,
Addressbook, AddressbookStore, CollectionMetadata, CollectionOperation,
CollectionOperationInfo, Error, synctoken::format_synctoken,
};
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc::Sender;
@@ -223,6 +223,28 @@ impl SqliteAddressbookStore {
Ok((objects, deleted_objects, new_synctoken))
}
async fn _list_objects<'e, E: Executor<'e, Database = Sqlite>>(
executor: E,
principal: &str,
addressbook_id: &str,
) -> Result<Vec<(u64, bool)>, rustical_store::Error> {
struct ObjectEntry {
length: u64,
deleted: bool,
}
Ok(sqlx::query_as!(
ObjectEntry,
"SELECT length(vcf) AS 'length!: u64', deleted_at AS 'deleted!: bool' FROM addressobjects WHERE principal = ? AND addressbook_id = ?",
principal,
addressbook_id
)
.fetch_all(executor)
.await.map_err(crate::Error::from)?
.into_iter()
.map(|row| (row.length, row.deleted))
.collect())
}
async fn _get_objects<'e, E: Executor<'e, Database = Sqlite>>(
executor: E,
principal: &str,
@@ -442,6 +464,29 @@ impl AddressbookStore for SqliteAddressbookStore {
Self::_sync_changes(&self.db, principal, addressbook_id, synctoken).await
}
#[instrument]
async fn addressbook_metadata(
&self,
principal: &str,
addressbook_id: &str,
) -> Result<CollectionMetadata, rustical_store::Error> {
let mut sizes = vec![];
let mut deleted_sizes = vec![];
for (size, deleted) in Self::_list_objects(&self.db, principal, addressbook_id).await? {
if deleted {
deleted_sizes.push(size)
} else {
sizes.push(size)
}
}
Ok(CollectionMetadata {
len: sizes.len(),
deleted_len: deleted_sizes.len(),
size: sizes.iter().sum(),
deleted_size: deleted_sizes.iter().sum(),
})
}
#[instrument]
async fn get_objects(
&self,

View File

@@ -5,7 +5,7 @@ use derive_more::derive::Constructor;
use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectType};
use rustical_store::calendar_store::CalendarQuery;
use rustical_store::synctoken::format_synctoken;
use rustical_store::{Calendar, CalendarStore, Error};
use rustical_store::{Calendar, CalendarStore, CollectionMetadata, Error};
use rustical_store::{CollectionOperation, CollectionOperationInfo};
use sqlx::types::chrono::NaiveDateTime;
use sqlx::{Acquire, Executor, Sqlite, SqlitePool, Transaction};
@@ -242,6 +242,28 @@ impl SqliteCalendarStore {
Ok(())
}
async fn _list_objects<'e, E: Executor<'e, Database = Sqlite>>(
executor: E,
principal: &str,
cal_id: &str,
) -> Result<Vec<(u64, bool)>, rustical_store::Error> {
struct ObjectEntry {
length: u64,
deleted: bool,
}
Ok(sqlx::query_as!(
ObjectEntry,
"SELECT length(ics) AS 'length!: u64', deleted_at AS 'deleted!: bool' FROM calendarobjects WHERE principal = ? AND cal_id = ?",
principal,
cal_id
)
.fetch_all(executor)
.await.map_err(crate::Error::from)?
.into_iter()
.map(|row| (row.length, row.deleted))
.collect())
}
async fn _get_objects<'e, E: Executor<'e, Database = Sqlite>>(
executor: E,
principal: &str,
@@ -552,6 +574,28 @@ impl CalendarStore for SqliteCalendarStore {
Self::_calendar_query(&self.db, principal, cal_id, query).await
}
async fn calendar_metadata(
&self,
principal: &str,
cal_id: &str,
) -> Result<CollectionMetadata, Error> {
let mut sizes = vec![];
let mut deleted_sizes = vec![];
for (size, deleted) in Self::_list_objects(&self.db, principal, cal_id).await? {
if deleted {
deleted_sizes.push(size)
} else {
sizes.push(size)
}
}
Ok(CollectionMetadata {
len: sizes.len(),
deleted_len: deleted_sizes.len(),
size: sizes.iter().sum(),
deleted_size: deleted_sizes.iter().sum(),
})
}
#[instrument]
async fn get_objects(
&self,

View File

@@ -114,9 +114,11 @@ impl AuthenticationProvider for SqlitePrincipalStore {
let password = user.password.map(Secret::into_inner);
sqlx::query!(
r#"
REPLACE INTO principals
(id, displayname, principal_type, password_hash)
VALUES (?, ?, ?, ?)
INSERT INTO principals
(id, displayname, principal_type, password_hash) VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
(displayname, principal_type, password_hash)
= (excluded.displayname, excluded.principal_type, excluded.password_hash)
"#,
user.id,
user.displayname,
@@ -204,7 +206,7 @@ impl AuthenticationProvider for SqlitePrincipalStore {
None,
None,
Params {
rounds: 100,
rounds: 10,
..Default::default()
},
&salt,

View File

@@ -16,8 +16,8 @@ impl Enum {
quote! {
impl #impl_generics ::rustical_xml::XmlDeserialize for #name #type_generics #where_clause {
fn deserialize<R: ::std::io::BufRead>(
reader: &mut quick_xml::NsReader<R>,
start: &quick_xml::events::BytesStart,
reader: &mut ::quick_xml::NsReader<R>,
start: &::quick_xml::events::BytesStart,
empty: bool
) -> Result<Self, rustical_xml::XmlError> {
#(#variant_branches);*
@@ -37,8 +37,8 @@ impl Enum {
quote! {
impl #impl_generics ::rustical_xml::XmlDeserialize for #name #type_generics #where_clause {
fn deserialize<R: std::io::BufRead>(
reader: &mut quick_xml::NsReader<R>,
start: &quick_xml::events::BytesStart,
reader: &mut ::quick_xml::NsReader<R>,
start: &::quick_xml::events::BytesStart,
empty: bool
) -> Result<Self, rustical_xml::XmlError> {
let (_ns, name) = reader.resolve_element(start.name());

View File

@@ -13,12 +13,12 @@ impl Enum {
quote! {
impl #impl_generics ::rustical_xml::XmlSerialize for #ident #type_generics #where_clause {
fn serialize<W: ::std::io::Write>(
fn serialize(
&self,
ns: Option<::quick_xml::name::Namespace>,
tag: Option<&[u8]>,
namespaces: &::std::collections::HashMap<::quick_xml::name::Namespace, &[u8]>,
writer: &mut ::quick_xml::Writer<W>
writer: &mut ::quick_xml::Writer<&mut Vec<u8>>
) -> ::std::io::Result<()> {
use ::quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};

View File

@@ -88,12 +88,12 @@ impl NamedStruct {
quote! {
impl #impl_generics ::rustical_xml::XmlSerialize for #ident #type_generics #where_clause {
fn serialize<W: ::std::io::Write>(
fn serialize(
&self,
ns: Option<::quick_xml::name::Namespace>,
tag: Option<&[u8]>,
namespaces: &::std::collections::HashMap<::quick_xml::name::Namespace, &[u8]>,
writer: &mut ::quick_xml::Writer<W>
writer: &mut ::quick_xml::Writer<&mut Vec<u8>>
) -> ::std::io::Result<()> {
use ::quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};

View File

@@ -118,8 +118,8 @@ impl NamedStruct {
quote! {
impl #impl_generics ::rustical_xml::XmlDeserialize for #ident #type_generics #where_clause {
fn deserialize<R: ::std::io::BufRead>(
reader: &mut quick_xml::NsReader<R>,
start: &quick_xml::events::BytesStart,
reader: &mut ::quick_xml::NsReader<R>,
start: &::quick_xml::events::BytesStart,
empty: bool
) -> Result<Self, rustical_xml::XmlError> {
use quick_xml::events::Event;

View File

@@ -7,24 +7,24 @@ use std::collections::HashMap;
pub use xml_derive::XmlSerialize;
pub trait XmlSerialize {
fn serialize<W: std::io::Write>(
fn serialize(
&self,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut quick_xml::Writer<W>,
writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()>;
fn attributes<'a>(&self) -> Option<impl IntoIterator<Item: Into<Attribute<'a>>>>;
fn attributes<'a>(&self) -> Option<Vec<Attribute<'a>>>;
}
impl<T: XmlSerialize> XmlSerialize for Option<T> {
fn serialize<W: std::io::Write>(
fn serialize(
&self,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut quick_xml::Writer<W>,
writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> {
if let Some(some) = self {
some.serialize(ns, tag, namespaces, writer)
@@ -33,43 +33,36 @@ impl<T: XmlSerialize> XmlSerialize for Option<T> {
}
}
#[allow(refining_impl_trait)]
fn attributes<'a>(&self) -> Option<Vec<Attribute<'a>>> {
None
}
}
pub trait XmlSerializeRoot {
fn serialize_root<W: std::io::Write>(
&self,
writer: &mut quick_xml::Writer<W>,
) -> std::io::Result<()>;
fn serialize_root(&self, writer: &mut quick_xml::Writer<&mut Vec<u8>>) -> std::io::Result<()>;
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 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)?;
Ok(String::from_utf8_lossy(&buf).to_string())
}
}
impl<T: XmlSerialize + XmlRootTag> XmlSerializeRoot for T {
fn serialize_root<W: std::io::Write>(
&self,
writer: &mut quick_xml::Writer<W>,
) -> std::io::Result<()> {
fn serialize_root(&self, writer: &mut quick_xml::Writer<&mut Vec<u8>>) -> std::io::Result<()> {
let namespaces = Self::root_ns_prefixes();
self.serialize(Self::root_ns(), Some(Self::root_tag()), &namespaces, writer)
}
}
impl XmlSerialize for () {
fn serialize<W: std::io::Write>(
fn serialize(
&self,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut quick_xml::Writer<W>,
writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> {
let prefix = ns
.map(|ns| namespaces.get(&ns))
@@ -96,7 +89,6 @@ impl XmlSerialize for () {
Ok(())
}
#[allow(refining_impl_trait)]
fn attributes<'a>(&self) -> Option<Vec<quick_xml::events::attributes::Attribute<'a>>> {
None
}

View File

@@ -104,12 +104,12 @@ impl<T: ValueDeserialize> XmlDeserialize for T {
}
impl<T: ValueSerialize> XmlSerialize for T {
fn serialize<W: std::io::Write>(
fn serialize(
&self,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut quick_xml::Writer<W>,
writer: &mut quick_xml::Writer<&mut Vec<u8>>,
) -> std::io::Result<()> {
let prefix = ns
.map(|ns| namespaces.get(&ns))
@@ -140,7 +140,6 @@ impl<T: ValueSerialize> XmlSerialize for T {
Ok(())
}
#[allow(refining_impl_trait)]
fn attributes<'a>(&self) -> Option<Vec<quick_xml::events::attributes::Attribute<'a>>> {
None
}

View File

@@ -22,6 +22,11 @@ fn test_struct_value_tagged() {
.unwrap();
assert_eq!(
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();
assert_eq!(
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();
assert_eq!(
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();
assert_eq!(
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>"#
);
}
@@ -124,12 +133,12 @@ fn test_struct_serialize_with() {
href: String,
}
fn serialize_href<W: ::std::io::Write>(
fn serialize_href(
val: &str,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut Writer<W>,
writer: &mut Writer<&mut Vec<u8>>,
) -> std::io::Result<()> {
val.to_uppercase().serialize(ns, tag, namespaces, writer)
}
@@ -141,7 +150,10 @@ fn test_struct_serialize_with() {
.unwrap();
assert_eq!(
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>"#
);
}

View File

@@ -12,13 +12,14 @@ a CalDAV/CardDAV server
- easy to backup, everything saved in one SQLite database
- also export feature in the frontend
- [WebDAV Push](https://github.com/bitfireAT/webdav-push/) support, so near-instant synchronisation to DAVx5
- **[WebDAV Push](https://github.com/bitfireAT/webdav-push/)** support, so near-instant synchronisation to DAVx5
- lightweight (the container image contains only one binary)
- adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks)
- deleted calendars are recoverable
- 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)
- [OpenID Connect](setup/oidc.md) support (with option to disable password login)
- **[OpenID Connect](setup/oidc.md)** support (with option to disable password login)
- Group-based **sharing**
## Tested Clients

View File

@@ -16,6 +16,9 @@ docker run \
1. Mount config file
2. Alternatively specify configuration using environment variables
!!! info
Note that you are expected to run RustiCal behind a reverse proxy with HTTPS. (The frontend will only work on non-localhost addresses with https) and clients like Apple Calendar also expect HTTPS.
## 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.

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

@@ -0,0 +1,80 @@
# 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>
# CalDAV root
/caldav-compat
/caldav-compat/principal...
```
```
/.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.
## `/caldav` vs `/caldav-compat` (relevant for group sharing)
To discover shared calendars the `calendar-home-set` property is used to list all principals the user has access to.
However, some clients don't support `calendar-home-set` containing multiple paths (e.g. Apple Calendar).
As a workaround `/caldav-compat` offers the same endpoints as `/caldav` with the only difference being that it does not return all calendar homes in `calendar-home-set`.
This means that clients under this path will probably not auto-discover group calendars so you can instead add them one-by-one using the principal path `/caldav-compat/principal/<principal_id>`.
## 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.
**Note**: Since Apple Calendar does not properly support the `calendar-home-set` property the `/caldav-compat` endpoints should be used.
That also means that Apple Calendar is not able to automatically discover group collections so in that case you'll have to manually add all principals with `/caldav-compat/principal/<principal_id>`.
## Evolution
Set up a collection account in the account settings.
Evolution correctly uses all calendar homes so group collections work properly.
## Home Assistant CalDAV integration
The underlying library `python-caldav` does not support multiple calendar homes so you should use the `/caldav-compat` endpoints.
As URL specify
```
https://<your-host>/caldav-compat
```
For group collections explicitly specify
```
https://<your-host>/caldav-compat/principal/<principal>
```
## Thunderbird
- Go to `New Account -> Calendar -> On The Network`
- Specify the root path of RustiCal
- Thunderbird will properly discover group calendars

View File

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

View File

@@ -2,8 +2,9 @@ use crate::config::NextcloudLoginConfig;
use axum::Router;
use axum::body::Body;
use axum::extract::Request;
use axum::response::Response;
use axum::routing::options;
use axum::response::{Redirect, Response};
use axum::routing::{any, options};
use axum_extra::TypedHeader;
use headers::{HeaderMapExt, UserAgent};
use http::{HeaderValue, StatusCode};
use rustical_caldav::caldav_router;
@@ -47,7 +48,29 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
auth_provider.clone(),
combined_cal_store.clone(),
subscription_store.clone(),
false,
))
.merge(caldav_router(
"/caldav-compat",
auth_provider.clone(),
combined_cal_store.clone(),
subscription_store.clone(),
true,
))
.route(
"/.well-known/caldav",
any(async |TypedHeader(ua): TypedHeader<UserAgent>| {
if ua.as_str().contains("remindd") || ua.as_str().contains("dataaccessd") {
// remindd is an Apple Calendar User Agent
// Even when explicitly configuring a principal URL in Apple Calendar Apple
// will not respect that configuration but call /.well-known/caldav,
// so sadly we have to do this user-agent filtering. :(
// (I should have never gotten an Apple device)
return Redirect::permanent("/caldav-compat");
}
Redirect::permanent("/caldav")
}),
)
.merge(carddav_router(
"/carddav",
auth_provider.clone(),

View File

@@ -1,12 +1,8 @@
use argon2::password_hash::SaltString;
use clap::{Parser, ValueEnum};
use password_hash::{PasswordHasher, rand_core::OsRng};
use pbkdf2::Params;
use rustical_frontend::FrontendConfig;
use crate::config::{
Config, DataStoreConfig, DavPushConfig, HttpConfig, SqliteDataStoreConfig, TracingConfig,
};
use clap::Parser;
use rustical_frontend::FrontendConfig;
mod membership;
pub mod principals;
@@ -33,49 +29,3 @@ pub fn cmd_gen_config(_args: GenConfigArgs) -> anyhow::Result<()> {
println!("{generated_config}");
Ok(())
}
#[derive(Debug, Clone, ValueEnum)]
enum PwhashAlgorithm {
#[value(help = "Use this for your password")]
Argon2,
#[value(help = "Significantly faster algorithm, use for app tokens")]
Pbkdf2,
}
#[derive(Debug, Parser)]
pub struct PwhashArgs {
#[arg(long, short = 'a')]
algorithm: PwhashAlgorithm,
#[arg(
long,
short = 'r',
help = "ONLY for pbkdf2: Number of rounds to calculate",
default_value_t = 100
)]
rounds: u32,
}
pub fn cmd_pwhash(args: PwhashArgs) -> anyhow::Result<()> {
println!("Enter your password:");
let password = rpassword::read_password()?;
let salt = SaltString::generate(OsRng);
let password_hash = match args.algorithm {
PwhashAlgorithm::Argon2 => argon2::Argon2::default()
.hash_password(password.as_bytes(), &salt)
.unwrap(),
PwhashAlgorithm::Pbkdf2 => pbkdf2::Pbkdf2
.hash_password_customized(
password.as_bytes(),
None,
None,
Params {
rounds: args.rounds,
..Default::default()
},
&salt,
)
.unwrap(),
};
println!("{password_hash}");
Ok(())
}

View File

@@ -4,8 +4,8 @@ use app::make_app;
use axum::ServiceExt;
use axum::extract::Request;
use clap::{Parser, Subcommand};
use commands::cmd_gen_config;
use commands::principals::{PrincipalsArgs, cmd_principals};
use commands::{cmd_gen_config, cmd_pwhash};
use config::{DataStoreConfig, SqliteDataStoreConfig};
use figment::Figment;
use figment::providers::{Env, Format, Toml};
@@ -43,7 +43,6 @@ struct Args {
#[derive(Debug, Subcommand)]
enum Command {
GenConfig(commands::GenConfigArgs),
Pwhash(commands::PwhashArgs),
Principals(PrincipalsArgs),
}
@@ -84,7 +83,6 @@ async fn main() -> Result<()> {
match args.command {
Some(Command::GenConfig(gen_config_args)) => cmd_gen_config(gen_config_args)?,
Some(Command::Pwhash(pwhash_args)) => cmd_pwhash(pwhash_args)?,
Some(Command::Principals(principals_args)) => cmd_principals(principals_args).await?,
None => {
let config: Config = Figment::new()