Compare commits

..

18 Commits

Author SHA1 Message Date
Lennart
c648ed315d version 0.9.1 2025-08-25 19:09:48 +02:00
Lennart
2cf481d4e6 make session cookie samesite=lax by default 2025-08-25 19:09:24 +02:00
Lennart
f3a1f27caf version 0.9.0 2025-08-23 20:06:38 +02:00
Lennart
0829093571 frontend: add dialog backdrop 2025-08-23 20:00:42 +02:00
Lennart
bfe17d0b65 caldav import: Add safeguard against empty addressbooks 2025-08-23 19:55:29 +02:00
Lennart
9050484932 Add addressbook import to frontend 2025-08-23 19:50:34 +02:00
Lennart
1e90ff3d6c carddav: Remove enforcement of UID matching filename (Apple Contacts doesn't play well) 2025-08-23 19:42:58 +02:00
Lennart
94ace71745 carddav: Change addressbook PUT to IMPORT 2025-08-23 19:01:19 +02:00
Lennart
f22d5ca04b clippy appeasement 2025-08-23 19:00:15 +02:00
Lennart
68a2e7e2a2 carddav: Require UID in address object 2025-08-23 18:09:03 +02:00
Lennart
4e3c3f3a3b Add calendar import endpoint and frontend form 2025-08-23 12:24:42 +02:00
Lennart
b7cfd3301b Add import_calendar method to CalendarStore 2025-08-23 12:23:05 +02:00
Lennart
9c114dc204 export: Include vtimezones
fixes #112
2025-08-22 21:32:34 +02:00
Lennart
9decef093d dav: add new http IMPORT method 2025-08-20 13:48:50 +02:00
Lennart
de2a8a2a8e bump version to 0.8.6 2025-08-17 15:48:37 +02:00
Lennart
51d2293ff9 frontend: Show unauthorized messages instead of redirecting to the login screen for non-user resources 2025-08-17 15:47:35 +02:00
Lennart
5c77719ce4 Add log warning for failed login attempts 2025-08-17 15:38:29 +02:00
Lennart
91996465f9 ical: Remove unused generic around CalendarObject 2025-08-17 15:38:07 +02:00
42 changed files with 1433 additions and 576 deletions

320
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.8.5"
version = "0.9.1"
edition = "2024"
description = "A CalDAV server"
repository = "https://github.com/lennart-k/rustical"

View File

@@ -7,6 +7,7 @@ accepted = [
"CDLA-Permissive-2.0",
"Zlib",
"AGPL-3.0",
"GPL-3.0",
"MPL-2.0",
]
workarounds = ["ring", "chrono", "rustls"]

View File

@@ -37,6 +37,7 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
.await?;
let mut timezones = HashMap::new();
let mut vtimezones = HashMap::new();
let objects = cal_store.get_objects(&principal, &calendar_id).await?;
let mut ical_calendar_builder = IcalCalendarBuilder::version("4.0")
@@ -65,6 +66,7 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
}
for object in &objects {
vtimezones.extend(object.get_vtimezones());
match object.get_data() {
CalendarObjectComponent::Event(EventObject {
event,
@@ -83,6 +85,10 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
}
}
for vtimezone in vtimezones.into_values() {
ical_calendar_builder = ical_calendar_builder.add_tz(vtimezone.to_owned());
}
let ical_calendar = ical_calendar_builder
.build()
.map_err(|parser_error| Error::IcalError(parser_error.into()))?;

View File

@@ -0,0 +1,102 @@
use crate::Error;
use crate::calendar::CalendarResourceService;
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use ical::{
generator::Emitter,
parser::{Component, ComponentMut},
};
use rustical_ical::{CalendarObject, CalendarObjectType};
use rustical_store::{Calendar, CalendarStore, SubscriptionStore, auth::Principal};
use std::io::BufReader;
use tracing::instrument;
#[instrument(skip(resource_service))]
pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
Path((principal, cal_id)): Path<(String, String)>,
user: Principal,
State(resource_service): State<CalendarResourceService<C, S>>,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
}
let mut parser = ical::IcalParser::new(BufReader::new(body.as_bytes()));
let mut cal = parser
.next()
.expect("input must contain calendar")
.unwrap()
.mutable();
if parser.next().is_some() {
return Err(rustical_ical::Error::InvalidData(
"multiple calendars, only one allowed".to_owned(),
)
.into());
}
// Extract calendar metadata
let displayname = cal
.get_property("X-WR-CALNAME")
.and_then(|prop| prop.value.to_owned());
let description = cal
.get_property("X-WR-CALDESC")
.and_then(|prop| prop.value.to_owned());
let timezone_id = cal
.get_property("X-WR-TIMEZONE")
.and_then(|prop| prop.value.to_owned());
// These properties should not appear in the expanded calendar objects
cal.remove_property("X-WR-CALNAME");
cal.remove_property("X-WR-CALDESC");
cal.remove_property("X-WR-TIMEZONE");
let cal = cal.verify().unwrap();
// Make sure timezone is valid
if let Some(timezone_id) = timezone_id.as_ref() {
assert!(
vtimezones_rs::VTIMEZONES.contains_key(timezone_id),
"Invalid calendar timezone id"
);
}
// Extract necessary component types
let mut cal_components = vec![];
if !cal.events.is_empty() {
cal_components.push(CalendarObjectType::Event);
}
if !cal.journals.is_empty() {
cal_components.push(CalendarObjectType::Journal);
}
if !cal.todos.is_empty() {
cal_components.push(CalendarObjectType::Todo);
}
let expanded_cals = cal.expand_calendar();
// Janky way to convert between IcalCalendar and CalendarObject
let objects = expanded_cals
.into_iter()
.map(|cal| cal.generate())
.map(CalendarObject::from_ics)
.collect::<Result<Vec<_>, _>>()?;
let new_cal = Calendar {
principal,
id: cal_id,
displayname,
order: 0,
description,
color: None,
timezone_id,
deleted_at: None,
synctoken: 0,
subscription_url: None,
push_topic: uuid::Uuid::new_v4().to_string(),
components: cal_components,
};
let cal_store = resource_service.cal_store;
cal_store.import_calendar(new_cal, objects, false).await?;
Ok(StatusCode::OK.into_response())
}

