Checkpoint: Migration to axum

This commit is contained in:
Lennart
2025-06-08 14:10:12 +02:00
parent 790c657b08
commit 95889e3df1
60 changed files with 1476 additions and 2205 deletions

627
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,11 +32,8 @@ debug = 0
[workspace.dependencies] [workspace.dependencies]
uuid = { version = "1.11", features = ["v4", "fast-rng"] } uuid = { version = "1.11", features = ["v4", "fast-rng"] }
async-trait = "0.1" async-trait = "0.1"
actix-web = "4.11" axum = "0.8"
tracing = { version = "0.1", features = ["async-await"] } tracing = { version = "0.1", features = ["async-await"] }
tracing-actix-web = "0.7"
actix-session = { version = "0.10", features = ["cookie-session"] }
actix-web-httpauth = "0.8"
anyhow = { version = "1.0", features = ["backtrace"] } anyhow = { version = "1.0", features = ["backtrace"] }
serde = { version = "1.0", features = ["serde_derive", "derive", "rc"] } serde = { version = "1.0", features = ["serde_derive", "derive", "rc"] }
futures-util = "0.3" futures-util = "0.3"
@@ -63,7 +60,7 @@ quick-xml = { version = "0.37" }
rust-embed = "8.5" rust-embed = "8.5"
futures-core = "0.3.31" futures-core = "0.3.31"
hex = { version = "0.4.3", features = ["serde"] } hex = { version = "0.4.3", features = ["serde"] }
mime_guess = "2.0.5" mime_guess = "2.0"
itertools = "0.14" itertools = "0.14"
log = "0.4" log = "0.4"
derive_more = { version = "2.0", features = [ derive_more = { version = "2.0", features = [
@@ -75,7 +72,7 @@ derive_more = { version = "2.0", features = [
"display", "display",
] } ] }
askama = { version = "0.14", features = ["serde_json"] } askama = { version = "0.14", features = ["serde_json"] }
askama_web = { version = "0.14.0", features = ["actix-web-4"] } askama_web = { version = "0.14.0", features = ["axum-0.8"] }
sqlx = { version = "0.8", default-features = false, features = [ sqlx = { version = "0.8", default-features = false, features = [
"sqlx-sqlite", "sqlx-sqlite",
"uuid", "uuid",
@@ -86,7 +83,6 @@ sqlx = { version = "0.8", default-features = false, features = [
"migrate", "migrate",
"json", "json",
] } ] }
http_02 = { package = "http", version = "0.2" } # actix-web uses a very outdated version
http = "1.3" http = "1.3"
headers = "0.4" headers = "0.4"
strum = "0.27" strum = "0.27"
@@ -95,7 +91,8 @@ serde_json = { version = "1.0", features = ["raw_value"] }
sqlx-sqlite = { version = "0.8", features = ["bundled"] } sqlx-sqlite = { version = "0.8", features = ["bundled"] }
ical = { version = "0.11", features = ["generator", "serde"] } ical = { version = "0.11", features = ["generator", "serde"] }
toml = "0.8" toml = "0.8"
rustical_dav = { path = "./crates/dav/", features = ["actix"] } tower = "0.5"
rustical_dav = { path = "./crates/dav/" }
rustical_dav_push = { path = "./crates/dav_push/" } rustical_dav_push = { path = "./crates/dav_push/" }
rustical_store = { path = "./crates/store/" } rustical_store = { path = "./crates/store/" }
rustical_store_sqlite = { path = "./crates/store_sqlite/" } rustical_store_sqlite = { path = "./crates/store_sqlite/" }
@@ -104,10 +101,11 @@ rustical_carddav = { path = "./crates/carddav/" }
rustical_frontend = { path = "./crates/frontend/" } rustical_frontend = { path = "./crates/frontend/" }
rustical_xml = { path = "./crates/xml/" } rustical_xml = { path = "./crates/xml/" }
rustical_oidc = { path = "./crates/oidc/" } rustical_oidc = { path = "./crates/oidc/" }
rustical_ical = { path = "./crates/ical/", features = ["actix"] } rustical_ical = { path = "./crates/ical/"}
chrono-tz = "0.10" chrono-tz = "0.10"
chrono-humanize = "0.2" chrono-humanize = "0.2"
rand = "0.8" rand = "0.8"
axum-extra = { version = "0.10", features = ["typed-header"] }
rrule = "0.14" rrule = "0.14"
argon2 = "0.5" argon2 = "0.5"
rpassword = "7.3" rpassword = "7.3"
@@ -129,9 +127,8 @@ clap = { version = "4.5", features = ["derive", "env"] }
rustical_store = { workspace = true } rustical_store = { workspace = true }
rustical_store_sqlite = { workspace = true } rustical_store_sqlite = { workspace = true }
rustical_caldav = { workspace = true } rustical_caldav = { workspace = true }
rustical_carddav = { workspace = true } rustical_carddav.workspace = true
rustical_frontend = { workspace = true } rustical_frontend = { workspace = true }
actix-web = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
@@ -140,8 +137,8 @@ anyhow = { workspace = true }
clap.workspace = true clap.workspace = true
sqlx = { workspace = true } sqlx = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
tracing-actix-web = { workspace = true }
uuid.workspace = true uuid.workspace = true
axum.workspace = true
opentelemetry = { version = "0.30", optional = true } opentelemetry = { version = "0.30", optional = true }
opentelemetry-otlp = { version = "0.30", optional = true, features = [ opentelemetry-otlp = { version = "0.30", optional = true, features = [

View File

@@ -7,15 +7,15 @@ repository.workspace = true
publish = false publish = false
[dependencies] [dependencies]
actix-web = { workspace = true } axum.workspace = true
axum-extra.workspace = true
tower.workspace = true
async-trait = { workspace = true } async-trait = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
quick-xml = { workspace = true } quick-xml = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-actix-web = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }
derive_more = { workspace = true } derive_more = { workspace = true }
actix-web-httpauth = { workspace = true }
base64 = { workspace = true } base64 = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
@@ -30,3 +30,4 @@ uuid.workspace = true
rustical_dav_push.workspace = true rustical_dav_push.workspace = true
rustical_ical.workspace = true rustical_ical.workspace = true
http.workspace = true http.workspace = true
headers.workspace = true

View File

@@ -1,13 +1,14 @@
use crate::Error; use crate::Error;
use crate::calendar::prop::SupportedCalendarComponentSet; use crate::calendar::prop::SupportedCalendarComponentSet;
use actix_web::HttpResponse; use crate::calendar::resource::CalendarResourceService;
use actix_web::web::{Data, Path}; use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use http::StatusCode;
use rustical_ical::CalendarObjectType; use rustical_ical::CalendarObjectType;
use rustical_store::auth::User; use rustical_store::auth::User;
use rustical_store::{Calendar, CalendarStore}; use rustical_store::{Calendar, CalendarStore, SubscriptionStore};
use rustical_xml::{Unparsed, XmlDeserialize, XmlDocument, XmlRootTag}; use rustical_xml::{Unparsed, XmlDeserialize, XmlDocument, XmlRootTag};
use tracing::instrument; use tracing::instrument;
use tracing_actix_web::RootSpan;
#[derive(XmlDeserialize, Clone, Debug)] #[derive(XmlDeserialize, Clone, Debug)]
pub struct MkcolCalendarProp { pub struct MkcolCalendarProp {
@@ -48,15 +49,13 @@ struct MkcalendarRequest {
set: PropElement, set: PropElement,
} }
#[instrument(parent = root_span.id(), skip(store, root_span))] #[instrument(skip(cal_store))]
pub async fn route_mkcalendar<C: CalendarStore>( pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
path: Path<(String, String)>, Path((principal, cal_id)): Path<(String, String)>,
body: String,
user: User, user: User,
store: Data<C>, State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
root_span: RootSpan, body: String,
) -> Result<HttpResponse, Error> { ) -> Result<Response, Error> {
let (principal, cal_id) = path.into_inner();
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
} }
@@ -87,12 +86,10 @@ pub async fn route_mkcalendar<C: CalendarStore>(
]), ]),
}; };
match store.insert_calendar(calendar).await { match cal_store.insert_calendar(calendar).await {
// The spec says we should return a mkcalendar-response but I don't know what goes into it. // The spec says we should return a mkcalendar-response but I don't know what goes into it.
// However, it works without one but breaks on iPadOS when using an empty one :) // However, it works without one but breaks on iPadOS when using an empty one :)
Ok(()) => Ok(HttpResponse::Created() Ok(()) => Ok(StatusCode::CREATED.into_response()),
.insert_header(("Cache-Control", "no-cache"))
.body("")),
Err(err) => { Err(err) => {
dbg!(err.to_string()); dbg!(err.to_string());
Err(err.into()) Err(err.into())

View File

@@ -1,3 +1,3 @@
pub mod mkcalendar; pub mod mkcalendar;
pub mod post; // pub mod post;
pub mod report; pub mod report;

View File

@@ -1,8 +1,7 @@
use crate::Error; use crate::Error;
use crate::calendar::resource::{CalendarResource, CalendarResourceService}; use crate::calendar::resource::{CalendarResource, CalendarResourceService};
use actix_web::http::header; use axum::extract::{Path, State};
use actix_web::web::{Data, Path}; use axum::response::Response;
use actix_web::{HttpRequest, HttpResponse};
use rustical_dav::privileges::UserPrivilege; use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource; use rustical_dav::resource::Resource;
use rustical_dav_push::register::PushRegister; use rustical_dav_push::register::PushRegister;
@@ -10,18 +9,14 @@ use rustical_store::auth::User;
use rustical_store::{CalendarStore, Subscription, SubscriptionStore}; use rustical_store::{CalendarStore, Subscription, SubscriptionStore};
use rustical_xml::XmlDocument; use rustical_xml::XmlDocument;
use tracing::instrument; use tracing::instrument;
use tracing_actix_web::RootSpan;
#[instrument(parent = root_span.id(), skip(resource_service, root_span, req))] #[instrument(skip(resource_service))]
pub async fn route_post<C: CalendarStore, S: SubscriptionStore>( pub async fn route_post<C: CalendarStore, S: SubscriptionStore>(
path: Path<(String, String)>, Path((principal, cal_id)): Path<(String, String)>,
body: String,
user: User, user: User,
resource_service: Data<CalendarResourceService<C, S>>, State(resource_service): State<CalendarResourceService<C, S>>,
root_span: RootSpan, body: String,
req: HttpRequest, ) -> Result<Response, Error> {
) -> Result<HttpResponse, Error> {
let (principal, cal_id) = path.into_inner();
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
} }

View File

@@ -1,5 +1,4 @@
use crate::{Error, calendar_object::resource::CalendarObjectPropWrapperName}; use crate::{Error, calendar_object::resource::CalendarObjectPropWrapperName};
use actix_web::dev::{Path, ResourceDef};
use rustical_dav::xml::PropfindType; use rustical_dav::xml::PropfindType;
use rustical_ical::CalendarObject; use rustical_ical::CalendarObject;
use rustical_store::CalendarStore; use rustical_store::CalendarStore;
@@ -23,23 +22,25 @@ pub async fn get_objects_calendar_multiget<C: CalendarStore>(
cal_id: &str, cal_id: &str,
store: &C, store: &C,
) -> Result<(Vec<CalendarObject>, Vec<String>), Error> { ) -> Result<(Vec<CalendarObject>, Vec<String>), Error> {
let resource_def = ResourceDef::prefix(path).join(&ResourceDef::new("/{object_id}.ics"));
let mut result = vec![]; let mut result = vec![];
let mut not_found = vec![]; let mut not_found = vec![];
for href in &cal_query.href { for href in &cal_query.href {
let mut path = Path::new(href.as_str()); if let Some(filename) = href.strip_prefix(path) {
if !resource_def.capture_match_info(&mut path) { if let Some(object_id) = filename.strip_suffix(".ics") {
not_found.push(href.to_owned());
continue;
};
let object_id = path.get("object_id").unwrap();
match store.get_object(principal, cal_id, object_id).await { match store.get_object(principal, cal_id, object_id).await {
Ok(object) => result.push(object), Ok(object) => result.push(object),
Err(rustical_store::Error::NotFound) => not_found.push(href.to_owned()), Err(rustical_store::Error::NotFound) => not_found.push(href.to_owned()),
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
}; };
} else {
not_found.push(href.to_owned());
continue;
}
} else {
not_found.push(href.to_owned());
continue;
}
} }
Ok((result, not_found)) Ok((result, not_found))

View File

@@ -1,12 +1,14 @@
use crate::{ use crate::{
CalDavPrincipalUri, Error, CalDavPrincipalUri, Error,
calendar::resource::CalendarResourceService,
calendar_object::resource::{ calendar_object::resource::{
CalendarObjectPropWrapper, CalendarObjectPropWrapperName, CalendarObjectResource, CalendarObjectPropWrapper, CalendarObjectPropWrapperName, CalendarObjectResource,
}, },
}; };
use actix_web::{ use axum::{
HttpRequest, Responder, Extension,
web::{Data, Path}, extract::{OriginalUri, Path, State},
response::IntoResponse,
}; };
use calendar_multiget::{CalendarMultigetRequest, get_objects_calendar_multiget}; use calendar_multiget::{CalendarMultigetRequest, get_objects_calendar_multiget};
use calendar_query::{CalendarQueryRequest, get_objects_calendar_query}; use calendar_query::{CalendarQueryRequest, get_objects_calendar_query};
@@ -19,7 +21,7 @@ use rustical_dav::{
}, },
}; };
use rustical_ical::CalendarObject; use rustical_ical::CalendarObject;
use rustical_store::{CalendarStore, auth::User}; use rustical_store::{CalendarStore, SubscriptionStore, auth::User};
use rustical_xml::{XmlDeserialize, XmlDocument}; use rustical_xml::{XmlDeserialize, XmlDocument};
use sync_collection::handle_sync_collection; use sync_collection::handle_sync_collection;
use tracing::instrument; use tracing::instrument;
@@ -85,16 +87,15 @@ fn objects_response(
}) })
} }
#[instrument(skip(req, cal_store))] #[instrument(skip(cal_store))]
pub async fn route_report_calendar<C: CalendarStore>( pub async fn route_report_calendar<C: CalendarStore, S: SubscriptionStore>(
path: Path<(String, String)>, Path((principal, cal_id)): Path<(String, String)>,
body: String,
user: User, user: User,
req: HttpRequest, Extension(puri): Extension<CalDavPrincipalUri>,
puri: Data<CalDavPrincipalUri>, State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
cal_store: Data<C>, OriginalUri(uri): OriginalUri,
) -> Result<impl Responder, Error> { body: String,
let (principal, cal_id) = path.into_inner(); ) -> Result<impl IntoResponse, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
} }
@@ -107,20 +108,12 @@ pub async fn route_report_calendar<C: CalendarStore>(
let objects = let objects =
get_objects_calendar_query(cal_query, &principal, &cal_id, cal_store.as_ref()) get_objects_calendar_query(cal_query, &principal, &cal_id, cal_store.as_ref())
.await?; .await?;
objects_response( objects_response(objects, vec![], uri.path(), &principal, &puri, &user, props)?
objects,
vec![],
req.path(),
&principal,
puri.as_ref(),
&user,
props,
)?
} }
ReportRequest::CalendarMultiget(cal_multiget) => { ReportRequest::CalendarMultiget(cal_multiget) => {
let (objects, not_found) = get_objects_calendar_multiget( let (objects, not_found) = get_objects_calendar_multiget(
cal_multiget, cal_multiget,
req.path(), uri.path(),
&principal, &principal,
&cal_id, &cal_id,
cal_store.as_ref(), cal_store.as_ref(),
@@ -129,9 +122,9 @@ pub async fn route_report_calendar<C: CalendarStore>(
objects_response( objects_response(
objects, objects,
not_found, not_found,
req.path(), uri.path(),
&principal, &principal,
puri.as_ref(), &puri,
&user, &user,
props, props,
)? )?
@@ -139,8 +132,8 @@ pub async fn route_report_calendar<C: CalendarStore>(
ReportRequest::SyncCollection(sync_collection) => { ReportRequest::SyncCollection(sync_collection) => {
handle_sync_collection( handle_sync_collection(
sync_collection, sync_collection,
req.path(), uri.path(),
puri.as_ref(), &puri,
&user, &user,
&principal, &principal,
&cal_id, &cal_id,

View File

@@ -1,19 +1,20 @@
use super::methods::mkcalendar::route_mkcalendar;
use super::methods::post::route_post;
use super::methods::report::route_report_calendar;
use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData, SupportedReportSet}; use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData, SupportedReportSet};
use crate::calendar_object::resource::{CalendarObjectResource, CalendarObjectResourceService}; use crate::calendar::methods::mkcalendar::route_mkcalendar;
use crate::calendar::methods::report::route_report_calendar;
use crate::calendar_object::resource::CalendarObjectResource;
use crate::{CalDavPrincipalUri, Error}; use crate::{CalDavPrincipalUri, Error};
use actix_web::http::Method;
use actix_web::web::{self};
use async_trait::async_trait; use async_trait::async_trait;
use axum::extract::Request;
use axum::handler::Handler;
use axum::response::Response;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use derive_more::derive::{From, Into}; use derive_more::derive::{From, Into};
use futures_util::future::BoxFuture;
use rustical_dav::extensions::{ use rustical_dav::extensions::{
CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp, CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp,
}; };
use rustical_dav::privileges::UserPrivilegeSet; use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceService}; use rustical_dav::resource::{AxumMethods, PrincipalUri, Resource, ResourceService};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner}; use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner};
use rustical_dav_push::{DavPushExtension, DavPushExtensionProp}; use rustical_dav_push::{DavPushExtension, DavPushExtensionProp};
use rustical_ical::CalDateTime; use rustical_ical::CalDateTime;
@@ -21,8 +22,10 @@ use rustical_store::auth::User;
use rustical_store::{Calendar, CalendarStore, SubscriptionStore}; use rustical_store::{Calendar, CalendarStore, SubscriptionStore};
use rustical_xml::{EnumVariants, PropName}; use rustical_xml::{EnumVariants, PropName};
use rustical_xml::{XmlDeserialize, XmlSerialize}; use rustical_xml::{XmlDeserialize, XmlSerialize};
use std::convert::Infallible;
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use tower::Service;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "CalendarPropName")] #[xml(unit_variants_ident = "CalendarPropName")]
@@ -313,6 +316,15 @@ pub struct CalendarResourceService<C: CalendarStore, S: SubscriptionStore> {
pub(crate) sub_store: Arc<S>, pub(crate) sub_store: Arc<S>,
} }
impl<C: CalendarStore, S: SubscriptionStore> Clone for CalendarResourceService<C, S> {
fn clone(&self) -> Self {
Self {
cal_store: self.cal_store.clone(),
sub_store: self.sub_store.clone(),
}
}
}
impl<C: CalendarStore, S: SubscriptionStore> CalendarResourceService<C, S> { impl<C: CalendarStore, S: SubscriptionStore> CalendarResourceService<C, S> {
pub fn new(cal_store: Arc<C>, sub_store: Arc<S>) -> Self { pub fn new(cal_store: Arc<C>, sub_store: Arc<S>) -> Self {
Self { Self {
@@ -386,17 +398,21 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
.await?; .await?;
Ok(()) Ok(())
} }
}
fn actix_scope(self) -> actix_web::Scope { impl<C: CalendarStore, S: SubscriptionStore> AxumMethods for CalendarResourceService<C, S> {
let report_method = web::method(Method::from_str("REPORT").unwrap()); fn report() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
let mkcalendar_method = web::method(Method::from_str("MKCALENDAR").unwrap()); Some(|state, req| {
web::scope("/{calendar_id}") let mut service = Handler::with_state(route_report_calendar::<C, S>, state);
.service(CalendarObjectResourceService::new(self.cal_store.clone()).actix_scope()) Box::pin(Service::call(&mut service, req))
.service( })
self.actix_resource() }
.route(report_method.to(route_report_calendar::<C>))
.route(mkcalendar_method.to(route_mkcalendar::<C>)) fn mkcalendar() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>>
.post(route_post::<C, S>), {
) Some(|state, req| {
let mut service = Handler::with_state(route_mkcalendar::<C, S>, state);
Box::pin(Service::call(&mut service, req))
})
} }
} }

View File

@@ -1,71 +1,70 @@
use super::resource::CalendarObjectPathComponents;
use crate::Error; use crate::Error;
use crate::calendar_object::resource::CalendarObjectResourceService;
use crate::error::Precondition; use crate::error::Precondition;
use actix_web::HttpRequest; use axum::body::Body;
use actix_web::HttpResponse; use axum::extract::{Path, State};
use actix_web::http::header; use axum::response::{IntoResponse, Response};
use actix_web::http::header::HeaderValue; use axum_extra::TypedHeader;
use actix_web::web::{Data, Path}; use headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch};
use http::StatusCode;
use rustical_ical::CalendarObject; use rustical_ical::CalendarObject;
use rustical_store::CalendarStore; use rustical_store::CalendarStore;
use rustical_store::auth::User; use rustical_store::auth::User;
use std::str::FromStr;
use tracing::instrument; use tracing::instrument;
use tracing_actix_web::RootSpan;
use super::resource::CalendarObjectPathComponents; #[instrument(skip(cal_store))]
#[instrument(parent = root_span.id(), skip(store, root_span))]
pub async fn get_event<C: CalendarStore>( pub async fn get_event<C: CalendarStore>(
path: Path<CalendarObjectPathComponents>, Path(CalendarObjectPathComponents {
store: Data<C>,
user: User,
root_span: RootSpan,
) -> Result<HttpResponse, Error> {
let CalendarObjectPathComponents {
principal, principal,
calendar_id, calendar_id,
object_id, object_id,
} = path.into_inner(); }): Path<CalendarObjectPathComponents>,
State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>,
user: User,
) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Ok(HttpResponse::Unauthorized().body("")); return Err(crate::Error::Unauthorized);
} }
let calendar = store.get_calendar(&principal, &calendar_id).await?; let calendar = cal_store.get_calendar(&principal, &calendar_id).await?;
if !user.is_principal(&calendar.principal) { if !user.is_principal(&calendar.principal) {
return Ok(HttpResponse::Unauthorized().body("")); return Err(crate::Error::Unauthorized);
} }
let event = store let event = cal_store
.get_object(&principal, &calendar_id, &object_id) .get_object(&principal, &calendar_id, &object_id)
.await?; .await?;
Ok(HttpResponse::Ok() let mut resp = Response::builder().status(StatusCode::OK);
.insert_header(("ETag", event.get_etag())) let hdrs = resp.headers_mut().unwrap();
.insert_header(("Content-Type", "text/calendar")) hdrs.typed_insert(ETag::from_str(&event.get_etag()).unwrap());
.body(event.get_ics().to_owned())) hdrs.typed_insert(ContentType::from_str("text/calendar").unwrap());
Ok(resp.body(Body::new(event.get_ics().to_owned())).unwrap())
} }
#[instrument(parent = root_span.id(), skip(store, req, root_span))] #[instrument(skip(cal_store))]
pub async fn put_event<C: CalendarStore>( pub async fn put_event<C: CalendarStore>(
path: Path<CalendarObjectPathComponents>, Path(CalendarObjectPathComponents {
store: Data<C>,
body: String,
user: User,
req: HttpRequest,
root_span: RootSpan,
) -> Result<HttpResponse, Error> {
let CalendarObjectPathComponents {
principal, principal,
calendar_id, calendar_id,
object_id, object_id,
} = path.into_inner(); }): Path<CalendarObjectPathComponents>,
State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>,
user: User,
if_none_match: Option<TypedHeader<IfNoneMatch>>,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Ok(HttpResponse::Unauthorized().finish()); return Err(crate::Error::Unauthorized);
} }
let overwrite = let overwrite = if let Some(TypedHeader(if_none_match)) = if_none_match {
Some(&HeaderValue::from_static("*")) != req.headers().get(header::IF_NONE_MATCH); if_none_match == IfNoneMatch::any()
} else {
true
};
let object = match CalendarObject::from_ics(object_id, body) { let object = match CalendarObject::from_ics(object_id, body) {
Ok(obj) => obj, Ok(obj) => obj,
@@ -73,9 +72,9 @@ pub async fn put_event<C: CalendarStore>(
return Err(Error::PreconditionFailed(Precondition::ValidCalendarData)); return Err(Error::PreconditionFailed(Precondition::ValidCalendarData));
} }
}; };
store cal_store
.put_object(principal, calendar_id, object, overwrite) .put_object(principal, calendar_id, object, overwrite)
.await?; .await?;
Ok(HttpResponse::Created().finish()) Ok(StatusCode::CREATED.into_response())
} }

