mirror of
https://github.com/lennart-k/rustical.git
synced 2025-12-14 01:12:24 +00:00
Initial commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/target
|
||||||
|
crates/*/target
|
||||||
|
|
||||||
|
db.json
|
||||||
|
config.toml
|
||||||
|
schema.sql
|
||||||
2579
Cargo.lock
generated
Normal file
2579
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "rustical"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rustical_store = { path = "./crates/store/" }
|
||||||
|
rustical_api = { path = "./crates/api/" }
|
||||||
|
rustical_dav = { path = "./crates/dav/" }
|
||||||
|
serde = { version = "1.0.188", features = ["derive"] }
|
||||||
|
tokio = { version = "1.32.0", features = [
|
||||||
|
"net",
|
||||||
|
"tracing",
|
||||||
|
"macros",
|
||||||
|
"rt-multi-thread",
|
||||||
|
"full",
|
||||||
|
] }
|
||||||
|
tracing = "0.1.37"
|
||||||
|
env_logger = "0.10.0"
|
||||||
|
actix-web = "4.4.0"
|
||||||
|
anyhow = { version = "1.0.75", features = ["backtrace"] }
|
||||||
|
serde_json = "1.0.105"
|
||||||
|
toml = "0.7.6"
|
||||||
|
clap = { version = "4.4.2", features = ["derive", "env"] }
|
||||||
2256
crates/api/Cargo.lock
generated
Normal file
2256
crates/api/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
crates/api/Cargo.toml
Normal file
12
crates/api/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "rustical_api"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
actix-web = "4.4.0"
|
||||||
|
anyhow = { version = "1.0.75", features = ["backtrace"] }
|
||||||
|
rustical_store = { path = "../store/" }
|
||||||
|
serde = "1.0.188"
|
||||||
|
serde_json = "1.0.105"
|
||||||
|
tokio = { version = "1.32.0", features = ["sync"] }
|
||||||
30
crates/api/src/lib.rs
Normal file
30
crates/api/src/lib.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use actix_web::{
|
||||||
|
http::Method,
|
||||||
|
web::{self, Data, Path},
|
||||||
|
Responder,
|
||||||
|
};
|
||||||
|
use rustical_store::calendar::CalendarStore;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
pub fn configure_api<C: CalendarStore>(cfg: &mut web::ServiceConfig, store: Data<RwLock<C>>) {
|
||||||
|
cfg.app_data(store)
|
||||||
|
.route("ping", web::method(Method::GET).to(get_ping::<C>))
|
||||||
|
.route(
|
||||||
|
"/{cid}/events",
|
||||||
|
web::method(Method::GET).to(get_events::<C>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_events<C: CalendarStore>(
|
||||||
|
store: Data<RwLock<C>>,
|
||||||
|
path: Path<String>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let cid = path.into_inner();
|
||||||
|
let events = store.read().await.get_events(&cid).await.unwrap();
|
||||||
|
serde_json::to_string_pretty(&events)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_ping<C: CalendarStore>(store: Data<RwLock<C>>) -> impl Responder {
|
||||||
|
let cals = store.read().await.get_calendars().await.unwrap();
|
||||||
|
serde_json::to_string_pretty(&cals)
|
||||||
|
}
|
||||||
2320
crates/dav/Cargo.lock
generated
Normal file
2320
crates/dav/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
crates/dav/Cargo.toml
Normal file
22
crates/dav/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "rustical_dav"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
actix-web = "4.4.0"
|
||||||
|
actix-web-httpauth = "0.8.0"
|
||||||
|
anyhow = { version = "1.0.75", features = ["backtrace"] }
|
||||||
|
base64 = "0.21.3"
|
||||||
|
derive_more = "0.99.17"
|
||||||
|
futures-util = "0.3.28"
|
||||||
|
quick-xml = { version = "0.30.0", features = [
|
||||||
|
"serde",
|
||||||
|
"serde-types",
|
||||||
|
"serialize",
|
||||||
|
] }
|
||||||
|
roxmltree = "0.18.0"
|
||||||
|
rustical_store = { path = "../store/" }
|
||||||
|
serde = { version = "1.0.188", features = ["serde_derive", "derive"] }
|
||||||
|
serde_json = "1.0.105"
|
||||||
|
tokio = { version = "1.32.0", features = ["sync", "full"] }
|
||||||
53
crates/dav/src/depth_extractor.rs
Normal file
53
crates/dav/src/depth_extractor.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use actix_web::{http::StatusCode, FromRequest, HttpRequest, ResponseError};
|
||||||
|
use derive_more::Display;
|
||||||
|
use futures_util::future::{err, ok, Ready};
|
||||||
|
|
||||||
|
#[derive(Debug, Display)]
|
||||||
|
pub struct InvalidDepthHeader {}
|
||||||
|
|
||||||
|
impl ResponseError for InvalidDepthHeader {
|
||||||
|
fn status_code(&self) -> actix_web::http::StatusCode {
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub enum Depth {
|
||||||
|
Zero,
|
||||||
|
One,
|
||||||
|
Infinity,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&[u8]> for Depth {
|
||||||
|
type Error = InvalidDepthHeader;
|
||||||
|
|
||||||
|
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
|
||||||
|
match value {
|
||||||
|
b"0" => Ok(Depth::Zero),
|
||||||
|
b"1" => Ok(Depth::One),
|
||||||
|
b"Infinity" | b"infinity" => Ok(Depth::Infinity),
|
||||||
|
_ => Err(InvalidDepthHeader {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequest for Depth {
|
||||||
|
type Error = InvalidDepthHeader;
|
||||||
|
type Future = Ready<Result<Self, Self::Error>>;
|
||||||
|
|
||||||
|
fn extract(req: &HttpRequest) -> Self::Future {
|
||||||
|
if let Some(depth_header) = req.headers().get("Depth") {
|
||||||
|
match depth_header.as_bytes().try_into() {
|
||||||
|
Ok(depth) => ok(depth),
|
||||||
|
Err(e) => err(e),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// default depth
|
||||||
|
ok(Depth::Zero)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_request(req: &HttpRequest, _payload: &mut actix_web::dev::Payload) -> Self::Future {
|
||||||
|
Self::extract(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
crates/dav/src/error.rs
Normal file
28
crates/dav/src/error.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
use actix_web::{http::StatusCode, HttpResponse};
|
||||||
|
use derive_more::{Display, Error};
|
||||||
|
|
||||||
|
#[derive(Debug, Display, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[display(fmt = "Internal server error")]
|
||||||
|
InternalError,
|
||||||
|
#[display(fmt = "Not found")]
|
||||||
|
NotFound,
|
||||||
|
#[display(fmt = "Bad request")]
|
||||||
|
BadRequest,
|
||||||
|
Unauthorized,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl actix_web::error::ResponseError for Error {
|
||||||
|
fn status_code(&self) -> StatusCode {
|
||||||
|
match *self {
|
||||||
|
Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Self::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
Self::BadRequest => StatusCode::BAD_REQUEST,
|
||||||
|
Self::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_response(&self) -> HttpResponse {
|
||||||
|
HttpResponse::build(self.status_code()).body(self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
78
crates/dav/src/lib.rs
Normal file
78
crates/dav/src/lib.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
use actix_web::http::Method;
|
||||||
|
use actix_web::web::{self, Data};
|
||||||
|
use actix_web::{guard, HttpResponse, Responder};
|
||||||
|
use actix_web_httpauth::middleware::HttpAuthentication;
|
||||||
|
use error::Error;
|
||||||
|
use routes::{calendar, event, principal, root};
|
||||||
|
use rustical_store::calendar::CalendarStore;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
pub mod depth_extractor;
|
||||||
|
pub mod error;
|
||||||
|
pub mod namespace;
|
||||||
|
mod propfind;
|
||||||
|
pub mod routes;
|
||||||
|
|
||||||
|
pub fn configure_well_known(cfg: &mut web::ServiceConfig, caldav_root: String) {
|
||||||
|
cfg.service(web::redirect("/caldav", caldav_root).permanent());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn configure_dav<C: CalendarStore>(cfg: &mut web::ServiceConfig, store: Data<RwLock<C>>) {
|
||||||
|
let propfind_method = || Method::from_str("PROPFIND").unwrap();
|
||||||
|
let report_method = || Method::from_str("REPORT").unwrap();
|
||||||
|
|
||||||
|
let auth = HttpAuthentication::basic(|req, creds| async move {
|
||||||
|
if creds.user_id().is_empty() {
|
||||||
|
// not authenticated
|
||||||
|
Err((actix_web::error::ErrorUnauthorized("Unauthorized"), req))
|
||||||
|
} else {
|
||||||
|
Ok(req)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cfg.app_data(store)
|
||||||
|
.service(
|
||||||
|
web::resource("{path:.*}")
|
||||||
|
// Without the guard this service would handle all requests
|
||||||
|
.guard(guard::Method(Method::OPTIONS))
|
||||||
|
.to(options_handler),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("")
|
||||||
|
.route(web::method(propfind_method()).to(root::route_propfind_root))
|
||||||
|
.wrap(auth.clone()),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("/{principal}")
|
||||||
|
.route(web::method(propfind_method()).to(principal::route_propfind_principal::<C>))
|
||||||
|
.wrap(auth.clone()),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("/{principal}/{calendar}")
|
||||||
|
.route(web::method(report_method()).to(calendar::route_report_calendar::<C>))
|
||||||
|
.route(web::method(propfind_method()).to(calendar::route_propfind_calendar::<C>))
|
||||||
|
.wrap(auth.clone()),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("/{principal}/{calendar}/{event}")
|
||||||
|
.route(web::method(Method::DELETE).to(event::delete_event::<C>))
|
||||||
|
.route(web::method(Method::GET).to(event::get_event::<C>))
|
||||||
|
.route(web::method(Method::PUT).to(event::put_event::<C>))
|
||||||
|
.wrap(auth.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn options_handler() -> impl Responder {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header((
|
||||||
|
"Allow",
|
||||||
|
"OPTIONS, GET, HEAD, POST, PUT, REPORT, PROPFIND, PROPPATCH",
|
||||||
|
))
|
||||||
|
.insert_header((
|
||||||
|
"DAV",
|
||||||
|
"1, 2, 3, calendar-access, extended-mkcol",
|
||||||
|
// "1, 2, 3, calendar-access, addressbook, extended-mkcol",
|
||||||
|
))
|
||||||
|
.body("options")
|
||||||
|
}
|
||||||
38
crates/dav/src/namespace.rs
Normal file
38
crates/dav/src/namespace.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use quick_xml::events::attributes::Attribute;
|
||||||
|
|
||||||
|
// An enum keeping track of the XML namespaces used for WebDAV and its extensions
|
||||||
|
//
|
||||||
|
// Can also generate appropriate attributes for quick_xml
|
||||||
|
pub enum Namespace {
|
||||||
|
Dav,
|
||||||
|
CalDAV,
|
||||||
|
ICal,
|
||||||
|
CServer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Namespace {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Dav => "DAV:",
|
||||||
|
Self::CalDAV => "urn:ietf:params:xml:ns:caldav",
|
||||||
|
Self::ICal => "http://apple.com/ns/ical/",
|
||||||
|
Self::CServer => "http://calendarserver.org/ns/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns an opinionated namespace attribute name
|
||||||
|
pub fn xml_attr(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Dav => "xmlns",
|
||||||
|
Self::CalDAV => "xmlns:C",
|
||||||
|
Self::ICal => "xmlns:IC",
|
||||||
|
Self::CServer => "xmlns:CS",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Namespace> for Attribute<'static> {
|
||||||
|
fn from(value: Namespace) -> Self {
|
||||||
|
(value.xml_attr(), value.as_str()).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
122
crates/dav/src/propfind.rs
Normal file
122
crates/dav/src/propfind.rs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use quick_xml::{
|
||||||
|
events::{attributes::Attribute, BytesText},
|
||||||
|
Writer,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn parse_propfind(body: &str) -> Result<Vec<&str>> {
|
||||||
|
if body.is_empty() {
|
||||||
|
// if body is empty, allprops must be returned (RFC 4918)
|
||||||
|
return Ok(vec!["allprops"]);
|
||||||
|
}
|
||||||
|
let doc = roxmltree::Document::parse(body)?;
|
||||||
|
|
||||||
|
let propfind_node = doc.root_element();
|
||||||
|
if propfind_node.tag_name().name() != "propfind" {
|
||||||
|
return Err(anyhow!("invalid tag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let prop_node = if let Some(el) = propfind_node.first_element_child() {
|
||||||
|
el
|
||||||
|
} else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
match prop_node.tag_name().name() {
|
||||||
|
"prop" => Ok(prop_node
|
||||||
|
.children()
|
||||||
|
.map(|node| node.tag_name().name())
|
||||||
|
.collect()),
|
||||||
|
_ => Err(anyhow!("invalid prop tag")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_resourcetype(
|
||||||
|
writer: &mut Writer<&mut Vec<u8>>,
|
||||||
|
types: Vec<&str>,
|
||||||
|
) -> Result<(), quick_xml::Error> {
|
||||||
|
writer
|
||||||
|
.create_element("resourcetype")
|
||||||
|
.write_inner_content(|writer| {
|
||||||
|
for resourcetype in types {
|
||||||
|
writer.create_element(resourcetype).write_empty()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_invalid_props_response(
|
||||||
|
writer: &mut Writer<&mut Vec<u8>>,
|
||||||
|
href: &str,
|
||||||
|
invalid_props: Vec<&str>,
|
||||||
|
) -> Result<(), quick_xml::Error> {
|
||||||
|
if invalid_props.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
write_propstat_response(writer, href, StatusCode::NOT_FOUND, |writer| {
|
||||||
|
for prop in invalid_props {
|
||||||
|
writer.create_element(prop).write_empty()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes a propstat response into a multistatus
|
||||||
|
// closure hooks into the <prop> element
|
||||||
|
pub fn write_propstat_response<F>(
|
||||||
|
writer: &mut Writer<&mut Vec<u8>>,
|
||||||
|
href: &str,
|
||||||
|
status: StatusCode,
|
||||||
|
prop_closure: F,
|
||||||
|
) -> Result<(), quick_xml::Error>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Writer<&mut Vec<u8>>) -> Result<(), quick_xml::Error>,
|
||||||
|
{
|
||||||
|
writer
|
||||||
|
.create_element("response")
|
||||||
|
.write_inner_content(|writer| {
|
||||||
|
writer
|
||||||
|
.create_element("href")
|
||||||
|
.write_text_content(BytesText::new(href))?;
|
||||||
|
|
||||||
|
writer
|
||||||
|
.create_element("propstat")
|
||||||
|
.write_inner_content(|writer| {
|
||||||
|
writer
|
||||||
|
.create_element("prop")
|
||||||
|
.write_inner_content(prop_closure)?;
|
||||||
|
|
||||||
|
writer
|
||||||
|
.create_element("status")
|
||||||
|
.write_text_content(BytesText::new(&format!("HTTP/1.1 {}", status)))?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_multistatus<'a, F, A>(namespaces: A, closure: F) -> Result<String>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Writer<&mut Vec<u8>>) -> Result<(), quick_xml::Error>,
|
||||||
|
A: IntoIterator,
|
||||||
|
A::Item: Into<Attribute<'a>>,
|
||||||
|
{
|
||||||
|
let mut output_buffer = Vec::new();
|
||||||
|
let mut writer = Writer::new_with_indent(&mut output_buffer, b' ', 2);
|
||||||
|
writer
|
||||||
|
.create_element("multistatus")
|
||||||
|
.with_attributes(namespaces)
|
||||||
|
.write_inner_content(closure)?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n{}",
|
||||||
|
std::str::from_utf8(&output_buffer)?
|
||||||
|
))
|
||||||
|
}
|
||||||
207
crates/dav/src/routes/calendar.rs
Normal file
207
crates/dav/src/routes/calendar.rs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
use crate::namespace::Namespace;
|
||||||
|
use crate::propfind::{
|
||||||
|
generate_multistatus, parse_propfind, write_invalid_props_response, write_propstat_response,
|
||||||
|
write_resourcetype,
|
||||||
|
};
|
||||||
|
use crate::Error;
|
||||||
|
use actix_web::http::header::ContentType;
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::web::{Data, Path};
|
||||||
|
use actix_web::{HttpRequest, HttpResponse};
|
||||||
|
use actix_web_httpauth::extractors::basic::BasicAuth;
|
||||||
|
use anyhow::Result;
|
||||||
|
use quick_xml::events::BytesText;
|
||||||
|
use quick_xml::Writer;
|
||||||
|
use roxmltree::{Node, NodeType};
|
||||||
|
use rustical_store::calendar::{Calendar, CalendarStore, Event};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
async fn handle_report_calendar_query(
|
||||||
|
query_node: Node<'_, '_>,
|
||||||
|
request: HttpRequest,
|
||||||
|
events: Vec<Event>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let prop_node = query_node
|
||||||
|
.children()
|
||||||
|
.find(|n| n.node_type() == NodeType::Element && n.tag_name().name() == "prop")
|
||||||
|
.ok_or(Error::BadRequest)?;
|
||||||
|
|
||||||
|
let props: Arc<HashSet<&str>> = Arc::new(
|
||||||
|
prop_node
|
||||||
|
.children()
|
||||||
|
.map(|node| node.tag_name().name())
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
let output = generate_multistatus(vec![Namespace::Dav, Namespace::CalDAV], |writer| {
|
||||||
|
for event in events {
|
||||||
|
write_propstat_response(
|
||||||
|
writer,
|
||||||
|
&format!("{}/{}", request.path(), event.get_uid()),
|
||||||
|
StatusCode::OK,
|
||||||
|
|writer| {
|
||||||
|
for prop in props.deref() {
|
||||||
|
match *prop {
|
||||||
|
"getetag" => {
|
||||||
|
writer
|
||||||
|
.create_element("getetag")
|
||||||
|
.write_text_content(BytesText::new(&event.get_etag()))?;
|
||||||
|
}
|
||||||
|
"calendar-data" => {
|
||||||
|
writer
|
||||||
|
.create_element("C:calendar-data")
|
||||||
|
.write_text_content(BytesText::new(event.to_ics()))?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(|_e| Error::InternalError)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::MultiStatus()
|
||||||
|
.content_type(ContentType::xml())
|
||||||
|
.body(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn route_report_calendar<C: CalendarStore>(
|
||||||
|
store: Data<RwLock<C>>,
|
||||||
|
body: String,
|
||||||
|
path: Path<(String, String)>,
|
||||||
|
request: HttpRequest,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let (_principal, cid) = path.into_inner();
|
||||||
|
|
||||||
|
let doc = roxmltree::Document::parse(&body).map_err(|_e| Error::InternalError)?;
|
||||||
|
let query_node = doc.root_element();
|
||||||
|
let events = store.read().await.get_events(&cid).await.unwrap();
|
||||||
|
|
||||||
|
// TODO: implement filtering
|
||||||
|
match query_node.tag_name().name() {
|
||||||
|
"calendar-query" => {}
|
||||||
|
"calendar-multiget" => {}
|
||||||
|
_ => return Err(Error::BadRequest),
|
||||||
|
};
|
||||||
|
handle_report_calendar_query(query_node, request, events).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_propfind_calendar_response(
|
||||||
|
props: Vec<&str>,
|
||||||
|
principal: &str,
|
||||||
|
path: &str,
|
||||||
|
calendar: &Calendar,
|
||||||
|
) -> Result<String> {
|
||||||
|
let mut props = props;
|
||||||
|
if props.contains(&"allprops") {
|
||||||
|
props.extend(
|
||||||
|
[
|
||||||
|
"resourcetype",
|
||||||
|
"current-user-principal",
|
||||||
|
"displayname",
|
||||||
|
"supported-calendar-component-set",
|
||||||
|
"getcontenttype",
|
||||||
|
"calendar-description",
|
||||||
|
]
|
||||||
|
.iter(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut invalid_props = Vec::<&str>::new();
|
||||||
|
let mut output_buffer = Vec::new();
|
||||||
|
let mut writer = Writer::new_with_indent(&mut output_buffer, b' ', 2);
|
||||||
|
|
||||||
|
write_propstat_response(&mut writer, path, StatusCode::OK, |writer| {
|
||||||
|
for prop in props {
|
||||||
|
match prop {
|
||||||
|
"resourcetype" => write_resourcetype(writer, vec!["C:calendar", "collection"])?,
|
||||||
|
"current-user-principal" => {
|
||||||
|
writer
|
||||||
|
.create_element("current-user-principal")
|
||||||
|
.write_inner_content(|writer| {
|
||||||
|
writer
|
||||||
|
.create_element("href")
|
||||||
|
.write_text_content(BytesText::new(&format!(
|
||||||
|
// TODO: Replace hard-coded string
|
||||||
|
"/dav/{principal}/",
|
||||||
|
)))?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
"displayname" => {
|
||||||
|
let el = writer.create_element("displayname");
|
||||||
|
if let Some(name) = calendar.clone().name {
|
||||||
|
el.write_text_content(BytesText::new(&name))?;
|
||||||
|
} else {
|
||||||
|
el.write_empty()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"supported-calendar-component-set" => {
|
||||||
|
writer
|
||||||
|
.create_element("C:supported-calendar-component-set")
|
||||||
|
.write_inner_content(|writer| {
|
||||||
|
writer
|
||||||
|
.create_element("C:comp")
|
||||||
|
.with_attribute(("name", "VEVENT"))
|
||||||
|
.write_empty()?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
"getcontenttype" => {
|
||||||
|
writer
|
||||||
|
.create_element("getcontenttype")
|
||||||
|
.write_text_content(BytesText::new("text/calendar"))?;
|
||||||
|
}
|
||||||
|
"allprops" => {}
|
||||||
|
_ => invalid_props.push(prop),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
write_invalid_props_response(&mut writer, path, invalid_props)?;
|
||||||
|
Ok(std::str::from_utf8(&output_buffer)?.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn route_propfind_calendar<C: CalendarStore>(
|
||||||
|
path: Path<(String, String)>,
|
||||||
|
body: String,
|
||||||
|
request: HttpRequest,
|
||||||
|
auth: BasicAuth,
|
||||||
|
store: Data<RwLock<C>>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let (_principal, cid) = path.into_inner();
|
||||||
|
let calendar = store
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get_calendar(&cid)
|
||||||
|
.await
|
||||||
|
.map_err(|_e| Error::InternalError)?;
|
||||||
|
|
||||||
|
let props = parse_propfind(&body).map_err(|_e| Error::BadRequest)?;
|
||||||
|
|
||||||
|
let responses_string = generate_propfind_calendar_response(
|
||||||
|
props.clone(),
|
||||||
|
auth.user_id(),
|
||||||
|
request.path(),
|
||||||
|
&calendar,
|
||||||
|
)
|
||||||
|
.map_err(|_e| Error::InternalError)?;
|
||||||
|
|
||||||
|
let output = generate_multistatus(vec![Namespace::Dav, Namespace::CalDAV], |writer| {
|
||||||
|
writer.write_event(quick_xml::events::Event::Text(BytesText::from_escaped(
|
||||||
|
responses_string,
|
||||||
|
)))?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(|_e| Error::InternalError)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::MultiStatus()
|
||||||
|
.content_type(ContentType::xml())
|
||||||
|
.body(output))
|
||||||
|
}
|
||||||
64
crates/dav/src/routes/event.rs
Normal file
64
crates/dav/src/routes/event.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use crate::Error;
|
||||||
|
use actix_web::web::{Data, Path};
|
||||||
|
use actix_web::HttpResponse;
|
||||||
|
use rustical_store::calendar::CalendarStore;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
pub async fn delete_event<C: CalendarStore>(
|
||||||
|
store: Data<RwLock<C>>,
|
||||||
|
path: Path<(String, String, String)>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let (_principal, mut cid, uid) = path.into_inner();
|
||||||
|
if cid.ends_with(".ics") {
|
||||||
|
cid.truncate(cid.len() - 4);
|
||||||
|
}
|
||||||
|
store
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.delete_event(&uid)
|
||||||
|
.await
|
||||||
|
.map_err(|_e| Error::InternalError)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().body(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_event<C: CalendarStore>(
|
||||||
|
store: Data<RwLock<C>>,
|
||||||
|
path: Path<(String, String, String)>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let (_principal, mut cid, uid) = path.into_inner();
|
||||||
|
if cid.ends_with(".ics") {
|
||||||
|
cid.truncate(cid.len() - 4);
|
||||||
|
}
|
||||||
|
let event = store
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get_event(&uid)
|
||||||
|
.await
|
||||||
|
.map_err(|_e| Error::NotFound)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok()
|
||||||
|
.insert_header(("ETag", event.get_etag()))
|
||||||
|
.body(event.to_ics().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_event<C: CalendarStore>(
|
||||||
|
store: Data<RwLock<C>>,
|
||||||
|
path: Path<(String, String, String)>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let (_principal, mut cid, uid) = path.into_inner();
|
||||||
|
// Incredibly bodged method of normalising the uid but works for a prototype
|
||||||
|
if cid.ends_with(".ics") {
|
||||||
|
cid.truncate(cid.len() - 4);
|
||||||
|
}
|
||||||
|
dbg!(&body);
|
||||||
|
store
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.upsert_event(uid, body)
|
||||||
|
.await
|
||||||
|
.map_err(|_e| Error::InternalError)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().body(""))
|
||||||
|
}
|
||||||
4
crates/dav/src/routes/mod.rs
Normal file
4
crates/dav/src/routes/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod calendar;
|
||||||
|
pub mod event;
|
||||||
|
pub mod principal;
|
||||||
|
pub mod root;
|
||||||
140
crates/dav/src/routes/principal.rs
Normal file
140
crates/dav/src/routes/principal.rs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
use crate::{
|
||||||
|
calendar::generate_propfind_calendar_response,
|
||||||
|
depth_extractor::Depth,
|
||||||
|
namespace::Namespace,
|
||||||
|
propfind::{
|
||||||
|
generate_multistatus, parse_propfind, write_invalid_props_response,
|
||||||
|
write_propstat_response, write_resourcetype,
|
||||||
|
},
|
||||||
|
Error,
|
||||||
|
};
|
||||||
|
use actix_web::{
|
||||||
|
http::{header::ContentType, StatusCode},
|
||||||
|
web::Data,
|
||||||
|
HttpRequest, HttpResponse,
|
||||||
|
};
|
||||||
|
use actix_web_httpauth::extractors::basic::BasicAuth;
|
||||||
|
use anyhow::Result;
|
||||||
|
use quick_xml::{
|
||||||
|
events::{BytesText, Event},
|
||||||
|
Writer,
|
||||||
|
};
|
||||||
|
use rustical_store::calendar::CalendarStore;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
// Executes the PROPFIND request and returns a XML string to be written into a <mulstistatus> object.
|
||||||
|
pub async fn generate_propfind_principal_response(
|
||||||
|
props: Vec<&str>,
|
||||||
|
principal: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<String, quick_xml::Error> {
|
||||||
|
let mut props = props;
|
||||||
|
if props.contains(&"allprops") {
|
||||||
|
props.extend(
|
||||||
|
[
|
||||||
|
"resourcetype",
|
||||||
|
"current-user-principal",
|
||||||
|
"principal-URL",
|
||||||
|
"calendar-home-set",
|
||||||
|
"calendar-user-address-set",
|
||||||
|
]
|
||||||
|
.iter(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut invalid_props = Vec::<&str>::new();
|
||||||
|
|
||||||
|
let mut output_buffer = Vec::new();
|
||||||
|
let mut writer = Writer::new_with_indent(&mut output_buffer, b' ', 2);
|
||||||
|
|
||||||
|
write_propstat_response(&mut writer, path, StatusCode::OK, |writer| {
|
||||||
|
let writer = writer;
|
||||||
|
for prop in props {
|
||||||
|
match prop {
|
||||||
|
"resourcetype" => write_resourcetype(writer, vec!["principal", "collection"])?,
|
||||||
|
"current-user-principal" | "principal-URL" => {
|
||||||
|
writer.create_element(prop).write_inner_content(|writer| {
|
||||||
|
writer
|
||||||
|
.create_element("href")
|
||||||
|
// TODO: Replace hard-coded string
|
||||||
|
.write_text_content(BytesText::new(&format!("/dav/{principal}/",)))?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
"calendar-home-set" | "calendar-user-address-set" => {
|
||||||
|
writer
|
||||||
|
.create_element(&format!("C:{prop}"))
|
||||||
|
.write_inner_content(|writer| {
|
||||||
|
writer
|
||||||
|
.create_element("href")
|
||||||
|
.write_text_content(BytesText::new(&format!(
|
||||||
|
// TODO: Replace hard-coded string
|
||||||
|
"/dav/{principal}/"
|
||||||
|
)))?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
"allprops" => {}
|
||||||
|
_ => invalid_props.push(prop),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
dbg!(&invalid_props);
|
||||||
|
write_invalid_props_response(&mut writer, path, invalid_props)?;
|
||||||
|
Ok(std::str::from_utf8(&output_buffer)?.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn route_propfind_principal<C: CalendarStore>(
|
||||||
|
body: String,
|
||||||
|
request: HttpRequest,
|
||||||
|
auth: BasicAuth,
|
||||||
|
store: Data<RwLock<C>>,
|
||||||
|
depth: Depth,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let props = parse_propfind(&body).map_err(|_e| Error::BadRequest)?;
|
||||||
|
|
||||||
|
let mut responses = Vec::new();
|
||||||
|
// also get calendars:
|
||||||
|
if depth != Depth::Zero {
|
||||||
|
let cals = store
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get_calendars()
|
||||||
|
.await
|
||||||
|
.map_err(|_e| Error::InternalError)?;
|
||||||
|
|
||||||
|
for cal in cals {
|
||||||
|
responses.push(
|
||||||
|
generate_propfind_calendar_response(
|
||||||
|
props.clone(),
|
||||||
|
auth.user_id(),
|
||||||
|
&format!("{}/{}", request.path(), cal.id),
|
||||||
|
&cal,
|
||||||
|
)
|
||||||
|
.map_err(|_e| Error::InternalError)?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
responses.push(
|
||||||
|
generate_propfind_principal_response(props.clone(), auth.user_id(), request.path())
|
||||||
|
.await
|
||||||
|
.map_err(|_e| Error::InternalError)?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = generate_multistatus(vec![Namespace::Dav, Namespace::CalDAV], |writer| {
|
||||||
|
for response in responses {
|
||||||
|
writer.write_event(Event::Text(BytesText::from_escaped(response)))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(|_e| Error::InternalError)?;
|
||||||
|
|
||||||
|
println!("{}", &output);
|
||||||
|
|
||||||
|
Ok(HttpResponse::MultiStatus()
|
||||||
|
.content_type(ContentType::xml())
|
||||||
|
.body(output))
|
||||||
|
}
|
||||||
85
crates/dav/src/routes/root.rs
Normal file
85
crates/dav/src/routes/root.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
use actix_web::{
|
||||||
|
http::{header::ContentType, StatusCode},
|
||||||
|
HttpRequest, HttpResponse,
|
||||||
|
};
|
||||||
|
use actix_web_httpauth::extractors::basic::BasicAuth;
|
||||||
|
use quick_xml::{
|
||||||
|
events::{BytesText, Event},
|
||||||
|
Writer,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
namespace::Namespace,
|
||||||
|
propfind::{
|
||||||
|
generate_multistatus, parse_propfind, write_invalid_props_response,
|
||||||
|
write_propstat_response, write_resourcetype,
|
||||||
|
},
|
||||||
|
Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Executes the PROPFIND request and returns a XML string to be written into a <mulstistatus> object.
|
||||||
|
pub async fn generate_propfind_root_response(
|
||||||
|
props: Vec<&str>,
|
||||||
|
principal: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<String, quick_xml::Error> {
|
||||||
|
let mut props = props;
|
||||||
|
if props.contains(&"allprops") {
|
||||||
|
props.extend(["resourcetype", "current-user-principal"].iter());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut invalid_props = Vec::<&str>::new();
|
||||||
|
|
||||||
|
let mut output_buffer = Vec::new();
|
||||||
|
let mut writer = Writer::new_with_indent(&mut output_buffer, b' ', 2);
|
||||||
|
|
||||||
|
write_propstat_response(&mut writer, path, StatusCode::OK, |writer| {
|
||||||
|
for prop in props {
|
||||||
|
match prop {
|
||||||
|
"resourcetype" => write_resourcetype(writer, vec!["collection"])?,
|
||||||
|
"current-user-principal" => {
|
||||||
|
writer
|
||||||
|
.create_element("current-user-principal")
|
||||||
|
.write_inner_content(|writer| {
|
||||||
|
writer
|
||||||
|
.create_element("href")
|
||||||
|
.write_text_content(BytesText::new(
|
||||||
|
// TODO: Replace hard-coded string
|
||||||
|
&format!("/dav/{principal}",),
|
||||||
|
))?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
"allprops" => {}
|
||||||
|
_ => invalid_props.push(prop),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
dbg!(&invalid_props);
|
||||||
|
write_invalid_props_response(&mut writer, path, invalid_props)?;
|
||||||
|
Ok(std::str::from_utf8(&output_buffer)?.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn route_propfind_root(
|
||||||
|
body: String,
|
||||||
|
request: HttpRequest,
|
||||||
|
auth: BasicAuth,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let props = parse_propfind(&body).map_err(|_e| Error::BadRequest)?;
|
||||||
|
|
||||||
|
let responses_string =
|
||||||
|
generate_propfind_root_response(props.clone(), auth.user_id(), request.path())
|
||||||
|
.await
|
||||||
|
.map_err(|_e| Error::InternalError)?;
|
||||||
|
|
||||||
|
let output = generate_multistatus(vec![Namespace::Dav, Namespace::CalDAV], |writer| {
|
||||||
|
writer.write_event(Event::Text(BytesText::from_escaped(responses_string)))?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(|_e| Error::InternalError)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::MultiStatus()
|
||||||
|
.content_type(ContentType::xml())
|
||||||
|
.body(output))
|
||||||
|
}
|
||||||
1748
crates/store/Cargo.lock
generated
Normal file
1748
crates/store/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
crates/store/Cargo.toml
Normal file
15
crates/store/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "rustical_store"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = { version = "1.0.75", features = ["backtrace"] }
|
||||||
|
async-trait = "0.1.73"
|
||||||
|
serde = { version = "1.0.188", features = ["derive", "rc"] }
|
||||||
|
serde_json = "1.0.105"
|
||||||
|
sha2 = "0.10.7"
|
||||||
|
sqlx = { version = "0.7.1", features = ["sqlx-sqlite", "sqlx-postgres", "uuid", "time", "chrono", "postgres", "sqlite", "runtime-tokio"] }
|
||||||
|
tokio = { version = "1.32.0", features = ["sync", "full"] }
|
||||||
108
crates/store/src/calendar.rs
Normal file
108
crates/store/src/calendar.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tokio::{fs::File, io::AsyncWriteExt};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Event {
|
||||||
|
uid: String,
|
||||||
|
ics: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Event {
|
||||||
|
pub fn get_uid(&self) -> &str {
|
||||||
|
&self.uid
|
||||||
|
}
|
||||||
|
pub fn get_etag(&self) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&self.uid);
|
||||||
|
hasher.update(self.to_ics());
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_ics(&self) -> &str {
|
||||||
|
&self.ics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Calendar {
|
||||||
|
pub id: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub ics: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Calendar {}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait CalendarStore: Send + Sync + 'static {
|
||||||
|
async fn get_calendar(&self, id: &str) -> Result<Calendar>;
|
||||||
|
async fn get_calendars(&self) -> Result<Vec<Calendar>>;
|
||||||
|
|
||||||
|
async fn get_events(&self, cid: &str) -> Result<Vec<Event>>;
|
||||||
|
async fn get_event(&self, uid: &str) -> Result<Event>;
|
||||||
|
async fn upsert_event(&mut self, uid: String, ics: String) -> Result<()>;
|
||||||
|
async fn delete_event(&mut self, uid: &str) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct JsonCalendarStore {
|
||||||
|
calendars: HashMap<String, Calendar>,
|
||||||
|
events: HashMap<String, Event>,
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonCalendarStore {
|
||||||
|
pub fn new(path: String) -> Self {
|
||||||
|
JsonCalendarStore {
|
||||||
|
calendars: HashMap::new(),
|
||||||
|
events: HashMap::new(),
|
||||||
|
path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save(&self) -> Result<()> {
|
||||||
|
let mut file = File::create(&self.path).await?;
|
||||||
|
let json = serde_json::to_string_pretty(&self)?;
|
||||||
|
file.write_all(json.as_bytes()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl CalendarStore for JsonCalendarStore {
|
||||||
|
async fn get_calendar(&self, id: &str) -> Result<Calendar> {
|
||||||
|
Ok(self.calendars.get(id).ok_or(anyhow!("not found"))?.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_calendars(&self) -> Result<Vec<Calendar>> {
|
||||||
|
Ok(vec![Calendar {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: Some("test".to_string()),
|
||||||
|
ics: "asd".to_string(),
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_events(&self, _cid: &str) -> Result<Vec<Event>> {
|
||||||
|
Ok(self.events.values().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_event(&self, uid: &str) -> Result<Event> {
|
||||||
|
Ok(self.events.get(uid).ok_or(anyhow!("not found"))?.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_event(&mut self, uid: String, ics: String) -> Result<()> {
|
||||||
|
self.events.insert(uid.clone(), Event { uid, ics });
|
||||||
|
self.save().await.unwrap();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_event(&mut self, uid: &str) -> Result<()> {
|
||||||
|
self.events.remove(uid);
|
||||||
|
self.save().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
2
crates/store/src/lib.rs
Normal file
2
crates/store/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod calendar;
|
||||||
|
pub mod users;
|
||||||
39
crates/store/src/users.rs
Normal file
39
crates/store/src/users.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserStore: Send + Sync + 'static {
|
||||||
|
async fn get_user(&self, id: &str) -> Result<User>;
|
||||||
|
async fn get_users(&self) -> Result<Vec<User>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TestUserStore {}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserStore for TestUserStore {
|
||||||
|
async fn get_user(&self, id: &str) -> Result<User> {
|
||||||
|
if id != "test" {
|
||||||
|
return Err(anyhow!("asd"));
|
||||||
|
}
|
||||||
|
Ok(User {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: Some("test".to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_users(&self) -> Result<Vec<User>> {
|
||||||
|
Ok(vec![User {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: Some("test".to_string()),
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/config.rs
Normal file
17
src/config.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct JsonCalendarStoreConfig {
|
||||||
|
pub db_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(tag = "backend", rename_all = "snake_case")]
|
||||||
|
pub enum CalendarStoreConfig {
|
||||||
|
Json(JsonCalendarStoreConfig),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub calendar_store: CalendarStoreConfig,
|
||||||
|
}
|
||||||
63
src/main.rs
Normal file
63
src/main.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use actix_web::middleware::{Logger, NormalizePath};
|
||||||
|
use actix_web::{web, App, HttpServer};
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use config::{CalendarStoreConfig, JsonCalendarStoreConfig};
|
||||||
|
use rustical_api::configure_api;
|
||||||
|
use rustical_dav::{configure_dav, configure_well_known};
|
||||||
|
use rustical_store::calendar::JsonCalendarStore;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
#[arg(short, long, env)]
|
||||||
|
config_file: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
|
||||||
|
|
||||||
|
let args = Args::parse();
|
||||||
|
let config: Config = toml::from_str(&fs::read_to_string(&args.config_file)?)?;
|
||||||
|
// TODO: Clean this jank up as soon more configuration options appear
|
||||||
|
let db_path = match config.calendar_store {
|
||||||
|
CalendarStoreConfig::Json(JsonCalendarStoreConfig { db_path }) => db_path,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cal_store = Arc::new(RwLock::new(
|
||||||
|
if let Ok(json) = fs::read_to_string(&db_path) {
|
||||||
|
serde_json::from_str::<JsonCalendarStore>(&json)?
|
||||||
|
} else {
|
||||||
|
JsonCalendarStore::new(db_path.to_string())
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
HttpServer::new(move || {
|
||||||
|
let cal_store = cal_store.clone();
|
||||||
|
App::new()
|
||||||
|
.wrap(Logger::new("[%s] %r"))
|
||||||
|
.wrap(NormalizePath::trim())
|
||||||
|
.service(
|
||||||
|
web::scope("/dav").configure(|cfg| configure_dav(cfg, cal_store.clone().into())),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
web::scope("/.well-known")
|
||||||
|
.configure(|cfg| configure_well_known(cfg, "/dav".to_string())),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
web::scope("/api").configure(|cfg| configure_api(cfg, cal_store.clone().into())),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.bind(("0.0.0.0", 4000))?
|
||||||
|
.run()
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user