mirror of
https://github.com/lennart-k/rustical.git
synced 2025-12-13 11:12:22 +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