View File

@@ -1,4 +1,5 @@
pub mod get;
pub mod import;
pub mod mkcalendar;
pub mod post;
pub mod report;

View File

@@ -1,4 +1,5 @@
use crate::calendar::methods::get::route_get;
use crate::calendar::methods::import::route_import;
use crate::calendar::methods::mkcalendar::route_mkcalendar;
use crate::calendar::methods::post::route_post;
use crate::calendar::methods::report::route_report_calendar;
@@ -138,6 +139,13 @@ impl<C: CalendarStore, S: SubscriptionStore> AxumMethods for CalendarResourceSer
})
}
fn import() -> Option<rustical_dav::resource::MethodFunction<Self>> {
Some(|state, req| {
let mut service = Handler::with_state(route_import::<C, S>, state);
Box::pin(Service::call(&mut service, req))
})
}
fn mkcalendar() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>>
{
Some(|state, req| {

View File

@@ -0,0 +1,67 @@
use std::io::BufReader;
use crate::Error;
use crate::addressbook::AddressbookResourceService;
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use ical::{
parser::{Component, ComponentMut, vcard},
property::Property,
};
use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::Principal};
use tracing::instrument;
#[instrument(skip(resource_service))]
pub async fn route_import<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>,
user: Principal,
State(resource_service): State<AddressbookResourceService<AS, S>>,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
}
let parser = vcard::VcardParser::new(BufReader::new(body.as_bytes()));
let mut objects = vec![];
for res in parser {
let mut card = res.unwrap();
let uid = card.get_uid();
if uid.is_none() {
let mut card_mut = card.mutable();
card_mut.set_property(Property {
name: "UID".to_owned(),
value: Some(uuid::Uuid::new_v4().to_string()),
params: None,
});
card = card_mut.verify().unwrap();
}
objects.push(card.try_into().unwrap());
}
if objects.is_empty() {
return Ok((StatusCode::BAD_REQUEST, "empty addressbook data").into_response());
}
let addressbook = Addressbook {
principal,
id: addressbook_id,
displayname: None,
description: None,
deleted_at: None,
synctoken: 0,
push_topic: uuid::Uuid::new_v4().to_string(),
};
let addr_store = resource_service.addr_store;
addr_store
.import_addressbook(addressbook, objects, false)
.await?;
Ok(StatusCode::OK.into_response())
}