View File

@@ -1,22 +1,35 @@
use super::methods::{get_event, put_event}; // use super::methods::{get_event, put_event};
use crate::{CalDavPrincipalUri, Error}; use crate::{
use actix_web::web; CalDavPrincipalUri, Error,
calendar_object::methods::{get_event, put_event},
};
use async_trait::async_trait; use async_trait::async_trait;
use axum::{extract::Request, handler::Handler, response::Response};
use derive_more::derive::{From, Into}; use derive_more::derive::{From, Into};
use futures_util::future::BoxFuture;
use rustical_dav::{ use rustical_dav::{
extensions::{CommonPropertiesExtension, CommonPropertiesProp}, extensions::{CommonPropertiesExtension, CommonPropertiesProp},
privileges::UserPrivilegeSet, privileges::UserPrivilegeSet,
resource::{PrincipalUri, Resource, ResourceService}, resource::{AxumMethods, PrincipalUri, Resource, ResourceService},
xml::Resourcetype, xml::Resourcetype,
}; };
use rustical_ical::{CalendarObject, UtcDateTime}; use rustical_ical::{CalendarObject, UtcDateTime};
use rustical_store::{CalendarStore, auth::User}; use rustical_store::{CalendarStore, auth::User};
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
use serde::Deserialize; use serde::{Deserialize, Deserializer};
use std::sync::Arc; use std::{convert::Infallible, sync::Arc};
use tower::Service;
pub struct CalendarObjectResourceService<C: CalendarStore> { pub struct CalendarObjectResourceService<C: CalendarStore> {
cal_store: Arc<C>, pub(crate) cal_store: Arc<C>,
}
impl<C: CalendarStore> Clone for CalendarObjectResourceService<C> {
fn clone(&self) -> Self {
Self {
cal_store: self.cal_store.clone(),
}
}
} }
impl<C: CalendarStore> CalendarObjectResourceService<C> { impl<C: CalendarStore> CalendarObjectResourceService<C> {
@@ -130,10 +143,23 @@ impl Resource for CalendarObjectResource {
} }
} }
fn deserialize_ics_name<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
let name: String = Deserialize::deserialize(deserializer)?;
if let Some(object_id) = name.strip_suffix(".ics") {
Ok(object_id.to_owned())
} else {
Err(serde::de::Error::custom("Missing .ics extension"))
}
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct CalendarObjectPathComponents { pub struct CalendarObjectPathComponents {
pub principal: String, pub principal: String,
pub calendar_id: String, pub calendar_id: String,
#[serde(deserialize_with = "deserialize_ics_name")]
pub object_id: String, pub object_id: String,
} }
@@ -180,12 +206,19 @@ impl<C: CalendarStore> ResourceService for CalendarObjectResourceService<C> {
.await?; .await?;
Ok(()) Ok(())
} }
}
fn actix_scope(self) -> actix_web::Scope { impl<C: CalendarStore> AxumMethods for CalendarObjectResourceService<C> {
web::scope("/{object_id}.ics").service( fn get() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
self.actix_resource() Some(|state, req| {
.get(get_event::<C>) let mut service = Handler::with_state(get_event::<C>, state);
.put(put_event::<C>), Box::pin(Service::call(&mut service, req))
) })
}
fn put() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| {
let mut service = Handler::with_state(put_event::<C>, state);
Box::pin(Service::call(&mut service, req))
})
} }
} }

View File

@@ -1,10 +1,9 @@
use crate::calendar::resource::{CalendarResource, CalendarResourceService}; use crate::calendar::resource::CalendarResource;
use crate::{CalDavPrincipalUri, Error}; use crate::{CalDavPrincipalUri, Error};
use actix_web::web;
use async_trait::async_trait; use async_trait::async_trait;
use rustical_dav::extensions::{CommonPropertiesExtension, CommonPropertiesProp}; use rustical_dav::extensions::{CommonPropertiesExtension, CommonPropertiesProp};
use rustical_dav::privileges::UserPrivilegeSet; use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceService}; use rustical_dav::resource::{AxumMethods, PrincipalUri, Resource, ResourceService};
use rustical_dav::xml::{Resourcetype, ResourcetypeInner}; use rustical_dav::xml::{Resourcetype, ResourcetypeInner};
use rustical_store::auth::User; use rustical_store::auth::User;
use rustical_store::{CalendarStore, SubscriptionStore}; use rustical_store::{CalendarStore, SubscriptionStore};
@@ -67,6 +66,16 @@ pub struct CalendarSetResourceService<C: CalendarStore, S: SubscriptionStore> {
sub_store: Arc<S>, sub_store: Arc<S>,
} }
impl<C: CalendarStore, S: SubscriptionStore> Clone for CalendarSetResourceService<C, S> {
fn clone(&self) -> Self {
Self {
name: self.name,
cal_store: self.cal_store.clone(),
sub_store: self.sub_store.clone(),
}
}
}
impl<C: CalendarStore, S: SubscriptionStore> CalendarSetResourceService<C, S> { impl<C: CalendarStore, S: SubscriptionStore> CalendarSetResourceService<C, S> {
pub fn new(name: &'static str, cal_store: Arc<C>, sub_store: Arc<S>) -> Self { pub fn new(name: &'static str, cal_store: Arc<C>, sub_store: Arc<S>) -> Self {
Self { Self {
@@ -116,16 +125,5 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarSetReso
}) })
.collect()) .collect())
} }
fn actix_scope(self) -> actix_web::Scope {
web::scope(&format!("/{}", self.name))
.service(
CalendarResourceService::<_, S>::new(
self.cal_store.clone(),
self.sub_store.clone(),
)
.actix_scope(),
)
.service(self.actix_resource())
}
} }
impl<C: CalendarStore, S: SubscriptionStore> AxumMethods for CalendarSetResourceService<C, S> {}

View File

@@ -1,7 +1,9 @@
use actix_web::{ use axum::{
HttpResponse, body::Body,
http::{StatusCode, header::ContentType}, response::{IntoResponse, Response},
}; };
use headers::{ContentType, HeaderMapExt};
use http::StatusCode;
use rustical_xml::{XmlSerialize, XmlSerializeRoot}; use rustical_xml::{XmlSerialize, XmlSerializeRoot};
use tracing::error; use tracing::error;
@@ -12,22 +14,18 @@ pub enum Precondition {
ValidCalendarData, ValidCalendarData,
} }
impl actix_web::ResponseError for Precondition { impl IntoResponse for Precondition {
fn status_code(&self) -> StatusCode { fn into_response(self) -> axum::response::Response {
StatusCode::PRECONDITION_FAILED
}
fn error_response(&self) -> HttpResponse<actix_web::body::BoxBody> {
let mut output: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into(); let mut output: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into();
let mut writer = quick_xml::Writer::new_with_indent(&mut output, b' ', 4); let mut writer = quick_xml::Writer::new_with_indent(&mut output, b' ', 4);
let error = rustical_dav::xml::ErrorElement(self); let error = rustical_dav::xml::ErrorElement(&self);
if let Err(err) = error.serialize_root(&mut writer) { if let Err(err) = error.serialize_root(&mut writer) {
return rustical_dav::Error::from(err).error_response(); return rustical_dav::Error::from(err).into_response();
} }
let mut res = Response::builder().status(StatusCode::PRECONDITION_FAILED);
HttpResponse::PreconditionFailed() res.headers_mut().unwrap().typed_insert(ContentType::xml());
.content_type(ContentType::xml()) res.body(Body::from(output)).unwrap()
.body(String::from_utf8(output).unwrap())
} }
} }
@@ -61,8 +59,8 @@ pub enum Error {
PreconditionFailed(Precondition), PreconditionFailed(Precondition),
} }
impl actix_web::ResponseError for Error { impl Error {
fn status_code(&self) -> actix_web::http::StatusCode { pub fn status_code(&self) -> StatusCode {
match self { match self {
Error::StoreError(err) => match err { Error::StoreError(err) => match err {
rustical_store::Error::NotFound => StatusCode::NOT_FOUND, rustical_store::Error::NotFound => StatusCode::NOT_FOUND,
@@ -78,16 +76,13 @@ impl actix_web::ResponseError for Error {
Error::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR, Error::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR,
Error::NotFound => StatusCode::NOT_FOUND, Error::NotFound => StatusCode::NOT_FOUND,
Error::IcalError(err) => err.status_code(), Error::IcalError(err) => err.status_code(),
Error::PreconditionFailed(err) => err.status_code(), Error::PreconditionFailed(_err) => StatusCode::PRECONDITION_FAILED,
}
}
fn error_response(&self) -> actix_web::HttpResponse<actix_web::body::BoxBody> {
error!("Error: {self}");
match self {
Error::DavError(err) => err.error_response(),
Error::IcalError(err) => err.error_response(),
Error::PreconditionFailed(err) => err.error_response(),
_ => HttpResponse::build(self.status_code()).body(self.to_string()),
} }
} }
} }
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
(self.status_code(), self.to_string()).into_response()
}
}

View File

@@ -1,28 +1,26 @@
use actix_web::HttpResponse; use axum::{Extension, Router};
use actix_web::body::BoxBody;
use actix_web::dev::{HttpServiceFactory, ServiceResponse};
use actix_web::http::header::{self, HeaderName, HeaderValue};
use actix_web::http::{Method, StatusCode};
use actix_web::middleware::{ErrorHandlerResponse, ErrorHandlers};
use actix_web::web::Data;
use derive_more::Constructor; use derive_more::Constructor;
use principal::PrincipalResourceService; use principal::PrincipalResourceService;
use rustical_dav::resource::{PrincipalUri, ResourceService}; use rustical_dav::resource::{PrincipalUri, ResourceService};
use rustical_dav::resources::RootResourceService; use rustical_dav::resources::RootResourceService;
use rustical_store::auth::{AuthenticationMiddleware, AuthenticationProvider, User}; use rustical_store::auth::middleware::AuthenticationLayer;
use rustical_store::auth::{AuthenticationProvider, User};
use rustical_store::{AddressbookStore, CalendarStore, ContactBirthdayStore, SubscriptionStore}; use rustical_store::{AddressbookStore, CalendarStore, ContactBirthdayStore, SubscriptionStore};
use std::sync::Arc; use std::sync::Arc;
use subscription::subscription_resource;
pub mod calendar; pub mod calendar;
pub mod calendar_object; pub mod calendar_object;
pub mod calendar_set; pub mod calendar_set;
pub mod error; pub mod error;
pub mod principal; pub mod principal;
mod subscription; // mod subscription;
pub use error::Error; pub use error::Error;
use crate::calendar::resource::CalendarResourceService;
use crate::calendar_object::resource::CalendarObjectResourceService;
use crate::calendar_set::CalendarSetResourceService;
#[derive(Debug, Clone, Constructor)] #[derive(Debug, Clone, Constructor)]
pub struct CalDavPrincipalUri(&'static str); pub struct CalDavPrincipalUri(&'static str);
@@ -32,33 +30,38 @@ impl PrincipalUri for CalDavPrincipalUri {
} }
} }
/// Quite a janky implementation but the default METHOD_NOT_ALLOWED response gives us the allowed // pub fn caldav_service<
/// methods of a resource // AP: AuthenticationProvider,
fn options_handler() -> ErrorHandlers<BoxBody> { // AS: AddressbookStore,
ErrorHandlers::new().handler(StatusCode::METHOD_NOT_ALLOWED, |res| { // C: CalendarStore,
Ok(ErrorHandlerResponse::Response( // S: SubscriptionStore,
if res.request().method() == Method::OPTIONS { // >(
let mut response = HttpResponse::Ok(); // prefix: &'static str,
response.insert_header(( // auth_provider: Arc<AP>,
HeaderName::from_static("dav"), // store: Arc<C>,
// https://datatracker.ietf.org/doc/html/rfc4918#section-18 // addr_store: Arc<AS>,
HeaderValue::from_static( // subscription_store: Arc<S>,
"1, 3, access-control, calendar-access, extended-mkcol, webdav-push", // ) -> impl HttpServiceFactory {
), // let birthday_store = Arc::new(ContactBirthdayStore::new(addr_store));
)); //
// RootResourceService::<_, User, CalDavPrincipalUri>::new(PrincipalResourceService {
// auth_provider: auth_provider.clone(),
// sub_store: subscription_store.clone(),
// birthday_store: birthday_store.clone(),
// cal_store: store.clone(),
// })
// .actix_scope()
// .wrap(AuthenticationMiddleware::new(auth_provider.clone()))
// .wrap(options_handler())
// .app_data(Data::from(store.clone()))
// .app_data(Data::from(birthday_store.clone()))
// .app_data(Data::new(CalDavPrincipalUri::new(
// format!("{prefix}/principal").leak(),
// )))
// .service(subscription_resource(subscription_store))
// }
if let Some(allow) = res.headers().get(header::ALLOW) { pub fn caldav_router<
response.insert_header((header::ALLOW, allow.to_owned()));
}
ServiceResponse::new(res.into_parts().0, response.finish()).map_into_right_body()
} else {
res.map_into_left_body()
},
))
})
}
pub fn caldav_service<
AP: AuthenticationProvider, AP: AuthenticationProvider,
AS: AddressbookStore, AS: AddressbookStore,
C: CalendarStore, C: CalendarStore,
@@ -69,22 +72,53 @@ pub fn caldav_service<
store: Arc<C>, store: Arc<C>,
addr_store: Arc<AS>, addr_store: Arc<AS>,
subscription_store: Arc<S>, subscription_store: Arc<S>,
) -> impl HttpServiceFactory { ) -> Router {
let birthday_store = Arc::new(ContactBirthdayStore::new(addr_store)); let birthday_store = Arc::new(ContactBirthdayStore::new(addr_store));
let principal_service = PrincipalResourceService {
RootResourceService::<_, User, CalDavPrincipalUri>::new(PrincipalResourceService {
auth_provider: auth_provider.clone(), auth_provider: auth_provider.clone(),
sub_store: subscription_store.clone(), sub_store: subscription_store.clone(),
birthday_store: birthday_store.clone(), birthday_store: birthday_store.clone(),
cal_store: store.clone(), cal_store: store.clone(),
}) };
.actix_scope()
.wrap(AuthenticationMiddleware::new(auth_provider.clone())) Router::new()
.wrap(options_handler()) .route_service(
.app_data(Data::from(store.clone())) "/",
.app_data(Data::from(birthday_store.clone())) RootResourceService::<_, User, CalDavPrincipalUri>::new(principal_service.clone())
.app_data(Data::new(CalDavPrincipalUri::new( .axum_service(),
format!("{prefix}/principal").leak(), )
))) .route_service("/principal/{principal}", principal_service.axum_service())
.service(subscription_resource(subscription_store)) .route_service(
"/principal/{principal}/calendar",
CalendarSetResourceService::new("calendar", store.clone(), subscription_store.clone())
.axum_service(),
)
.route_service(
"/principal/{principal}/calendar/{calendar_id}",
CalendarResourceService::new(store.clone(), subscription_store.clone()).axum_service(),
)
.route_service(
"/principal/{principal}/calendar/{calendar_id}/{object_id}",
CalendarObjectResourceService::new(store.clone()).axum_service(),
)
.route_service(
"/principal/{principal}/birthdays",
CalendarSetResourceService::new(
"birthdays",
birthday_store.clone(),
subscription_store.clone(),
)
.axum_service(),
)
.route_service(
"/principal/{principal}/birthdays/{calendar_id}",
CalendarResourceService::new(birthday_store.clone(), subscription_store.clone())
.axum_service(),
)
.route_service(
"/principal/{principal}/birthdays/{calendar_id}/{object_id}",
CalendarObjectResourceService::new(birthday_store.clone()).axum_service(),
)
.layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CalDavPrincipalUri(prefix)))
} }

