Compare commits

..

80 Commits

Author SHA1 Message Date
Lennart
e7138b5f8c version 0.7.0 2025-07-23 21:32:12 +02:00
Lennart
84af24a2b7 frontend: fill id with uuid for creation forms 2025-07-23 21:31:10 +02:00
Lennart
4bd6271e33 Update vtimezones-rs 2025-07-23 21:15:15 +02:00
Lennart
d817c1384c frontend: Add error handling to collection forms 2025-07-23 20:48:28 +02:00
Lennart
f8abc22e63 clippy appeasement 2025-07-23 20:41:06 +02:00
Lennart
b7b5ca4f91 Update dependencies 2025-07-23 20:31:16 +02:00
Lennart
caca2d28ed update vtimezones-rs 2025-07-23 20:23:21 +02:00
Lennart
3db2f13c1b rename vzic-rs to vtimezones-rs 2025-07-23 18:19:23 +02:00
Lennart
db01024682 add comment 2025-07-23 18:08:04 +02:00
Lennart
b2f15f2d77 fix: Add timezone-id support to mkcalendar 2025-07-23 18:04:19 +02:00
Lennart
89dd94904b frontend: Add timezone fields to calendar forms 2025-07-23 17:59:54 +02:00
Lennart
5d0263abc1 caldav: Add vtimezone repository to date timezone with timezone-id 2025-07-23 17:55:55 +02:00
Lennart
0ef3e19bd3 caldav: Fix principal collection permissions 2025-07-23 11:28:14 +02:00
Lennart
44912057fc subscription store: Correctly return whether subscription already existed 2025-07-23 11:09:48 +02:00
Lennart
c4f613a803 Add example compose.yml 2025-07-23 11:05:05 +02:00
Lennart
eb8f301e45 update dependencies 2025-07-22 17:57:24 +02:00
Lennart
d59ae25eba v0.6.5 2025-07-22 16:57:08 +02:00
Lennart
d4daa35df6 auth: Make app token validation faster by supplying hint to the app token name 2025-07-22 16:48:04 +02:00
Lennart
ea43876410 auth: User faster app token hash 2025-07-22 16:10:19 +02:00
Lennart
18af1b9aa2 remove calendar-proxy from DAV header 2025-07-22 15:41:24 +02:00
Lennart
e69c75102c version 0.6.4 2025-07-22 10:55:28 +02:00
Lennart
09f1bd20ae close connection if request body might not have been consumed
hopefully fixes #77
2025-07-22 10:53:12 +02:00
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
106 changed files with 3206 additions and 4095 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"
}