View File

@@ -1,5 +1,5 @@
pub mod get;
pub mod import;
pub mod mkcol;
pub mod post;
pub mod put;
pub mod report;

View File

@@ -1,47 +0,0 @@
use crate::Error;
use crate::addressbook::AddressbookResourceService;
use axum::response::IntoResponse;
use axum::{
extract::{Path, State},
response::Response,
};
use http::StatusCode;
use ical::VcardParser;
use rustical_ical::AddressObject;
use rustical_store::Addressbook;
use rustical_store::{AddressbookStore, SubscriptionStore, auth::Principal};
use tracing::instrument;
#[instrument(skip(addr_store))]
pub async fn route_put<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>,
State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,
user: Principal,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
}
let mut objects = vec![];
for object in VcardParser::new(body.as_bytes()) {
let object = object.map_err(rustical_ical::Error::from)?;
objects.push(AddressObject::try_from(object)?);
}
let addressbook = Addressbook {
id: addressbook_id.clone(),
principal: principal.clone(),
displayname: None,
description: None,
deleted_at: None,
synctoken: Default::default(),
push_topic: uuid::Uuid::new_v4().to_string(),
};
addr_store
.import_addressbook(principal.clone(), addressbook, objects)
.await?;
Ok(StatusCode::CREATED.into_response())
}

View File

@@ -3,8 +3,8 @@ use super::methods::report::route_report_addressbook;
use crate::address_object::AddressObjectResourceService;
use crate::address_object::resource::AddressObjectResource;
use crate::addressbook::methods::get::route_get;
use crate::addressbook::methods::import::route_import;
use crate::addressbook::methods::post::route_post;
use crate::addressbook::methods::put::route_put;
use crate::addressbook::resource::AddressbookResource;
use crate::{CardDavPrincipalUri, Error};
use async_trait::async_trait;
@@ -139,9 +139,9 @@ impl<AS: AddressbookStore, S: SubscriptionStore> AxumMethods for AddressbookReso
})
}
fn put() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
fn import() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| {
let mut service = Handler::with_state(route_put::<AS, S>, state);
let mut service = Handler::with_state(route_import::<AS, S>, state);
Box::pin(Service::call(&mut service, req))
})
}

View File

