Compare commits

..

1 Commits

Author SHA1 Message Date
Lennart
cc384b6124 Merge pull request #97 from lennart-k/feature/sharing
Fix issues with group collections
2025-07-18 14:14:23 +02:00
19 changed files with 90 additions and 707 deletions

22
Cargo.lock generated
View File

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

View File

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

View File

@@ -12,14 +12,13 @@ a CalDAV/CardDAV server
- easy to backup, everything saved in one SQLite database - easy to backup, everything saved in one SQLite database
- also export feature in the frontend - 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) - lightweight (the container image contains only one binary)
- adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks) - adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks)
- deleted calendars are recoverable - deleted calendars are recoverable
- Nextcloud login flow (In DAVx5 you can login through the Nextcloud flow and automatically generate an app token) - Nextcloud login flow (In DAVx5 you can login through the Nextcloud flow and automatically generate an app token)
- Apple configuration profiles (skip copy-pasting passwords and instead generate the configuration in the frontend) - Apple configuration profiles (skip copy-pasting passwords and instead generate the configuration in the frontend)
- **OpenID Connect** support (with option to disable password login) - OpenID Connect support (with option to disable password login)
- Group-based **sharing**
## Getting Started ## Getting Started

View File

@@ -16,7 +16,6 @@ pub(crate) struct TimeRangeElement {
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)] #[allow(dead_code)]
// https://www.rfc-editor.org/rfc/rfc4791#section-9.7.3
struct ParamFilterElement { struct ParamFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
is_not_defined: Option<()>, is_not_defined: Option<()>,
@@ -33,13 +32,11 @@ struct TextMatchElement {
#[xml(ty = "attr")] #[xml(ty = "attr")]
collation: String, collation: String,
#[xml(ty = "attr")] #[xml(ty = "attr")]
// "yes" or "no", default: "no" negate_collation: String,
negate_condition: Option<String>,
} }
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)] #[allow(dead_code)]
// https://www.rfc-editor.org/rfc/rfc4791#section-9.7.2
pub(crate) struct PropFilterElement { pub(crate) struct PropFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
is_not_defined: Option<()>, is_not_defined: Option<()>,
@@ -49,9 +46,6 @@ pub(crate) struct PropFilterElement {
text_match: Option<TextMatchElement>, text_match: Option<TextMatchElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)] #[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
param_filter: Vec<ParamFilterElement>, param_filter: Vec<ParamFilterElement>,
#[xml(ty = "attr")]
name: String,
} }
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq)]
@@ -67,7 +61,7 @@ pub(crate) struct CompFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)] #[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
pub(crate) comp_filter: Vec<CompFilterElement>, pub(crate) comp_filter: Vec<CompFilterElement>,
#[xml(ty = "attr")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV", ty = "attr")]
pub(crate) name: String, pub(crate) name: String,
} }
@@ -209,102 +203,3 @@ pub async fn get_objects_calendar_query<C: CalendarStore>(
} }
Ok(objects) 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

@@ -34,7 +34,7 @@ pub enum CalendarProp {
CalendarTimezoneId(Option<String>), CalendarTimezoneId(Option<String>),
#[xml(ns = "rustical_dav::namespace::NS_ICAL")] #[xml(ns = "rustical_dav::namespace::NS_ICAL")]
CalendarOrder(Option<i64>), CalendarOrder(Option<i64>),
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")] #[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)]
SupportedCalendarComponentSet(SupportedCalendarComponentSet), SupportedCalendarComponentSet(SupportedCalendarComponentSet),
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)] #[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)]
SupportedCalendarData(SupportedCalendarData), SupportedCalendarData(SupportedCalendarData),

View File

