diff --git a/Cargo.lock b/Cargo.lock index 9f1dc22..f738407 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1445,6 +1445,30 @@ dependencies = [ "hashbrown 0.15.3", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http 1.3.1", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.3.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -3096,6 +3120,8 @@ dependencies = [ "async-trait", "derive_more 2.0.1", "futures-util", + "headers", + "http 0.2.12", "itertools 0.14.0", "log", "quick-xml", diff --git a/Cargo.toml b/Cargo.toml index 46ce990..7e9ae6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ debug = 0 [workspace.dependencies] uuid = { version = "1.11", features = ["v4", "fast-rng"] } async-trait = "0.1" -actix-web = "4.9" +actix-web = "4.11" tracing = { version = "0.1", features = ["async-await"] } tracing-actix-web = "0.7" actix-session = { version = "0.10", features = ["cookie-session"] } @@ -86,13 +86,15 @@ sqlx = { version = "0.8", default-features = false, features = [ "migrate", "json", ] } +http = "0.2" # This version is used by actix-web +headers = "0.4" strum = "0.27" strum_macros = "0.27" serde_json = { version = "1.0", features = ["raw_value"] } sqlx-sqlite = { version = "0.8", features = ["bundled"] } ical = { version = "0.11", features = ["generator", "serde"] } toml = "0.8" -rustical_dav = { path = "./crates/dav/" } +rustical_dav = { path = "./crates/dav/", features = ["actix"] } rustical_dav_push = { path = "./crates/dav_push/" } rustical_store = { path = "./crates/store/" } rustical_store_sqlite = { path = "./crates/store_sqlite/" } diff --git a/crates/dav/Cargo.toml b/crates/dav/Cargo.toml index 64240a2..11f9f61 100644 --- a/crates/dav/Cargo.toml +++ b/crates/dav/Cargo.toml @@ -6,9 +6,11 @@ description.workspace = true repository.workspace = true publish = false +[features] +actix = ["dep:actix-web"] + [dependencies] rustical_xml.workspace = true -actix-web.workspace = true async-trait.workspace = true futures-util.workspace = true quick-xml.workspace = true @@ -20,3 +22,6 @@ derive_more.workspace = true tracing.workspace = true tracing-actix-web.workspace = true tokio.workspace = true +http.workspace = true +actix-web = { workspace = true, optional = true } +headers.workspace = true diff --git a/crates/dav/src/error.rs b/crates/dav/src/error.rs index 61d7b21..4549168 100644 --- a/crates/dav/src/error.rs +++ b/crates/dav/src/error.rs @@ -1,4 +1,4 @@ -use actix_web::{http::StatusCode, HttpResponse}; +use actix_web::{HttpResponse, http::StatusCode}; use rustical_xml::XmlError; use thiserror::Error; use tracing::error; @@ -25,6 +25,9 @@ pub enum Error { #[error(transparent)] IOError(#[from] std::io::Error), + + #[error("Precondition Failed")] + PreconditionFailed, } impl actix_web::error::ResponseError for Error { @@ -44,6 +47,7 @@ impl actix_web::error::ResponseError for Error { _ => StatusCode::BAD_REQUEST, }, Error::PropReadOnly => StatusCode::CONFLICT, + Error::PreconditionFailed => StatusCode::PRECONDITION_FAILED, Self::IOError(_) => StatusCode::INTERNAL_SERVER_ERROR, } } diff --git a/crates/dav/src/resource/methods/delete.rs b/crates/dav/src/resource/methods/delete.rs index bfbb115..35954e0 100644 --- a/crates/dav/src/resource/methods/delete.rs +++ b/crates/dav/src/resource/methods/delete.rs @@ -2,50 +2,94 @@ use crate::Error; use crate::privileges::UserPrivilege; use crate::resource::Resource; use crate::resource::ResourceService; -use actix_web::HttpRequest; -use actix_web::HttpResponse; -use actix_web::Responder; -use actix_web::http::header::IfMatch; -use actix_web::http::header::IfNoneMatch; -use actix_web::web; -use actix_web::web::Data; -use actix_web::web::Path; +use headers::Header; +use headers::{HeaderValue, IfMatch, IfNoneMatch}; +use itertools::Itertools; use tracing::instrument; use tracing_actix_web::RootSpan; +#[cfg(feature = "actix")] #[instrument(parent = root_span.id(), skip(path, req, root_span, resource_service))] -pub async fn route_delete( - path: Path, - req: HttpRequest, +pub async fn actix_route_delete( + path: actix_web::web::Path, + req: actix_web::HttpRequest, principal: R::Principal, - resource_service: Data, + resource_service: actix_web::web::Data, root_span: RootSpan, - if_match: web::Header, - if_none_match: web::Header, -) -> Result { +) -> Result { let no_trash = req .headers() .get("X-No-Trashbin") .map(|val| matches!(val.to_str(), Ok("1"))) .unwrap_or(false); - let resource = resource_service.get_resource(&path).await?; + // 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::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::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("")) +} + +pub async fn route_delete( + path_components: &R::PathComponents, + principal: R::Principal, + resource_service: &R, + no_trash: bool, + if_match: Option, + if_none_match: Option, +) -> Result<(), R::Error> { + let resource = resource_service.get_resource(&path_components).await?; let privileges = resource.get_user_privileges(&principal)?; if !privileges.has(&UserPrivilege::Write) { return Err(Error::Unauthorized.into()); } - if !resource.satisfies_if_match(&if_match) { - // Precondition failed - return Ok(HttpResponse::PreconditionFailed().finish()); + if let Some(if_match) = if_match { + if !resource.satisfies_if_match(&if_match) { + // Precondition failed + return Err(crate::Error::PreconditionFailed.into()); + } } - if resource.satisfies_if_none_match(&if_none_match) { - // Precondition failed - return Ok(HttpResponse::PreconditionFailed().finish()); + if let Some(if_none_match) = if_none_match { + if resource.satisfies_if_none_match(&if_none_match) { + // Precondition failed + return Err(crate::Error::PreconditionFailed.into()); + } } - - resource_service.delete_resource(&path, !no_trash).await?; - - Ok(HttpResponse::Ok().body("")) + resource_service + .delete_resource(path_components, !no_trash) + .await?; + Ok(()) } diff --git a/crates/dav/src/resource/methods/mod.rs b/crates/dav/src/resource/methods/mod.rs index 5d1b389..2b06cfb 100644 --- a/crates/dav/src/resource/methods/mod.rs +++ b/crates/dav/src/resource/methods/mod.rs @@ -5,3 +5,9 @@ mod proppatch; pub(crate) use delete::route_delete; pub(crate) use propfind::route_propfind; pub(crate) use proppatch::route_proppatch; + +#[cfg(feature = "actix")] +pub(crate) use delete::actix_route_delete; + +#[cfg(feature = "actix")] +pub(crate) use propfind::actix_route_propfind; diff --git a/crates/dav/src/resource/methods/propfind.rs b/crates/dav/src/resource/methods/propfind.rs index e9be1c5..d60e170 100644 --- a/crates/dav/src/resource/methods/propfind.rs +++ b/crates/dav/src/resource/methods/propfind.rs @@ -1,35 +1,58 @@ use crate::Error; use crate::header::Depth; use crate::privileges::UserPrivilege; +use crate::resource::PrincipalUri; use crate::resource::Resource; use crate::resource::ResourceService; use crate::xml::MultistatusElement; use crate::xml::PropElement; use crate::xml::PropfindElement; use crate::xml::PropfindType; -use actix_web::HttpRequest; -use actix_web::web::Data; -use actix_web::web::Path; use rustical_xml::XmlDocument; use tracing::instrument; use tracing_actix_web::RootSpan; +#[cfg(feature = "actix")] #[instrument(parent = root_span.id(), skip(path, req, root_span, resource_service, puri))] #[allow(clippy::type_complexity)] -pub(crate) async fn route_propfind( - path: Path, +pub(crate) async fn actix_route_propfind( + path: ::actix_web::web::Path, body: String, - req: HttpRequest, + req: ::actix_web::HttpRequest, user: R::Principal, depth: Depth, root_span: RootSpan, - resource_service: Data, - puri: Data, + resource_service: ::actix_web::web::Data, + puri: ::actix_web::web::Data, ) -> Result< MultistatusElement<::Prop, ::Prop>, R::Error, > { - let resource = resource_service.get_resource(&path).await?; + route_propfind( + &path.into_inner(), + req.path(), + body, + user, + depth, + resource_service.as_ref(), + puri.as_ref(), + ) + .await +} + +pub(crate) async fn route_propfind( + path_components: &R::PathComponents, + path: &str, + body: String, + user: R::Principal, + depth: Depth, + resource_service: &R, + puri: &impl PrincipalUri, +) -> Result< + MultistatusElement<::Prop, ::Prop>, + R::Error, +> { + let resource = resource_service.get_resource(path_components).await?; let privileges = resource.get_user_privileges(&user)?; if !privileges.has(&UserPrivilege::Read) { return Err(Error::Unauthorized.into()); @@ -56,17 +79,17 @@ pub(crate) async fn route_propfind( let mut member_responses = Vec::new(); if depth != Depth::Zero { - for (subpath, member) in resource_service.get_members(&path).await? { + for (subpath, member) in resource_service.get_members(path_components).await? { member_responses.push(member.propfind( - &format!("{}/{}", req.path().trim_end_matches('/'), subpath), + &format!("{}/{}", path.trim_end_matches('/'), subpath), &props, - puri.as_ref(), + puri, &user, )?); } } - let response = resource.propfind(req.path(), &props, puri.as_ref(), &user)?; + let response = resource.propfind(path, &props, puri, &user)?; Ok(MultistatusElement { responses: vec![response], diff --git a/crates/dav/src/resource/mod.rs b/crates/dav/src/resource/mod.rs index 34cbb5b..9648653 100644 --- a/crates/dav/src/resource/mod.rs +++ b/crates/dav/src/resource/mod.rs @@ -3,8 +3,8 @@ use crate::xml::Resourcetype; use crate::xml::multistatus::{PropTagWrapper, PropstatElement, PropstatWrapper}; use crate::xml::{TagList, multistatus::ResponseElement}; use crate::{Error, Principal}; -use actix_web::http::header::{EntityTag, IfMatch, IfNoneMatch}; -use actix_web::{ResponseError, http::StatusCode}; +use headers::{ETag, IfMatch, IfNoneMatch}; +use http::StatusCode; use itertools::Itertools; use quick_xml::name::Namespace; pub use resource_service::ResourceService; @@ -27,7 +27,7 @@ impl ResourcePropName for T {} pub trait Resource: Clone + 'static { type Prop: ResourceProp + PartialEq + Clone + EnumVariants + EnumUnitVariants; - type Error: ResponseError + From; + type Error: From; type Principal: Principal; fn get_resourcetype(&self) -> Resourcetype; @@ -63,34 +63,26 @@ pub trait Resource: Clone + 'static { } fn satisfies_if_match(&self, if_match: &IfMatch) -> bool { - match if_match { - IfMatch::Any => true, - // This is not nice but if the header doesn't exist, actix just gives us an empty - // IfMatch::Items header - IfMatch::Items(items) if items.is_empty() => true, - IfMatch::Items(items) => { - if let Some(etag) = self.get_etag() { - let etag = EntityTag::new_strong(etag.to_owned()); - return items.iter().any(|item| item.strong_eq(&etag)); - } - false + if let Some(etag) = self.get_etag() { + if let Ok(etag) = ETag::from_str(&etag) { + if_match.precondition_passes(&etag) + } else { + if_match.is_any() } + } else { + if_match.is_any() } } fn satisfies_if_none_match(&self, if_none_match: &IfNoneMatch) -> bool { - match if_none_match { - IfNoneMatch::Any => false, - // This is not nice but if the header doesn't exist, actix just gives us an empty - // IfNoneMatch::Items header - IfNoneMatch::Items(items) if items.is_empty() => false, - IfNoneMatch::Items(items) => { - if let Some(etag) = self.get_etag() { - let etag = EntityTag::new_strong(etag.to_owned()); - return items.iter().all(|item| item.strong_ne(&etag)); - } - true + if let Some(etag) = self.get_etag() { + if let Ok(etag) = ETag::from_str(&etag) { + if_none_match.precondition_passes(&etag) + } else { + if_none_match != &IfNoneMatch::any() } + } else { + if_none_match != &IfNoneMatch::any() } } diff --git a/crates/dav/src/resource/resource_service.rs b/crates/dav/src/resource/resource_service.rs index 326c28d..7a9b029 100644 --- a/crates/dav/src/resource/resource_service.rs +++ b/crates/dav/src/resource/resource_service.rs @@ -1,4 +1,4 @@ -use super::methods::{route_delete, route_propfind, route_proppatch}; +use super::methods::{actix_route_delete, actix_route_propfind, route_proppatch}; use super::{PrincipalUri, Resource}; use crate::Principal; use actix_web::web::Data; @@ -48,9 +48,11 @@ pub trait ResourceService: Sized + 'static { fn actix_resource(self) -> actix_web::Resource { web::resource("") .app_data(Data::new(self)) - .route(web::method(Method::from_str("PROPFIND").unwrap()).to(route_propfind::)) + .route( + web::method(Method::from_str("PROPFIND").unwrap()).to(actix_route_propfind::), + ) .route(web::method(Method::from_str("PROPPATCH").unwrap()).to(route_proppatch::)) - .delete(route_delete::) + .delete(actix_route_delete::) } fn actix_scope(self) -> actix_web::Scope;