Compare commits

...

33 Commits

Author SHA1 Message Date
Lennart
03294ec106 version 0.8.0 2025-07-25 23:26:57 +02:00
Lennart
a22235d976 sqlite_store: Drop timezone column in favour of timezone_id 2025-07-25 23:01:51 +02:00
Lennart
1ba9a97b3f update .sqlx queries 2025-07-25 22:52:26 +02:00
Lennart
51036ec6d5 Update vtimezone-rs to fix missing timezones 2025-07-25 22:51:35 +02:00
Lennart
e1a10338e0 Calendar data model: Switch to only saving timezone id 2025-07-25 22:32:01 +02:00
Lennart
918f27e8c2 frontend: Fix timezone removal 2025-07-25 22:30:52 +02:00
Lennart
dd34dd23d1 ical: Work on calendar object data structure 2025-07-25 21:44:57 +02:00
Lennart
9910e4ee31 Remove duplicate UTC implementation from CalTimezone 2025-07-25 19:06:23 +02:00
Lennart
c22469dea6 update ical dependency 2025-07-25 18:38:21 +02:00
Lennart
f2899aec6b Move to own ical-rs fork and refactor timezone-related stuff 2025-07-25 18:22:06 +02:00
Lennart K
f9380ca7e4 clippy appeasement 2025-07-24 11:46:28 +02:00
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
52 changed files with 1515 additions and 3768 deletions

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT *\n FROM calendars\n WHERE principal = ? AND deleted_at IS NOT NULL",
"query": "SELECT principal, id, displayname, \"order\", description, color, timezone_id, deleted_at, synctoken, subscription_url, push_topic, comp_event, comp_todo, comp_journal\n FROM calendars\n WHERE principal = ? AND deleted_at IS NOT NULL",
"describe": {
"columns": [
{
@@ -14,68 +14,63 @@
"type_info": "Text"
},
{
"name": "synctoken",
"name": "displayname",
"ordinal": 2,
"type_info": "Integer"
"type_info": "Text"
},
{
"name": "displayname",
"name": "order",
"ordinal": 3,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "description",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "order",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "color",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "timezone",
"ordinal": 7,
"ordinal": 5,
"type_info": "Text"
},
{
"name": "timezone_id",
"ordinal": 8,
"ordinal": 6,
"type_info": "Text"
},
{
"name": "deleted_at",
"ordinal": 9,
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "synctoken",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "subscription_url",
"ordinal": 10,
"ordinal": 9,
"type_info": "Text"
},
{
"name": "push_topic",
"ordinal": 11,
"ordinal": 10,
"type_info": "Text"
},
{
"name": "comp_event",
"ordinal": 12,
"ordinal": 11,
"type_info": "Bool"
},
{
"name": "comp_todo",
"ordinal": 13,
"ordinal": 12,
"type_info": "Bool"
},
{
"name": "comp_journal",
"ordinal": 14,
"ordinal": 13,
"type_info": "Bool"
}
],
@@ -85,14 +80,13 @@
"nullable": [
false,
false,
false,
true,
true,
false,
true,
true,
true,
true,
false,
true,
false,
false,
@@ -100,5 +94,5 @@
false
]
},
"hash": "cce62f7829bd688cd8c7928b587bc31f0e50865c214b1df113350bea2c254237"
"hash": "27ac68a4eea40c1cac663cad034028cf6c373354b29e3a5290c18f58101913cd"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE calendars SET principal = ?, id = ?, displayname = ?, description = ?, \"order\" = ?, color = ?, timezone_id = ?, push_topic = ?, comp_event = ?, comp_todo = ?, comp_journal = ?\n WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 13
},
"nullable": []
},
"hash": "46ae176a06e314492f661c28436d6370883052c854da43475d7ced60cf8326e3"
}

View File

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

View File

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

View File

@@ -39,43 +39,38 @@
"type_info": "Text"
},
{
"name": "timezone",
"name": "timezone_id",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "timezone_id",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "deleted_at",
"ordinal": 9,
"ordinal": 8,
"type_info": "Datetime"
},
{
"name": "subscription_url",
"ordinal": 10,
"ordinal": 9,
"type_info": "Text"
},
{
"name": "push_topic",
"ordinal": 11,
"ordinal": 10,
"type_info": "Text"
},
{
"name": "comp_event",
"ordinal": 12,
"ordinal": 11,
"type_info": "Bool"
},
{
"name": "comp_todo",
"ordinal": 13,
"ordinal": 12,
"type_info": "Bool"
},
{
"name": "comp_journal",
"ordinal": 14,
"ordinal": 13,
"type_info": "Bool"
}
],
@@ -93,7 +88,6 @@
true,
true,
true,
true,
false,
false,
false,

View File

@@ -39,43 +39,38 @@
"type_info": "Text"
},
{
"name": "timezone",
"name": "timezone_id",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "timezone_id",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "deleted_at",
"ordinal": 9,
"ordinal": 8,
"type_info": "Datetime"
},
{
"name": "subscription_url",
"ordinal": 10,
"ordinal": 9,
"type_info": "Text"
},
{
"name": "push_topic",
"ordinal": 11,
"ordinal": 10,
"type_info": "Text"
},
{
"name": "comp_event",
"ordinal": 12,
"ordinal": 11,
"type_info": "Bool"
},
{
"name": "comp_todo",
"ordinal": 13,
"ordinal": 12,
"type_info": "Bool"
},
{
"name": "comp_journal",
"ordinal": 14,
"ordinal": 13,
"type_info": "Bool"
}
],
@@ -93,7 +88,6 @@
true,
true,
true,
true,
false,
false,
false,