@@ -26,21 +26,21 @@ enum SetPropertyPropWrapper<T: XmlDeserialize> {
// We are <prop> // We are <prop>
#[derive(XmlDeserialize, Clone, Debug)] #[derive(XmlDeserialize, Clone, Debug)]
struct SetPropertyPropWrapperWrapper<T: XmlDeserialize>( struct SetPropertyPropWrapperWrapper<T: XmlDeserialize>(
#[xml(ty = "untagged", flatten)] Vec<SetPropertyPropWrapper<T>>, #[xml(ty = "untagged")] SetPropertyPropWrapper<T>,
); );
// We are <set> // We are <set>
#[derive(XmlDeserialize, Clone, Debug)] #[derive(XmlDeserialize, Clone, Debug)]
struct SetPropertyElement<T: XmlDeserialize> { struct SetPropertyElement<T: XmlDeserialize> {
#[xml(ns = "crate::namespace::NS_DAV")] #[xml(ns = "crate::namespace::NS_DAV")]
prop: SetPropertyPropWrapperWrapper<T>, prop: T,
} }
#[derive(XmlDeserialize, Clone, Debug)] #[derive(XmlDeserialize, Clone, Debug)]
struct TagName(#[xml(ty = "tag_name")] String); struct TagName(#[xml(ty = "tag_name")] String);
#[derive(XmlDeserialize, Clone, Debug)] #[derive(XmlDeserialize, Clone, Debug)]
struct PropertyElement(#[xml(ty = "untagged", flatten)] Vec<TagName>); struct PropertyElement(#[xml(ty = "untagged")] TagName);
#[derive(XmlDeserialize, Clone, Debug)] #[derive(XmlDeserialize, Clone, Debug)]
struct RemovePropertyElement { struct RemovePropertyElement {
@@ -81,8 +81,9 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
let href = path.to_owned(); let href = path.to_owned();
// Extract operations // Extract operations
let PropertyupdateElement::<<R::Resource as Resource>::Prop>(operations) = let PropertyupdateElement::<SetPropertyPropWrapperWrapper<<R::Resource as Resource>::Prop>>(
XmlDocument::parse_str(body).map_err(Error::XmlError)?; operations,
) = XmlDocument::parse_str(body).map_err(Error::XmlError)?;
let mut resource = resource_service let mut resource = resource_service
.get_resource(path_components, false) .get_resource(path_components, false)
@@ -99,17 +100,17 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
for operation in operations.into_iter() { for operation in operations.into_iter() {
match operation { match operation {
Operation::Set(SetPropertyElement { Operation::Set(SetPropertyElement {
prop: SetPropertyPropWrapperWrapper(properties), prop: SetPropertyPropWrapperWrapper(property),
}) => { }) => {
for property in properties {
match property { match property {
SetPropertyPropWrapper::Valid(prop) => { SetPropertyPropWrapper::Valid(prop) => {
let propname: <<R::Resource as Resource>::Prop as PropName>::Names = let propname: <<R::Resource as Resource>::Prop as PropName>::Names =
prop.clone().into(); prop.clone().into();
let (ns, propname): (Option<Namespace>, &str) = propname.into(); let (ns, propname): (Option<Namespace>, &str) = propname.into();
match resource.set_prop(prop) { match resource.set_prop(prop) {
Ok(()) => props_ok Ok(()) => {
.push((ns.map(NamespaceOwned::from), propname.to_owned())), props_ok.push((ns.map(NamespaceOwned::from), propname.to_owned()))
}
Err(Error::PropReadOnly) => props_conflict Err(Error::PropReadOnly) => props_conflict
.push((ns.map(NamespaceOwned::from), propname.to_owned())), .push((ns.map(NamespaceOwned::from), propname.to_owned())),
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
@@ -138,12 +139,9 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
} }
} }
} }
}
Operation::Remove(remove_el) => { Operation::Remove(remove_el) => {
for tagname in remove_el.prop.0 { let propname = remove_el.prop.0.0;
let propname = tagname.0; match <<R::Resource as Resource>::Prop as PropName>::Names::from_str(&propname) {
match <<R::Resource as Resource>::Prop as PropName>::Names::from_str(&propname)
{
Ok(prop) => match resource.remove_prop(&prop) { Ok(prop) => match resource.remove_prop(&prop) {
Ok(()) => props_ok.push((None, propname)), Ok(()) => props_ok.push((None, propname)),
Err(Error::PropReadOnly) => props_conflict.push({ Err(Error::PropReadOnly) => props_conflict.push({
@@ -158,7 +156,6 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
} }
} }
} }
}
if props_not_found.is_empty() && props_conflict.is_empty() { if props_not_found.is_empty() && props_conflict.is_empty() {
// Only save if no errors occured // Only save if no errors occured

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -224,15 +224,15 @@ ul.collection-list {
min-height: 80px; min-height: 80px;
height: fit-content; height: fit-content;
grid-template-areas: grid-template-areas:
". color-chip" ". . color-chip"
"title color-chip" "title comps color-chip"
"description color-chip" "description description color-chip"
"subscription-url color-chip" "subscription-url subscription-url color-chip"
"metadata color-chip" "metadata metadata color-chip"
"actions color-chip" "actions actions color-chip"
". color-chip"; ". . color-chip";
grid-template-rows: 12px auto auto auto auto auto 12px; grid-template-rows: 12px auto auto auto auto auto 12px;
grid-template-columns: auto 80px; grid-template-columns: min-content auto 80px;
row-gap: 4px; row-gap: 4px;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
@@ -260,7 +260,7 @@ ul.collection-list {
} }
.comps { .comps {
display: inline; grid-area: comps;
span { span {
margin: 0 2px; margin: 0 2px;
@@ -293,7 +293,6 @@ ul.collection-list {
} }
.actions { .actions {
pointer-events: all;
grid-area: actions; grid-area: actions;
width: fit-content; width: fit-content;
display: flex; display: flex;

View File

@@ -16,12 +16,6 @@
method="GET"> method="GET">
<button type="submit">Download</button> <button type="submit">Download</button>
</form> </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 <delete-button trash
href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button> href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button>
</div> </div>

View File

@@ -8,12 +8,12 @@
<span class="title"> <span class="title">
{%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%} {%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%}
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }} {{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
</span>
<div class="comps"> <div class="comps">
{% for comp in calendar.components %} {% for comp in calendar.components %}
<span>{{ comp }}</span> <span>{{ comp }}</span>
{% endfor %} {% endfor %}
</div> </div>
</span>
<span class="description"> <span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %} {% if let Some(description) = calendar.description %}{{ description }}{% endif %}
</span> </span>
@@ -25,14 +25,6 @@
<button type="submit">Download</button> <button type="submit">Download</button>
</form> </form>
{% if !calendar.id.starts_with("_birthdays_") %} {% if !calendar.id.starts_with("_birthdays_") %}
<edit-calendar-form
principal="{{ calendar.principal }}"
cal_id="{{ calendar.id }}"
displayname="{{ calendar.displayname.as_deref().unwrap_or_default() }}"
description="{{ calendar.description.as_deref().unwrap_or_default() }}"
color="{{ calendar.color.as_deref().unwrap_or_default() }}"
components="{{ calendar.components | json }}"
></edit-calendar-form>
<delete-button trash href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button> <delete-button trash href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button>
{% endif %} {% endif %}
</div> </div>
@@ -57,12 +49,12 @@
<span class="title"> <span class="title">
{%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%} {%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%}
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }} {{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
</span>
<div class="comps"> <div class="comps">
{% for comp in calendar.components %} {% for comp in calendar.components %}
<span>{{ comp }}</span> <span>{{ comp }}</span>
{% endfor %} {% endfor %}
</div> </div>
</span>
<span class="description"> <span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %} {% if let Some(description) = calendar.description %}{{ description }}{% endif %}
</span> </span>

View File

@@ -6,9 +6,7 @@
window.rusticalUser = JSON.parse(document.querySelector('#data-rustical-user').innerHTML) window.rusticalUser = JSON.parse(document.querySelector('#data-rustical-user').innerHTML)
</script> </script>
<script type="module" src="/frontend/assets/js/create-calendar-form.mjs" async></script> <script type="module" src="/frontend/assets/js/create-calendar-form.mjs" async></script>
<script type="module" src="/frontend/assets/js/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/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> <script type="module" src="/frontend/assets/js/delete-button.mjs" async></script>
{% endblock %} {% endblock %}
{% block header_center %} {% block header_center %}

View File

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

View File

@@ -12,14 +12,13 @@ a CalDAV/CardDAV server
- easy to backup, everything saved in one SQLite database - easy to backup, everything saved in one SQLite database
- also export feature in the frontend - 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) - lightweight (the container image contains only one binary)
- adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks) - adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks)
- deleted calendars are recoverable - deleted calendars are recoverable
- Nextcloud login flow (In DAVx5 you can login through the Nextcloud flow and automatically generate an app token) - Nextcloud login flow (In DAVx5 you can login through the Nextcloud flow and automatically generate an app token)
- Apple configuration profiles (skip copy-pasting passwords and instead generate the configuration in the frontend) - Apple configuration profiles (skip copy-pasting passwords and instead generate the configuration in the frontend)
- **[OpenID Connect](setup/oidc.md)** support (with option to disable password login) - [OpenID Connect](setup/oidc.md) support (with option to disable password login)
- Group-based **sharing**
## Tested Clients ## Tested Clients

View File

@@ -16,9 +16,6 @@ docker run \
1. Mount config file 1. Mount config file
2. Alternatively specify configuration using environment variables 2. Alternatively specify configuration using environment variables
!!! info
Note that you are expected to run RustiCal behind a reverse proxy with HTTPS. (The frontend will only work on non-localhost addresses with https) and clients like Apple Calendar also expect HTTPS.
## User management ## User management
In case you already have an OIDC server set up, see [here](setup/oidc.md) how to set up OIDC login and maybe skip this section. In case you already have an OIDC server set up, see [here](setup/oidc.md) how to set up OIDC login and maybe skip this section.

View File

@@ -72,9 +72,3 @@ For group collections explicitly specify
``` ```
https://<your-host>/caldav-compat/principal/<principal> https://<your-host>/caldav-compat/principal/<principal>
``` ```
## Thunderbird
- Go to `New Account -> Calendar -> On The Network`
- Specify the root path of RustiCal
- Thunderbird will properly discover group calendars