View File

@@ -1,10 +1,9 @@
use crate::calendar_set::{CalendarSetResource, CalendarSetResourceService}; use crate::calendar_set::CalendarSetResource;
use crate::{CalDavPrincipalUri, Error}; use crate::{CalDavPrincipalUri, Error};
use actix_web::web;
use async_trait::async_trait; use async_trait::async_trait;
use rustical_dav::extensions::{CommonPropertiesExtension, CommonPropertiesProp}; use rustical_dav::extensions::{CommonPropertiesExtension, CommonPropertiesProp};
use rustical_dav::privileges::UserPrivilegeSet; use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceService}; use rustical_dav::resource::{AxumMethods, PrincipalUri, Resource, ResourceService};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner}; use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner};
use rustical_store::auth::user::PrincipalType; use rustical_store::auth::user::PrincipalType;
use rustical_store::auth::{AuthenticationProvider, User}; use rustical_store::auth::{AuthenticationProvider, User};
@@ -194,25 +193,9 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore, BS: Ca
), ),
]) ])
} }
}
fn actix_scope(self) -> actix_web::Scope { impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore, BS: CalendarStore>
web::scope("/principal/{principal}") AxumMethods for PrincipalResourceService<AP, S, CS, BS>
.service( {
CalendarSetResourceService::<_, S>::new(
"calendar",
self.cal_store.clone(),
self.sub_store.clone(),
)
.actix_scope(),
)
.service(
CalendarSetResourceService::<_, S>::new(
"birthdays",
self.birthday_store.clone(),
self.sub_store.clone(),
)
.actix_scope(),
)
.service(self.actix_resource())
}
} }

View File

@@ -7,15 +7,15 @@ repository.workspace = true
publish = false publish = false
[dependencies] [dependencies]
actix-web = { workspace = true } axum.workspace = true
axum-extra.workspace = true
tower.workspace = true
async-trait = { workspace = true } async-trait = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
quick-xml = { workspace = true } quick-xml = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-actix-web = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }
derive_more = { workspace = true } derive_more = { workspace = true }
actix-web-httpauth = { workspace = true }
base64 = { workspace = true } base64 = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -1,37 +1,37 @@
use std::str::FromStr;
use super::resource::AddressObjectPathComponents; use super::resource::AddressObjectPathComponents;
use crate::Error; use crate::Error;
use crate::address_object::resource::AddressObjectResourceService;
use crate::addressbook::resource::AddressbookResource; use crate::addressbook::resource::AddressbookResource;
use actix_web::HttpRequest; use axum::body::Body;
use actix_web::HttpResponse; use axum::extract::{Path, State};
use actix_web::http::header; use axum::response::{IntoResponse, Response};
use actix_web::http::header::HeaderValue; use axum_extra::TypedHeader;
use actix_web::web::{Data, Path}; use axum_extra::headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch};
use http::StatusCode;
use rustical_dav::privileges::UserPrivilege; use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource; use rustical_dav::resource::Resource;
use rustical_ical::AddressObject; use rustical_ical::AddressObject;
use rustical_store::AddressbookStore; use rustical_store::AddressbookStore;
use rustical_store::auth::User; use rustical_store::auth::User;
use tracing::instrument; use tracing::instrument;
use tracing_actix_web::RootSpan;
#[instrument(parent = root_span.id(), skip(store, root_span))] #[instrument(skip(addr_store))]
pub async fn get_object<AS: AddressbookStore>( pub async fn get_object<AS: AddressbookStore>(
path: Path<AddressObjectPathComponents>, Path(AddressObjectPathComponents {
store: Data<AS>,
user: User,
root_span: RootSpan,
) -> Result<HttpResponse, Error> {
let AddressObjectPathComponents {
principal, principal,
addressbook_id, addressbook_id,
object_id, object_id,
} = path.into_inner(); }): Path<AddressObjectPathComponents>,
State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>,
user: User,
) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
} }
let addressbook = store let addressbook = addr_store
.get_addressbook(&principal, &addressbook_id, false) .get_addressbook(&principal, &addressbook_id, false)
.await?; .await?;
let addressbook_resource = AddressbookResource(addressbook); let addressbook_resource = AddressbookResource(addressbook);
@@ -42,42 +42,43 @@ pub async fn get_object<AS: AddressbookStore>(
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
} }
let object = store let object = addr_store
.get_object(&principal, &addressbook_id, &object_id, false) .get_object(&principal, &addressbook_id, &object_id, false)
.await?; .await?;
Ok(HttpResponse::Ok() let mut resp = Response::builder().status(StatusCode::OK);
.insert_header(("ETag", object.get_etag())) let hdrs = resp.headers_mut().unwrap();
.insert_header(("Content-Type", "text/vcard")) hdrs.typed_insert(ETag::from_str(&object.get_etag()).unwrap());
.body(object.get_vcf().to_owned())) hdrs.typed_insert(ContentType::from_str("text/vcard").unwrap());
Ok(resp.body(Body::new(object.get_vcf().to_owned())).unwrap())
} }
#[instrument(parent = root_span.id(), skip(store, req, root_span))] #[instrument(skip(addr_store, body))]
pub async fn put_object<AS: AddressbookStore>( pub async fn put_object<AS: AddressbookStore>(
path: Path<AddressObjectPathComponents>, Path(AddressObjectPathComponents {
store: Data<AS>,
body: String,
user: User,
req: HttpRequest,
root_span: RootSpan,
) -> Result<HttpResponse, Error> {
let AddressObjectPathComponents {
principal, principal,
addressbook_id, addressbook_id,
object_id, object_id,
} = path.into_inner(); }): Path<AddressObjectPathComponents>,
State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>,
user: User,
if_none_match: Option<TypedHeader<IfNoneMatch>>,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
} }
let overwrite = let overwrite = if let Some(TypedHeader(if_none_match)) = if_none_match {
Some(&HeaderValue::from_static("*")) != req.headers().get(header::IF_NONE_MATCH); if_none_match == IfNoneMatch::any()
} else {
true
};
let object = AddressObject::from_vcf(object_id, body)?; let object = AddressObject::from_vcf(object_id, body)?;
store addr_store
.put_object(principal, addressbook_id, object, overwrite) .put_object(principal, addressbook_id, object, overwrite)
.await?; .await?;
Ok(HttpResponse::Created().finish()) Ok(StatusCode::CREATED.into_response())
} }

View File

@@ -1,24 +1,34 @@
use crate::{CardDavPrincipalUri, Error}; use crate::{CardDavPrincipalUri, Error};
use actix_web::web;
use async_trait::async_trait; use async_trait::async_trait;
use axum::{extract::Request, handler::Handler, response::Response};
use derive_more::derive::{Constructor, From, Into}; use derive_more::derive::{Constructor, From, Into};
use futures_util::future::BoxFuture;
use rustical_dav::{ use rustical_dav::{
extensions::{CommonPropertiesExtension, CommonPropertiesProp}, extensions::{CommonPropertiesExtension, CommonPropertiesProp},
privileges::UserPrivilegeSet, privileges::UserPrivilegeSet,
resource::{PrincipalUri, Resource, ResourceService}, resource::{AxumMethods, PrincipalUri, Resource, ResourceService},
xml::Resourcetype, xml::Resourcetype,
}; };
use rustical_ical::AddressObject; use rustical_ical::AddressObject;
use rustical_store::{AddressbookStore, auth::User}; use rustical_store::{AddressbookStore, auth::User};
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
use serde::Deserialize; use serde::Deserialize;
use std::sync::Arc; use std::{convert::Infallible, sync::Arc};
use tower::Service;
use super::methods::{get_object, put_object}; use super::methods::{get_object, put_object};
#[derive(Constructor)] #[derive(Constructor)]
pub struct AddressObjectResourceService<AS: AddressbookStore> { pub struct AddressObjectResourceService<AS: AddressbookStore> {
addr_store: Arc<AS>, pub(crate) addr_store: Arc<AS>,
}
impl<AS: AddressbookStore> Clone for AddressObjectResourceService<AS> {
fn clone(&self) -> Self {
Self {
addr_store: self.addr_store.clone(),
}
}
} }
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
@@ -148,13 +158,20 @@ impl<AS: AddressbookStore> ResourceService for AddressObjectResourceService<AS>
.await?; .await?;
Ok(()) Ok(())
} }
}
#[inline] impl<AS: AddressbookStore> AxumMethods for AddressObjectResourceService<AS> {
fn actix_scope(self) -> actix_web::Scope { fn get() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
web::scope("/{object_id}.vcf").service( Some(|state, req| {
self.actix_resource() let mut service = Handler::with_state(get_object::<AS>, state);
.get(get_object::<AS>) Box::pin(Service::call(&mut service, req))
.put(put_object::<AS>), })
) }
fn put() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| {
let mut service = Handler::with_state(put_object::<AS>, state);
Box::pin(Service::call(&mut service, req))
})
} }
} }

View File

@@ -1,10 +1,12 @@
use crate::Error; use crate::{Error, addressbook::resource::AddressbookResourceService};
use actix_web::web::Path; use axum::{
use actix_web::{HttpResponse, web::Data}; extract::{Path, State},
use rustical_store::{Addressbook, AddressbookStore, auth::User}; response::{IntoResponse, Response},
};
use http::StatusCode;
use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::User};
use rustical_xml::{XmlDeserialize, XmlDocument, XmlRootTag}; use rustical_xml::{XmlDeserialize, XmlDocument, XmlRootTag};
use tracing::instrument; use tracing::instrument;
use tracing_actix_web::RootSpan;
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq)]
pub struct Resourcetype { pub struct Resourcetype {
@@ -39,15 +41,13 @@ struct MkcolRequest {
set: PropElement<MkcolAddressbookProp>, set: PropElement<MkcolAddressbookProp>,
} }
#[instrument(parent = root_span.id(), skip(store, root_span))] #[instrument(skip(addr_store))]
pub async fn route_mkcol<AS: AddressbookStore>( pub async fn route_mkcol<AS: AddressbookStore, S: SubscriptionStore>(
path: Path<(String, String)>, Path((principal, addressbook_id)): Path<(String, String)>,
body: String,
user: User, user: User,
store: Data<AS>, State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,
root_span: RootSpan, body: String,
) -> Result<HttpResponse, Error> { ) -> Result<Response, Error> {
let (principal, addressbook_id) = path.into_inner();
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
} }
@@ -65,7 +65,7 @@ pub async fn route_mkcol<AS: AddressbookStore>(
push_topic: uuid::Uuid::new_v4().to_string(), push_topic: uuid::Uuid::new_v4().to_string(),
}; };
match store match addr_store
.get_addressbook(&principal, &addressbook_id, true) .get_addressbook(&principal, &addressbook_id, true)
.await .await
{ {
@@ -74,7 +74,11 @@ pub async fn route_mkcol<AS: AddressbookStore>(
} }
Ok(_) => { Ok(_) => {
// oh no, there's a conflict // oh no, there's a conflict
return Ok(HttpResponse::Conflict().body("An addressbook already exists at this URI")); return Ok((
StatusCode::CONFLICT,
"An addressbook already exists at this URI",
)
.into_response());
} }
Err(err) => { Err(err) => {
// some other error // some other error
@@ -82,12 +86,10 @@ pub async fn route_mkcol<AS: AddressbookStore>(
} }
} }
match store.insert_addressbook(addressbook).await { match addr_store.insert_addressbook(addressbook).await {
// TODO: The spec says we should return a mkcol-response. // TODO: The spec says we should return a mkcol-response.
// However, it works without one but breaks on iPadOS when using an empty one :) // However, it works without one but breaks on iPadOS when using an empty one :)
Ok(()) => Ok(HttpResponse::Created() Ok(()) => Ok(StatusCode::CREATED.into_response()),
.insert_header(("Cache-Control", "no-cache"))
.body("")),
Err(err) => { Err(err) => {
dbg!(err.to_string()); dbg!(err.to_string());
Err(err.into()) Err(err.into())

View File

@@ -1,3 +1,3 @@
pub mod mkcol; pub mod mkcol;
pub mod post; // pub mod post;
pub mod report; pub mod report;

View File

@@ -4,8 +4,6 @@ use crate::{
AddressObjectPropWrapper, AddressObjectPropWrapperName, AddressObjectResource, AddressObjectPropWrapper, AddressObjectPropWrapperName, AddressObjectResource,
}, },
}; };
use actix_web::dev::{Path, ResourceDef};
use http::StatusCode; use http::StatusCode;
use rustical_dav::{ use rustical_dav::{
resource::{PrincipalUri, Resource}, resource::{PrincipalUri, Resource},
@@ -32,27 +30,28 @@ pub async fn get_objects_addressbook_multiget<AS: AddressbookStore>(
addressbook_id: &str, addressbook_id: &str,
store: &AS, store: &AS,
) -> Result<(Vec<AddressObject>, Vec<String>), Error> { ) -> Result<(Vec<AddressObject>, Vec<String>), Error> {
let resource_def = ResourceDef::prefix(path).join(&ResourceDef::new("/{object_id}.vcf"));
let mut result = vec![]; let mut result = vec![];
let mut not_found = vec![]; let mut not_found = vec![];
for href in &addressbook_multiget.href { for href in &addressbook_multiget.href {
let mut path = Path::new(href.as_str()); if let Some(filename) = href.strip_prefix(path) {
if !resource_def.capture_match_info(&mut path) { if let Some(object_id) = filename.strip_suffix(".vcf") {
not_found.push(href.to_owned());
continue;
};
let object_id = path.get("object_id").unwrap();
match store match store
.get_object(principal, addressbook_id, object_id, false) .get_object(principal, addressbook_id, object_id, false)
.await .await
{ {
Ok(object) => result.push(object), Ok(object) => result.push(object),
Err(rustical_store::Error::NotFound) => not_found.push(href.to_owned()), Err(rustical_store::Error::NotFound) => not_found.push(href.to_owned()),
// TODO: Maybe add error handling on a per-object basis
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
}; };
} else {
not_found.push(href.to_owned());
continue;
}
} else {
not_found.push(href.to_owned());
continue;
}
} }
Ok((result, not_found)) Ok((result, not_found))

View File

@@ -1,11 +1,15 @@
use crate::{CardDavPrincipalUri, Error, address_object::resource::AddressObjectPropWrapperName}; use crate::{
use actix_web::{ CardDavPrincipalUri, Error, address_object::resource::AddressObjectPropWrapperName,
HttpRequest, Responder, addressbook::resource::AddressbookResourceService,
web::{Data, Path},
}; };
use addressbook_multiget::{AddressbookMultigetRequest, handle_addressbook_multiget}; use addressbook_multiget::{AddressbookMultigetRequest, handle_addressbook_multiget};
use axum::{
Extension,
extract::{OriginalUri, Path, State},
response::IntoResponse,
};
use rustical_dav::xml::{PropfindType, sync_collection::SyncCollectionRequest}; use rustical_dav::xml::{PropfindType, sync_collection::SyncCollectionRequest};
use rustical_store::{AddressbookStore, auth::User}; use rustical_store::{AddressbookStore, SubscriptionStore, auth::User};
use rustical_xml::{XmlDeserialize, XmlDocument}; use rustical_xml::{XmlDeserialize, XmlDocument};
use sync_collection::handle_sync_collection; use sync_collection::handle_sync_collection;
use tracing::instrument; use tracing::instrument;
@@ -30,16 +34,15 @@ impl ReportRequest {
} }
} }
#[instrument(skip(req, addr_store))] #[instrument(skip(addr_store))]
pub async fn route_report_addressbook<AS: AddressbookStore>( pub async fn route_report_addressbook<AS: AddressbookStore, S: SubscriptionStore>(
path: Path<(String, String)>, Path((principal, addressbook_id)): Path<(String, String)>,
body: String,
user: User, user: User,
req: HttpRequest, OriginalUri(uri): OriginalUri,
puri: Data<CardDavPrincipalUri>, Extension(puri): Extension<CardDavPrincipalUri>,
addr_store: Data<AS>, State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,
) -> Result<impl Responder, Error> { body: String,
let (principal, addressbook_id) = path.into_inner(); ) -> Result<impl IntoResponse, Error> {
if !user.is_principal(&principal) { if !user.is_principal(&principal) {
return Err(Error::Unauthorized); return Err(Error::Unauthorized);
} }
@@ -51,8 +54,8 @@ pub async fn route_report_addressbook<AS: AddressbookStore>(
handle_addressbook_multiget( handle_addressbook_multiget(
addr_multiget, addr_multiget,
request.props(), request.props(),
req.path(), uri.path(),
puri.as_ref(), &puri,
&user, &user,
&principal, &principal,
&addressbook_id, &addressbook_id,
@@ -63,8 +66,8 @@ pub async fn route_report_addressbook<AS: AddressbookStore>(
ReportRequest::SyncCollection(sync_collection) => { ReportRequest::SyncCollection(sync_collection) => {
handle_sync_collection( handle_sync_collection(
sync_collection, sync_collection,
req.path(), uri.path(),
puri.as_ref(), &puri,
&user, &user,
&principal, &principal,
&addressbook_id, &addressbook_id,

View File

@@ -1,25 +1,27 @@
use super::methods::mkcol::route_mkcol; use super::methods::mkcol::route_mkcol;
use super::methods::post::route_post;
use super::methods::report::route_report_addressbook; use super::methods::report::route_report_addressbook;
use super::prop::{SupportedAddressData, SupportedReportSet}; use super::prop::{SupportedAddressData, SupportedReportSet};
use crate::address_object::resource::{AddressObjectResource, AddressObjectResourceService}; use crate::address_object::resource::AddressObjectResource;
use crate::{CardDavPrincipalUri, Error}; use crate::{CardDavPrincipalUri, Error};
use actix_web::http::Method;
use actix_web::web;
use async_trait::async_trait; use async_trait::async_trait;
use axum::extract::Request;
use axum::handler::Handler;
use axum::response::Response;
use derive_more::derive::{From, Into}; use derive_more::derive::{From, Into};
use futures_util::future::BoxFuture;
use rustical_dav::extensions::{ use rustical_dav::extensions::{
CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp, CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp,
}; };
use rustical_dav::privileges::UserPrivilegeSet; use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceService}; use rustical_dav::resource::{AxumMethods, PrincipalUri, Resource, ResourceService};
use rustical_dav::xml::{Resourcetype, ResourcetypeInner}; use rustical_dav::xml::{Resourcetype, ResourcetypeInner};
use rustical_dav_push::{DavPushExtension, DavPushExtensionProp}; use rustical_dav_push::{DavPushExtension, DavPushExtensionProp};
use rustical_store::auth::User; use rustical_store::auth::User;
use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore}; use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore};
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize}; use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
use std::str::FromStr; use std::convert::Infallible;
use std::sync::Arc; use std::sync::Arc;
use tower::Service;
pub struct AddressbookResourceService<AS: AddressbookStore, S: SubscriptionStore> { pub struct AddressbookResourceService<AS: AddressbookStore, S: SubscriptionStore> {
pub(crate) addr_store: Arc<AS>, pub(crate) addr_store: Arc<AS>,
@@ -35,6 +37,15 @@ impl<A: AddressbookStore, S: SubscriptionStore> AddressbookResourceService<A, S>
} }
} }
impl<A: AddressbookStore, S: SubscriptionStore> Clone for AddressbookResourceService<A, S> {
fn clone(&self) -> Self {
Self {
addr_store: self.addr_store.clone(),
sub_store: self.sub_store.clone(),
}
}
}
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)] #[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "AddressbookPropName")] #[xml(unit_variants_ident = "AddressbookPropName")]
pub enum AddressbookProp { pub enum AddressbookProp {
@@ -255,18 +266,20 @@ impl<AS: AddressbookStore, S: SubscriptionStore> ResourceService
.await?; .await?;
Ok(()) Ok(())
} }
}
#[inline] impl<AS: AddressbookStore, S: SubscriptionStore> AxumMethods for AddressbookResourceService<AS, S> {
fn actix_scope(self) -> actix_web::Scope { fn report() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
let mkcol_method = web::method(Method::from_str("MKCOL").unwrap()); Some(|state, req| {
let report_method = web::method(Method::from_str("REPORT").unwrap()); let mut service = Handler::with_state(route_report_addressbook::<AS, S>, state);
web::scope("/{addressbook_id}") Box::pin(Service::call(&mut service, req))
.service(AddressObjectResourceService::<AS>::new(self.addr_store.clone()).actix_scope()) })
.service( }
self.actix_resource()
.route(mkcol_method.to(route_mkcol::<AS>)) fn mkcol() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
.route(report_method.to(route_report_addressbook::<AS>)) Some(|state, req| {
.post(route_post::<AS, S>), let mut service = Handler::with_state(route_mkcol::<AS, S>, state);
) Box::pin(Service::call(&mut service, req))
})
} }
} }