View File

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

462
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.6.3"
version = "0.8.0"
edition = "2024"
description = "A CalDAV server"
repository = "https://github.com/lennart-k/rustical"
@@ -95,8 +95,12 @@ strum = "0.27"
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"
ical = { git = "https://github.com/lennart-k/ical-rs", features = [
"generator",
"serde",
"chrono-tz",
] }
toml = "0.9"
tower = "0.5"
tower-http = { version = "0.6", features = [
"trace",
@@ -126,7 +130,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 +139,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.2"
ece = { version = "2.3", default-features = false, features = [
"backend-openssl",
] }

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

@@ -63,7 +63,6 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
params: None,
});
}
let mut ical_calendar = ical_calendar_builder.build();
for object in &objects {
match object.get_data() {
@@ -73,17 +72,21 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
..
}) => {
timezones.extend(object_timezones);
ical_calendar.events.push(event.clone());
ical_calendar_builder = ical_calendar_builder.add_event(event.clone());
}
CalendarObjectComponent::Todo(TodoObject { todo, .. }) => {
ical_calendar.todos.push(todo.clone());
CalendarObjectComponent::Todo(TodoObject(todo)) => {
ical_calendar_builder = ical_calendar_builder.add_todo(todo.clone());
}
CalendarObjectComponent::Journal(JournalObject { journal, .. }) => {
ical_calendar.journals.push(journal.clone());
CalendarObjectComponent::Journal(JournalObject(journal)) => {
ical_calendar_builder = ical_calendar_builder.add_journal(journal.clone());
}
}
}
let ical_calendar = ical_calendar_builder
.build()
.map_err(|parser_error| Error::IcalError(parser_error.into()))?;
let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ContentType::from_str("text/calendar").unwrap());

View File