454
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
members = ["crates/*"]
[workspace.package]
version = "0.4.7"
version = "0.7.0"
edition = "2024"
description = "A CalDAV server"
repository = "https://github.com/lennart-k/rustical"
@@ -96,7 +96,7 @@ strum_macros = "0.27"
serde_json = { version = "1.0", features = ["raw_value"] }
sqlx-sqlite = { version = "0.8", features = ["bundled"] }
ical = { version = "0.11", features = ["generator", "serde"] }
toml = "0.8"
toml = "0.9"
tower = "0.5"
tower-http = { version = "0.6", features = [
"trace",
@@ -126,7 +126,7 @@ syn = { version = "2.0", features = ["full"] }
quote = "1.0"
proc-macro2 = "1.0"
heck = "0.5"
darling = "0.20"
darling = "0.21"
reqwest = { version = "0.12", features = [
"rustls-tls",
"charset",
@@ -135,6 +135,7 @@ reqwest = { version = "0.12", features = [
openidconnect = "4.0"
clap = { version = "4.5", features = ["derive", "env"] }
matchit-serde = { git = "https://github.com/lennart-k/matchit-serde", rev = "f0591d13" }
vtimezones-rs = "0.1"
ece = { version = "2.3", default-features = false, features = [
"backend-openssl",
] }

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

22
compose.oidc.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
rustical:
image: ghcr.io/lennart-k/rustical:latest
restart: unless-stopped
environment:
RUSTICAL_FRONTEND__ALLOW_PASSWORD_LOGIN: "false"
RUSTICAL_OIDC__NAME: "Authelia"
RUSTICAL_OIDC__ISSUER: "https://auth.example.com"
RUSTICAL_OIDC__CLIENT_ID: "{{ rustical_oidc_client_id }}"
RUSTICAL_OIDC__CLIENT_SECRET: "{{ rustical_oidc_client_secret }}"
RUSTICAL_OIDC__CLAIM_USERID: "preferred_username"
RUSTICAL_OIDC__SCOPES: '["openid", "profile", "groups"]'
RUSTICAL_OIDC__REQUIRE_GROUP: "app:rustical" # optional
RUSTICAL_OIDC__ALLOW_SIGN_UP: "true"
volumes:
- data:/var/lib/rustical
# Here you probably want to you expose instead
ports:
- 4000:4000
volumes:
data:

View File

@@ -42,3 +42,4 @@ headers.workspace = true
tower-http.workspace = true
strum.workspace = true
strum_macros.workspace = true
vtimezones-rs.workspace = true

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(),
);
if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(ical_calendar.generate())).unwrap())
}
}

View File

@@ -82,12 +82,25 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
request.displayname = None
}
let mut timezone = request.calendar_timezone;
if let Some(tzid) = request.calendar_timezone_id.as_ref() {
// Validate timezone id and set timezone accordingly
timezone = Some(
vtimezones_rs::VTIMEZONES
.get(tzid)
.ok_or(rustical_dav::Error::BadRequest(format!(
"Invalid timezone-id: {tzid}"
)))?
.to_string(),
);
}
let calendar = Calendar {
id: cal_id.to_owned(),
principal: principal.to_owned(),
order: request.calendar_order.unwrap_or(0),
displayname: request.displayname,
timezone: request.calendar_timezone,
timezone,
timezone_id: request.calendar_timezone_id,
color: request.calendar_color,
description: request.calendar_description,

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

@@ -15,7 +15,6 @@ use rustical_store::Calendar;
use rustical_store::auth::Principal;
use rustical_xml::{EnumVariants, PropName};
use rustical_xml::{XmlDeserialize, XmlSerialize};
use std::str::FromStr;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "CalendarPropName")]
@@ -34,7 +33,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),
@@ -193,20 +192,22 @@ impl Resource for CalendarResource {
}
CalendarProp::CalendarTimezone(timezone) => {
// TODO: Ensure that timezone-id is also updated
// We probably want to prohibit non-IANA timezones
self.cal.timezone = timezone;
Ok(())
}
CalendarProp::TimezoneServiceSet(_) => Err(rustical_dav::Error::PropReadOnly),
CalendarProp::CalendarTimezoneId(timezone_id) => {
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
))
})?;
// TODO: Ensure that timezone is also updated (For now hope that clients play nice)
// Validate timezone id and set timezone accordingly
self.cal.timezone = Some(
vtimezones_rs::VTIMEZONES
.get(tzid)
.ok_or(rustical_dav::Error::BadRequest(format!(
"Invalid timezone-id: {tzid}"
)))?
.to_string(),
);
}
self.cal.timezone_id = timezone_id;
Ok(())
@@ -308,3 +309,15 @@ impl Resource for CalendarResource {
))
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_tzdb_version() {
// Ensure that both chrono_tz and vzic_rs use the same tzdb version
assert_eq!(
chrono_tz::IANA_TZDB_VERSION,
vtimezones_rs::IANA_TZDB_VERSION
);
}
}

View File