View File

@@ -1,4 +1,5 @@
use actix_web::{HttpResponse, http::StatusCode}; use axum::response::IntoResponse;
use http::StatusCode;
use tracing::error; use tracing::error;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -28,8 +29,8 @@ pub enum Error {
IcalError(#[from] rustical_ical::Error), IcalError(#[from] rustical_ical::Error),
} }
impl actix_web::ResponseError for Error { impl Error {
fn status_code(&self) -> actix_web::http::StatusCode { pub fn status_code(&self) -> StatusCode {
match self { match self {
Error::StoreError(err) => match err { Error::StoreError(err) => match err {
rustical_store::Error::NotFound => StatusCode::NOT_FOUND, rustical_store::Error::NotFound => StatusCode::NOT_FOUND,
@@ -38,8 +39,7 @@ impl actix_web::ResponseError for Error {
_ => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::INTERNAL_SERVER_ERROR,
}, },
Error::ChronoParseError(_) => StatusCode::INTERNAL_SERVER_ERROR, Error::ChronoParseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
Error::DavError(err) => StatusCode::try_from(err.status_code().as_u16()) Error::DavError(err) => err.status_code(),
.expect("Just converting between versions"),
Error::Unauthorized => StatusCode::UNAUTHORIZED, Error::Unauthorized => StatusCode::UNAUTHORIZED,
Error::XmlDecodeError(_) => StatusCode::BAD_REQUEST, Error::XmlDecodeError(_) => StatusCode::BAD_REQUEST,
Error::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR, Error::NotImplemented => StatusCode::INTERNAL_SERVER_ERROR,
@@ -47,12 +47,10 @@ impl actix_web::ResponseError for Error {
Self::IcalError(err) => err.status_code(), Self::IcalError(err) => err.status_code(),
} }
} }
fn error_response(&self) -> actix_web::HttpResponse<actix_web::body::BoxBody> { }
error!("Error: {self}");
match self { impl IntoResponse for Error {
Error::DavError(err) => err.error_response(), fn into_response(self) -> axum::response::Response {
Error::IcalError(err) => err.error_response(), (self.status_code(), self.to_string()).into_response()
_ => HttpResponse::build(self.status_code()).body(self.to_string()),
}
} }
} }

View File

@@ -1,22 +1,15 @@
use actix_web::{ use crate::address_object::resource::AddressObjectResourceService;
HttpResponse, use crate::addressbook::resource::AddressbookResourceService;
body::BoxBody, use axum::{Extension, Router};
dev::{HttpServiceFactory, ServiceResponse},
http::{
Method, StatusCode,
header::{self, HeaderName, HeaderValue},
},
middleware::{ErrorHandlerResponse, ErrorHandlers},
web::Data,
};
use derive_more::Constructor; use derive_more::Constructor;
pub use error::Error; pub use error::Error;
use principal::PrincipalResourceService; use principal::PrincipalResourceService;
use rustical_dav::resource::{PrincipalUri, ResourceService}; use rustical_dav::resource::{PrincipalUri, ResourceService};
use rustical_dav::resources::RootResourceService; use rustical_dav::resources::RootResourceService;
use rustical_store::auth::middleware::AuthenticationLayer;
use rustical_store::{ use rustical_store::{
AddressbookStore, SubscriptionStore, AddressbookStore, SubscriptionStore,
auth::{AuthenticationMiddleware, AuthenticationProvider, User}, auth::{AuthenticationProvider, User},
}; };
use std::sync::Arc; use std::sync::Arc;
@@ -34,51 +27,33 @@ impl PrincipalUri for CardDavPrincipalUri {
} }
} }
/// Quite a janky implementation but the default METHOD_NOT_ALLOWED response gives us the allowed pub fn carddav_router<AP: AuthenticationProvider, A: AddressbookStore, S: SubscriptionStore>(
/// methods of a resource
fn options_handler() -> ErrorHandlers<BoxBody> {
ErrorHandlers::new().handler(StatusCode::METHOD_NOT_ALLOWED, |res| {
Ok(ErrorHandlerResponse::Response(
if res.request().method() == Method::OPTIONS {
let mut response = HttpResponse::Ok();
response.insert_header((
HeaderName::from_static("dav"),
// https://datatracker.ietf.org/doc/html/rfc4918#section-18
HeaderValue::from_static(
"1, 3, access-control, addressbook, extended-mkcol, webdav-push",
),
));
if let Some(allow) = res.headers().get(header::ALLOW) {
response.insert_header((header::ALLOW, allow.to_owned()));
}
ServiceResponse::new(res.into_parts().0, response.finish()).map_into_right_body()
} else {
res.map_into_left_body()
},
))
})
}
pub fn carddav_service<AP: AuthenticationProvider, A: AddressbookStore, S: SubscriptionStore>(
prefix: &'static str, prefix: &'static str,
auth_provider: Arc<AP>, auth_provider: Arc<AP>,
store: Arc<A>, store: Arc<A>,
subscription_store: Arc<S>, subscription_store: Arc<S>,
) -> impl HttpServiceFactory { ) -> Router {
RootResourceService::<_, User, CardDavPrincipalUri>::new( let principal_service = PrincipalResourceService::new(
PrincipalResourceService::<_, _, S>::new(
store.clone(), store.clone(),
auth_provider.clone(), auth_provider.clone(),
subscription_store.clone(), subscription_store.clone(),
), );
Router::new()
.route_service(
"/",
RootResourceService::<_, User, CardDavPrincipalUri>::new(principal_service.clone())
.axum_service(),
) )
.actix_scope() .route_service("/principal/{principal}", principal_service.axum_service())
.wrap(AuthenticationMiddleware::new(auth_provider.clone())) .route_service(
.wrap(options_handler()) "/principal/{principal}/{addressbook_id}",
.app_data(Data::from(store.clone())) AddressbookResourceService::new(store.clone(), subscription_store.clone())
.app_data(Data::new(CardDavPrincipalUri::new( .axum_service(),
format!("{prefix}/principal").leak(), )
))) .route_service(
// TODO: Add endpoint to delete subscriptions "/principal/{principal}/{addressbook_id}/{object_id}",
AddressObjectResourceService::new(store.clone()).axum_service(),
)
.layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CardDavPrincipalUri(prefix)))
} }

View File

@@ -1,10 +1,9 @@
use crate::addressbook::resource::{AddressbookResource, AddressbookResourceService}; use crate::addressbook::resource::AddressbookResource;
use crate::{CardDavPrincipalUri, Error}; use crate::{CardDavPrincipalUri, Error};
use actix_web::web;
use async_trait::async_trait; use async_trait::async_trait;
use rustical_dav::extensions::{CommonPropertiesExtension, CommonPropertiesProp}; use rustical_dav::extensions::{CommonPropertiesExtension, CommonPropertiesProp};
use rustical_dav::privileges::UserPrivilegeSet; use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceService}; use rustical_dav::resource::{AxumMethods, PrincipalUri, Resource, ResourceService};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner}; use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner};
use rustical_store::auth::{AuthenticationProvider, User}; use rustical_store::auth::{AuthenticationProvider, User};
use rustical_store::{AddressbookStore, SubscriptionStore}; use rustical_store::{AddressbookStore, SubscriptionStore};
@@ -175,16 +174,9 @@ impl<A: AddressbookStore, AP: AuthenticationProvider, S: SubscriptionStore> Reso
.map(|addressbook| (addressbook.id.to_owned(), addressbook.into())) .map(|addressbook| (addressbook.id.to_owned(), addressbook.into()))
.collect()) .collect())
} }
}
fn actix_scope(self) -> actix_web::Scope { impl<A: AddressbookStore, AP: AuthenticationProvider, S: SubscriptionStore> AxumMethods
web::scope("/principal/{principal}") for PrincipalResourceService<A, AP, S>
.service( {
AddressbookResourceService::<_, S>::new(
self.addr_store.clone(),
self.sub_store.clone(),
)
.actix_scope(),
)
.service(self.actix_resource())
}
} }

View File

@@ -7,15 +7,10 @@ repository.workspace = true
publish = false publish = false
[features] [features]
actix = ["dep:actix-web", "dep:tracing-actix-web", "dep:http_02"]
axum = ["dep:axum", "dep:axum-extra", "dep:tower"]
[dependencies] [dependencies]
axum = { version = "0.8", optional = true } axum = { version = "0.8" }
axum-extra = { version = "0.10", optional = true, features = ["typed-header"] } tower = { version = "0.5" }
tower = { version = "0.5", optional = true } axum-extra.workspace = true
http_02 = { workspace = true, optional = true }
rustical_xml.workspace = true rustical_xml.workspace = true
async-trait.workspace = true async-trait.workspace = true
@@ -29,6 +24,4 @@ derive_more.workspace = true
tracing.workspace = true tracing.workspace = true
tokio.workspace = true tokio.workspace = true
http.workspace = true http.workspace = true
actix-web = { workspace = true, optional = true }
tracing-actix-web = { workspace = true, optional = true }
headers.workspace = true headers.workspace = true

View File