@@ -4,6 +4,7 @@ use crate::calendar::prop::SupportedCalendarComponentSet;
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use http::{Method, StatusCode};
use ical::IcalParser;
use rustical_dav::xml::HrefElement;
use rustical_ical::CalendarObjectType;
use rustical_store::auth::Principal;
@@ -82,13 +83,38 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
request.displayname = None
}
let timezone_id = if let Some(tzid) = request.calendar_timezone_id {
Some(tzid)
} else if let Some(tz) = request.calendar_timezone {
// TODO: Proper error (calendar-timezone precondition)
let calendar = IcalParser::new(tz.as_bytes())
.next()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?
.map_err(|_| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?;
let timezone = calendar
.timezones
.first()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?;
let timezone: chrono_tz::Tz = timezone
.try_into()
.map_err(|_| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?;
Some(timezone.name().to_owned())
} else {
None
};
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_id: request.calendar_timezone_id,
timezone_id,
color: request.calendar_color,
description: request.calendar_description,
deleted_at: None,

View File

@@ -3,6 +3,7 @@ use crate::Error;
use crate::calendar::prop::ReportMethod;
use chrono::{DateTime, Utc};
use derive_more::derive::{From, Into};
use ical::IcalParser;
use rustical_dav::extensions::{
CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp,
};
@@ -15,7 +16,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")]
@@ -133,7 +133,9 @@ impl Resource for CalendarResource {
CalendarProp::CalendarDescription(self.cal.description.clone())
}
CalendarPropName::CalendarTimezone => {
CalendarProp::CalendarTimezone(self.cal.timezone.clone())
CalendarProp::CalendarTimezone(self.cal.timezone_id.as_ref().and_then(|tzid| {
vtimezones_rs::VTIMEZONES.get(tzid).map(|tz| tz.to_string())
}))
}
// chrono_tz uses the IANA database
CalendarPropName::TimezoneServiceSet => CalendarProp::TimezoneServiceSet(
@@ -192,18 +194,42 @@ impl Resource for CalendarResource {
Ok(())
}
CalendarProp::CalendarTimezone(timezone) => {
// TODO: Ensure that timezone-id is also updated
self.cal.timezone = timezone;
if let Some(tz) = timezone {
// TODO: Proper error (calendar-timezone precondition)
let calendar = IcalParser::new(tz.as_bytes())
.next()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?
.map_err(|_| {
rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
)
})?;
let timezone =
calendar
.timezones
.first()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?;
let timezone: chrono_tz::Tz = timezone.try_into().map_err(|_| {
rustical_dav::Error::BadRequest("No timezone data provided".to_owned())
})?;
self.cal.timezone_id = Some(timezone.name().to_owned());
}
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)
if !vtimezones_rs::VTIMEZONES.contains_key(tzid) {
return Err(rustical_dav::Error::BadRequest(format!(
"Invalid timezone-id: {tzid}"
)));
}
}
self.cal.timezone_id = timezone_id;
Ok(())
@@ -244,15 +270,11 @@ impl Resource for CalendarResource {
self.cal.description = None;
Ok(())
}
CalendarPropName::CalendarTimezone => {
self.cal.timezone = None;
Ok(())
}
CalendarPropName::TimezoneServiceSet => Err(rustical_dav::Error::PropReadOnly),
CalendarPropName::CalendarTimezoneId => {
CalendarPropName::CalendarTimezone | CalendarPropName::CalendarTimezoneId => {
self.cal.timezone_id = None;
Ok(())
}
CalendarPropName::TimezoneServiceSet => Err(rustical_dav::Error::PropReadOnly),
CalendarPropName::CalendarOrder => {
self.cal.order = 0;
Ok(())
@@ -305,3 +327,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

@@ -78,12 +78,13 @@ pub async fn put_event<C: CalendarStore>(
true
};
let object = match CalendarObject::from_ics(object_id, body) {
let object = match CalendarObject::from_ics(body) {
Ok(obj) => obj,
Err(_) => {
return Err(Error::PreconditionFailed(Precondition::ValidCalendarData));
}
};
assert_eq!(object.get_id(), object_id);
cal_store
.put_object(principal, calendar_id, object, overwrite)
.await?;

View File

@@ -41,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",
// ),
])
}
@@ -126,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

@@ -46,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,

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,6 @@
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")
@@ -15,14 +14,12 @@ export class CreateAddressbookForm extends LitElement {
return this
}
client = createClient("/carddav")
@property()
user: string = ''
@property()
principal: string = ''
@property()
addr_id: string = ''
addr_id: string = self.crypto.randomUUID()
@property()
displayname: string = ''
@property()
@@ -49,7 +46,7 @@ export class CreateAddressbookForm extends LitElement {
<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>
@@ -80,8 +77,12 @@ export class CreateAddressbookForm extends LitElement {
alert("Empty displayname")
return
}
await this.client.createDirectory(`/principal/${this.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>
@@ -91,7 +92,14 @@ export class CreateAddressbookForm extends LitElement {
</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,6 @@
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")
@@ -14,19 +13,19 @@ export class CreateCalendarForm extends LitElement {
return this
}
client = createClient("/caldav")
@property()
user: string = ''
@property()
principal: string = ''
@property()
cal_id: string = ''
cal_id: string = self.crypto.randomUUID()
@property()
displayname: string = ''
@property()
description: string = ''
@property()
timezone_id: string = ''
@property()
color: string = ''
@property()
isSubscription: boolean = false
@@ -38,7 +37,6 @@ export class CreateCalendarForm extends LitElement {
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
override render() {
return html`
<button @click=${() => this.dialog.value.showModal()}>Create calendar</button>
@@ -57,7 +55,7 @@ export class CreateCalendarForm extends LitElement {
<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>
@@ -65,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} />
@@ -119,12 +122,18 @@ export class CreateCalendarForm extends LitElement {
alert("No calendar components selected")
return
}
await this.client.createDirectory(`/principal/${this.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>${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>` : ''}
@@ -136,6 +145,11 @@ export class CreateCalendarForm extends LitElement {
</mkcol>
`
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}

View File

@@ -63,7 +63,7 @@ export class EditAddressbookForm extends LitElement {
alert("Empty displayname")
return
}
await fetch(`/carddav/principal/${this.principal}/${this.addr_id}`, {
let response = await fetch(`/carddav/principal/${this.principal}/${this.addr_id}`, {
method: 'PROPPATCH',
headers: {
'Content-Type': 'application/xml'
@@ -85,6 +85,12 @@ export class EditAddressbookForm extends LitElement {
`
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}

View File

@@ -23,6 +23,8 @@ export class EditCalendarForm extends LitElement {
@property()
description: string = ''
@property()
timezone_id: string = ''
@property()
color: string = ''
@property({
converter: {
@@ -47,6 +49,11 @@ export class EditCalendarForm 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" .value=${this.description} @change=${e => this.description = e.target.value} />
@@ -90,7 +97,7 @@ export class EditCalendarForm extends LitElement {
alert("No calendar components selected")
return
}
await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
let response = await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
method: 'PROPPATCH',
headers: {
'Content-Type': 'application/xml'
@@ -100,6 +107,7 @@ export class EditCalendarForm extends LitElement {
<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>
@@ -109,6 +117,7 @@ export class EditCalendarForm extends LitElement {
</set>
<remove>
<prop>
${!this.timezone_id ? `<CAL:calendar-timezone-id />` : ''}
${!this.description ? '<CAL:calendar-description />' : ''}
${!this.color ? '<ICAL:calendar-color />' : ''}
</prop>
@@ -116,6 +125,12 @@ export class EditCalendarForm extends LitElement {
</propertyupdate>
`
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}

View File

@@ -25,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, a as escapeXml } from "./index-b86iLJlP.mjs";
import { a as an } from "./webdav-D0R7xCzX.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
@@ -15,10 +14,9 @@ var __decorateClass = (decorators, target, key, kind) => {
let CreateAddressbookForm = class extends i {
constructor() {
super();
this.client = an("/carddav");
this.user = "";
this.principal = "";
this.addr_id = "";
this.addr_id = self.crypto.randomUUID();
this.displayname = "";
this.description = "";
this.dialog = e();
@@ -45,7 +43,7 @@ let CreateAddressbookForm = class extends i {
<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>
@@ -79,8 +77,12 @@ let CreateAddressbookForm = class extends i {
alert("Empty displayname");
return;
}
await this.client.createDirectory(`/principal/${this.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>
@@ -91,6 +93,10 @@ let CreateAddressbookForm = class extends i {
</mkcol>
`
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
window.location.reload();
return null;
}

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, a as escapeXml } from "./index-b86iLJlP.mjs";
import { a as an } from "./webdav-D0R7xCzX.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
@@ -15,12 +14,12 @@ var __decorateClass = (decorators, target, key, kind) => {
let CreateCalendarForm = class extends i {
constructor() {
super();
this.client = an("/caldav");
this.user = "";
this.principal = "";
this.cal_id = "";
this.cal_id = self.crypto.randomUUID();
this.displayname = "";
this.description = "";
this.timezone_id = "";
this.color = "";
this.isSubscription = false;
this.subscriptionUrl = "";
@@ -49,7 +48,7 @@ let CreateCalendarForm = class extends i {
<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>
@@ -57,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} />
@@ -114,12 +118,17 @@ let CreateCalendarForm = class extends i {
alert("No calendar components selected");
return;
}
await this.client.createDirectory(`/principal/${this.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>${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>` : ""}
@@ -131,6 +140,10 @@ let CreateCalendarForm = class extends i {
</mkcol>
`
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
window.location.reload();
return null;
}
@@ -150,6 +163,9 @@ __decorateClass([
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "description", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "timezone_id", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "color", 2);

View File

@@ -64,7 +64,7 @@ let EditAddressbookForm = class extends i {
alert("Empty displayname");
return;
}
await fetch(`/carddav/principal/${this.principal}/${this.addr_id}`, {
let response = await fetch(`/carddav/principal/${this.principal}/${this.addr_id}`, {
method: "PROPPATCH",
headers: {
"Content-Type": "application/xml"
@@ -85,6 +85,10 @@ let EditAddressbookForm = class extends i {
</propertyupdate>
`
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
window.location.reload();
return null;
}

View File

@@ -16,6 +16,7 @@ let EditCalendarForm = class extends i {
super();
this.displayname = "";
this.description = "";
this.timezone_id = "";
this.color = "";
this.components = /* @__PURE__ */ new Set();
this.dialog = e();
@@ -35,6 +36,11 @@ let EditCalendarForm = 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" .value=${this.description} @change=${(e2) => this.description = e2.target.value} />
@@ -81,7 +87,7 @@ let EditCalendarForm = class extends i {
alert("No calendar components selected");
return;
}
await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
let response = await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
method: "PROPPATCH",
headers: {
"Content-Type": "application/xml"
@@ -91,6 +97,7 @@ let EditCalendarForm = class extends i {
<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>
@@ -100,6 +107,7 @@ let EditCalendarForm = class extends i {
</set>
<remove>
<prop>
${!this.timezone_id ? `<CAL:calendar-timezone-id />` : ""}
${!this.description ? "<CAL:calendar-description />" : ""}
${!this.color ? "<ICAL:calendar-color />" : ""}
</prop>
@@ -107,6 +115,10 @@ let EditCalendarForm = class extends i {
</propertyupdate>
`
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
window.location.reload();
return null;
}
@@ -123,6 +135,9 @@ __decorateClass([
__decorateClass([
n$1()
], EditCalendarForm.prototype, "description", 2);
__decorateClass([
n$1()
], EditCalendarForm.prototype, "timezone_id", 2);
__decorateClass([
n$1()
], EditCalendarForm.prototype, "color", 2);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,7 @@
<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() }}"

View File

@@ -25,7 +25,7 @@
{% if let Some(timezone_id) = calendar.timezone_id %}
<p>{{ timezone_id }}</p>
{% endif %}
{% if let Some(timezone) = calendar.timezone %}
{% if let Some(timezone) = calendar.get_vtimezone() %}
<textarea rows="16" readonly>{{ timezone }}</textarea>
{% endif %}

View File

@@ -56,9 +56,13 @@ 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,

View File

@@ -95,10 +95,8 @@ impl AddressObject {
let uid = format!("{}-anniversary", self.get_id());
let year_suffix = year.map(|year| format!(" ({year})")).unwrap_or_default();
Some(CalendarObject::from_ics(
uid.clone(),
format!(
r#"BEGIN:VCALENDAR
Some(CalendarObject::from_ics(format!(
r#"BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//github.com/lennart-k/rustical birthday calendar//EN
@@ -116,8 +114,7 @@ DESCRIPTION:💍 {fullname}{year_suffix}
END:VALARM
END:VEVENT
END:VCALENDAR"#,
),
)?)
))?)
} else {
None
},
@@ -139,10 +136,8 @@ END:VCALENDAR"#,
let uid = format!("{}-birthday", self.get_id());
let year_suffix = year.map(|year| format!(" ({year})")).unwrap_or_default();
Some(CalendarObject::from_ics(
uid.clone(),
format!(
r#"BEGIN:VCALENDAR
Some(CalendarObject::from_ics(format!(
r#"BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//github.com/lennart-k/rustical birthday calendar//EN
@@ -160,8 +155,7 @@ DESCRIPTION:🎂 {fullname}{year_suffix}
END:VALARM
END:VEVENT
END:VCALENDAR"#,
),
)?)
))?)
} else {
None
},

View File

@@ -1,19 +1,17 @@
use crate::Error;
use crate::{CalDateTime, ComponentMut, parse_duration};
use crate::{CalDateTime, parse_duration};
use chrono::{DateTime, Duration, Utc};
use ical::{
generator::IcalEvent,
parser::{Component, ical::component::IcalTimeZone},
property::Property,
};
use ical::parser::ComponentMut;
use ical::{generator::IcalEvent, parser::Component, property::Property};
use rrule::{RRule, RRuleSet};
use std::{collections::HashMap, str::FromStr};
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct EventObject {
pub event: IcalEvent,
pub timezones: HashMap<String, IcalTimeZone>,
pub(crate) ics: String,
// If a timezone is None that means that in the VCALENDAR object there's a timezone defined
// with that name but its not from the Olson DB
pub timezones: HashMap<String, Option<chrono_tz::Tz>>,
}
impl EventObject {
@@ -128,7 +126,7 @@ impl EventObject {
} else {
date.format()
};
let mut ev = self.event.clone();
let mut ev = self.event.clone().mutable();
ev.remove_property("RRULE");
ev.remove_property("RDATE");
ev.remove_property("EXDATE");
@@ -163,7 +161,7 @@ impl EventObject {
params: dtstart_prop.params,
});
}
events.push(ev);
events.push(ev.verify()?);
}
Ok(events)
} else {
@@ -242,11 +240,7 @@ END:VEVENT\r\n",
#[test]
fn test_expand_recurrence() {
let event = CalendarObject::from_ics(
"318ec6503573d9576818daf93dac07317058d95c".to_string(),
ICS.to_string(),
)
.unwrap();
let event = CalendarObject::from_ics(ICS.to_string()).unwrap();
let event = event.event().unwrap();
let events: Vec<String> = event

View File

@@ -1,7 +1,5 @@
use derive_more::From;
use ical::parser::ical::component::IcalJournal;
#[derive(Debug, Clone)]
pub struct JournalObject {
pub journal: IcalJournal,
pub(crate) ics: String,
}
#[derive(Debug, Clone, From)]
pub struct JournalObject(pub IcalJournal);

View File

@@ -4,10 +4,8 @@ use crate::Error;
use chrono::DateTime;
use chrono::Utc;
use derive_more::Display;
use ical::{
generator::{Emitter, IcalCalendar},
parser::{Component, ical::component::IcalTimeZone},
};
use ical::generator::{Emitter, IcalCalendar};
use ical::property::Property;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{collections::HashMap, io::BufReader};
@@ -47,8 +45,7 @@ impl rustical_xml::ValueDeserialize for CalendarObjectType {
"VJOURNAL" => Ok(Self::Journal),
_ => Err(rustical_xml::XmlError::InvalidValue(
rustical_xml::ParseValueError::Other(format!(
"Invalid value '{}', must be VEVENT, VTODO, or VJOURNAL",
val
"Invalid value '{val}', must be VEVENT, VTODO, or VJOURNAL"
)),
)),
}
@@ -62,15 +59,21 @@ pub enum CalendarObjectComponent {
Journal(JournalObject),
}
#[derive(Debug, Clone)]
pub struct CalendarObject {
id: String,
impl Default for CalendarObjectComponent {
fn default() -> Self {
Self::Event(EventObject::default())
}
}
#[derive(Debug, Clone, Default)]
pub struct CalendarObject<const VERIFIED: bool = true> {
data: CalendarObjectComponent,
cal: IcalCalendar,
properties: Vec<Property>,
ics: String,
}
impl CalendarObject {
pub fn from_ics(object_id: String, ics: String) -> Result<Self, Error> {
pub fn from_ics(ics: String) -> Result<Self, Error> {
let mut parser = ical::IcalParser::new(BufReader::new(ics.as_bytes()));
let cal = parser.next().ok_or(Error::MissingCalendar)??;
if parser.next().is_some() {
@@ -91,52 +94,30 @@ impl CalendarObject {
));
}
let timezones: HashMap<String, IcalTimeZone> = cal
let timezones: HashMap<String, Option<chrono_tz::Tz>> = cal
.timezones
.clone()
.into_iter()
.filter_map(|timezone| {
let timezone_prop = timezone.get_property("TZID")?.to_owned();
let tzid = timezone_prop.value?;
Some((tzid, timezone))
})
.map(|timezone| (timezone.get_tzid().to_owned(), (&timezone).try_into().ok()))
.collect();
if let Some(event) = cal.events.first() {
return Ok(CalendarObject {
id: object_id,
cal: cal.clone(),
data: CalendarObjectComponent::Event(EventObject {
event: event.clone(),
timezones,
ics,
}),
});
}
if let Some(todo) = cal.todos.first() {
return Ok(CalendarObject {
id: object_id,
cal: cal.clone(),
data: CalendarObjectComponent::Todo(TodoObject {
todo: todo.clone(),
ics,
}),
});
}
if let Some(journal) = cal.journals.first() {
return Ok(CalendarObject {
id: object_id,
cal: cal.clone(),
data: CalendarObjectComponent::Journal(JournalObject {
journal: journal.clone(),
ics,
}),
});
}
let data = if let Some(event) = cal.events.into_iter().next() {
CalendarObjectComponent::Event(EventObject { event, timezones })
} else if let Some(todo) = cal.todos.into_iter().next() {
CalendarObjectComponent::Todo(todo.into())
} else if let Some(journal) = cal.journals.into_iter().next() {
CalendarObjectComponent::Journal(journal.into())
} else {
return Err(Error::InvalidData(
"iCalendar component type not supported :(".to_owned(),
));
};
Err(Error::InvalidData(
"iCalendar component type not supported :(".to_owned(),
))
Ok(Self {
data,
properties: cal.properties,
ics,
})
}
pub fn get_data(&self) -> &CalendarObjectComponent {
@@ -144,29 +125,26 @@ impl CalendarObject {
}
pub fn get_id(&self) -> &str {
&self.id
match &self.data {
CalendarObjectComponent::Todo(todo) => todo.0.get_uid(),
CalendarObjectComponent::Event(event) => event.event.get_uid(),
CalendarObjectComponent::Journal(journal) => journal.0.get_uid(),
}
}
pub fn get_etag(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(&self.id);
hasher.update(self.get_id());
hasher.update(self.get_ics());
format!("\"{:x}\"", hasher.finalize())
}
pub fn get_ics(&self) -> &str {
match &self.data {
CalendarObjectComponent::Todo(todo) => &todo.ics,
CalendarObjectComponent::Event(event) => &event.ics,
CalendarObjectComponent::Journal(journal) => &journal.ics,
}
&self.ics
}
pub fn get_component_name(&self) -> &str {
match self.data {
CalendarObjectComponent::Todo(_) => "VTODO",
CalendarObjectComponent::Event(_) => "VEVENT",
CalendarObjectComponent::Journal(_) => "VJOURNAL",
}
self.get_object_type().as_str()
}
pub fn get_object_type(&self) -> CalendarObjectType {
@@ -206,8 +184,11 @@ impl CalendarObject {
// Only events can be expanded
match &self.data {
CalendarObjectComponent::Event(event) => {
let mut cal = self.cal.clone();
cal.events = event.expand_recurrence(start, end)?;
let cal = IcalCalendar {
properties: self.properties.clone(),
events: event.expand_recurrence(start, end)?,
..Default::default()
};
Ok(cal.generate())
}
_ => Ok(self.get_ics().to_string()),

View File

@@ -1,7 +1,5 @@
use derive_more::From;
use ical::parser::ical::component::IcalTodo;
#[derive(Debug, Clone)]
pub struct TodoObject {
pub todo: IcalTodo,
pub(crate) ics: String,
}
#[derive(Debug, Clone, From)]
pub struct TodoObject(pub IcalTodo);

View File

@@ -1,6 +1,3 @@
mod property_ext;
pub use property_ext::*;
mod timestamp;
mod timezone;
pub use timestamp::*;

View File

@@ -1,46 +0,0 @@
use ical::{generator::IcalEvent, property::Property};
pub trait IcalProperty {
fn get_param(&self, name: &str) -> Option<Vec<&str>>;
fn get_value_type(&self) -> Option<&str>;
fn get_tzid(&self) -> Option<&str>;
}
impl IcalProperty for ical::property::Property {
fn get_param(&self, name: &str) -> Option<Vec<&str>> {
self.params
.as_ref()?
.iter()
.find(|(key, _)| name == key)
.map(|(_, value)| value.iter().map(String::as_str).collect())
}
fn get_value_type(&self) -> Option<&str> {
self.get_param("VALUE")
.and_then(|params| params.into_iter().next())
}
fn get_tzid(&self) -> Option<&str> {
self.get_param("TZID")
.and_then(|params| params.into_iter().next())
}
}
pub trait ComponentMut {
fn remove_property(&mut self, name: &str);
fn set_property(&mut self, prop: Property);
fn push_property(&mut self, prop: Property);
}
impl ComponentMut for IcalEvent {
fn remove_property(&mut self, name: &str) {
self.properties.retain(|prop| prop.name != name);
}
fn set_property(&mut self, prop: Property) {
self.remove_property(&prop.name);
self.push_property(prop);
}
fn push_property(&mut self, prop: Property) {
self.properties.push(prop);
}
}

View File

@@ -1,12 +1,8 @@
use super::timezone::CalTimezone;
use crate::IcalProperty;
use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use chrono_tz::Tz;
use derive_more::derive::Deref;
use ical::{
parser::{Component, ical::component::IcalTimeZone},
property::Property,
};
use ical::property::Property;
use lazy_static::lazy_static;
use rustical_xml::{ValueDeserialize, ValueSerialize};
use std::{borrow::Cow, collections::HashMap, ops::Add};
@@ -112,7 +108,7 @@ impl From<DateTime<Local>> for CalDateTime {
impl From<DateTime<Utc>> for CalDateTime {
fn from(value: DateTime<Utc>) -> Self {
CalDateTime::DateTime(value.with_timezone(&CalTimezone::Utc))
CalDateTime::DateTime(value.with_timezone(&CalTimezone::Olson(chrono_tz::UTC)))
}
}
@@ -136,7 +132,7 @@ impl Add<Duration> for CalDateTime {
impl CalDateTime {
pub fn parse_prop(
prop: &Property,
timezones: &HashMap<String, IcalTimeZone>,
timezones: &HashMap<String, Option<chrono_tz::Tz>>,
) -> Result<Self, CalDateTimeError> {
let prop_value = prop
.value
@@ -145,28 +141,9 @@ impl CalDateTime {
"empty property".to_owned(),
))?;
// Use the TZID parameter from the property
let timezone = if let Some(tzid) = prop.get_tzid() {
let timezone = if let Some(tzid) = prop.get_param("TZID") {
if let Some(timezone) = timezones.get(tzid) {
// X-LIC-LOCATION is often used to refer to a standardised timezone from the Olson
// database
if let Some(olson_name) = timezone
.get_property("X-LIC-LOCATION")
.map(|prop| prop.value.to_owned())
.unwrap_or_default()
{
if let Ok(tz) = olson_name.parse::<Tz>() {
Some(tz)
} else {
return Err(CalDateTimeError::InvalidOlson(olson_name));
}
} else {
// If the TZID matches a name from the Olson database (e.g. Europe/Berlin) we
// guess that we can just use it
tzid.parse::<Tz>().ok()
// TODO: If None: Too bad, we need to manually parse it
// For now it's just treated as localtime
}
timezone.to_owned()
} else {
// TZID refers to timezone that does not exist
return Err(CalDateTimeError::InvalidTZID(tzid.to_string()));
@@ -183,7 +160,7 @@ impl CalDateTime {
pub fn format(&self) -> String {
match self {
Self::DateTime(datetime) => match datetime.timezone() {
CalTimezone::Utc => datetime.format(UTC_DATE_TIME).to_string(),
CalTimezone::Olson(chrono_tz::UTC) => datetime.format(UTC_DATE_TIME).to_string(),
_ => datetime.format(LOCAL_DATE_TIME).to_string(),
},
Self::Date(date, _) => date.format(LOCAL_DATE).to_string(),
@@ -208,7 +185,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

@@ -1,11 +1,10 @@
use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone, Utc};
use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone};
use chrono_tz::Tz;
use derive_more::{Display, From};
#[derive(Debug, Clone, From, PartialEq, Eq)]
pub enum CalTimezone {
Local,
Utc,
Olson(Tz),
}
@@ -13,7 +12,6 @@ impl From<CalTimezone> for rrule::Tz {
fn from(value: CalTimezone) -> Self {
match value {
CalTimezone::Local => Self::LOCAL,
CalTimezone::Utc => Self::UTC,
CalTimezone::Olson(tz) => Self::Tz(tz),
}
}
@@ -23,7 +21,6 @@ impl From<rrule::Tz> for CalTimezone {
fn from(value: rrule::Tz) -> Self {
match value {
rrule::Tz::Local(_) => Self::Local,
rrule::Tz::Tz(chrono_tz::UTC) => Self::Utc,
rrule::Tz::Tz(tz) => Self::Olson(tz),
}
}
@@ -32,7 +29,6 @@ impl From<rrule::Tz> for CalTimezone {
#[derive(Debug, Clone, PartialEq, Display)]
pub enum CalTimezoneOffset {
Local(chrono::FixedOffset),
Utc(chrono::Utc),
Olson(chrono_tz::TzOffset),
}
@@ -40,7 +36,6 @@ impl chrono::Offset for CalTimezoneOffset {
fn fix(&self) -> chrono::FixedOffset {
match self {
Self::Local(local) => local.fix(),
Self::Utc(utc) => utc.fix(),
Self::Olson(olson) => olson.fix(),
}
}
@@ -52,7 +47,6 @@ impl TimeZone for CalTimezone {
fn from_offset(offset: &Self::Offset) -> Self {
match offset {
CalTimezoneOffset::Local(_) => Self::Local,
CalTimezoneOffset::Utc(_) => Self::Utc,
CalTimezoneOffset::Olson(offset) => Self::Olson(Tz::from_offset(offset)),
}
}
@@ -62,9 +56,6 @@ impl TimeZone for CalTimezone {
Self::Local => Local
.offset_from_local_date(local)
.map(CalTimezoneOffset::Local),
Self::Utc => Utc
.offset_from_local_date(local)
.map(CalTimezoneOffset::Utc),
Self::Olson(tz) => tz
.offset_from_local_date(local)
.map(CalTimezoneOffset::Olson),
@@ -79,9 +70,6 @@ impl TimeZone for CalTimezone {
Self::Local => Local
.offset_from_local_datetime(local)
.map(CalTimezoneOffset::Local),
Self::Utc => Utc
.offset_from_local_datetime(local)
.map(CalTimezoneOffset::Utc),
Self::Olson(tz) => tz
.offset_from_local_datetime(local)
.map(CalTimezoneOffset::Olson),
@@ -91,7 +79,6 @@ impl TimeZone for CalTimezone {
fn offset_from_utc_datetime(&self, utc: &NaiveDateTime) -> Self::Offset {
match self {
Self::Local => CalTimezoneOffset::Local(Local.offset_from_utc_datetime(utc)),
Self::Utc => CalTimezoneOffset::Utc(Utc.offset_from_utc_datetime(utc)),
Self::Olson(tz) => CalTimezoneOffset::Olson(tz.offset_from_utc_datetime(utc)),
}
}
@@ -99,7 +86,6 @@ impl TimeZone for CalTimezone {
fn offset_from_utc_date(&self, utc: &NaiveDate) -> Self::Offset {
match self {
Self::Local => CalTimezoneOffset::Local(Local.offset_from_utc_date(utc)),
Self::Utc => CalTimezoneOffset::Utc(Utc.offset_from_utc_date(utc)),
Self::Olson(tz) => CalTimezoneOffset::Olson(tz.offset_from_utc_date(utc)),
}
}

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
@@ -33,6 +32,7 @@ headers.workspace = true
tower.workspace = true
futures-core.workspace = true
tower-sessions.workspace = true
vtimezones-rs.workspace = true
[dev-dependencies]
rstest = { workspace = true }

View File

@@ -1,3 +1,5 @@
use std::str::FromStr;
use crate::synctoken::format_synctoken;
use chrono::NaiveDateTime;
use rustical_ical::CalendarObjectType;
@@ -11,7 +13,6 @@ pub struct Calendar {
pub order: i64,
pub description: Option<String>,
pub color: Option<String>,
pub timezone: Option<String>,
pub timezone_id: Option<String>,
pub deleted_at: Option<NaiveDateTime>,
pub synctoken: i64,
@@ -24,4 +25,16 @@ impl Calendar {
pub fn format_synctoken(&self) -> String {
format_synctoken(self.synctoken)
}
pub fn get_timezone(&self) -> Option<chrono_tz::Tz> {
self.timezone_id
.as_ref()
.and_then(|tzid| chrono_tz::Tz::from_str(tzid).ok())
}
pub fn get_vtimezone(&self) -> Option<&'static str> {
self.timezone_id
.as_ref()
.and_then(|tzid| vtimezones_rs::VTIMEZONES.get(tzid).cloned())
}
}

View File

@@ -20,7 +20,6 @@ fn birthday_calendar(addressbook: Addressbook) -> Calendar {
order: 0,
description: None,
color: None,
timezone: None,
timezone_id: None,
deleted_at: addressbook.deleted_at,
synctoken: addressbook.synctoken,

View File

@@ -0,0 +1 @@
ALTER TABLE calendars ADD COLUMN timezone TEXT;

View File

@@ -0,0 +1,3 @@
-- We don't want to save timezones as ics anymore
-- but instead just rely on the TZDB identifier
ALTER TABLE calendars DROP COLUMN timezone;

View File

@@ -22,7 +22,17 @@ impl TryFrom<CalendarObjectRow> for CalendarObject {
type Error = rustical_store::Error;
fn try_from(value: CalendarObjectRow) -> Result<Self, Self::Error> {
Ok(CalendarObject::from_ics(value.id, value.ics)?)
let object = CalendarObject::from_ics(value.ics)?;
if object.get_id() != value.id {
return Err(rustical_store::Error::IcalError(
rustical_ical::Error::InvalidData(format!(
"object_id={} and UID={} don't match",
object.get_id(),
value.id
)),
));
}
Ok(object)
}
}
@@ -34,7 +44,6 @@ struct CalendarRow {
order: i64,
description: Option<String>,
color: Option<String>,
timezone: Option<String>,
timezone_id: Option<String>,
deleted_at: Option<NaiveDateTime>,
synctoken: i64,
@@ -64,7 +73,6 @@ impl From<CalendarRow> for Calendar {
order: value.order,
description: value.description,
color: value.color,
timezone: value.timezone,
timezone_id: value.timezone_id,
deleted_at: value.deleted_at,
synctoken: value.synctoken,
@@ -127,7 +135,7 @@ impl SqliteCalendarStore {
) -> Result<Vec<Calendar>, Error> {
let cals = sqlx::query_as!(
CalendarRow,
r#"SELECT *
r#"SELECT principal, id, displayname, "order", description, color, timezone_id, deleted_at, synctoken, subscription_url, push_topic, comp_event, comp_todo, comp_journal
FROM calendars
WHERE principal = ? AND deleted_at IS NOT NULL"#,
principal
@@ -147,8 +155,8 @@ impl SqliteCalendarStore {
let comp_journal = calendar.components.contains(&CalendarObjectType::Journal);
sqlx::query!(
r#"INSERT INTO calendars (principal, id, displayname, description, "order", color, subscription_url, timezone, timezone_id, push_topic, comp_event, comp_todo, comp_journal)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
r#"INSERT INTO calendars (principal, id, displayname, description, "order", color, subscription_url, timezone_id, push_topic, comp_event, comp_todo, comp_journal)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
calendar.principal,
calendar.id,
calendar.displayname,
@@ -156,7 +164,6 @@ impl SqliteCalendarStore {
calendar.order,
calendar.color,
calendar.subscription_url,
calendar.timezone,
calendar.timezone_id,
calendar.push_topic,
comp_event, comp_todo, comp_journal
@@ -178,7 +185,7 @@ impl SqliteCalendarStore {
let comp_journal = calendar.components.contains(&CalendarObjectType::Journal);
let result = sqlx::query!(
r#"UPDATE calendars SET principal = ?, id = ?, displayname = ?, description = ?, "order" = ?, color = ?, timezone = ?, timezone_id = ?, push_topic = ?, comp_event = ?, comp_todo = ?, comp_journal = ?
r#"UPDATE calendars SET principal = ?, id = ?, displayname = ?, description = ?, "order" = ?, color = ?, timezone_id = ?, push_topic = ?, comp_event = ?, comp_todo = ?, comp_journal = ?
WHERE (principal, id) = (?, ?)"#,
calendar.principal,
calendar.id,
@@ -186,7 +193,6 @@ impl SqliteCalendarStore {
calendar.description,
calendar.order,
calendar.color,
calendar.timezone,
calendar.timezone_id,
calendar.push_topic,
comp_event, comp_todo, comp_journal,

View File

@@ -149,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;
}
}
@@ -206,7 +221,10 @@ impl AuthenticationProvider for SqlitePrincipalStore {
None,
None,
Params {
rounds: 10,
// 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

@@ -8,6 +8,8 @@ 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

View File

@@ -1,11 +1,13 @@
use crate::config::NextcloudLoginConfig;
use axum::Router;
use axum::body::Body;
use axum::body::{Body, HttpBody};
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::{Redirect, Response};
use axum::routing::{any, options};
use axum_extra::TypedHeader;
use headers::{HeaderMapExt, UserAgent};
use http::header::CONNECTION;
use http::{HeaderValue, StatusCode};
use rustical_caldav::caldav_router;
use rustical_carddav::carddav_router;
@@ -178,4 +180,21 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
},
),
)
.layer(axum::middleware::from_fn(
async |req: Request, next: Next| {
// Closes the connection if the request body might've not been fully consumed
// Otherwise subsequent requests reusing the connection might fail.
// See https://github.com/lennart-k/rustical/issues/77
let body_empty = req.body().is_end_stream();
let mut response = next.run(req).await;
if !body_empty
&& (response.status().is_server_error() || response.status().is_client_error())
{
response
.headers_mut()
.insert(CONNECTION, HeaderValue::from_static("close"));
}
response
},
))
}