@@ -51,7 +51,7 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
type Principal = Principal;
type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy, webdav-push";
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, webdav-push";
async fn get_resource(
&self,

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());
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 {
Router::new().nest(
prefix,
RootResourceService::<_, Principal, CalDavPrincipalUri>::new(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())
simplified_home_set,
})
.axum_router()
.layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CalDavPrincipalUri(prefix))),
)
.route(
"/.well-known/caldav",
any(async || Redirect::permanent(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 {
@@ -39,11 +41,6 @@ impl Resource for PrincipalResource {
Resourcetype(&[
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "collection"),
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "principal"),
// https://github.com/apple/ccs-calendarserver/blob/13c706b985fb728b9aab42dc0fef85aae21921c3/doc/Extensions/caldav-proxy.txt
// ResourcetypeInner(
// Some(rustical_dav::namespace::NS_CALENDARSERVER),
// "calendar-proxy-write",
// ),
])
}
@@ -64,9 +61,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())
}
@@ -116,7 +121,7 @@ impl Resource for PrincipalResource {
}
fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_read(
Ok(UserPrivilegeSet::owner_only(
user.is_principal(&self.principal.id),
))
}

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,
}
}
}
@@ -43,7 +46,7 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
type Principal = Principal;
type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy";
const DAV_HEADER: &str = "1, 3, access-control, calendar-access";
async fn get_resource(
&self,
@@ -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());
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(),
);
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,17 +99,17 @@ 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),
}) => {
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()))
}
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()),
@@ -139,9 +138,12 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
}
}
}
}
Operation::Remove(remove_el) => {
let propname = remove_el.prop.0.0;
match <<R::Resource as Resource>::Prop as PropName>::Names::from_str(&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({
@@ -156,6 +158,7 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
}
}
}
}
if props_not_found.is_empty() && props_conflict.is_empty() {
// Only save if no errors occured

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,9 +116,8 @@ 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
{
let (mut props, mut invalid_props): (HashSet<<Self::Prop as PropName>::Names>, Vec<_>) =
match prop {
PropfindType::Propname => {
let props = Self::list_props()
.into_iter()
@@ -146,6 +146,11 @@ pub trait Resource: Clone + Send + 'static {
),
};
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()
.map(|prop| self.get_prop(principal_uri, principal, &prop))

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

@@ -24,7 +24,6 @@ rustical_dav.workspace = true
rustical_store.workspace = true
http.workspace = true
base64.workspace = true
rand.workspace = true
ece.workspace = true
axum.workspace = true
openssl.workspace = true

View File

@@ -1,7 +1,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 {
@@ -14,16 +14,16 @@ export class CreateAddressbookForm extends LitElement {
return this
}
client = createClient("/carddav")
@property()
user: String = ''
user: string = ''
@property()
addr_id: String = ''
principal: string = ''
@property()
displayname: String = ''
addr_id: string = self.crypto.randomUUID()
@property()
description: String = ''
displayname: string = ''
@property()
description: string = ''
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
@@ -34,9 +34,19 @@ 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} />
<input type="text" name="id" value=${this.addr_id} @change=${e => this.addr_id = e.target.value} />
</label>
<br>
<label>
@@ -67,19 +77,29 @@ 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}`, {
data: `
let response = await fetch(`/carddav/principal/${this.principal || this.user}/${this.addr_id}`, {
method: 'MKCOL',
headers: {
'Content-Type': 'application/xml'
},
body: `
<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>
`
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}

View File

@@ -1,7 +1,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 {
@@ -13,36 +13,49 @@ export class CreateCalendarForm extends LitElement {
return this
}
client = createClient("/caldav")
@property()
user: String = ''
user: string = ''
@property()
cal_id: String = ''
principal: string = ''
@property()
displayname: String = ''
cal_id: string = self.crypto.randomUUID()
@property()
description: String = ''
displayname: string = ''
@property()
color: String = ''
description: string = ''
@property()
subscriptionUrl: String = ''
timezone_id: string = ''
@property()
color: string = ''
@property()
isSubscription: boolean = false
@property()
subscriptionUrl: string = ''
@property()
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()}>Create calendar</button>
<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} />
<input type="text" name="id" value=${this.cal_id} @change=${e => this.cal_id = e.target.value} />
</label>
<br>
<label>
@@ -50,6 +63,11 @@ export class CreateCalendarForm extends LitElement {
<input type="text" name="displayname" value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label>
<br>
<label>
Timezone (optional)
<input type="text" name="timezone" .value=${this.timezone_id} @change=${e => this.timezone_id = e.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" @change=${e => this.description = e.target.value} />
@@ -60,16 +78,26 @@ export class CreateCalendarForm extends LitElement {
<input type="color" name="color" @change=${e => this.color = e.target.value} />
</label>
<br>
<br>
<label>
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,23 +122,34 @@ export class CreateCalendarForm extends LitElement {
alert("No calendar components selected")
return
}
await this.client.createDirectory(`/principal/${this.user}/${this.cal_id}`, {
data: `
let response = await fetch(`/caldav/principal/${this.principal || this.user}/${this.cal_id}`, {
method: 'MKCOL',
headers: {
'Content-Type': 'application/xml'
},
body: `
<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.timezone_id ? `<CAL:calendar-timezone-id>${escapeXml(this.timezone_id)}</CAL:calendar-timezone-id>` : ''}
${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>
</mkcol>
`
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}

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,103 @@
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
}
let response = 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>
`
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}
}
declare global {
interface HTMLElementTagNameMap {
'edit-addressbook-form': EditAddressbookForm
}
}

View File

@@ -0,0 +1,142 @@
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()
timezone_id: 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>
Timezone (optional)
<input type="text" name="timezone" .value=${this.timezone_id} @change=${e => this.timezone_id = 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
}
let response = 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.timezone_id ? `<CAL:calendar-timezone-id>${escapeXml(this.timezone_id)}</CAL:calendar-timezone-id>` : ''}
${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>
`
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
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: {
@@ -23,7 +25,7 @@ export default defineConfig({
format: "es",
manualChunks: {
lit: ["lit"],
webdav: ["webdav"],
// webdav: ["webdav"],
}
}
},

View File

@@ -1,7 +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 { a as an } from "./webdav-D0R7xCzX.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) => {
@@ -15,9 +14,9 @@ var __decorateClass = (decorators, target, key, kind) => {
let CreateAddressbookForm = class extends i {
constructor() {
super();
this.client = an("/carddav");
this.user = "";
this.addr_id = "";
this.principal = "";
this.addr_id = self.crypto.randomUUID();
this.displayname = "";
this.description = "";
this.dialog = e();
@@ -32,9 +31,19 @@ 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} />
<input type="text" name="id" value=${this.addr_id} @change=${(e2) => this.addr_id = e2.target.value} />
</label>
<br>
<label>
@@ -68,18 +77,26 @@ let CreateAddressbookForm = class extends i {
alert("Empty displayname");
return;
}
await this.client.createDirectory(`/principal/${this.user}/${this.addr_id}`, {
data: `
let response = await fetch(`/carddav/principal/${this.principal || this.user}/${this.addr_id}`, {
method: "MKCOL",
headers: {
"Content-Type": "application/xml"
},
body: `
<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>
`
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
window.location.reload();
return null;
}
@@ -87,6 +104,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,7 +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 { a as an } from "./webdav-D0R7xCzX.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) => {
@@ -15,12 +14,14 @@ var __decorateClass = (decorators, target, key, kind) => {
let CreateCalendarForm = class extends i {
constructor() {
super();
this.client = an("/caldav");
this.user = "";
this.cal_id = "";
this.principal = "";
this.cal_id = self.crypto.randomUUID();
this.displayname = "";
this.description = "";
this.timezone_id = "";
this.color = "";
this.isSubscription = false;
this.subscriptionUrl = "";
this.components = /* @__PURE__ */ new Set();
this.dialog = e();
@@ -35,9 +36,19 @@ 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} />
<input type="text" name="id" value=${this.cal_id} @change=${(e2) => this.cal_id = e2.target.value} />
</label>
<br>
<label>
@@ -45,6 +56,11 @@ let CreateCalendarForm = class extends i {
<input type="text" name="displayname" value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} />
</label>
<br>
<label>
Timezone (optional)
<input type="text" name="timezone" .value=${this.timezone_id} @change=${(e2) => this.timezone_id = e2.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" @change=${(e2) => this.description = e2.target.value} />
@@ -55,16 +71,26 @@ let CreateCalendarForm = class extends i {
<input type="color" name="color" @change=${(e2) => this.color = e2.target.value} />
</label>
<br>
<br>
<label>
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,23 +118,32 @@ let CreateCalendarForm = class extends i {
alert("No calendar components selected");
return;
}
await this.client.createDirectory(`/principal/${this.user}/${this.cal_id}`, {
data: `
let response = await fetch(`/caldav/principal/${this.principal || this.user}/${this.cal_id}`, {
method: "MKCOL",
headers: {
"Content-Type": "application/xml"
},
body: `
<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.timezone_id ? `<CAL:calendar-timezone-id>${escapeXml(this.timezone_id)}</CAL:calendar-timezone-id>` : ""}
${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>
</mkcol>
`
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
window.location.reload();
return null;
}
@@ -116,6 +151,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);
@@ -125,9 +163,15 @@ __decorateClass([
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "description", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "timezone_id", 2);
__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,113 @@
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;
}
let response = 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>
`
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
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,156 @@
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.timezone_id = "";
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>
Timezone (optional)
<input type="text" name="timezone" .value=${this.timezone_id} @change=${(e2) => this.timezone_id = 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;
}
let response = 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.timezone_id ? `<CAL:calendar-timezone-id>${escapeXml(this.timezone_id)}</CAL:calendar-timezone-id>` : ""}
${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>
`
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
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, "timezone_id", 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
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,37 +200,53 @@ table {
}
}
#page-user {
ul {
ul.collection-list {
padding-left: 0;
li.collection-list-item {
list-style: none;
display: contents;
display: block;
position: relative;
background: color-mix(in srgb, var(--background-color), var(--dilute-color) 5%);
border: 2px solid var(--border-color);
border-radius: 12px;
margin: 12px 0;
box-shadow: 4px 2px 12px -6px black;
overflow: hidden;
a {
background: #EEE;
position: absolute;
inset: 2px;
}
.inner {
display: grid;
min-height: 80px;
height: fit-content;
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;
". 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;
border: 2px solid black;
border-radius: 12px;
margin: 12px;
box-shadow: 4px 2px 12px -6px black;
overflow: hidden;
position: relative;
z-index: 1;
pointer-events: none;
a,
button {
pointer-events: all;
cursor: pointer;
}
.title {
font-weight: bold;
@@ -195,7 +260,7 @@ table {
}
.comps {
grid-area: comps;
display: inline;
span {
margin: 0 2px;
@@ -212,6 +277,11 @@ table {
white-space: nowrap;
}
.metadata {
grid-area: metadata;
white-space: nowrap;
}
.subscription-url {
grid-area: subscription-url;
white-space: nowrap;
@@ -223,16 +293,16 @@ table {
}
.actions {
pointer-events: all;
grid-area: actions;
width: fit-content;
display: flex;
gap: 12px;
}
}
&:hover {
background: #DDD;
}
}
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,87 @@
<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 }}"
timezone_id="{{ calendar.timezone_id.as_deref().unwrap_or_default() }}"
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>
</body>
<footer>
<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>
</footer>
</body>
</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>
<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>"#,
)))
.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

@@ -56,15 +56,19 @@ pub async fn route_post_app_token<AP: AuthenticationProvider>(
assert!(!name.is_empty());
assert_eq!(user_id, user.id);
let token = generate_app_token();
auth_provider
let mut token_id = auth_provider
.add_app_token(&user.id, name.to_owned(), token.clone())
.await?;
// Get first 4 characters of token identifier
token_id.truncate(4);
// This will be a hint for the token validator which app token hash to verify against
let token = format!("{token_id}_{token}");
if apple {
let profile = AppleConfig {
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 +83,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 {
section: ProfileSection {
user: user.clone(),
app_tokens: auth_provider.get_app_tokens(&user.id).await.unwrap(),
calendars,
deleted_calendars,
addressbooks,
deleted_addressbooks,
user,
is_apple,
davx5_hostname,
},
user,
}
.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

@@ -208,7 +208,7 @@ impl CalDateTime {
matches!(&self, Self::Date(_, _))
}
pub fn as_datetime(&self) -> Cow<DateTime<CalTimezone>> {
pub fn as_datetime(&self) -> Cow<'_, DateTime<CalTimezone>> {
match self {
Self::DateTime(datetime) => Cow::Borrowed(datetime),
Self::Date(date, tz) => Cow::Owned(

View File

@@ -22,7 +22,6 @@ chrono-tz = { workspace = true }
derive_more = { workspace = true, features = ["as_ref"] }
rustical_xml.workspace = true
tokio.workspace = true
rand.workspace = true
clap.workspace = true
rustical_dav.workspace = true
rustical_ical.workspace = true

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,
@@ -147,8 +149,23 @@ impl AuthenticationProvider for SqlitePrincipalStore {
user_id: &str,
token: &str,
) -> Result<Option<Principal>, Error> {
#[instrument(skip(password))]
fn verify_password(password: &str, hash: &str) -> Result<(), password_auth::VerifyError> {
password_auth::verify_password(password, hash)
}
// Allow to specify the token id to use to make validation faster
// Doesn't match the whole length of the token id to keep the length in bounds
// Example: asd_selgkh
// where the app token id starts with asd and its value is selgkh
let (token_id_prefix, token) = token.split_once('_').unwrap_or(("", token));
for app_token in &self.get_app_tokens(user_id).await? {
if password_auth::verify_password(token, app_token.token.as_ref()).is_ok() {
// Wrong token id
if !app_token.id.starts_with(token_id_prefix) {
continue;
}
if verify_password(token, app_token.token.as_ref()).is_ok() {
return self.get_principal(user_id).await;
}
}
@@ -204,7 +221,10 @@ impl AuthenticationProvider for SqlitePrincipalStore {
None,
None,
Params {
rounds: 100,
// The app token has a high entropy so we are quite safe from quessing attacks
// Also if an attacker got access to the hashes they'd have already gotten
// access to the whole database.
rounds: 2,
..Default::default()
},
&salt,

View File

@@ -31,6 +31,11 @@ impl SubscriptionStore for SqliteStore {
}
async fn upsert_subscription(&self, sub: Subscription) -> Result<bool, Error> {
let already_exists = match self.get_subscription(&sub.id).await {
Ok(_) => true,
Err(Error::NotFound) => false,
Err(err) => return Err(err),
};
sqlx::query!(
r#"INSERT OR REPLACE INTO davpush_subscriptions (id, topic, expiration, push_resource, public_key, public_key_type, auth_secret) VALUES (?, ?, ?, ?, ?, ?, ?)"#,
sub.id,
@@ -41,8 +46,7 @@ impl SubscriptionStore for SqliteStore {
sub.public_key_type,
sub.auth_secret
).execute(&self.db).await.map_err(crate::Error::from)?;
// TODO: Correctly return whether a subscription already existed
Ok(false)
Ok(already_exists)
}
async fn delete_subscription(&self, id: &str) -> Result<(), Error> {
sqlx::query!(r#"DELETE FROM davpush_subscriptions WHERE id = ? "#, id)

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

@@ -8,17 +8,20 @@ a CalDAV/CardDAV server
you'd still be one of the first testers so expect bugs and rough edges.
If you still want to play around with it in its current state, absolutely feel free to do so and to open up an issue if something is not working. :)
[Installation](installation/index.md){ .md-button }
## Features
- easy to backup, everything saved in one SQLite database
- also export feature in the frontend
- [WebDAV Push](https://github.com/bitfireAT/webdav-push/) support, so near-instant synchronisation to DAVx5
- **[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

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