@@ -53,30 +53,6 @@ impl Error {
} }
} }
#[cfg(feature = "actix")]
impl actix_web::error::ResponseError for Error {
fn status_code(&self) -> actix_web::http::StatusCode {
self.status_code()
.as_u16()
.try_into()
.expect("Just converting between versions")
}
fn error_response(&self) -> actix_web::HttpResponse {
use actix_web::ResponseError;
error!("Error: {self}");
match self {
Error::Unauthorized => actix_web::HttpResponse::build(ResponseError::status_code(self))
.append_header(("WWW-Authenticate", "Basic"))
.body(self.to_string()),
_ => actix_web::HttpResponse::build(ResponseError::status_code(self))
.body(self.to_string()),
}
}
}
#[cfg(feature = "axum")]
impl axum::response::IntoResponse for Error { impl axum::response::IntoResponse for Error {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
use axum::body::Body; use axum::body::Body;

View File

@@ -1,8 +1,4 @@
#[cfg(feature = "actix")]
use actix_web::{HttpRequest, ResponseError};
#[cfg(feature = "axum")]
use axum::{body::Body, extract::FromRequestParts, response::IntoResponse}; use axum::{body::Body, extract::FromRequestParts, response::IntoResponse};
use futures_util::future::{Ready, err, ok};
use rustical_xml::{ValueDeserialize, ValueSerialize, XmlError}; use rustical_xml::{ValueDeserialize, ValueSerialize, XmlError};
use thiserror::Error; use thiserror::Error;
@@ -10,14 +6,6 @@ use thiserror::Error;
#[error("Invalid Depth header")] #[error("Invalid Depth header")]
pub struct InvalidDepthHeader; pub struct InvalidDepthHeader;
#[cfg(feature = "actix")]
impl ResponseError for InvalidDepthHeader {
fn status_code(&self) -> actix_web::http::StatusCode {
http_02::StatusCode::BAD_REQUEST
}
}
#[cfg(feature = "axum")]
impl IntoResponse for InvalidDepthHeader { impl IntoResponse for InvalidDepthHeader {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
axum::response::Response::builder() axum::response::Response::builder()
@@ -71,35 +59,12 @@ impl TryFrom<&[u8]> for Depth {
} }
} }
#[cfg(feature = "actix")]
impl actix_web::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)
}
}
#[cfg(feature = "axum")]
impl<S: Send + Sync> FromRequestParts<S> for Depth { impl<S: Send + Sync> FromRequestParts<S> for Depth {
type Rejection = InvalidDepthHeader; type Rejection = InvalidDepthHeader;
async fn from_request_parts( async fn from_request_parts(
parts: &mut axum::http::request::Parts, parts: &mut axum::http::request::Parts,
state: &S, _state: &S,
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
if let Some(depth_header) = parts.headers.get("Depth") { if let Some(depth_header) = parts.headers.get("Depth") {
depth_header.as_bytes().try_into() depth_header.as_bytes().try_into()

View File

@@ -1,19 +1,9 @@
#[cfg(feature = "actix")]
use actix_web::{FromRequest, HttpRequest, ResponseError, http::StatusCode};
use futures_util::future::{Ready, err, ok};
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
#[error("Invalid Overwrite header")] #[error("Invalid Overwrite header")]
pub struct InvalidOverwriteHeader; pub struct InvalidOverwriteHeader;
#[cfg(feature = "actix")]
impl ResponseError for InvalidOverwriteHeader {
fn status_code(&self) -> actix_web::http::StatusCode {
StatusCode::BAD_REQUEST
}
}
#[derive(Debug, PartialEq, Default)] #[derive(Debug, PartialEq, Default)]
pub enum Overwrite { pub enum Overwrite {
#[default] #[default]
@@ -38,25 +28,3 @@ impl TryFrom<&[u8]> for Overwrite {
} }
} }
} }
#[cfg(feature = "actix")]
impl FromRequest for Overwrite {
type Error = InvalidOverwriteHeader;
type Future = Ready<Result<Self, Self::Error>>;
fn extract(req: &HttpRequest) -> Self::Future {
if let Some(overwrite_header) = req.headers().get("Overwrite") {
match overwrite_header.as_bytes().try_into() {
Ok(depth) => ok(depth),
Err(e) => err(e),
}
} else {
// default depth
ok(Overwrite::F)
}
}
fn from_request(req: &HttpRequest, _payload: &mut actix_web::dev::Payload) -> Self::Future {
Self::extract(req)
}
}

View File

@@ -6,7 +6,6 @@ pub mod privileges;
pub mod resource; pub mod resource;
pub mod resources; pub mod resources;
pub mod xml; pub mod xml;
pub use error::Error; pub use error::Error;
pub trait Principal: std::fmt::Debug + Clone + Send + Sync + 'static { pub trait Principal: std::fmt::Debug + Clone + Send + Sync + 'static {

View File

@@ -2,10 +2,10 @@ use axum::body::Body;
use futures_util::future::BoxFuture; use futures_util::future::BoxFuture;
use headers::Allow; use headers::Allow;
use http::{Method, Request, Response}; use http::{Method, Request, Response};
use std::{convert::Infallible, str::FromStr, sync::Arc}; use std::{convert::Infallible, str::FromStr};
pub type MethodFunction<Service> = pub type MethodFunction<Service> =
fn(Arc<Service>, Request<Body>) -> BoxFuture<'static, Result<Response<Body>, Infallible>>; fn(Service, Request<Body>) -> BoxFuture<'static, Result<Response<Body>, Infallible>>;
pub trait AxumMethods: Sized + Send + Sync + 'static { pub trait AxumMethods: Sized + Send + Sync + 'static {
#[inline] #[inline]

View File

@@ -2,6 +2,7 @@ use super::methods::{axum_route_propfind, axum_route_proppatch};
use crate::resource::{ResourceService, axum_methods::AxumMethods}; use crate::resource::{ResourceService, axum_methods::AxumMethods};
use axum::{ use axum::{
body::Body, body::Body,
extract::FromRequestParts,
handler::Handler, handler::Handler,
http::{Request, Response}, http::{Request, Response},
response::IntoResponse, response::IntoResponse,
@@ -9,16 +10,16 @@ use axum::{
use futures_util::future::BoxFuture; use futures_util::future::BoxFuture;
use headers::HeaderMapExt; use headers::HeaderMapExt;
use http::{HeaderValue, StatusCode}; use http::{HeaderValue, StatusCode};
use std::{convert::Infallible, sync::Arc}; use std::convert::Infallible;
use tower::Service; use tower::Service;
#[derive(Clone)] #[derive(Clone)]
pub struct AxumService<RS: ResourceService + AxumMethods> { pub struct AxumService<RS: ResourceService + AxumMethods> {
resource_service: Arc<RS>, resource_service: RS,
} }
impl<RS: ResourceService + AxumMethods> AxumService<RS> { impl<RS: ResourceService + AxumMethods> AxumService<RS> {
pub fn new(resource_service: Arc<RS>) -> Self { pub fn new(resource_service: RS) -> Self {
Self { resource_service } Self { resource_service }
} }
} }
@@ -27,6 +28,7 @@ impl<RS: ResourceService + AxumMethods + Clone + Send + Sync> Service<Request<Bo
for AxumService<RS> for AxumService<RS>
where where
RS::Error: IntoResponse + Send + Sync + 'static, RS::Error: IntoResponse + Send + Sync + 'static,
RS::Principal: FromRequestParts<RS>,
{ {
type Error = Infallible; type Error = Infallible;
type Response = Response<Body>; type Response = Response<Body>;

View File

@@ -2,77 +2,15 @@ use crate::Error;
use crate::privileges::UserPrivilege; use crate::privileges::UserPrivilege;
use crate::resource::Resource; use crate::resource::Resource;
use crate::resource::ResourceService; use crate::resource::ResourceService;
#[cfg(feature = "axum")] use axum::extract::{Path, State};
use axum::extract::{Extension, Path, State};
#[cfg(feature = "axum")]
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
use headers::Header; use headers::{IfMatch, IfNoneMatch};
use headers::{HeaderValue, IfMatch, IfNoneMatch};
#[cfg(feature = "axum")]
use http::HeaderMap; use http::HeaderMap;
use itertools::Itertools;
#[cfg(feature = "axum")]
use std::sync::Arc;
use tracing::instrument;
#[cfg(feature = "actix")]
#[instrument(parent = root_span.id(), skip(path, req, root_span, resource_service))]
pub async fn actix_route_delete<R: ResourceService>(
path: actix_web::web::Path<R::PathComponents>,
req: actix_web::HttpRequest,
principal: R::Principal,
resource_service: actix_web::web::Data<R>,
root_span: tracing_actix_web::RootSpan,
) -> Result<actix_web::HttpResponse, R::Error> {
let no_trash = req
.headers()
.get("X-No-Trashbin")
.map(|val| matches!(val.to_str(), Ok("1")))
.unwrap_or(false);
// This weird conversion stuff is because we want to use the headers library (to be
// framework-agnostic in the future) which uses http==1.0,
// while actix-web still uses http==0.2
let if_match = req
.headers()
.get_all(http_02::header::IF_MATCH)
.map(|val_02| HeaderValue::from_bytes(val_02.as_bytes()).unwrap())
.collect_vec();
let if_none_match = req
.headers()
.get_all(http_02::header::IF_NONE_MATCH)
.map(|val_02| HeaderValue::from_bytes(val_02.as_bytes()).unwrap())
.collect_vec();
let if_match = if if_match.is_empty() {
None
} else {
Some(IfMatch::decode(&mut if_match.iter()).unwrap())
};
let if_none_match = if if_none_match.is_empty() {
None
} else {
Some(IfNoneMatch::decode(&mut if_none_match.iter()).unwrap())
};
route_delete(
&path.into_inner(),
&principal,
resource_service.as_ref(),
no_trash,
if_match,
if_none_match,
)
.await?;
Ok(actix_web::HttpResponse::Ok().body(""))
}
#[cfg(feature = "axum")]
pub(crate) async fn axum_route_delete<R: ResourceService>( pub(crate) async fn axum_route_delete<R: ResourceService>(
Path(path): Path<R::PathComponents>, Path(path): Path<R::PathComponents>,
State(resource_service): State<Arc<R>>, State(resource_service): State<R>,
Extension(principal): Extension<R::Principal>, principal: R::Principal,
if_match: Option<TypedHeader<IfMatch>>, if_match: Option<TypedHeader<IfMatch>>,
if_none_match: Option<TypedHeader<IfNoneMatch>>, if_none_match: Option<TypedHeader<IfNoneMatch>>,
header_map: HeaderMap, header_map: HeaderMap,
@@ -84,7 +22,7 @@ pub(crate) async fn axum_route_delete<R: ResourceService>(
route_delete( route_delete(
&path, &path,
&principal, &principal,
resource_service.as_ref(), &resource_service,
no_trash, no_trash,
if_match.map(|hdr| hdr.0), if_match.map(|hdr| hdr.0),
if_none_match.map(|hdr| hdr.0), if_none_match.map(|hdr| hdr.0),

View File

@@ -2,17 +2,6 @@ mod delete;
mod propfind; mod propfind;
mod proppatch; mod proppatch;
#[cfg(feature = "actix")]
pub(crate) use delete::actix_route_delete;
#[cfg(feature = "axum")]
pub(crate) use delete::axum_route_delete; pub(crate) use delete::axum_route_delete;
#[cfg(feature = "actix")]
pub(crate) use propfind::actix_route_propfind;
#[cfg(feature = "axum")]
pub(crate) use propfind::axum_route_propfind; pub(crate) use propfind::axum_route_propfind;
#[cfg(feature = "actix")]
pub(crate) use proppatch::actix_route_proppatch;
#[cfg(feature = "axum")]
pub(crate) use proppatch::axum_route_proppatch; pub(crate) use proppatch::axum_route_proppatch;

View File

@@ -7,47 +7,15 @@ use crate::resource::ResourceService;
use crate::xml::MultistatusElement; use crate::xml::MultistatusElement;
use crate::xml::PropfindElement; use crate::xml::PropfindElement;
use crate::xml::PropfindType; use crate::xml::PropfindType;
#[cfg(feature = "axum")]
use axum::extract::{Extension, OriginalUri, Path, State}; use axum::extract::{Extension, OriginalUri, Path, State};
use rustical_xml::PropName; use rustical_xml::PropName;
use rustical_xml::XmlDocument; use rustical_xml::XmlDocument;
use std::sync::Arc;
use tracing::instrument;
#[cfg(feature = "actix")]
#[instrument(parent = root_span.id(), skip(path, req, root_span, resource_service, puri))]
#[allow(clippy::type_complexity)]
pub(crate) async fn actix_route_propfind<R: ResourceService>(
path: ::actix_web::web::Path<R::PathComponents>,
body: String,
req: ::actix_web::HttpRequest,
user: R::Principal,
depth: Depth,
root_span: tracing_actix_web::RootSpan,
resource_service: ::actix_web::web::Data<R>,
puri: ::actix_web::web::Data<R::PrincipalUri>,
) -> Result<
MultistatusElement<<R::Resource as Resource>::Prop, <R::MemberType as Resource>::Prop>,
R::Error,
> {
route_propfind(
&path.into_inner(),
req.path(),
&body,
&user,
&depth,
resource_service.as_ref(),
puri.as_ref(),
)
.await
}
#[cfg(feature = "axum")]
pub(crate) async fn axum_route_propfind<R: ResourceService>( pub(crate) async fn axum_route_propfind<R: ResourceService>(
Path(path): Path<R::PathComponents>, Path(path): Path<R::PathComponents>,
State(resource_service): State<Arc<R>>, State(resource_service): State<R>,
depth: Depth, depth: Depth,
Extension(principal): Extension<R::Principal>, principal: R::Principal,
uri: OriginalUri, uri: OriginalUri,
Extension(puri): Extension<R::PrincipalUri>, Extension(puri): Extension<R::PrincipalUri>,
body: String, body: String,
@@ -61,7 +29,7 @@ pub(crate) async fn axum_route_propfind<R: ResourceService>(
&body, &body,
&principal, &principal,
&depth, &depth,
resource_service.as_ref(), &resource_service,
&puri, &puri,
) )
.await .await
@@ -115,7 +83,7 @@ pub(crate) async fn route_propfind<R: ResourceService>(
} }
} }
let response = resource.propfind_typed(path, &propfind_self.prop, puri, &principal)?; let response = resource.propfind_typed(path, &propfind_self.prop, puri, principal)?;
Ok(MultistatusElement { Ok(MultistatusElement {
responses: vec![response], responses: vec![response],

View File

@@ -1,13 +1,11 @@
use crate::Error; use crate::Error;
use crate::privileges::UserPrivilege; use crate::privileges::UserPrivilege;
use std::sync::Arc;
use crate::resource::Resource; use crate::resource::Resource;
use crate::resource::ResourceService; use crate::resource::ResourceService;
#[cfg(feature = "axum")]
use axum::extract::{Extension, OriginalUri, Path, State};
use crate::xml::MultistatusElement; use crate::xml::MultistatusElement;
use crate::xml::TagList; use crate::xml::TagList;
use crate::xml::multistatus::{PropstatElement, PropstatWrapper, ResponseElement}; use crate::xml::multistatus::{PropstatElement, PropstatWrapper, ResponseElement};
use axum::extract::{OriginalUri, Path, State};
use http::StatusCode; use http::StatusCode;
use quick_xml::name::Namespace; use quick_xml::name::Namespace;
use rustical_xml::NamespaceOwned; use rustical_xml::NamespaceOwned;
@@ -17,7 +15,6 @@ use rustical_xml::XmlDeserialize;
use rustical_xml::XmlDocument; use rustical_xml::XmlDocument;
use rustical_xml::XmlRootTag; use rustical_xml::XmlRootTag;
use std::str::FromStr; use std::str::FromStr;
use tracing::instrument;
#[derive(XmlDeserialize, Clone, Debug)] #[derive(XmlDeserialize, Clone, Debug)]
#[xml(untagged)] #[xml(untagged)]
@@ -64,46 +61,14 @@ enum Operation<T: XmlDeserialize> {
#[xml(ns = "crate::namespace::NS_DAV")] #[xml(ns = "crate::namespace::NS_DAV")]
struct PropertyupdateElement<T: XmlDeserialize>(#[xml(ty = "untagged", flatten)] Vec<Operation<T>>); struct PropertyupdateElement<T: XmlDeserialize>(#[xml(ty = "untagged", flatten)] Vec<Operation<T>>);
#[cfg(feature = "actix")]
#[instrument(parent = root_span.id(), skip(path, req, root_span, resource_service))]
pub(crate) async fn actix_route_proppatch<R: ResourceService>(
path: actix_web::web::Path<R::PathComponents>,
body: String,
req: actix_web::HttpRequest,
principal: R::Principal,
root_span: tracing_actix_web::RootSpan,
resource_service: actix_web::web::Data<R>,
) -> Result<MultistatusElement<String, String>, R::Error> {
route_proppatch(
&path.into_inner(),
req.path(),
&body,
&principal,
resource_service.as_ref(),
)
.await
}
#[cfg(feature = "axum")]
pub(crate) async fn axum_route_proppatch<R: ResourceService>( pub(crate) async fn axum_route_proppatch<R: ResourceService>(
Path(path): Path<R::PathComponents>, Path(path): Path<R::PathComponents>,
State(resource_service): State<Arc<R>>, State(resource_service): State<R>,
Extension(principal): Extension<R::Principal>, principal: R::Principal,
uri: OriginalUri, uri: OriginalUri,
body: String, body: String,
) -> Result< ) -> Result<MultistatusElement<String, String>, R::Error> {
MultistatusElement<String, String>, route_proppatch(&path, uri.path(), &body, &principal, &resource_service).await
R::Error,
> {
route_proppatch(
&path,
uri.path(),
&body,
&principal,
resource_service.as_ref(),
)
.await
} }
pub(crate) async fn route_proppatch<R: ResourceService>( pub(crate) async fn route_proppatch<R: ResourceService>(
@@ -118,10 +83,10 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
// Extract operations // Extract operations
let PropertyupdateElement::<SetPropertyPropWrapperWrapper<<R::Resource as Resource>::Prop>>( let PropertyupdateElement::<SetPropertyPropWrapperWrapper<<R::Resource as Resource>::Prop>>(
operations, operations,
) = XmlDocument::parse_str(&body).map_err(Error::XmlError)?; ) = XmlDocument::parse_str(body).map_err(Error::XmlError)?;
let mut resource = resource_service.get_resource(path_components).await?; let mut resource = resource_service.get_resource(path_components).await?;
let privileges = resource.get_user_privileges(&principal)?; let privileges = resource.get_user_privileges(principal)?;
if !privileges.has(&UserPrivilege::Write) { if !privileges.has(&UserPrivilege::Write) {
return Err(Error::Unauthorized.into()); return Err(Error::Unauthorized.into());
} }

View File

@@ -12,17 +12,13 @@ use rustical_xml::{EnumVariants, NamespaceOwned, PropName, XmlDeserialize, XmlSe
use std::collections::HashSet; use std::collections::HashSet;
use std::str::FromStr; use std::str::FromStr;
#[cfg(feature = "axum")]
mod axum_methods; mod axum_methods;
#[cfg(feature = "axum")]
mod axum_service; mod axum_service;
mod methods; mod methods;
mod principal_uri; mod principal_uri;
mod resource_service; mod resource_service;
#[cfg(feature = "axum")]
pub use axum_methods::AxumMethods; pub use axum_methods::AxumMethods;
#[cfg(feature = "axum")]
pub use axum_service::AxumService; pub use axum_service::AxumService;
pub use principal_uri::PrincipalUri; pub use principal_uri::PrincipalUri;

View File

@@ -1,14 +1,8 @@
#[cfg(feature = "actix")]
use super::methods::{actix_route_delete, actix_route_propfind, actix_route_proppatch};
use super::{PrincipalUri, Resource}; use super::{PrincipalUri, Resource};
use crate::Principal; use crate::Principal;
#[cfg(feature = "axum")]
use crate::resource::{AxumMethods, AxumService}; use crate::resource::{AxumMethods, AxumService};
#[cfg(feature = "actix")]
use actix_web::{http::Method, web, web::Data};
use async_trait::async_trait; use async_trait::async_trait;
use serde::Deserialize; use serde::Deserialize;
use std::{str::FromStr, sync::Arc};
#[async_trait] #[async_trait]
pub trait ResourceService: Sized + Send + Sync + 'static { pub trait ResourceService: Sized + Send + Sync + 'static {
@@ -28,11 +22,6 @@ pub trait ResourceService: Sized + Send + Sync + 'static {
Ok(vec![]) Ok(vec![])
} }
async fn test_get_members(&self, _path: &Self::PathComponents) -> Result<String, Self::Error> {
// ) -> Result<Vec<Self::MemberType>, Self::Error> {
Ok("asd".to_string())
}
async fn get_resource( async fn get_resource(
&self, &self,
_path: &Self::PathComponents, _path: &Self::PathComponents,
@@ -54,36 +43,10 @@ pub trait ResourceService: Sized + Send + Sync + 'static {
Err(crate::Error::Unauthorized.into()) Err(crate::Error::Unauthorized.into())
} }
#[cfg(feature = "actix")]
#[inline]
fn actix_resource(self) -> actix_web::Resource
where
Self::Error: actix_web::ResponseError,
Self::Principal: actix_web::FromRequest,
{
web::resource("")
.app_data(Data::new(self))
.route(
web::method(Method::from_str("PROPFIND").unwrap()).to(actix_route_propfind::<Self>),
)
.route(
web::method(Method::from_str("PROPPATCH").unwrap())
.to(actix_route_proppatch::<Self>),
)
.delete(actix_route_delete::<Self>)
}
#[cfg(feature = "actix")]
fn actix_scope(self) -> actix_web::Scope
where
Self::Error: actix_web::ResponseError,
Self::Principal: actix_web::FromRequest;
#[cfg(feature = "axum")]
fn axum_service(self) -> AxumService<Self> fn axum_service(self) -> AxumService<Self>
where where
Self: Clone + Send + Sync + AxumMethods, Self: Clone + Send + Sync + AxumMethods,
{ {
AxumService::new(Arc::new(self)) AxumService::new(self)
} }
} }

View File

@@ -3,7 +3,7 @@ use crate::extensions::{
CommonPropertiesExtension, CommonPropertiesProp, CommonPropertiesPropName, CommonPropertiesExtension, CommonPropertiesProp, CommonPropertiesPropName,
}; };
use crate::privileges::UserPrivilegeSet; use crate::privileges::UserPrivilegeSet;
use crate::resource::{PrincipalUri, Resource, ResourceService}; use crate::resource::{AxumMethods, PrincipalUri, Resource, ResourceService};
use crate::xml::{Resourcetype, ResourcetypeInner}; use crate::xml::{Resourcetype, ResourcetypeInner};
use async_trait::async_trait; use async_trait::async_trait;
use std::marker::PhantomData; use std::marker::PhantomData;
@@ -74,15 +74,9 @@ impl<PRS: ResourceService<Principal = P> + Clone, P: Principal, PURI: PrincipalU
async fn get_resource(&self, _: &()) -> Result<Self::Resource, Self::Error> { async fn get_resource(&self, _: &()) -> Result<Self::Resource, Self::Error> {
Ok(RootResource::<PRS::Resource, P>::default()) Ok(RootResource::<PRS::Resource, P>::default())
} }
}
#[cfg(feature = "actix")] impl<PRS: ResourceService<Principal = P> + Clone, P: Principal, PURI: PrincipalUri> AxumMethods
fn actix_scope(self) -> actix_web::Scope for RootResourceService<PRS, P, PURI>
where
Self::Error: actix_web::ResponseError,
Self::Principal: actix_web::FromRequest,
{ {
actix_web::web::scope("")
.service(self.0.clone().actix_scope())
.service(self.actix_resource())
}
} }

View File

@@ -1,8 +1,4 @@
use crate::xml::TagList; use crate::xml::TagList;
#[cfg(feature = "actix")]
use actix_web::{
HttpRequest, HttpResponse, Responder, ResponseError, body::BoxBody, http::header::ContentType,
};
use http::StatusCode; use http::StatusCode;
use quick_xml::name::Namespace; use quick_xml::name::Namespace;
use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot}; use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot};
@@ -108,24 +104,6 @@ impl<T1: XmlSerialize, T2: XmlSerialize> Default for MultistatusElement<T1, T2>
} }
} }
#[cfg(feature = "actix")]
impl<T1: XmlSerialize, T2: XmlSerialize> Responder for MultistatusElement<T1, T2> {
type Body = BoxBody;
fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> {
let mut output: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into();
let mut writer = quick_xml::Writer::new_with_indent(&mut output, b' ', 4);
if let Err(err) = self.serialize_root(&mut writer) {
return crate::Error::from(err).error_response();
}
HttpResponse::MultiStatus()
.content_type(ContentType::xml())
.body(String::from_utf8(output).unwrap())
}
}
#[cfg(feature = "axum")]
impl<T1: XmlSerialize, T2: XmlSerialize> axum::response::IntoResponse impl<T1: XmlSerialize, T2: XmlSerialize> axum::response::IntoResponse
for MultistatusElement<T1, T2> for MultistatusElement<T1, T2>
{ {

View File

@@ -8,7 +8,6 @@ publish = false
[dependencies] [dependencies]
rustical_xml.workspace = true rustical_xml.workspace = true
actix-web = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }
quick-xml = { workspace = true } quick-xml = { workspace = true }
@@ -18,7 +17,6 @@ itertools = { workspace = true }
log = { workspace = true } log = { workspace = true }
derive_more = { workspace = true } derive_more = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-actix-web = { workspace = true }
reqwest.workspace = true reqwest.workspace = true
tokio.workspace = true tokio.workspace = true
rustical_dav.workspace = true rustical_dav.workspace = true

View File

@@ -7,14 +7,15 @@ repository.workspace = true
publish = false publish = false
[dependencies] [dependencies]
tower.workspace = true
http.workspace = true
axum.workspace = true
askama.workspace = true askama.workspace = true
async-trait.workspace = true
askama_web.workspace = true askama_web.workspace = true
actix-session.workspace = true async-trait.workspace = true
serde.workspace = true serde.workspace = true
thiserror.workspace = true thiserror.workspace = true
tokio.workspace = true tokio.workspace = true
actix-web.workspace = true
rustical_store.workspace = true rustical_store.workspace = true
rust-embed.workspace = true rust-embed.workspace = true
futures-core.workspace = true futures-core.workspace = true
@@ -27,3 +28,5 @@ uuid.workspace = true
url.workspace = true url.workspace = true
tracing.workspace = true tracing.workspace = true
rustical_oidc.workspace = true rustical_oidc.workspace = true
axum-extra.workspace= true
headers.workspace = true

View File

@@ -1,97 +1,72 @@
use std::marker::PhantomData; use axum::{
RequestExt,
use actix_web::{ body::Body,
body::BoxBody, extract::{Path, Request},
dev::{ response::{IntoResponse, Response},
HttpServiceFactory, ResourceDef, Service, ServiceFactory, ServiceRequest, ServiceResponse,
},
http::{header, Method},
HttpResponse,
}; };
use futures_core::future::LocalBoxFuture; use futures_core::future::BoxFuture;
use headers::{ContentType, ETag, HeaderMapExt};
use http::{Method, StatusCode};
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
use std::{convert::Infallible, marker::PhantomData, str::FromStr};
use tower::Service;
#[derive(RustEmbed)] #[derive(Clone, RustEmbed)]
#[folder = "public/assets"] #[folder = "public/assets"]
pub struct Assets; pub struct Assets;
#[derive(Clone)]
pub struct EmbedService<E> pub struct EmbedService<E>
where where
E: 'static + RustEmbed, E: 'static + RustEmbed,
{ {
_embed: PhantomData<E>, _embed: PhantomData<E>,
prefix: String,
} }
impl<E> EmbedService<E> impl<E> EmbedService<E>
where where
E: 'static + RustEmbed, E: 'static + RustEmbed,
{ {
pub fn new(prefix: String) -> Self { pub fn new() -> Self {
Self { Self {
prefix,
_embed: PhantomData, _embed: PhantomData,
} }
} }
} }
impl<E> HttpServiceFactory for EmbedService<E> impl<E> Service<Request> for EmbedService<E>
where where
E: 'static + RustEmbed, E: 'static + RustEmbed,
{ {
fn register(self, config: &mut actix_web::dev::AppService) { type Response = Response;
let resource_def = if config.is_root() { type Error = Infallible;
ResourceDef::root_prefix(&self.prefix) type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
} else {
ResourceDef::prefix(&self.prefix) #[inline]
}; fn poll_ready(
config.register_service(resource_def, None, self, None); &mut self,
} _cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
Ok(()).into()
} }
impl<E> ServiceFactory<ServiceRequest> for EmbedService<E> #[inline]
where fn call(&mut self, mut req: Request) -> Self::Future {
E: 'static + RustEmbed,
{
type Response = ServiceResponse;
type Error = actix_web::Error;
type Config = ();
type Service = EmbedService<E>;
type InitError = ();
type Future = LocalBoxFuture<'static, Result<Self::Service, Self::InitError>>;
fn new_service(&self, _: ()) -> Self::Future {
let prefix = self.prefix.clone();
Box::pin(async move {
Ok(Self {
prefix,
_embed: PhantomData,
})
})
}
}
impl<E> Service<ServiceRequest> for EmbedService<E>
where
E: 'static + RustEmbed,
{
type Response = ServiceResponse<BoxBody>;
type Error = actix_web::Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
actix_web::dev::always_ready!();
fn call(&self, req: ServiceRequest) -> Self::Future {
Box::pin(async move { Box::pin(async move {
if req.method() != Method::GET && req.method() != Method::HEAD { if req.method() != Method::GET && req.method() != Method::HEAD {
return Ok(req.into_response(HttpResponse::MethodNotAllowed())); return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response());
} }
let path = req.match_info().unprocessed().trim_start_matches('/'); let path: String = if let Ok(Path(path)) = req.extract_parts().await.unwrap() {
path
} else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
match E::get(path) { match E::get(&path) {
Some(file) => { Some(file) => {
let data = file.data; let data = file.data;
let hash = hex::encode(file.metadata.sha256_hash()); let hash = hex::encode(file.metadata.sha256_hash());
let etag = format!("\"{hash}\"");
let mime = mime_guess::from_path(path).first_or_octet_stream(); let mime = mime_guess::from_path(path).first_or_octet_stream();
let body = if req.method() == Method::HEAD { let body = if req.method() == Method::HEAD {
@@ -99,14 +74,13 @@ where
} else { } else {
data data
}; };
Ok(req.into_response( let mut res = Response::builder().status(StatusCode::OK);
HttpResponse::Ok() let hdrs = res.headers_mut().unwrap();
.content_type(mime) hdrs.typed_insert(ContentType::from(mime));
.insert_header((header::ETAG, hash)) hdrs.typed_insert(ETag::from_str(&etag).unwrap());
.body(body), Ok(res.body(Body::from(body)).unwrap())
))
} }
None => Ok(req.into_response(HttpResponse::NotFound())), None => Ok(StatusCode::NOT_FOUND.into_response()),
} }
}) })
} }

View File

@@ -1,30 +1,23 @@
use actix_session::{
SessionMiddleware,
config::CookieContentSecurity,
storage::{CookieSessionStore, SessionStore},
};
use actix_web::{
HttpRequest, HttpResponse, Responder,
cookie::{Key, SameSite},
dev::ServiceResponse,
http::{Method, StatusCode, header},
middleware::{ErrorHandlerResponse, ErrorHandlers},
web::{self, Data, Form, Path, Redirect},
};
use askama::Template; use askama::Template;
use askama_web::WebTemplate; // use askama_web::WebTemplate;
use assets::{Assets, EmbedService}; // use assets::{Assets, EmbedService};
use async_trait::async_trait; use async_trait::async_trait;
use axum::{Extension, Router, response::IntoResponse, routing::get};
use http::Uri;
use rand::{Rng, distributions::Alphanumeric}; use rand::{Rng, distributions::Alphanumeric};
use routes::{ use rustical_oidc::OidcConfig;
addressbook::{route_addressbook, route_addressbook_restore}, // use routes::{
calendar::{route_calendar, route_calendar_restore}, // addressbook::{route_addressbook, route_addressbook_restore},
login::{route_get_login, route_post_login, route_post_logout}, // calendar::{route_calendar, route_calendar_restore},
}; // login::{route_get_login, route_post_login, route_post_logout},
use rustical_oidc::{OidcConfig, OidcServiceConfig, UserStore, configure_oidc}; // };
// use rustical_oidc::{OidcConfig, OidcServiceConfig, UserStore, configure_oidc};
use rustical_store::{ use rustical_store::{
Addressbook, AddressbookStore, Calendar, CalendarStore, Addressbook, AddressbookStore, Calendar, CalendarStore,
auth::{AuthenticationMiddleware, AuthenticationProvider, User, user::AppToken}, auth::{
AuthenticationMiddleware, AuthenticationProvider, User, middleware::AuthenticationLayer,
user::AppToken,
},
}; };
use serde::Deserialize; use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
@@ -40,6 +33,11 @@ pub const ROUTE_USER_NAMED: &str = "frontend_user_named";
pub use config::{FrontendConfig, generate_frontend_secret}; pub use config::{FrontendConfig, generate_frontend_secret};
use crate::{
assets::{Assets, EmbedService},
routes::login::{route_get_login, route_post_login},
};
pub fn generate_app_token() -> String { pub fn generate_app_token() -> String {
rand::thread_rng() rand::thread_rng()
.sample_iter(Alphanumeric) .sample_iter(Alphanumeric)
@@ -48,313 +46,312 @@ pub fn generate_app_token() -> String {
.collect() .collect()
} }
#[derive(Template, WebTemplate)] // #[derive(Template, WebTemplate)]
#[template(path = "pages/user.html")] // #[template(path = "pages/user.html")]
struct UserPage { // struct UserPage {
pub user: User, // pub user: User,
pub app_tokens: Vec<AppToken>, // pub app_tokens: Vec<AppToken>,
pub calendars: Vec<Calendar>, // pub calendars: Vec<Calendar>,
pub deleted_calendars: Vec<Calendar>, // pub deleted_calendars: Vec<Calendar>,
pub addressbooks: Vec<Addressbook>, // pub addressbooks: Vec<Addressbook>,
pub deleted_addressbooks: Vec<Addressbook>, // pub deleted_addressbooks: Vec<Addressbook>,
pub is_apple: bool, // pub is_apple: bool,
} // }
//
// async fn route_user_named<CS: CalendarStore, AS: AddressbookStore, AP: AuthenticationProvider>(
// path: Path<String>,
// cal_store: Data<CS>,
// addr_store: Data<AS>,
// auth_provider: Data<AP>,
// user: User,
// req: HttpRequest,
// ) -> impl Responder {
// let user_id = path.into_inner();
// if user_id != user.id {
// return actix_web::HttpResponse::Unauthorized().body("Unauthorized");
// }
//
// let mut calendars = vec![];
// for group in user.memberships() {
// calendars.extend(cal_store.get_calendars(group).await.unwrap());
// }
//
// let mut deleted_calendars = vec![];
// for group in user.memberships() {
// deleted_calendars.extend(cal_store.get_deleted_calendars(group).await.unwrap());
// }
//
// let mut addressbooks = vec![];
// for group in user.memberships() {
// addressbooks.extend(addr_store.get_addressbooks(group).await.unwrap());
// }
//
// let mut deleted_addressbooks = vec![];
// for group in user.memberships() {
// deleted_addressbooks.extend(addr_store.get_deleted_addressbooks(group).await.unwrap());
// }
//
// let is_apple = req
// .headers()
// .get(header::USER_AGENT)
// .and_then(|user_agent| user_agent.to_str().ok())
// .map(|ua| ua.contains("Apple") || ua.contains("Mac OS"))
// .unwrap_or_default();
//
// UserPage {
// app_tokens: auth_provider.get_app_tokens(&user.id).await.unwrap(),
// calendars,
// deleted_calendars,
// addressbooks,
// deleted_addressbooks,
// user,
// is_apple,
// }
// .respond_to(&req)
// }
//
// async fn route_get_home(user: User, req: HttpRequest) -> Redirect {
// Redirect::to(
// req.url_for(ROUTE_USER_NAMED, &[user.id])
// .unwrap()
// .to_string(),
// )
// .see_other()
// }
//
// async fn route_root(user: Option<User>, req: HttpRequest) -> impl Responder {
// let redirect_url = match user {
// Some(_) => req.url_for_static(ROUTE_NAME_HOME).unwrap(),
// None => req
// .resource_map()
// .url_for::<[_; 0], String>(&req, "frontend_login", [])
// .unwrap(),
// };
// web::Redirect::to(redirect_url.to_string()).permanent()
// }
//
// #[derive(Template)]
// #[template(path = "apple_configuration/template.xml")]
// pub struct AppleConfig {
// token_name: String,
// account_description: String,
// hostname: String,
// caldav_principal_url: String,
// carddav_principal_url: String,
// user: String,
// token: String,
// caldav_profile_uuid: Uuid,
// carddav_profile_uuid: Uuid,
// plist_uuid: Uuid,
// }
//
// #[derive(Debug, Clone, Deserialize)]
// pub(crate) struct PostAppTokenForm {
// name: String,
// #[serde(default)]
// apple: bool,
// }
//
// async fn route_post_app_token<AP: AuthenticationProvider>(
// user: User,
// auth_provider: Data<AP>,
// path: Path<String>,
// Form(PostAppTokenForm { apple, name }): Form<PostAppTokenForm>,
// req: HttpRequest,
// ) -> Result<HttpResponse, rustical_store::Error> {
// assert!(!name.is_empty());
// assert_eq!(path.into_inner(), user.id);
// let token = generate_app_token();
// auth_provider
// .add_app_token(&user.id, name.to_owned(), token.clone())
// .await?;
// if apple {
// let hostname = req.full_url().host_str().unwrap().to_owned();
// let profile = AppleConfig {
// token_name: name,
// account_description: format!("{}@{}", &user.id, &hostname),
// hostname,
// caldav_principal_url: req
// .url_for("caldav_principal", [&user.id])
// .unwrap()
// .to_string(),
// carddav_principal_url: req
// .url_for("carddav_principal", [&user.id])
// .unwrap()
// .to_string(),
// user: user.id.to_owned(),
// token,
// caldav_profile_uuid: Uuid::new_v4(),
// carddav_profile_uuid: Uuid::new_v4(),
// plist_uuid: Uuid::new_v4(),
// }
// .render()
// .unwrap();
// Ok(HttpResponse::Ok()
// .insert_header(header::ContentDisposition::attachment(format!(
// "rustical-{}.mobileconfig",
// user.id
// )))
// .insert_header((
// header::CONTENT_TYPE,
// "application/x-apple-aspen-config; charset=utf-8",
// ))
// .body(profile))
// } else {
// Ok(HttpResponse::Ok().body(token))
// }
// }
//
// async fn route_delete_app_token<AP: AuthenticationProvider>(
// user: User,
// auth_provider: Data<AP>,
// path: Path<(String, String)>,
// ) -> Result<Redirect, rustical_store::Error> {
// let (path_user, token_id) = path.into_inner();
// assert_eq!(path_user, user.id);
// auth_provider.remove_app_token(&user.id, &token_id).await?;
// Ok(Redirect::to("/frontend/user").see_other())
// }
//
// pub(crate) fn unauthorized_handler<B>(
// res: ServiceResponse<B>,
// ) -> actix_web::Result<ErrorHandlerResponse<B>> {
// let (req, _) = res.into_parts();
// let redirect_uri = req.uri().to_string();
// let mut login_url = req.url_for_static("frontend_login").unwrap();
// login_url
// .query_pairs_mut()
// .append_pair("redirect_uri", &redirect_uri);
// let login_url = login_url.to_string();
//
// let response = HttpResponse::Unauthorized().body(format!(
// r#"<!Doctype html>
// <html>
// <head>
// <meta http-equiv="refresh" content="1; url={login_url}" />
// </head>
// <body>
// Unauthorized, redirecting to <a href="{login_url}">login page</a>
// </body>
// <html>
// "#
// ));
//
// let res = ServiceResponse::new(req, response)
// .map_into_boxed_body()
// .map_into_right_body();
//
// Ok(ErrorHandlerResponse::Response(res))
// }
//
// pub fn session_middleware(frontend_secret: [u8; 64]) -> SessionMiddleware<impl SessionStore> {
// SessionMiddleware::builder(CookieSessionStore::default(), Key::from(&frontend_secret))
// .cookie_secure(true)
// .cookie_same_site(SameSite::Strict)
// .cookie_content_security(CookieContentSecurity::Private)
// .build()
// }
async fn route_user_named<CS: CalendarStore, AS: AddressbookStore, AP: AuthenticationProvider>( pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: AddressbookStore>(
path: Path<String>,
cal_store: Data<CS>,
addr_store: Data<AS>,
auth_provider: Data<AP>,
user: User,
req: HttpRequest,
) -> impl Responder {
let user_id = path.into_inner();
if user_id != user.id {
return actix_web::HttpResponse::Unauthorized().body("Unauthorized");
}
let mut calendars = vec![];
for group in user.memberships() {
calendars.extend(cal_store.get_calendars(group).await.unwrap());
}
let mut deleted_calendars = vec![];
for group in user.memberships() {
deleted_calendars.extend(cal_store.get_deleted_calendars(group).await.unwrap());
}
let mut addressbooks = vec![];
for group in user.memberships() {
addressbooks.extend(addr_store.get_addressbooks(group).await.unwrap());
}
let mut deleted_addressbooks = vec![];
for group in user.memberships() {
deleted_addressbooks.extend(addr_store.get_deleted_addressbooks(group).await.unwrap());
}
let is_apple = req
.headers()
.get(header::USER_AGENT)
.and_then(|user_agent| user_agent.to_str().ok())
.map(|ua| ua.contains("Apple") || ua.contains("Mac OS"))
.unwrap_or_default();
UserPage {
app_tokens: auth_provider.get_app_tokens(&user.id).await.unwrap(),
calendars,
deleted_calendars,
addressbooks,
deleted_addressbooks,
user,
is_apple,
}
.respond_to(&req)
}
async fn route_get_home(user: User, req: HttpRequest) -> Redirect {
Redirect::to(
req.url_for(ROUTE_USER_NAMED, &[user.id])
.unwrap()
.to_string(),
)
.see_other()
}
async fn route_root(user: Option<User>, req: HttpRequest) -> impl Responder {
let redirect_url = match user {
Some(_) => req.url_for_static(ROUTE_NAME_HOME).unwrap(),
None => req
.resource_map()
.url_for::<[_; 0], String>(&req, "frontend_login", [])
.unwrap(),
};
web::Redirect::to(redirect_url.to_string()).permanent()
}
#[derive(Template)]
#[template(path = "apple_configuration/template.xml")]
pub struct AppleConfig {
token_name: String,
account_description: String,
hostname: String,
caldav_principal_url: String,
carddav_principal_url: String,
user: String,
token: String,
caldav_profile_uuid: Uuid,
carddav_profile_uuid: Uuid,
plist_uuid: Uuid,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct PostAppTokenForm {
name: String,
#[serde(default)]
apple: bool,
}
async fn route_post_app_token<AP: AuthenticationProvider>(
user: User,
auth_provider: Data<AP>,
path: Path<String>,
Form(PostAppTokenForm { apple, name }): Form<PostAppTokenForm>,
req: HttpRequest,
) -> Result<HttpResponse, rustical_store::Error> {
assert!(!name.is_empty());
assert_eq!(path.into_inner(), user.id);
let token = generate_app_token();
auth_provider
.add_app_token(&user.id, name.to_owned(), token.clone())
.await?;
if apple {
let hostname = req.full_url().host_str().unwrap().to_owned();
let profile = AppleConfig {
token_name: name,
account_description: format!("{}@{}", &user.id, &hostname),
hostname,
caldav_principal_url: req
.url_for("caldav_principal", [&user.id])
.unwrap()
.to_string(),
carddav_principal_url: req
.url_for("carddav_principal", [&user.id])
.unwrap()
.to_string(),
user: user.id.to_owned(),
token,
caldav_profile_uuid: Uuid::new_v4(),
carddav_profile_uuid: Uuid::new_v4(),
plist_uuid: Uuid::new_v4(),
}
.render()
.unwrap();
Ok(HttpResponse::Ok()
.insert_header(header::ContentDisposition::attachment(format!(
"rustical-{}.mobileconfig",
user.id
)))
.insert_header((
header::CONTENT_TYPE,
"application/x-apple-aspen-config; charset=utf-8",
))
.body(profile))
} else {
Ok(HttpResponse::Ok().body(token))
}
}
async fn route_delete_app_token<AP: AuthenticationProvider>(
user: User,
auth_provider: Data<AP>,
path: Path<(String, String)>,
) -> Result<Redirect, rustical_store::Error> {
let (path_user, token_id) = path.into_inner();
assert_eq!(path_user, user.id);
auth_provider.remove_app_token(&user.id, &token_id).await?;
Ok(Redirect::to("/frontend/user").see_other())
}
pub(crate) fn unauthorized_handler<B>(
res: ServiceResponse<B>,
) -> actix_web::Result<ErrorHandlerResponse<B>> {
let (req, _) = res.into_parts();
let redirect_uri = req.uri().to_string();
let mut login_url = req.url_for_static("frontend_login").unwrap();
login_url
.query_pairs_mut()
.append_pair("redirect_uri", &redirect_uri);
let login_url = login_url.to_string();
let response = HttpResponse::Unauthorized().body(format!(
r#"<!Doctype html>
<html>
<head>
<meta http-equiv="refresh" content="1; url={login_url}" />
</head>
<body>
Unauthorized, redirecting to <a href="{login_url}">login page</a>
</body>
<html>
"#
));
let res = ServiceResponse::new(req, response)
.map_into_boxed_body()
.map_into_right_body();
Ok(ErrorHandlerResponse::Response(res))
}
pub fn session_middleware(frontend_secret: [u8; 64]) -> SessionMiddleware<impl SessionStore> {
SessionMiddleware::builder(CookieSessionStore::default(), Key::from(&frontend_secret))
.cookie_secure(true)
.cookie_same_site(SameSite::Strict)
.cookie_content_security(CookieContentSecurity::Private)
.build()
}
pub fn configure_frontend<AP: AuthenticationProvider, CS: CalendarStore, AS: AddressbookStore>(
cfg: &mut web::ServiceConfig,
auth_provider: Arc<AP>, auth_provider: Arc<AP>,
cal_store: Arc<CS>, cal_store: Arc<CS>,
addr_store: Arc<AS>, addr_store: Arc<AS>,
frontend_config: FrontendConfig, frontend_config: FrontendConfig,
oidc_config: Option<OidcConfig>, oidc_config: Option<OidcConfig>,
) { ) -> Router {
let mut scope = web::scope("") let mut router = Router::new().layer(AuthenticationLayer::new(auth_provider.clone()));
.wrap(ErrorHandlers::new().handler(StatusCode::UNAUTHORIZED, unauthorized_handler)) router = router
.wrap(AuthenticationMiddleware::new(auth_provider.clone())) .route("/login", get(route_get_login).post(route_post_login::<AP>))
.wrap(session_middleware(frontend_config.secret_key)) .route_service("/assets/{*file}", EmbedService::<Assets>::new())
.app_data(Data::from(auth_provider.clone())) .layer(Extension(auth_provider.clone()))
.app_data(Data::from(cal_store.clone())) .layer(Extension(cal_store.clone()))
.app_data(Data::from(addr_store.clone())) .layer(Extension(addr_store.clone()))
.app_data(Data::new(frontend_config.clone())) .layer(Extension(frontend_config.clone()))
.app_data(Data::new(oidc_config.clone())) .layer(Extension(oidc_config.clone()));
.service(EmbedService::<Assets>::new("/assets".to_owned())) // .wrap(session_middleware(frontend_config.secret_key))
.service(web::resource("").route(web::method(Method::GET).to(route_root))) // .service(web::resource("").route(web::method(Method::GET).to(route_root)))
.service( // .service(
web::resource("/user") // web::resource("/user")
.get(route_get_home) // .get(route_get_home)
.name(ROUTE_NAME_HOME), // .name(ROUTE_NAME_HOME),
) // )
.service( // .service(
web::resource("/user/{user}") // web::resource("/user/{user}")
.get(route_user_named::<CS, AS, AP>) // .get(route_user_named::<CS, AS, AP>)
.name(ROUTE_USER_NAMED), // .name(ROUTE_USER_NAMED),
) // )
// App token management // // App token management
.service(web::resource("/user/{user}/app_token").post(route_post_app_token::<AP>)) // .service(web::resource("/user/{user}/app_token").post(route_post_app_token::<AP>))
.service( // .service(
// POST because HTML5 forms don't support DELETE method // // POST because HTML5 forms don't support DELETE method
web::resource("/user/{user}/app_token/{id}/delete").post(route_delete_app_token::<AP>), // web::resource("/user/{user}/app_token/{id}/delete").post(route_delete_app_token::<AP>),
) // )
// Calendar // // Calendar
.service(web::resource("/user/{user}/calendar/{calendar}").get(route_calendar::<CS>)) // .service(web::resource("/user/{user}/calendar/{calendar}").get(route_calendar::<CS>))
.service( // .service(
web::resource("/user/{user}/calendar/{calendar}/restore") // web::resource("/user/{user}/calendar/{calendar}/restore")
.post(route_calendar_restore::<CS>), // .post(route_calendar_restore::<CS>),
) // )
// Addressbook // // Addressbook
.service( // .service(
web::resource("/user/{user}/addressbook/{addressbook}").get(route_addressbook::<AS>), // web::resource("/user/{user}/addressbook/{addressbook}").get(route_addressbook::<AS>),
) // )
.service( // .service(
web::resource("/user/{user}/addressbook/{addressbook}/restore") // web::resource("/user/{user}/addressbook/{addressbook}/restore")
.post(route_addressbook_restore::<AS>), // .post(route_addressbook_restore::<AS>),
) // )
// Login // // Login
.service( // .service(
web::resource("/login") // web::resource("/login")
.name("frontend_login") // .name("frontend_login")
.get(route_get_login) // .get(route_get_login)
.post(route_post_login::<AP>), // .post(route_post_login::<AP>),
) // )
.service( // .service(
web::resource("/logout") // web::resource("/logout")
.name("frontend_logout") // .name("frontend_logout")
.post(route_post_logout), // .post(route_post_logout),
); // );
if let Some(oidc_config) = oidc_config { // if let Some(oidc_config) = oidc_config {
scope = scope.service(web::scope("/login/oidc").configure(|cfg| { // scope = scope.service(web::scope("/login/oidc").configure(|cfg| {
configure_oidc( // configure_oidc(
cfg, // cfg,
oidc_config, // oidc_config,
OidcServiceConfig { // OidcServiceConfig {
default_redirect_route_name: ROUTE_NAME_HOME, // default_redirect_route_name: ROUTE_NAME_HOME,
session_key_user_id: "user", // session_key_user_id: "user",
}, // },
Arc::new(OidcUserStore(auth_provider.clone())), // Arc::new(OidcUserStore(auth_provider.clone())),
) // )
})); // }));
} // }
cfg.service(scope); router
}
struct OidcUserStore<AP: AuthenticationProvider>(Arc<AP>);
#[async_trait]
impl<AP: AuthenticationProvider> UserStore for OidcUserStore<AP> {
type Error = rustical_store::Error;
async fn user_exists(&self, id: &str) -> Result<bool, Self::Error> {
Ok(self.0.get_principal(id).await?.is_some())
}
async fn insert_user(&self, id: &str) -> Result<(), Self::Error> {
self.0
.insert_principal(
User {
id: id.to_owned(),
displayname: None,
principal_type: Default::default(),
password: None,
memberships: vec![],
},
false,
)
.await
}
} }
//
// struct OidcUserStore<AP: AuthenticationProvider>(Arc<AP>);
//
// #[async_trait]
// impl<AP: AuthenticationProvider> UserStore for OidcUserStore<AP> {
// type Error = rustical_store::Error;
//
// async fn user_exists(&self, id: &str) -> Result<bool, Self::Error> {
// Ok(self.0.get_principal(id).await?.is_some())
// }
//
// async fn insert_user(&self, id: &str) -> Result<(), Self::Error> {
// self.0
// .insert_principal(
// User {
// id: id.to_owned(),
// displayname: None,
// principal_type: Default::default(),
// password: None,
// memberships: vec![],
// },
// false,
// )
// .await
// }
// }

View File

@@ -1,16 +1,11 @@
use actix_web::{
http::StatusCode,
middleware::ErrorHandlers,
web::{self, Data, ServiceConfig},
};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use routes::{get_nextcloud_flow, post_nextcloud_flow, post_nextcloud_login, post_nextcloud_poll}; // use routes::{get_nextcloud_flow, post_nextcloud_flow, post_nextcloud_login, post_nextcloud_poll};
use rustical_store::auth::{AuthenticationMiddleware, AuthenticationProvider}; use rustical_store::auth::{AuthenticationMiddleware, AuthenticationProvider};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
mod routes; // mod routes;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct NextcloudFlow { struct NextcloudFlow {
@@ -47,32 +42,32 @@ pub struct NextcloudFlows {
flows: RwLock<HashMap<String, NextcloudFlow>>, flows: RwLock<HashMap<String, NextcloudFlow>>,
} }
use crate::{session_middleware, unauthorized_handler}; // use crate::{session_middleware, unauthorized_handler};
//
pub fn configure_nextcloud_login<AP: AuthenticationProvider>( // pub fn configure_nextcloud_login<AP: AuthenticationProvider>(
cfg: &mut ServiceConfig, // cfg: &mut ServiceConfig,
nextcloud_flows_state: Arc<NextcloudFlows>, // nextcloud_flows_state: Arc<NextcloudFlows>,
auth_provider: Arc<AP>, // auth_provider: Arc<AP>,
frontend_secret: [u8; 64], // frontend_secret: [u8; 64],
) { // ) {
cfg.service( // cfg.service(
web::scope("/index.php/login/v2") // web::scope("/index.php/login/v2")
.wrap(ErrorHandlers::new().handler(StatusCode::UNAUTHORIZED, unauthorized_handler)) // .wrap(ErrorHandlers::new().handler(StatusCode::UNAUTHORIZED, unauthorized_handler))
.wrap(AuthenticationMiddleware::new(auth_provider.clone())) // .wrap(AuthenticationMiddleware::new(auth_provider.clone()))
.wrap(session_middleware(frontend_secret)) // .wrap(session_middleware(frontend_secret))
.app_data(Data::from(nextcloud_flows_state)) // .app_data(Data::from(nextcloud_flows_state))
.app_data(Data::from(auth_provider.clone())) // .app_data(Data::from(auth_provider.clone()))
.service(web::resource("").post(post_nextcloud_login)) // .service(web::resource("").post(post_nextcloud_login))
.service( // .service(
web::resource("/poll/{flow}") // web::resource("/poll/{flow}")
.name("nc_login_poll") // .name("nc_login_poll")
.post(post_nextcloud_poll::<AP>), // .post(post_nextcloud_poll::<AP>),
) // )
.service( // .service(
web::resource("/flow/{flow}") // web::resource("/flow/{flow}")
.name("nc_login_flow") // .name("nc_login_flow")
.get(get_nextcloud_flow) // .get(get_nextcloud_flow)
.post(post_nextcloud_flow), // .post(post_nextcloud_flow),
), // ),
); // );
} // }

View File

@@ -1,13 +1,14 @@
use std::sync::Arc;
use crate::{FrontendConfig, OidcConfig}; use crate::{FrontendConfig, OidcConfig};
use actix_session::Session;
use actix_web::{
HttpRequest, HttpResponse, Responder,
error::{ErrorNotFound, ErrorUnauthorized},
web::{Data, Form, Query, Redirect},
};
use askama::Template; use askama::Template;
use askama_web::WebTemplate; use askama_web::WebTemplate;
use rustical_oidc::ROUTE_NAME_OIDC_LOGIN; use axum::{
Extension, Form,
extract::{OriginalUri, Query},
response::{IntoResponse, Redirect, Response},
};
use http::StatusCode;
use rustical_store::auth::AuthenticationProvider; use rustical_store::auth::AuthenticationProvider;
use serde::Deserialize; use serde::Deserialize;
use tracing::instrument; use tracing::instrument;
@@ -30,29 +31,28 @@ pub struct GetLoginQuery {
redirect_uri: Option<String>, redirect_uri: Option<String>,
} }
#[instrument(skip(req, config, oidc_config))] #[instrument(skip(config, oidc_config))]
pub async fn route_get_login( pub async fn route_get_login(
Query(GetLoginQuery { redirect_uri }): Query<GetLoginQuery>, Query(GetLoginQuery { redirect_uri }): Query<GetLoginQuery>,
req: HttpRequest, Extension(config): Extension<FrontendConfig>,
config: Data<FrontendConfig>, Extension(oidc_config): Extension<Option<OidcConfig>>,
oidc_config: Data<Option<OidcConfig>>, ) -> Response {
) -> HttpResponse { // let oidc_data = oidc_config
let oidc_data = oidc_config // .as_ref()
.as_ref() // .as_ref()
.as_ref() // .map(|oidc_config| OidcProviderData {
.map(|oidc_config| OidcProviderData { // name: &oidc_config.name,
name: &oidc_config.name, // redirect_url: req
redirect_url: req // .url_for_static(ROUTE_NAME_OIDC_LOGIN)
.url_for_static(ROUTE_NAME_OIDC_LOGIN) // .unwrap()
.unwrap() // .to_string(),
.to_string(), // });
});
LoginPage { LoginPage {
redirect_uri, redirect_uri,
allow_password_login: config.allow_password_login, allow_password_login: config.allow_password_login,
oidc_data, oidc_data: None,
} }
.respond_to(&req) .into_response()
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -62,43 +62,38 @@ pub struct PostLoginForm {
redirect_uri: Option<String>, redirect_uri: Option<String>,
} }
#[instrument(skip(req, password, auth_provider, session, config))] // #[instrument(skip(password, auth_provider, config))]
pub async fn route_post_login<AP: AuthenticationProvider>( pub async fn route_post_login<AP: AuthenticationProvider>(
req: HttpRequest, Extension(auth_provider): Extension<Arc<AP>>,
Extension(config): Extension<FrontendConfig>,
OriginalUri(orig_uri): OriginalUri,
Form(PostLoginForm { Form(PostLoginForm {
username, username,
password, password,
redirect_uri, redirect_uri,
}): Form<PostLoginForm>, }): Form<PostLoginForm>,
session: Session, ) -> Response {
auth_provider: Data<AP>,
config: Data<FrontendConfig>,
) -> HttpResponse {
if !config.allow_password_login { if !config.allow_password_login {
return ErrorNotFound("Password authentication disabled").error_response(); return StatusCode::METHOD_NOT_ALLOWED.into_response();
} }
// Ensure that redirect_uri never goes cross-origin // Ensure that redirect_uri never goes cross-origin
let default_redirect = "/frontend/user".to_string(); let default_redirect = "/frontend/user".to_string();
let redirect_uri = redirect_uri.unwrap_or(default_redirect.clone()); let redirect_uri = redirect_uri.unwrap_or(default_redirect.clone());
let redirect_uri = req // let redirect_uri = orig_uri
.full_url() // .join(&redirect_uri)
.join(&redirect_uri) // .ok()
.ok() // .and_then(|uri| orig_uri.make_relative(&uri))
.and_then(|uri| req.full_url().make_relative(&uri)) // .unwrap_or(default_redirect);
.unwrap_or(default_redirect);
if let Ok(Some(user)) = auth_provider.validate_password(&username, &password).await { if let Ok(Some(user)) = auth_provider.validate_password(&username, &password).await {
session.insert("user", user.id).unwrap(); // session.insert("user", user.id).unwrap();
Redirect::to(redirect_uri) Redirect::to(&redirect_uri).into_response()
.see_other()
.respond_to(&req)
.map_into_boxed_body()
} else { } else {
ErrorUnauthorized("Unauthorized").error_response() StatusCode::UNAUTHORIZED.into_response()
} }
} }
pub async fn route_post_logout(req: HttpRequest, session: Session) -> Redirect { pub async fn route_post_logout() -> Redirect {
session.remove("user"); // session.remove("user");
Redirect::to(req.url_for_static("frontend_login").unwrap().to_string()).see_other() Redirect::to("/")
} }

View File

@@ -1,4 +1,3 @@
pub mod addressbook; // pub mod addressbook;
pub mod calendar; // pub mod calendar;
pub mod login; pub mod login;

View File

@@ -5,8 +5,6 @@ edition.workspace = true
description.workspace = true description.workspace = true
repository.workspace = true repository.workspace = true
[features]
actix = ["dep:actix-web"]
[dependencies] [dependencies]
chrono.workspace = true chrono.workspace = true
@@ -20,4 +18,4 @@ regex.workspace = true
rrule.workspace = true rrule.workspace = true
serde.workspace = true serde.workspace = true
sha2.workspace = true sha2.workspace = true
actix-web = { workspace = true, optional = true } axum.workspace = true

View File

@@ -1,3 +1,5 @@
use axum::{http::StatusCode, response::IntoResponse};
use crate::CalDateTimeError; use crate::CalDateTimeError;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -21,15 +23,18 @@ pub enum Error {
RRuleError(#[from] rrule::RRuleError), RRuleError(#[from] rrule::RRuleError),
} }
#[cfg(feature = "actix")] impl Error {
impl actix_web::ResponseError for Error { pub fn status_code(&self) -> StatusCode {
fn status_code(&self) -> actix_web::http::StatusCode {
match self { match self {
Self::InvalidData(_) => actix_web::http::StatusCode::BAD_REQUEST, Self::InvalidData(_) => StatusCode::BAD_REQUEST,
Self::MissingCalendar | Self::MissingContact => { Self::MissingCalendar | Self::MissingContact => StatusCode::BAD_REQUEST,
actix_web::http::StatusCode::BAD_REQUEST _ => StatusCode::INTERNAL_SERVER_ERROR,
}
_ => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
} }
} }
} }
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
(self.status_code(), self.to_string()).into_response()
}
}

View File

@@ -9,7 +9,7 @@ repository.workspace = true
openidconnect.workspace = true openidconnect.workspace = true
serde.workspace = true serde.workspace = true
reqwest.workspace = true reqwest.workspace = true
actix-web.workspace = true
actix-session.workspace = true
thiserror.workspace = true thiserror.workspace = true
async-trait.workspace = true async-trait.workspace = true
axum.workspace = true
axum_session = "0.16"

View File

@@ -1,7 +1,5 @@
use actix_session::SessionInsertError; use axum::http::StatusCode;
use actix_web::{ use axum::response::IntoResponse;
HttpResponse, ResponseError, body::BoxBody, error::UrlGenerationError, http::StatusCode,
};
use openidconnect::{ClaimsVerificationError, ConfigurationError, url::ParseError}; use openidconnect::{ClaimsVerificationError, ConfigurationError, url::ParseError};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -9,28 +7,18 @@ pub enum OidcError {
#[error("Cannot generate redirect url, something's not configured correctly")] #[error("Cannot generate redirect url, something's not configured correctly")]
OidcParseError(#[from] ParseError), OidcParseError(#[from] ParseError),
#[error("Cannot generate redirect url, something's not configured correctly")]
ActixUrlGenerationError(#[from] UrlGenerationError),
#[error(transparent)] #[error(transparent)]
OidcConfigurationError(#[from] ConfigurationError), OidcConfigurationError(#[from] ConfigurationError),
#[error(transparent)] #[error(transparent)]
OidcClaimsVerificationError(#[from] ClaimsVerificationError), OidcClaimsVerificationError(#[from] ClaimsVerificationError),
#[error(transparent)]
SessionInsertError(#[from] SessionInsertError),
#[error("{0}")] #[error("{0}")]
Other(&'static str), Other(&'static str),
} }
impl ResponseError for OidcError { impl IntoResponse for OidcError {
fn status_code(&self) -> StatusCode { fn into_response(self) -> axum::response::Response {
StatusCode::INTERNAL_SERVER_ERROR (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
}
fn error_response(&self) -> HttpResponse<BoxBody> {
HttpResponse::build(self.status_code()).body(self.to_string())
} }
} }

View File

@@ -1,8 +1,7 @@
use actix_session::Session; use axum::{
use actix_web::{ Extension, Form,
HttpRequest, HttpResponse, Responder, ResponseError, extract::{Query, Request, State},
http::StatusCode, response::{IntoResponse, Redirect, Response},
web::{self, Data, Form, Query, Redirect, ServiceConfig},
}; };
pub use config::OidcConfig; pub use config::OidcConfig;
use config::UserIdClaim; use config::UserIdClaim;
@@ -93,47 +92,44 @@ pub struct GetOidcForm {
} }
/// Endpoint that redirects to the authorize endpoint of the OIDC service /// Endpoint that redirects to the authorize endpoint of the OIDC service
pub async fn route_post_oidc( // pub async fn route_post_oidc(
req: HttpRequest, // Form(GetOidcForm { redirect_uri }): Form<GetOidcForm>,
Form(GetOidcForm { redirect_uri }): Form<GetOidcForm>, // State(oidc_config): State<OidcConfig>,
oidc_config: Data<OidcConfig>, // // session: Session,
session: Session, // req: Request,
) -> Result<HttpResponse, OidcError> { // ) -> Result<Response, OidcError> {
let http_client = get_http_client(); // let http_client = get_http_client();
let oidc_client = get_oidc_client( // let oidc_client = get_oidc_client(
oidc_config.as_ref().clone(), // oidc_config.clone(),
&http_client, // &http_client,
RedirectUrl::new(req.url_for_static(ROUTE_NAME_OIDC_CALLBACK)?.to_string())?, // RedirectUrl::new(req.url_for_static(ROUTE_NAME_OIDC_CALLBACK)?.to_string())?,
) // )
.await?; // .await?;
//
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); // let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
//
let (auth_url, csrf_token, nonce) = oidc_client // let (auth_url, csrf_token, nonce) = oidc_client
.authorize_url( // .authorize_url(
AuthenticationFlow::<CoreResponseType>::AuthorizationCode, // AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
CsrfToken::new_random, // CsrfToken::new_random,
Nonce::new_random, // Nonce::new_random,
) // )
.add_scopes(oidc_config.scopes.clone()) // .add_scopes(oidc_config.scopes.clone())
.set_pkce_challenge(pkce_challenge) // .set_pkce_challenge(pkce_challenge)
.url(); // .url();
//
session.insert( // // session.insert(
SESSION_KEY_OIDC_STATE, // // SESSION_KEY_OIDC_STATE,
OidcState { // // OidcState {
state: csrf_token, // // state: csrf_token,
nonce, // // nonce,
pkce_verifier, // // pkce_verifier,
redirect_uri, // // redirect_uri,
}, // // },
)?; // // )?;
//
Ok(Redirect::to(auth_url.to_string()) // Ok(Redirect::to(auth_url.as_str()).into_response())
.see_other() // }
.respond_to(&req)
.map_into_boxed_body())
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct AuthCallbackQuery { pub struct AuthCallbackQuery {
@@ -142,124 +138,124 @@ pub struct AuthCallbackQuery {
state: String, state: String,
} }
/// Handle callback from IdP page // Handle callback from IdP page
pub async fn route_get_oidc_callback<US: UserStore>( // pub async fn route_get_oidc_callback<US: UserStore>(
req: HttpRequest, // Extension(oidc_config): Extension<OidcConfig>,
oidc_config: Data<OidcConfig>, // session: Session,
session: Session, // Extension(user_store): Extension<US>,
user_store: Data<US>, // Query(AuthCallbackQuery { code, iss, state }): Query<AuthCallbackQuery>,
Query(AuthCallbackQuery { code, iss, state }): Query<AuthCallbackQuery>, // State(service_config): State<OidcServiceConfig>,
service_config: Data<OidcServiceConfig>, // req: Request,
) -> Result<HttpResponse, OidcError> { // ) -> Result<Response, OidcError> {
assert_eq!(iss, oidc_config.issuer); // assert_eq!(iss, oidc_config.issuer);
let oidc_state = session // let oidc_state = session
.remove_as::<OidcState>(SESSION_KEY_OIDC_STATE) // .remove_as::<OidcState>(SESSION_KEY_OIDC_STATE)
.ok_or(OidcError::Other("No local OIDC state"))? // .ok_or(OidcError::Other("No local OIDC state"))?
.map_err(|_| OidcError::Other("Error parsing OIDC state"))?; // .map_err(|_| OidcError::Other("Error parsing OIDC state"))?;
//
// assert_eq!(oidc_state.state.secret(), &state);
//
// let http_client = get_http_client();
// let oidc_client = get_oidc_client(
// oidc_config.clone(),
// &http_client,
// RedirectUrl::new(req.url_for_static(ROUTE_NAME_OIDC_CALLBACK)?.to_string())?,
// )
// .await?;
//
// let token_response = oidc_client
// .exchange_code(code)?
// .set_pkce_verifier(oidc_state.pkce_verifier)
// .request_async(&http_client)
// .await
// .map_err(|_| OidcError::Other("Error requesting token"))?;
// let id_claims = token_response
// .id_token()
// .ok_or(OidcError::Other("OIDC provider did not return an ID token"))?
// .claims(&oidc_client.id_token_verifier(), &oidc_state.nonce)?;
//
// let user_info_claims: UserInfoClaims<GroupAdditionalClaims, CoreGenderClaim> = oidc_client
// .user_info(
// token_response.access_token().clone(),
// Some(id_claims.subject().clone()),
// )?
// .request_async(&http_client)
// .await
// .map_err(|_| OidcError::Other("Error fetching user info"))?;
//
// if let Some(require_group) = &oidc_config.require_group {
// if !user_info_claims
// .additional_claims()
// .groups
// .contains(require_group)
// {
// return Ok(HttpResponse::build(StatusCode::UNAUTHORIZED)
// .body("User is not in an authorized group to use RustiCal"));
// }
// }
//
// let user_id = match oidc_config.claim_userid {
// UserIdClaim::Sub => user_info_claims.subject().to_string(),
// UserIdClaim::PreferredUsername => user_info_claims
// .preferred_username()
// .ok_or(OidcError::Other("Missing preferred_username claim"))?
// .to_string(),
// };
//
// match user_store.user_exists(&user_id).await {
// Ok(false) => {
// // User does not exist
// if !oidc_config.allow_sign_up {
// return Ok(HttpResponse::Unauthorized().body("User sign up disabled"));
// }
// // Create new user
// if let Err(err) = user_store.insert_user(&user_id).await {
// return Ok(err.error_response());
// }
// }
// Ok(true) => {}
// Err(err) => {
// return Ok(err.error_response());
// }
// }
//
// let default_redirect = req
// .url_for_static(service_config.default_redirect_route_name)?
// .to_string();
// let redirect_uri = oidc_state.redirect_uri.unwrap_or(default_redirect.clone());
// let redirect_uri = req
// .full_url()
// .join(&redirect_uri)
// .ok()
// .and_then(|uri| req.full_url().make_relative(&uri))
// .unwrap_or(default_redirect);
//
// // Complete login flow
// session.insert(service_config.session_key_user_id, user_id.clone())?;
//
// Ok(Redirect::to(redirect_uri)
// .temporary()
// .respond_to(&req)
// .map_into_boxed_body())
// }
assert_eq!(oidc_state.state.secret(), &state); // pub fn configure_oidc<US: UserStore>(
// cfg: &mut ServiceConfig,
let http_client = get_http_client(); // oidc_config: OidcConfig,
let oidc_client = get_oidc_client( // service_config: OidcServiceConfig,
oidc_config.get_ref().clone(), // user_store: Arc<US>,
&http_client, // ) {
RedirectUrl::new(req.url_for_static(ROUTE_NAME_OIDC_CALLBACK)?.to_string())?, // cfg.app_data(Data::new(oidc_config))
) // .app_data(Data::new(service_config))
.await?; // .app_data(Data::from(user_store))
// .service(
let token_response = oidc_client // web::resource("")
.exchange_code(code)? // .name(ROUTE_NAME_OIDC_LOGIN)
.set_pkce_verifier(oidc_state.pkce_verifier) // .post(route_post_oidc),
.request_async(&http_client) // )
.await // .service(
.map_err(|_| OidcError::Other("Error requesting token"))?; // web::resource("/callback")
let id_claims = token_response // .name(ROUTE_NAME_OIDC_CALLBACK)
.id_token() // .get(route_get_oidc_callback::<US>),
.ok_or(OidcError::Other("OIDC provider did not return an ID token"))? // );
.claims(&oidc_client.id_token_verifier(), &oidc_state.nonce)?; // }
let user_info_claims: UserInfoClaims<GroupAdditionalClaims, CoreGenderClaim> = oidc_client
.user_info(
token_response.access_token().clone(),
Some(id_claims.subject().clone()),
)?
.request_async(&http_client)
.await
.map_err(|_| OidcError::Other("Error fetching user info"))?;
if let Some(require_group) = &oidc_config.require_group {
if !user_info_claims
.additional_claims()
.groups
.contains(require_group)
{
return Ok(HttpResponse::build(StatusCode::UNAUTHORIZED)
.body("User is not in an authorized group to use RustiCal"));
}
}
let user_id = match oidc_config.claim_userid {
UserIdClaim::Sub => user_info_claims.subject().to_string(),
UserIdClaim::PreferredUsername => user_info_claims
.preferred_username()
.ok_or(OidcError::Other("Missing preferred_username claim"))?
.to_string(),
};
match user_store.user_exists(&user_id).await {
Ok(false) => {
// User does not exist
if !oidc_config.allow_sign_up {
return Ok(HttpResponse::Unauthorized().body("User sign up disabled"));
}
// Create new user
if let Err(err) = user_store.insert_user(&user_id).await {
return Ok(err.error_response());
}
}
Ok(true) => {}
Err(err) => {
return Ok(err.error_response());
}
}
let default_redirect = req
.url_for_static(service_config.default_redirect_route_name)?
.to_string();
let redirect_uri = oidc_state.redirect_uri.unwrap_or(default_redirect.clone());
let redirect_uri = req
.full_url()
.join(&redirect_uri)
.ok()
.and_then(|uri| req.full_url().make_relative(&uri))
.unwrap_or(default_redirect);
// Complete login flow
session.insert(service_config.session_key_user_id, user_id.clone())?;
Ok(Redirect::to(redirect_uri)
.temporary()
.respond_to(&req)
.map_into_boxed_body())
}
pub fn configure_oidc<US: UserStore>(
cfg: &mut ServiceConfig,
oidc_config: OidcConfig,
service_config: OidcServiceConfig,
user_store: Arc<US>,
) {
cfg.app_data(Data::new(oidc_config))
.app_data(Data::new(service_config))
.app_data(Data::from(user_store))
.service(
web::resource("")
.name(ROUTE_NAME_OIDC_LOGIN)
.post(route_post_oidc),
)
.service(
web::resource("/callback")
.name(ROUTE_NAME_OIDC_CALLBACK)
.get(route_get_oidc_callback::<US>),
);
}

View File

@@ -1,9 +1,9 @@
use actix_web::ResponseError;
use async_trait::async_trait; use async_trait::async_trait;
use axum::response::IntoResponse;
#[async_trait] #[async_trait]
pub trait UserStore: 'static { pub trait UserStore: 'static + Send {
type Error: ResponseError; type Error: IntoResponse;
async fn user_exists(&self, id: &str) -> Result<bool, Self::Error>; async fn user_exists(&self, id: &str) -> Result<bool, Self::Error>;
async fn insert_user(&self, id: &str) -> Result<(), Self::Error>; async fn insert_user(&self, id: &str) -> Result<(), Self::Error>;

View File

@@ -16,12 +16,9 @@ chrono = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
lazy_static = { workspace = true } lazy_static = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
actix-web = { workspace = true }
actix-session = { workspace = true }
actix-web-httpauth = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
chrono-tz = { workspace = true } chrono-tz = { workspace = true }
derive_more = { workspace = true } derive_more = { workspace = true, features = ["as_ref"] }
rustical_xml.workspace = true rustical_xml.workspace = true
tokio.workspace = true tokio.workspace = true
rand.workspace = true rand.workspace = true
@@ -29,7 +26,12 @@ uuid.workspace = true
clap.workspace = true clap.workspace = true
rustical_dav.workspace = true rustical_dav.workspace = true
rustical_ical.workspace = true rustical_ical.workspace = true
axum.workspace = true
http.workspace = true
rrule.workspace = true rrule.workspace = true
headers.workspace = true
tower.workspace = true
futures-core.workspace = true
[dev-dependencies] [dev-dependencies]
rstest = { workspace = true } rstest = { workspace = true }

View File

@@ -1,103 +1,91 @@
use super::AuthenticationProvider; use super::AuthenticationProvider;
use actix_session::Session; use axum::{extract::Request, response::Response};
use actix_web::{ use futures_core::future::BoxFuture;
FromRequest, HttpMessage, use headers::{Authorization, HeaderMapExt, authorization::Basic};
dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready},
http::header::Header,
};
use actix_web_httpauth::headers::authorization::{Authorization, Basic};
use std::{ use std::{
future::{Future, Ready, ready},
pin::Pin, pin::Pin,
sync::Arc, sync::Arc,
task::{Context, Poll},
}; };
use tower::{Layer, Service};
use tracing::{Instrument, info_span}; use tracing::{Instrument, info_span};
pub struct AuthenticationMiddleware<AP: AuthenticationProvider> { pub struct AuthenticationLayer<AP: AuthenticationProvider> {
auth_provider: Arc<AP>, auth_provider: Arc<AP>,
} }
impl<AP: AuthenticationProvider> AuthenticationMiddleware<AP> { impl<AP: AuthenticationProvider> Clone for AuthenticationLayer<AP> {
fn clone(&self) -> Self {
Self {
auth_provider: self.auth_provider.clone(),
}
}
}
impl<AP: AuthenticationProvider> AuthenticationLayer<AP> {
pub fn new(auth_provider: Arc<AP>) -> Self { pub fn new(auth_provider: Arc<AP>) -> Self {
Self { auth_provider } Self { auth_provider }
} }
} }
impl<AP: AuthenticationProvider, S, B> Transform<S, ServiceRequest> for AuthenticationMiddleware<AP> impl<S, AP: AuthenticationProvider> Layer<S> for AuthenticationLayer<AP> {
where type Service = AuthenticationMiddleware<S, AP>;
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
S::Future: 'static,
B: 'static,
AP: 'static,
{
type Error = actix_web::Error;
type Response = ServiceResponse<B>;
type InitError = ();
type Transform = InnerAuthenticationMiddleware<S, AP>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future { fn layer(&self, inner: S) -> Self::Service {
ready(Ok(InnerAuthenticationMiddleware { Self::Service {
service: Arc::new(service), inner,
auth_provider: Arc::clone(&self.auth_provider), auth_provider: self.auth_provider.clone(),
})) }
} }
} }
pub struct InnerAuthenticationMiddleware<S, AP: AuthenticationProvider> { pub struct AuthenticationMiddleware<S, AP: AuthenticationProvider> {
service: Arc<S>, inner: S,
auth_provider: Arc<AP>, auth_provider: Arc<AP>,
} }
impl<S, B, AP> Service<ServiceRequest> for InnerAuthenticationMiddleware<S, AP> impl<S: Clone, AP: AuthenticationProvider> Clone for AuthenticationMiddleware<S, AP> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
auth_provider: self.auth_provider.clone(),
}
}
}
impl<S: Clone, AP: AuthenticationProvider> Service<Request> for AuthenticationMiddleware<S, AP>
where where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static, S: Service<Request, Response = Response> + Send + 'static,
S::Future: 'static, S::Future: Send + 'static,
AP: AuthenticationProvider,
{ {
type Response = ServiceResponse<B>; type Response = S::Response;
type Error = actix_web::Error; type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>; type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service); fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&self, req: ServiceRequest) -> Self::Future { fn call(&mut self, mut request: Request) -> Self::Future {
let service = Arc::clone(&self.service); let auth_header: Option<Authorization<Basic>> = request.headers().typed_get();
let auth_provider = Arc::clone(&self.auth_provider); let ap = self.auth_provider.clone();
let mut inner = self.inner.clone();
// request.extensions_mut();
Box::pin(async move { Box::pin(async move {
if let Ok(auth) = Authorization::<Basic>::parse(req.request()) { if let Some(auth) = auth_header {
let user_id = auth.as_ref().user_id(); let user_id = auth.username();
if let Some(password) = auth.as_ref().password() { let password = auth.password();
if let Ok(Some(user)) = auth_provider if let Ok(Some(user)) = ap
.validate_app_token(user_id, password) .validate_app_token(user_id, password)
.instrument(info_span!("validate_user_token")) .instrument(info_span!("validate_user_token"))
.await .await
{ {
req.extensions_mut().insert(user); request.extensions_mut().insert(user);
} }
} }
} let response = inner.call(request).await?;
Ok(response)
// Extract user from session cookie
if let Ok(session) = Session::extract(req.request()).await {
match session.get::<String>("user") {
Ok(Some(user_id)) => match auth_provider.get_principal(&user_id).await {
Ok(Some(user)) => {
req.extensions_mut().insert(user);
}
Ok(None) => {}
Err(err) => {
dbg!(err);
}
},
Ok(None) => {}
Err(err) => {
dbg!(err);
}
};
}
service.call(req).await
}) })
} }
} }

View File

@@ -1,16 +1,14 @@
use actix_web::{ use axum::{
FromRequest, HttpMessage, HttpResponse, ResponseError, body::Body,
body::BoxBody, extract::FromRequestParts,
http::{StatusCode, header}, response::{IntoResponse, Response},
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use derive_more::Display; use derive_more::Display;
use http::{HeaderValue, StatusCode, header};
use rustical_xml::ValueSerialize; use rustical_xml::ValueSerialize;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::fmt::Display;
fmt::Display,
future::{Ready, ready},
};
use crate::Secret; use crate::Secret;
@@ -121,33 +119,28 @@ impl rustical_dav::Principal for User {
#[derive(Clone, Debug, Display)] #[derive(Clone, Debug, Display)]
pub struct UnauthorizedError; pub struct UnauthorizedError;
impl ResponseError for UnauthorizedError { impl IntoResponse for UnauthorizedError {
fn status_code(&self) -> actix_web::http::StatusCode { fn into_response(self) -> axum::response::Response {
StatusCode::UNAUTHORIZED let mut resp = Response::builder().status(StatusCode::UNAUTHORIZED);
} resp.headers_mut().unwrap().insert(
fn error_response(&self) -> HttpResponse<BoxBody> {
HttpResponse::build(StatusCode::UNAUTHORIZED)
.insert_header((
header::WWW_AUTHENTICATE, header::WWW_AUTHENTICATE,
r#"Basic realm="RustiCal", charset="UTF-8""#, HeaderValue::from_static(r#"Basic realm="RustiCal", charset="UTF-8""#),
)) );
.finish() resp.body(Body::empty()).unwrap()
} }
} }
impl FromRequest for User { impl<S: Send + Sync + Clone> FromRequestParts<S> for User {
type Error = UnauthorizedError; type Rejection = UnauthorizedError;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request( async fn from_request_parts(
req: &actix_web::HttpRequest, parts: &mut http::request::Parts,
_payload: &mut actix_web::dev::Payload, _state: &S,
) -> Self::Future { ) -> Result<Self, Self::Rejection> {
ready( parts
req.extensions() .extensions
.get::<Self>() .get::<Self>()
.cloned() .cloned()
.ok_or(UnauthorizedError), .ok_or(UnauthorizedError)
)
} }
} }

View File

@@ -1,4 +1,4 @@
use actix_web::{ResponseError, http::StatusCode}; use http::StatusCode;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
@@ -27,8 +27,8 @@ pub enum Error {
IcalError(#[from] rustical_ical::Error), IcalError(#[from] rustical_ical::Error),
} }
impl ResponseError for Error { impl Error {
fn status_code(&self) -> actix_web::http::StatusCode { pub fn status_code(&self) -> StatusCode {
match self { match self {
Self::NotFound => StatusCode::NOT_FOUND, Self::NotFound => StatusCode::NOT_FOUND,
Self::AlreadyExists => StatusCode::CONFLICT, Self::AlreadyExists => StatusCode::CONFLICT,

View File

@@ -1,16 +1,14 @@
use actix_web::body::MessageBody; use axum::Router;
use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse}; use axum::response::Redirect;
use actix_web::middleware::NormalizePath; use axum::routing::get;
use actix_web::{App, web}; use rustical_caldav::caldav_router;
use rustical_caldav::caldav_service; use rustical_carddav::carddav_router;
use rustical_carddav::carddav_service; use rustical_frontend::nextcloud_login::NextcloudFlows;
use rustical_frontend::nextcloud_login::{NextcloudFlows, configure_nextcloud_login}; use rustical_frontend::{FrontendConfig, frontend_router};
use rustical_frontend::{FrontendConfig, configure_frontend};
use rustical_oidc::OidcConfig; use rustical_oidc::OidcConfig;
use rustical_store::auth::AuthenticationProvider; use rustical_store::auth::AuthenticationProvider;
use rustical_store::{AddressbookStore, CalendarStore, SubscriptionStore}; use rustical_store::{AddressbookStore, CalendarStore, SubscriptionStore};
use std::sync::Arc; use std::sync::Arc;
use tracing_actix_web::TracingLogger;
use crate::config::NextcloudLoginConfig; use crate::config::NextcloudLoginConfig;
@@ -24,61 +22,61 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
oidc_config: Option<OidcConfig>, oidc_config: Option<OidcConfig>,
nextcloud_login_config: NextcloudLoginConfig, nextcloud_login_config: NextcloudLoginConfig,
nextcloud_flows_state: Arc<NextcloudFlows>, nextcloud_flows_state: Arc<NextcloudFlows>,
) -> App< ) -> Router {
impl ServiceFactory< let mut router = Router::new()
ServiceRequest, .nest(
Response = ServiceResponse<impl MessageBody>, "/caldav",
Config = (), caldav_router(
InitError = (),
Error = actix_web::Error,
>,
> {
let mut app = App::new()
// .wrap(Logger::new("[%s] %r"))
.wrap(TracingLogger::default())
.wrap(NormalizePath::trim())
.service(web::scope("/caldav").service(caldav_service(
"/caldav", "/caldav",
auth_provider.clone(), auth_provider.clone(),
cal_store.clone(), cal_store.clone(),
addr_store.clone(), addr_store.clone(),
subscription_store.clone(), subscription_store.clone(),
))) ),
.service(web::scope("/carddav").service(carddav_service( )
.nest(
"/carddav",
carddav_router(
"/carddav", "/carddav",
auth_provider.clone(), auth_provider.clone(),
addr_store.clone(), addr_store.clone(),
subscription_store, subscription_store.clone(),
))) ),
.service( )
web::scope("/.well-known") .route(
.service(web::redirect("/caldav", "/caldav")) "/.well-known/caldav",
.service(web::redirect("/carddav", "/carddav")), get(async || Redirect::permanent("/caldav")),
)
.route(
"/.well-known/carddav",
get(async || Redirect::permanent("/caldav")),
); );
if nextcloud_login_config.enabled {
app = app.configure(|cfg| {
configure_nextcloud_login(
cfg,
nextcloud_flows_state,
auth_provider.clone(),
frontend_config.secret_key,
)
});
}
if frontend_config.enabled { if frontend_config.enabled {
app = app router = router
.service(web::scope("/frontend").configure(|cfg| { .nest(
configure_frontend( "/frontend",
cfg, frontend_router(
auth_provider.clone(), auth_provider.clone(),
cal_store.clone(), cal_store.clone(),
addr_store.clone(), addr_store.clone(),
frontend_config, frontend_config,
oidc_config, oidc_config,
),
) )
})) .route("/", get(async || Redirect::to("/frontend")));
.service(web::redirect("/", "/frontend").see_other());
} }
app
router
// if nextcloud_login_config.enabled {
// app = app.configure(|cfg| {
// configure_nextcloud_login(
// cfg,
// nextcloud_flows_state,
// auth_provider.clone(),
// frontend_config.secret_key,
// )
// });
// }
} }

View File

@@ -1,6 +1,4 @@
use crate::config::Config; use crate::config::Config;
use actix_web::HttpServer;
use actix_web::http::KeepAlive;
use anyhow::Result; use anyhow::Result;
use app::make_app; use app::make_app;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@@ -105,8 +103,7 @@ async fn main() -> Result<()> {
let nextcloud_flows = Arc::new(NextcloudFlows::default()); let nextcloud_flows = Arc::new(NextcloudFlows::default());
HttpServer::new(move || { let app = make_app(
make_app(
addr_store.clone(), addr_store.clone(),
cal_store.clone(), cal_store.clone(),
subscription_store.clone(), subscription_store.clone(),
@@ -115,57 +112,15 @@ async fn main() -> Result<()> {
config.oidc.clone(), config.oidc.clone(),
config.nextcloud_login.clone(), config.nextcloud_login.clone(),
nextcloud_flows.clone(), nextcloud_flows.clone(),
) );
})
.bind((config.http.host, config.http.port))? let listener = tokio::net::TcpListener::bind(&format!(
// Workaround for a weird bug where "{}:{}",
// new requests might timeout since they cannot properly reuse the connection config.http.host, config.http.port
// https://github.com/lennart-k/rustical/issues/10 ))
.keep_alive(KeepAlive::Disabled)
.run()
.await?; .await?;
axum::serve(listener, app).await?;
} }
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use crate::{app::make_app, config::NextcloudLoginConfig, get_data_stores};
use actix_web::{http::StatusCode, test::TestRequest};
use rustical_frontend::nextcloud_login::NextcloudFlows;
use rustical_frontend::{FrontendConfig, generate_frontend_secret};
use std::sync::Arc;
#[tokio::test]
async fn test_main() {
let (addr_store, cal_store, subscription_store, principal_store, _update_recv) =
get_data_stores(
true,
&crate::config::DataStoreConfig::Sqlite(crate::config::SqliteDataStoreConfig {
db_url: "".to_owned(),
}),
)
.await
.unwrap();
let app = make_app(
addr_store,
cal_store,
subscription_store,
principal_store,
FrontendConfig {
enabled: false,
secret_key: generate_frontend_secret(),
allow_password_login: false,
},
None,
NextcloudLoginConfig { enabled: false },
Arc::new(NextcloudFlows::default()),
);
let app = actix_web::test::init_service(app).await;
let req = TestRequest::get().uri("/").to_request();
let resp = actix_web::test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
}