@@ -38,6 +38,11 @@ pub trait AxumMethods: Sized + Send + Sync + 'static {
None
}
#[inline]
fn import() -> Option<MethodFunction<Self>> {
None
}
#[inline]
fn allow_header() -> Allow {
let mut allow = vec![
@@ -67,6 +72,9 @@ pub trait AxumMethods: Sized + Send + Sync + 'static {
if Self::put().is_some() {
allow.push(Method::PUT);
}
if Self::import().is_some() {
allow.push(Method::from_str("IMPORT").unwrap());
}
allow.into_iter().collect()
}

View File

@@ -97,6 +97,11 @@ where
return svc(self.resource_service.clone(), req);
}
}
"IMPORT" => {
if let Some(svc) = RS::import() {
return svc(self.resource_service.clone(), req);
}
}
_ => {}
};
Box::pin(async move {

View File

@@ -0,0 +1,92 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
@customElement("import-addressbook-form")
export class ImportAddressbookForm extends LitElement {
constructor() {
super()
}
protected override createRenderRoot() {
return this
}
@property()
user: string = ''
@property()
principal: string
@property()
addressbook_id: string = self.crypto.randomUUID()
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
file: File;
override render() {
return html`
<button @click=${() => this.dialog.value.showModal()}>Import addressbook</button>
<dialog ${ref(this.dialog)}>
<h3>Import addressbook</h3>
<form @submit=${this.submit} ${ref(this.form)}>
<label>
principal (for group addressbook)
<select name="principal" value=${this.user} @change=${e => this.principal = e.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map(membership => html`
<option value=${membership}>${membership}</option>
`)}
</select>
</label>
<br>
<label>
id
<input type="text" name="id" value=${this.addressbook_id} @change=${e => this.addressbook_id = e.target.value} />
</label>
<br>
<label>
file
<input type="file" accept="text/vcard" name="file" @change=${e => this.file = e.target.files[0]} />
</label>
<button type="submit">Import</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()
this.principal ||= this.user
if (!this.principal) {
alert("Empty principal")
return
}
if (!this.addressbook_id) {
alert("Empty id")
return
}
let response = await fetch(`/carddav/principal/${this.principal}/${this.addressbook_id}`, {
method: 'IMPORT',
headers: {
'Content-Type': 'text/vcard'
},
body: this.file,
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}
}
declare global {
interface HTMLElementTagNameMap {
'import-addressbook-form': ImportAddressbookForm
}
}

View File

@@ -0,0 +1,92 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
@customElement("import-calendar-form")
export class ImportCalendarForm extends LitElement {
constructor() {
super()
}
protected override createRenderRoot() {
return this
}
@property()
user: string = ''
@property()
principal: string
@property()
cal_id: string = self.crypto.randomUUID()
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
file: File;
override render() {
return html`
<button @click=${() => this.dialog.value.showModal()}>Import calendar</button>
<dialog ${ref(this.dialog)}>
<h3>Import calendar</h3>
<form @submit=${this.submit} ${ref(this.form)}>
<label>
principal (for group calendars)
<select name="principal" value=${this.user} @change=${e => this.principal = e.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map(membership => html`
<option value=${membership}>${membership}</option>
`)}
</select>
</label>
<br>
<label>
id
<input type="text" name="id" value=${this.cal_id} @change=${e => this.cal_id = e.target.value} />
</label>
<br>
<label>
file
<input type="file" accept="text/calendar" name="file" @change=${e => this.file = e.target.files[0]} />
</label>
<button type="submit">Import</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()
this.principal ||= this.user
if (!this.principal) {
alert("Empty principal")
return
}
if (!this.cal_id) {
alert("Empty id")
return
}
let response = await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
method: 'IMPORT',
headers: {
'Content-Type': 'text/calendar'
},
body: this.file,
})
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`)
return null
}
window.location.reload()
return null
}
}
declare global {
interface HTMLElementTagNameMap {
'import-calendar-form': ImportCalendarForm
}
}

View File

@@ -16,8 +16,10 @@ export default defineConfig({
input: [
"lib/create-calendar-form.ts",
"lib/edit-calendar-form.ts",
"lib/import-calendar-form.ts",
"lib/create-addressbook-form.ts",
"lib/edit-addressbook-form.ts",
"lib/import-addressbook-form.ts",
"lib/delete-button.ts",
],
output: {

View File

@@ -1,6 +1,7 @@
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 { e, n } from "./ref-CPp9J0V5.mjs";
import { e as escapeXml } from "./index-_IB1wMbZ.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {

View File

@@ -1,6 +1,7 @@
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 { e, n } from "./ref-CPp9J0V5.mjs";
import { e as escapeXml } from "./index-_IB1wMbZ.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {

View File

@@ -1,6 +1,7 @@
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 { e, n } from "./ref-CPp9J0V5.mjs";
import { e as escapeXml } from "./index-_IB1wMbZ.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {

View File

@@ -1,6 +1,7 @@
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 { e, n } from "./ref-CPp9J0V5.mjs";
import { e as escapeXml } from "./index-_IB1wMbZ.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {

View File

@@ -0,0 +1,100 @@
import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n } from "./ref-CPp9J0V5.mjs";
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 ImportAddressbookForm = class extends i {
constructor() {
super();
this.user = "";
this.addressbook_id = self.crypto.randomUUID();
this.dialog = e();
this.form = e();
}
createRenderRoot() {
return this;
}
render() {
return x`
<button @click=${() => this.dialog.value.showModal()}>Import addressbook</button>
<dialog ${n(this.dialog)}>
<h3>Import addressbook</h3>
<form @submit=${this.submit} ${n(this.form)}>
<label>
principal (for group addressbook)
<select name="principal" value=${this.user} @change=${(e2) => this.principal = e2.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map((membership) => x`
<option value=${membership}>${membership}</option>
`)}
</select>
</label>
<br>
<label>
id
<input type="text" name="id" value=${this.addressbook_id} @change=${(e2) => this.addressbook_id = e2.target.value} />
</label>
<br>
<label>
file
<input type="file" accept="text/vcard" name="file" @change=${(e2) => this.file = e2.target.files[0]} />
</label>
<button type="submit">Import</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();
this.principal || (this.principal = this.user);
if (!this.principal) {
alert("Empty principal");
return;
}
if (!this.addressbook_id) {
alert("Empty id");
return;
}
let response = await fetch(`/carddav/principal/${this.principal}/${this.addressbook_id}`, {
method: "IMPORT",
headers: {
"Content-Type": "text/vcard"
},
body: this.file
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
window.location.reload();
return null;
}
};
__decorateClass([
n$1()
], ImportAddressbookForm.prototype, "user", 2);
__decorateClass([
n$1()
], ImportAddressbookForm.prototype, "principal", 2);
__decorateClass([
n$1()
], ImportAddressbookForm.prototype, "addressbook_id", 2);
ImportAddressbookForm = __decorateClass([
t("import-addressbook-form")
], ImportAddressbookForm);
export {
ImportAddressbookForm
};

View File

@@ -0,0 +1,100 @@
import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n } from "./ref-CPp9J0V5.mjs";
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 ImportCalendarForm = class extends i {
constructor() {
super();
this.user = "";
this.cal_id = self.crypto.randomUUID();
this.dialog = e();
this.form = e();
}
createRenderRoot() {
return this;
}
render() {
return x`
<button @click=${() => this.dialog.value.showModal()}>Import calendar</button>
<dialog ${n(this.dialog)}>
<h3>Import calendar</h3>
<form @submit=${this.submit} ${n(this.form)}>
<label>
principal (for group calendars)
<select name="principal" value=${this.user} @change=${(e2) => this.principal = e2.target.value}>
<option value=${this.user}>${this.user}</option>
${window.rusticalUser.memberships.map((membership) => x`
<option value=${membership}>${membership}</option>
`)}
</select>
</label>
<br>
<label>
id
<input type="text" name="id" value=${this.cal_id} @change=${(e2) => this.cal_id = e2.target.value} />
</label>
<br>
<label>
file
<input type="file" accept="text/calendar" name="file" @change=${(e2) => this.file = e2.target.files[0]} />
</label>
<button type="submit">Import</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();
this.principal || (this.principal = this.user);
if (!this.principal) {
alert("Empty principal");
return;
}
if (!this.cal_id) {
alert("Empty id");
return;
}
let response = await fetch(`/caldav/principal/${this.principal}/${this.cal_id}`, {
method: "IMPORT",
headers: {
"Content-Type": "text/calendar"
},
body: this.file
});
if (response.status >= 400) {
alert(`Error ${response.status}: ${await response.text()}`);
return null;
}
window.location.reload();
return null;
}
};
__decorateClass([
n$1()
], ImportCalendarForm.prototype, "user", 2);
__decorateClass([
n$1()
], ImportCalendarForm.prototype, "principal", 2);
__decorateClass([
n$1()
], ImportCalendarForm.prototype, "cal_id", 2);
ImportCalendarForm = __decorateClass([
t("import-calendar-form")
], ImportCalendarForm);
export {
ImportCalendarForm
};

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -317,6 +317,10 @@ dialog {
padding: 32px;
}
dialog::backdrop {
background: color-mix(in srgb, var(--background-color), transparent 50%);
}
footer {
display: flex;
justify-content: center;

View File

@@ -65,4 +65,5 @@
{% endif %}
<create-addressbook-form user="{{ user.id }}"></create-addressbook-form>
<import-addressbook-form user="{{ user.id }}"></import-addressbook-form>

View File

@@ -84,4 +84,5 @@
</ul>
{% endif %}
<create-calendar-form user="{{ user.id }}"></create-calendar-form>
<import-calendar-form user="{{ user.id }}"></import-calendar-form>

View File

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

View File

@@ -45,38 +45,38 @@ pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: Addres
frontend_config: FrontendConfig,
oidc_config: Option<OidcConfig>,
) -> Router {
let mut router = Router::new();
router = router
.route("/", get(route_root))
.route("/user", get(route_get_home))
.route("/user/{user}", get(route_user_named::<CS, AS, AP>))
let user_router = Router::new()
.route("/", get(route_get_home))
.route("/{user}", get(route_user_named::<CS, AS, AP>))
// App token management
.route("/user/{user}/app_token", post(route_post_app_token::<AP>))
.route("/{user}/app_token", post(route_post_app_token::<AP>))
.route(
// POST because HTML5 forms don't support DELETE method
"/user/{user}/app_token/{id}/delete",
"/{user}/app_token/{id}/delete",
post(route_delete_app_token::<AP>),
)
// Calendar
.route("/user/{user}/calendar", get(route_calendars::<CS>))
.route("/{user}/calendar", get(route_calendars::<CS>))
.route("/{user}/calendar/{calendar}", get(route_calendar::<CS>))
.route(
"/user/{user}/calendar/{calendar}",
get(route_calendar::<CS>),
)
.route(
"/user/{user}/calendar/{calendar}/restore",
"/{user}/calendar/{calendar}/restore",
post(route_calendar_restore::<CS>),
)
// Addressbook
.route("/user/{user}/addressbook", get(route_addressbooks::<AS>))
.route("/{user}/addressbook", get(route_addressbooks::<AS>))
.route(
"/user/{user}/addressbook/{addressbook}",
"/{user}/addressbook/{addressbook}",
get(route_addressbook::<AS>),
)
.route(
"/user/{user}/addressbook/{addressbook}/restore",
"/{user}/addressbook/{addressbook}/restore",
post(route_addressbook_restore::<AS>),
)
.layer(middleware::from_fn(unauthorized_handler));
let router = Router::new()
.route("/", get(route_root))
.nest("/user", user_router)
.route("/login", get(route_get_login).post(route_post_login::<AP>))
.route("/logout", post(route_post_logout));
@@ -109,8 +109,7 @@ pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: Addres
.layer(Extension(cal_store.clone()))
.layer(Extension(addr_store.clone()))
.layer(Extension(frontend_config.clone()))
.layer(Extension(oidc_config.clone()))
.layer(middleware::from_fn(unauthorized_handler));
.layer(Extension(oidc_config.clone()));
Router::new()
.nest(prefix, router)

View File

@@ -13,7 +13,7 @@ use http::StatusCode;
use rustical_store::auth::AuthenticationProvider;
use serde::Deserialize;
use tower_sessions::Session;
use tracing::instrument;
use tracing::{instrument, warn};
use url::Url;
#[derive(Template, WebTemplate)]
@@ -98,6 +98,7 @@ pub async fn route_post_login<AP: AuthenticationProvider>(
session.insert("user", user.id).await.unwrap();
Redirect::to(&redirect_uri).into_response()
} else {
warn!("Failed password login attempt as {username}");
StatusCode::UNAUTHORIZED.into_response()
}
}

View File

@@ -20,19 +20,21 @@ impl TryFrom<VcardContact> for AddressObject {
type Error = Error;
fn try_from(vcard: VcardContact) -> Result<Self, Self::Error> {
let id = vcard
.get_property("UID")
.ok_or(Error::InvalidData("Missing UID".to_owned()))?
.value
.clone()
.ok_or(Error::InvalidData("Missing UID".to_owned()))?;
let uid = vcard
.get_uid()
.ok_or(Error::InvalidData("missing UID".to_owned()))?
.to_owned();
let vcf = vcard.generate();
Ok(Self { id, vcf, vcard })
Ok(Self {
vcf,
vcard,
id: uid,
})
}
}
impl AddressObject {
pub fn from_vcf(object_id: String, vcf: String) -> Result<Self, Error> {
pub fn from_vcf(id: String, vcf: String) -> Result<Self, Error> {
let mut parser = vcard::VcardParser::new(BufReader::new(vcf.as_bytes()));
let vcard = parser.next().ok_or(Error::MissingContact)??;
if parser.next().is_some() {
@@ -40,11 +42,7 @@ impl AddressObject {
"multiple vcards, only one allowed".to_owned(),
));
}
Ok(Self {
id: object_id,
vcf,
vcard,
})
Ok(Self { id, vcf, vcard })
}
pub fn get_id(&self) -> &str {
@@ -53,7 +51,7 @@ impl AddressObject {
pub fn get_etag(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(&self.id);
hasher.update(self.get_id());
hasher.update(self.get_vcf());
format!("\"{:x}\"", hasher.finalize())
}

View File

@@ -5,6 +5,7 @@ use chrono::DateTime;
use chrono::Utc;
use derive_more::Display;
use ical::generator::{Emitter, IcalCalendar};
use ical::parser::ical::component::IcalTimeZone;
use ical::property::Property;
use serde::Deserialize;
use serde::Serialize;
@@ -67,10 +68,11 @@ impl Default for CalendarObjectComponent {
}
#[derive(Debug, Clone, Default)]
pub struct CalendarObject<const VERIFIED: bool = true> {
pub struct CalendarObject {
data: CalendarObjectComponent,
properties: Vec<Property>,
ics: String,
vtimezones: HashMap<String, IcalTimeZone>,
}
impl CalendarObject {
@@ -102,6 +104,13 @@ impl CalendarObject {
.map(|timezone| (timezone.get_tzid().to_owned(), (&timezone).try_into().ok()))
.collect();
let vtimezones = cal
.timezones
.clone()
.into_iter()
.map(|timezone| (timezone.get_tzid().to_owned(), timezone))
.collect();
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() {
@@ -118,9 +127,14 @@ impl CalendarObject {
data,
properties: cal.properties,
ics,
vtimezones,
})
}
pub fn get_vtimezones(&self) -> &HashMap<String, IcalTimeZone> {
&self.vtimezones
}
pub fn get_data(&self) -> &CalendarObjectComponent {
&self.data
}

View File

@@ -76,8 +76,8 @@ pub trait AddressbookStore: Send + Sync + 'static {
async fn import_addressbook(
&self,
principal: String,
addressbook: Addressbook,
objects: Vec<AddressObject>,
merge_existing: bool,
) -> Result<(), Error>;
}

View File

@@ -34,6 +34,12 @@ pub trait CalendarStore: Send + Sync + 'static {
use_trashbin: bool,
) -> Result<(), Error>;
async fn restore_calendar(&self, principal: &str, name: &str) -> Result<(), Error>;
async fn import_calendar(
&self,
calendar: Calendar,
objects: Vec<CalendarObject>,
merge_existing: bool,
) -> Result<(), Error>;
async fn sync_changes(
&self,

View File

@@ -189,6 +189,24 @@ impl<CS: CalendarStore, BS: CalendarStore> CalendarStore for CombinedCalendarSto
}
}
#[inline]
async fn import_calendar(
&self,
calendar: Calendar,
objects: Vec<CalendarObject>,
merge_existing: bool,
) -> Result<(), Error> {
if calendar.id.starts_with(BIRTHDAYS_PREFIX) {
self.birthday_store
.import_calendar(calendar, objects, merge_existing)
.await
} else {
self.cal_store
.import_calendar(calendar, objects, merge_existing)
.await
}
}
#[inline]
async fn delete_calendar(
&self,

View File

@@ -83,6 +83,15 @@ impl<AS: AddressbookStore> CalendarStore for ContactBirthdayStore<AS> {
Err(Error::ReadOnly)
}
async fn import_calendar(
&self,
_calendar: Calendar,
_objects: Vec<CalendarObject>,
_merge_existing: bool,
) -> Result<(), Error> {
Err(Error::ReadOnly)
}
async fn sync_changes(
&self,
principal: &str,

View File

@@ -17,7 +17,7 @@ struct AddressObjectRow {
}
impl TryFrom<AddressObjectRow> for AddressObject {
type Error = crate::Error;
type Error = rustical_store::Error;
fn try_from(value: AddressObjectRow) -> Result<Self, Self::Error> {
Ok(Self::from_vcf(value.id, value.vcf)?)
@@ -259,7 +259,7 @@ impl SqliteAddressbookStore {
.fetch_all(executor)
.await.map_err(crate::Error::from)?
.into_iter()
.map(|row| row.try_into().map_err(rustical_store::Error::from))
.map(|row| row.try_into())
.collect()
}
@@ -270,7 +270,7 @@ impl SqliteAddressbookStore {
object_id: &str,
show_deleted: bool,
) -> Result<AddressObject, rustical_store::Error> {
Ok(sqlx::query_as!(
sqlx::query_as!(
AddressObjectRow,
"SELECT id, vcf FROM addressobjects WHERE (principal, addressbook_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) OR ?)",
principal,
@@ -281,7 +281,7 @@ impl SqliteAddressbookStore {
.fetch_one(executor)
.await
.map_err(crate::Error::from)?
.try_into()?)
.try_into()
}
async fn _put_object<'e, E: Executor<'e, Database = Sqlite>>(
@@ -627,20 +627,32 @@ impl AddressbookStore for SqliteAddressbookStore {
#[instrument(skip(objects))]
async fn import_addressbook(
&self,
principal: String,
addressbook: Addressbook,
objects: Vec<AddressObject>,
merge_existing: bool,
) -> Result<(), Error> {
let mut tx = self.db.begin().await.map_err(crate::Error::from)?;
let addressbook_id = addressbook.id.clone();
Self::_insert_addressbook(&mut *tx, addressbook).await?;
let existing =
match Self::_get_addressbook(&mut *tx, &addressbook.principal, &addressbook.id, true)
.await
{
Ok(addressbook) => Some(addressbook),
Err(Error::NotFound) => None,
Err(err) => return Err(err),
};
if existing.is_some() && !merge_existing {
return Err(Error::AlreadyExists);
}
if existing.is_none() {
Self::_insert_addressbook(&mut *tx, addressbook.clone()).await?;
}
for object in objects {
Self::_put_object(
&mut *tx,
principal.clone(),
addressbook_id.clone(),
addressbook.principal.clone(),
addressbook.id.clone(),
object,
false,
)

View File

@@ -570,6 +570,43 @@ impl CalendarStore for SqliteCalendarStore {
Self::_restore_calendar(&self.db, principal, id).await
}
#[instrument]
async fn import_calendar(
&self,
calendar: Calendar,
objects: Vec<CalendarObject>,
merge_existing: bool,
) -> Result<(), Error> {
let mut tx = self.db.begin().await.map_err(crate::Error::from)?;
let existing_cal =
match Self::_get_calendar(&mut *tx, &calendar.principal, &calendar.id, true).await {
Ok(cal) => Some(cal),
Err(Error::NotFound) => None,
Err(err) => return Err(err),
};
if existing_cal.is_some() && !merge_existing {
return Err(Error::AlreadyExists);
}
if existing_cal.is_none() {
Self::_insert_calendar(&mut *tx, calendar.clone()).await?;
}
for object in objects {
Self::_put_object(
&mut *tx,
calendar.principal.clone(),
calendar.id.clone(),
object,
false,
)
.await?;
}
tx.commit().await.map_err(crate::Error::from)?;
Ok(())
}
#[instrument]
async fn calendar_query(
&self,

View File

@@ -38,6 +38,7 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
oidc_config: Option<OidcConfig>,
nextcloud_login_config: NextcloudLoginConfig,
dav_push_enabled: bool,
session_cookie_samesite_strict: bool,
) -> Router<()> {
let combined_cal_store = Arc::new(CombinedCalendarStore::new(
cal_store.clone(),
@@ -128,7 +129,11 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
SessionManagerLayer::new(session_store)
.with_name("rustical_session")
.with_secure(true)
.with_same_site(SameSite::Strict)
.with_same_site(if session_cookie_samesite_strict {
SameSite::Strict
} else {
SameSite::Lax
})
.with_expiry(Expiry::OnInactivity(
tower_sessions::cookie::time::Duration::hours(2),
)),

View File

@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
pub struct HttpConfig {
pub host: String,
pub port: u16,
pub session_cookie_samesite_strict: bool,
}
impl Default for HttpConfig {
@@ -14,6 +15,7 @@ impl Default for HttpConfig {
Self {
host: "0.0.0.0".to_owned(),
port: 4000,
session_cookie_samesite_strict: false,
}
}
}

View File

@@ -116,6 +116,7 @@ async fn main() -> Result<()> {
config.oidc.clone(),
config.nextcloud_login.clone(),
config.dav_push.enabled,
config.http.session_cookie_samesite_strict,
);
let app = ServiceExt::<Request>::into_make_service(
NormalizePathLayer::trim_trailing_slash().layer(app),