diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 630cf331..f8236d07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,7 @@ jobs: - linux / features-socks - linux / features-sso_login - linux / features-require_auth_for_profile_requests + - linux / features-appservice include: - name: linux / features-no-encryption @@ -132,6 +133,9 @@ jobs: - name: linux / features-sso_login cargo_args: --features sso_login + - name: linux / features-appservice + cargo_args: --no-default-features --features "appservice, native-tls" + steps: - name: Checkout uses: actions/checkout@v1 diff --git a/Cargo.toml b/Cargo.toml index 57e18193..050a6c8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,4 +6,5 @@ members = [ "matrix_sdk_test_macros", "matrix_sdk_crypto", "matrix_sdk_common", + "matrix_sdk_appservice" ] diff --git a/matrix_sdk/Cargo.toml b/matrix_sdk/Cargo.toml index 0ab3b50f..abee49cc 100644 --- a/matrix_sdk/Cargo.toml +++ b/matrix_sdk/Cargo.toml @@ -26,6 +26,7 @@ rustls-tls = ["reqwest/rustls-tls"] socks = ["reqwest/socks"] sso_login = ["warp", "rand", "tokio-stream"] require_auth_for_profile_requests = [] +appservice = ["matrix-sdk-common/appservice", "serde_yaml"] docs = ["encryption", "sled_cryptostore", "sled_state_store", "sso_login"] @@ -40,6 +41,7 @@ url = "2.2.0" zeroize = "1.2.0" mime = "0.3.16" rand = { version = "0.8.2", optional = true } +serde_yaml = { version = "0.8", optional = true } bytes = "1.0.1" matrix-sdk-common = { version = "0.2.0", path = "../matrix_sdk_common" } @@ -93,6 +95,7 @@ tracing-subscriber = "0.2.15" tempfile = "3.2.0" mockito = "0.29.0" lazy_static = "1.4.0" +matrix-sdk-common = { version = "0.2.0", path = "../matrix_sdk_common" } [[example]] name = "emoji_verification" diff --git a/matrix_sdk/src/client.rs b/matrix_sdk/src/client.rs index a6d9b651..a5154fe2 100644 --- a/matrix_sdk/src/client.rs +++ b/matrix_sdk/src/client.rs @@ -405,6 +405,7 @@ pub struct RequestConfig { pub(crate) retry_limit: Option, pub(crate) retry_timeout: Option, pub(crate) force_auth: bool, + pub(crate) assert_identity: bool, } #[cfg(not(tarpaulin_include))] @@ -426,6 +427,7 @@ impl Default for RequestConfig { retry_limit: Default::default(), retry_timeout: Default::default(), force_auth: false, + assert_identity: false, } } } @@ -464,10 +466,21 @@ impl RequestConfig { /// Force sending authorization even if the endpoint does not require it. Default is only /// sending authorization if it is required #[cfg(feature = "require_auth_for_profile_requests")] + #[cfg_attr(feature = "docs", doc(cfg(require_auth_for_profile_requests)))] pub(crate) fn force_auth(mut self) -> Self { self.force_auth = true; self } + + /// [Assert the identity][identity-assertion] of requests to the `user_id` in the `Session` + /// + /// [identity-assertion]: https://spec.matrix.org/unstable/application-service-api/#identity-assertion + #[cfg(feature = "appservice")] + #[cfg_attr(feature = "docs", doc(cfg(appservice)))] + pub fn assert_identity(mut self) -> Self { + self.assert_identity = true; + self + } } impl Client { @@ -521,6 +534,31 @@ impl Client { }) } + /// Process a [transaction] received from the homeserver + /// + /// # Arguments + /// + /// * `incoming_transaction` - The incoming transaction received from the homeserver. + /// + /// [transaction]: https://matrix.org/docs/spec/application_service/r0.1.2#put-matrix-app-v1-transactions-txnid + #[cfg(feature = "appservice")] + #[cfg_attr(feature = "docs", doc(cfg(appservice)))] + pub async fn receive_transaction( + &self, + incoming_transaction: crate::api_appservice::event::push_events::v1::IncomingRequest, + ) -> Result<()> { + let txn_id = incoming_transaction.txn_id.clone(); + let response = incoming_transaction.try_into_sync_response(txn_id)?; + let base_client = self.base_client.clone(); + let sync_response = base_client.receive_sync_response(response).await?; + + if let Some(handler) = self.event_handler.read().await.as_ref() { + handler.handle_sync(&sync_response).await; + } + + Ok(()) + } + /// Is the client logged in. pub async fn logged_in(&self) -> bool { self.base_client.logged_in().await diff --git a/matrix_sdk/src/error.rs b/matrix_sdk/src/error.rs index 3fd66c24..fc284640 100644 --- a/matrix_sdk/src/error.rs +++ b/matrix_sdk/src/error.rs @@ -78,6 +78,15 @@ pub enum HttpError { /// The given request can't be cloned and thus can't be retried. #[error("The request cannot be cloned")] UnableToCloneRequest, + + /// Tried to send a request without `user_id` in the `Session` + #[error("missing user_id in session")] + #[cfg(feature = "appservice")] + UserIdRequired, + + /// Tried to assert identity without appservice feature enabled + #[error("appservice feature not enabled")] + NeedsAppserviceFeature, } /// Internal representation of errors. diff --git a/matrix_sdk/src/http_client.rs b/matrix_sdk/src/http_client.rs index 88ccb7aa..63c3142a 100644 --- a/matrix_sdk/src/http_client.rs +++ b/matrix_sdk/src/http_client.rs @@ -100,6 +100,9 @@ pub(crate) struct HttpClient { pub(crate) request_config: RequestConfig, } +#[cfg(feature = "appservice")] +use crate::OutgoingRequestAppserviceExt; + impl HttpClient { async fn send_request( &self, @@ -112,7 +115,7 @@ impl HttpClient { None => self.request_config, }; - let request = { + let request = if !self.request_config.assert_identity { let read_guard; let access_token = if config.force_auth { read_guard = session.read().await; @@ -140,6 +143,35 @@ impl HttpClient { request .try_into_http_request::(&self.homeserver.to_string(), access_token)? .map(|body| body.freeze()) + } else { + // this should be unreachable since assert_identity on request_config can only be set + // with the appservice feature active + #[cfg(not(feature = "appservice"))] + return Err(HttpError::NeedsAppserviceFeature); + + #[cfg(feature = "appservice")] + { + let read_guard = session.read().await; + let access_token = if let Some(session) = read_guard.as_ref() { + SendAccessToken::Always(session.access_token.as_str()) + } else { + return Err(HttpError::AuthenticationRequired); + }; + + let user_id = if let Some(session) = read_guard.as_ref() { + session.user_id.clone() + } else { + return Err(HttpError::UserIdRequired); + }; + + request + .try_into_http_request_with_user_id::( + &self.homeserver.to_string(), + access_token, + user_id, + )? + .map(|body| body.freeze()) + } }; self.inner.send_request(request, config).await diff --git a/matrix_sdk_appservice/Cargo.toml b/matrix_sdk_appservice/Cargo.toml new file mode 100644 index 00000000..766fefac --- /dev/null +++ b/matrix_sdk_appservice/Cargo.toml @@ -0,0 +1,41 @@ +[package] +authors = ["Johannes Becker "] +edition = "2018" +homepage = "https://github.com/matrix-org/matrix-rust-sdk" +keywords = ["matrix", "chat", "messaging", "ruma", "nio", "appservice"] +license = "Apache-2.0" +name = "matrix-sdk-appservice" +version = "0.1.0" + +[features] +default = ["actix"] +actix = ["actix-rt", "actix-web"] + +docs = [] + +[dependencies] +actix-rt = { version = "2", optional = true } +actix-web = { version = "4.0.0-beta.6", optional = true } +futures = "0.3" +futures-util = "0.3" +http = "0.2" +regex = "1" +serde_yaml = "0.8" +thiserror = "1.0" +tracing = "0.1" +url = "2" + +matrix-sdk = { version = "0.2", path = "../matrix_sdk", default-features = false, features = ["appservice", "native-tls"] } + +[dev-dependencies] +env_logger = "0.8" +mockito = "0.30" +serde_json = "1" +tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros"] } +tracing-subscriber = "0.2" + +matrix-sdk-test = { version = "0.2", path = "../matrix_sdk_test" } + +[[example]] +name = "actix_autojoin" +required-features = ["actix"] diff --git a/matrix_sdk_appservice/examples/actix_autojoin.rs b/matrix_sdk_appservice/examples/actix_autojoin.rs new file mode 100644 index 00000000..1f380cca --- /dev/null +++ b/matrix_sdk_appservice/examples/actix_autojoin.rs @@ -0,0 +1,80 @@ +use std::{convert::TryFrom, env}; + +use actix_web::{App, HttpServer}; +use matrix_sdk::{ + async_trait, + events::{ + room::member::{MemberEventContent, MembershipState}, + SyncStateEvent, + }, + identifiers::UserId, + room::Room, + EventHandler, +}; +use matrix_sdk_appservice::{Appservice, AppserviceRegistration}; + +struct AppserviceEventHandler { + appservice: Appservice, +} + +impl AppserviceEventHandler { + pub fn new(appservice: Appservice) -> Self { + Self { appservice } + } +} + +#[async_trait] +impl EventHandler for AppserviceEventHandler { + async fn on_room_member(&self, room: Room, event: &SyncStateEvent) { + if !self + .appservice + .user_id_is_in_namespace(&event.state_key) + .unwrap() + { + dbg!("not an appservice user"); + return; + } + + if let MembershipState::Invite = event.content.membership { + let user_id = UserId::try_from(event.state_key.clone()).unwrap(); + + let client = self + .appservice + .client_with_localpart(user_id.localpart()) + .await + .unwrap(); + + client.join_room_by_id(room.room_id()).await.unwrap(); + } + } +} + +#[actix_web::main] +pub async fn main() -> std::io::Result<()> { + env::set_var( + "RUST_LOG", + "actix_web=debug,actix_server=info,matrix_sdk=debug", + ); + tracing_subscriber::fmt::init(); + + let homeserver_url = "http://localhost:8008"; + let server_name = "localhost"; + let registration = + AppserviceRegistration::try_from_yaml_file("./tests/registration.yaml").unwrap(); + + let appservice = Appservice::new(homeserver_url, server_name, registration) + .await + .unwrap(); + + let event_handler = AppserviceEventHandler::new(appservice.clone()); + + appservice + .client() + .set_event_handler(Box::new(event_handler)) + .await; + + HttpServer::new(move || App::new().service(appservice.actix_service())) + .bind(("0.0.0.0", 8090))? + .run() + .await +} diff --git a/matrix_sdk_appservice/src/actix.rs b/matrix_sdk_appservice/src/actix.rs new file mode 100644 index 00000000..3e899822 --- /dev/null +++ b/matrix_sdk_appservice/src/actix.rs @@ -0,0 +1,182 @@ +// Copyright 2021 Famedly GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{ + convert::{TryFrom, TryInto}, + pin::Pin, +}; + +use actix_web::{ + dev::Payload, + error::PayloadError, + get, + http::PathAndQuery, + put, + web::{self, BytesMut, Data}, + App, FromRequest, HttpRequest, HttpResponse, HttpServer, +}; +use futures::Future; +use futures_util::{TryFutureExt, TryStreamExt}; +use matrix_sdk::api_appservice as api; + +pub use actix_web::Scope; + +use crate::{error::Error, Appservice}; + +pub async fn run_server( + appservice: Appservice, + host: impl AsRef, + port: impl Into, +) -> Result<(), Error> { + HttpServer::new(move || App::new().service(appservice.actix_service())) + .bind((host.as_ref(), port.into()))? + .run() + .await?; + + Ok(()) +} + +pub fn get_scope() -> Scope { + gen_scope("/"). // handle legacy routes + service(gen_scope("/_matrix/app/v1")) +} + +fn gen_scope(scope: &str) -> Scope { + web::scope(scope) + .service(push_transactions) + .service(query_user_id) + .service(query_room_alias) +} + +#[tracing::instrument] +#[put("/transactions/{txn_id}")] +async fn push_transactions( + request: IncomingRequest, + appservice: Data, +) -> Result { + if !appservice.verify_hs_token(request.access_token) { + return Ok(HttpResponse::Unauthorized().finish()); + } + + appservice + .client() + .receive_transaction(request.incoming) + .await + .unwrap(); + + Ok(HttpResponse::Ok().json("{}")) +} + +#[tracing::instrument] +#[get("/users/{user_id}")] +async fn query_user_id( + request: IncomingRequest, + appservice: Data, +) -> Result { + if !appservice.verify_hs_token(request.access_token) { + return Ok(HttpResponse::Unauthorized().finish()); + } + + Ok(HttpResponse::Ok().json("{}")) +} + +#[tracing::instrument] +#[get("/rooms/{room_alias}")] +async fn query_room_alias( + request: IncomingRequest, + appservice: Data, +) -> Result { + if !appservice.verify_hs_token(request.access_token) { + return Ok(HttpResponse::Unauthorized().finish()); + } + + Ok(HttpResponse::Ok().json("{}")) +} + +#[derive(Debug)] +pub struct IncomingRequest { + access_token: String, + incoming: T, +} + +impl FromRequest for IncomingRequest { + type Error = Error; + type Future = Pin>>>; + type Config = (); + + fn from_request(request: &HttpRequest, payload: &mut Payload) -> Self::Future { + let request = request.to_owned(); + let payload = payload.take(); + + Box::pin(async move { + let uri = request.uri().to_owned(); + + let uri = if !uri.path().starts_with("/_matrix/app/v1") { + // rename legacy routes + let mut parts = uri.into_parts(); + let path_and_query = match parts.path_and_query { + Some(path_and_query) => format!("/_matrix/app/v1{}", path_and_query), + None => "/_matrix/app/v1".to_owned(), + }; + parts.path_and_query = + Some(PathAndQuery::try_from(path_and_query).map_err(http::Error::from)?); + parts.try_into().map_err(http::Error::from)? + } else { + uri + }; + + let mut builder = http::request::Builder::new() + .method(request.method()) + .uri(uri); + + let headers = builder + .headers_mut() + .ok_or(Error::UnknownHttpRequestBuilder)?; + for (key, value) in request.headers().iter() { + headers.append(key, value.to_owned()); + } + + let bytes = payload + .try_fold(BytesMut::new(), |mut body, chunk| async move { + body.extend_from_slice(&chunk); + Ok::<_, PayloadError>(body) + }) + .and_then(|bytes| async move { Ok::, _>(bytes.into_iter().collect()) }) + .await?; + + let access_token = match request.uri().query() { + Some(query) => { + let query: Vec<(String, String)> = matrix_sdk::urlencoded::from_str(query)?; + query + .into_iter() + .find(|(key, _)| key == "access_token") + .map(|(_, value)| value) + } + None => None, + }; + + let access_token = match access_token { + Some(access_token) => access_token, + None => return Err(Error::MissingAccessToken), + }; + + let request = builder.body(bytes)?; + + Ok(IncomingRequest { + access_token, + incoming: matrix_sdk::IncomingRequest::try_from_http_request(request)?, + }) + }) + } +} diff --git a/matrix_sdk_appservice/src/error.rs b/matrix_sdk_appservice/src/error.rs new file mode 100644 index 00000000..fcbdf997 --- /dev/null +++ b/matrix_sdk_appservice/src/error.rs @@ -0,0 +1,74 @@ +// Copyright 2021 Famedly GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("tried to run without webserver configured")] + RunWithoutServer, + + #[error("missing access token")] + MissingAccessToken, + + #[error("missing host on registration url")] + MissingRegistrationHost, + + #[error("http request builder error")] + UnknownHttpRequestBuilder, + + #[error("no port found")] + MissingRegistrationPort, + + #[error(transparent)] + HttpRequest(#[from] matrix_sdk::FromHttpRequestError), + + #[error(transparent)] + Identifier(#[from] matrix_sdk::identifiers::Error), + + #[error(transparent)] + Http(#[from] http::Error), + + #[error(transparent)] + Url(#[from] url::ParseError), + + #[error(transparent)] + Serde(#[from] matrix_sdk::SerdeError), + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error(transparent)] + InvalidUri(#[from] http::uri::InvalidUri), + + #[error(transparent)] + Matrix(#[from] matrix_sdk::Error), + + #[error(transparent)] + Regex(#[from] regex::Error), + + #[error(transparent)] + SerdeYaml(#[from] serde_yaml::Error), + + #[cfg(feature = "actix")] + #[error(transparent)] + Actix(#[from] actix_web::Error), + + #[cfg(feature = "actix")] + #[error(transparent)] + ActixPayload(#[from] actix_web::error::PayloadError), +} + +#[cfg(feature = "actix")] +impl actix_web::error::ResponseError for Error {} diff --git a/matrix_sdk_appservice/src/lib.rs b/matrix_sdk_appservice/src/lib.rs new file mode 100644 index 00000000..7eb6c33d --- /dev/null +++ b/matrix_sdk_appservice/src/lib.rs @@ -0,0 +1,314 @@ +// Copyright 2021 Famedly GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Matrix [Application Service] library +//! +//! # Quickstart +//! +//! ```no_run +//! # async { +//! use matrix_sdk_appservice::{Appservice, AppserviceRegistration}; +//! +//! let homeserver_url = "http://localhost:8008"; +//! let server_name = "localhost"; +//! let registration = AppserviceRegistration::try_from_yaml_str( +//! r" +//! id: appservice +//! url: http://localhost:9009 +//! as_token: as_token +//! hs_token: hs_token +//! sender_localpart: _appservice +//! namespaces: +//! users: +//! - exclusive: true +//! regex: '@_appservice_.*' +//! ") +//! .unwrap(); +//! +//! let appservice = Appservice::new(homeserver_url, server_name, registration).await.unwrap(); +//! let (host, port) = appservice.get_host_and_port_from_registration().unwrap(); +//! appservice.run(host, port).await.unwrap(); +//! # }; +//! ``` +//! +//! [Application Service]: https://matrix.org/docs/spec/application_service/r0.1.2 + +use std::{ + convert::{TryFrom, TryInto}, + fs::File, + ops::Deref, + path::PathBuf, +}; + +use http::Uri; +use matrix_sdk::{ + api::{ + error::ErrorKind, + r0::{ + account::register::{LoginType, Request as RegistrationRequest}, + uiaa::UiaaResponse, + }, + }, + api_appservice::Registration, + assign, + identifiers::{self, DeviceId, ServerNameBox, UserId}, + reqwest::Url, + Client, ClientConfig, FromHttpResponseError, HttpError, RequestConfig, ServerError, Session, +}; +use regex::Regex; +#[cfg(not(feature = "actix"))] +use tracing::error; +use tracing::warn; + +#[doc(inline)] +pub use matrix_sdk::api_appservice as api; + +#[cfg(feature = "actix")] +mod actix; +mod error; + +pub use error::Error; +pub type Result = std::result::Result; +pub type Host = String; +pub type Port = u16; + +/// Appservice Registration +#[derive(Debug, Clone)] +pub struct AppserviceRegistration { + inner: Registration, +} + +impl AppserviceRegistration { + /// Try to load registration from yaml string + pub fn try_from_yaml_str(value: impl AsRef) -> Result { + Ok(Self { + inner: serde_yaml::from_str(value.as_ref())?, + }) + } + + /// Try to load registration from yaml file + pub fn try_from_yaml_file(path: impl Into) -> Result { + let file = File::open(path.into())?; + + Ok(Self { + inner: serde_yaml::from_reader(file)?, + }) + } +} + +impl From for AppserviceRegistration { + fn from(value: Registration) -> Self { + Self { inner: value } + } +} + +impl Deref for AppserviceRegistration { + type Target = Registration; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +async fn create_client( + homeserver_url: &Url, + server_name: &ServerNameBox, + registration: &AppserviceRegistration, + localpart: Option<&str>, +) -> Result { + let client = if localpart.is_some() { + let request_config = RequestConfig::default().assert_identity(); + let config = ClientConfig::default().request_config(request_config); + Client::new_with_config(homeserver_url.clone(), config)? + } else { + Client::new(homeserver_url.clone())? + }; + + let session = Session { + access_token: registration.as_token.clone(), + user_id: UserId::parse_with_server_name( + localpart.unwrap_or(®istration.sender_localpart), + &server_name, + )?, + device_id: DeviceId::new(), + }; + client.restore_login(session).await?; + + Ok(client) +} + +/// Appservice +#[derive(Debug, Clone)] +pub struct Appservice { + homeserver_url: Url, + server_name: ServerNameBox, + registration: AppserviceRegistration, + client_sender_localpart: Client, +} + +impl Appservice { + /// Create new Appservice + pub async fn new( + homeserver_url: impl TryInto, + server_name: impl TryInto, + registration: AppserviceRegistration, + ) -> Result { + let homeserver_url = homeserver_url.try_into()?; + let server_name = server_name.try_into()?; + + let client = create_client(&homeserver_url, &server_name, ®istration, None).await?; + + Ok(Appservice { + homeserver_url, + server_name, + registration, + client_sender_localpart: client, + }) + } + + /// Get `Client` for the user associated with the application service + /// (`sender_localpart` of the [registration]) + /// + /// [registration]: https://matrix.org/docs/spec/application_service/r0.1.2#registration + pub fn client(&self) -> Client { + self.client_sender_localpart.clone() + } + + /// Get `Client` for the given `localpart` + /// + /// If the `localpart` is covered by the `namespaces` in the [registration] all requests to the + /// homeserver will [assert the identity] to the according virtual user. + /// + /// [registration]: https://matrix.org/docs/spec/application_service/r0.1.2#registration + /// [assert the identity]: + /// https://matrix.org/docs/spec/application_service/r0.1.2#identity-assertion + pub async fn client_with_localpart( + &self, + localpart: impl AsRef + Into>, + ) -> Result { + let user_id = UserId::parse_with_server_name(localpart, &self.server_name)?; + let localpart = user_id.localpart().to_owned(); + + let client = create_client( + &self.homeserver_url, + &self.server_name, + &self.registration, + Some(&localpart), + ) + .await?; + + self.ensure_registered(localpart).await?; + + Ok(client) + } + + async fn ensure_registered(&self, localpart: impl AsRef) -> Result<()> { + let request = assign!(RegistrationRequest::new(), { + username: Some(localpart.as_ref()), + login_type: Some(&LoginType::ApplicationService), + }); + + match self.client().register(request).await { + Ok(_) => (), + Err(error) => match error { + matrix_sdk::Error::Http(HttpError::UiaaError(FromHttpResponseError::Http( + ServerError::Known(UiaaResponse::MatrixError(ref matrix_error)), + ))) => { + match matrix_error.kind { + ErrorKind::UserInUse => { + // TODO: persist the fact that we registered that user + warn!("{}", matrix_error.message); + } + _ => return Err(error.into()), + } + } + _ => return Err(error.into()), + }, + } + + Ok(()) + } + + /// Get the Appservice [registration] + /// + /// [registration]: https://matrix.org/docs/spec/application_service/r0.1.2#registration + pub fn registration(&self) -> &Registration { + &self.registration + } + + /// Compare the given `hs_token` against `registration.hs_token` + pub fn verify_hs_token(&self, hs_token: impl AsRef) -> bool { + self.registration.hs_token == hs_token.as_ref() + } + + /// Check if given `user_id` is in any of the registration user namespaces + pub fn user_id_is_in_namespace(&self, user_id: impl AsRef) -> Result { + for user in &self.registration.namespaces.users { + // TODO: precompile on Appservice construction + let re = Regex::new(&user.regex)?; + if re.is_match(user_id.as_ref()) { + return Ok(true); + } + } + + Ok(false) + } + + /// Get host and port from registration url + /// + /// If no port is found it falls back to scheme defaults: 80 for http and 443 for https + pub fn get_host_and_port_from_registration(&self) -> Result<(Host, Port)> { + let uri = Uri::try_from(&self.registration.url)?; + + let host = uri.host().ok_or(Error::MissingRegistrationHost)?.to_owned(); + let port = match uri.port() { + Some(port) => Ok(port.as_u16()), + None => match uri.scheme_str() { + Some("http") => Ok(80), + Some("https") => Ok(443), + _ => Err(Error::MissingRegistrationPort), + }, + }?; + + Ok((host, port)) + } + + /// Service to register on an Actix `App` + #[cfg(feature = "actix")] + #[cfg_attr(docs, doc(cfg(feature = "actix")))] + pub fn actix_service(&self) -> actix::Scope { + actix::get_scope().data(self.clone()) + } + + /// Convenience method that runs an http server depending on the selected server feature + /// + /// This is a blocking call that tries to listen on the provided host and port + pub async fn run(&self, host: impl AsRef, port: impl Into) -> Result<()> { + #[cfg(feature = "actix")] + { + actix::run_server(self.clone(), host, port).await?; + Ok(()) + } + + #[cfg(not(feature = "actix"))] + { + error!( + "tried to bind {}:{} but no server feature activated", + host.as_ref(), + port.into() + ); + unimplemented!(); + } + } +} diff --git a/matrix_sdk_appservice/tests/actix.rs b/matrix_sdk_appservice/tests/actix.rs new file mode 100644 index 00000000..eab23010 --- /dev/null +++ b/matrix_sdk_appservice/tests/actix.rs @@ -0,0 +1,115 @@ +#[cfg(feature = "actix")] +mod actix { + use actix_web::{test, App}; + use matrix_sdk_appservice::*; + use std::env; + + async fn appservice() -> Appservice { + env::set_var( + "RUST_LOG", + "mockito=debug,matrix_sdk=debug,ruma=debug,actix_web=debug", + ); + let _ = tracing_subscriber::fmt::try_init(); + + Appservice::new( + mockito::server_url().as_ref(), + "test.local", + AppserviceRegistration::try_from_yaml_file("./tests/registration.yaml").unwrap(), + ) + .await + .unwrap() + } + + #[actix_rt::test] + async fn test_transactions() { + let appservice = appservice().await; + let app = test::init_service(App::new().service(appservice.actix_service())).await; + + let transactions = r#"{ + "events": [ + { + "content": {}, + "type": "m.dummy" + } + ] + }"#; + + let transactions: serde_json::Value = serde_json::from_str(transactions).unwrap(); + + let req = test::TestRequest::put() + .uri("/_matrix/app/v1/transactions/1?access_token=hs_token") + .set_json(&transactions) + .to_request(); + + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), 200); + } + + #[actix_rt::test] + async fn test_users() { + let appservice = appservice().await; + let app = test::init_service(App::new().service(appservice.actix_service())).await; + + let req = test::TestRequest::get() + .uri("/_matrix/app/v1/users/%40_botty_1%3Adev.famedly.local?access_token=hs_token") + .to_request(); + + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), 200); + } + + #[actix_rt::test] + async fn test_invalid_access_token() { + let appservice = appservice().await; + let app = test::init_service(App::new().service(appservice.actix_service())).await; + + let transactions = r#"{ + "events": [ + { + "content": {}, + "type": "m.dummy" + } + ] + }"#; + + let transactions: serde_json::Value = serde_json::from_str(transactions).unwrap(); + + let req = test::TestRequest::put() + .uri("/_matrix/app/v1/transactions/1?access_token=invalid_token") + .set_json(&transactions) + .to_request(); + + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), 401); + } + + #[actix_rt::test] + async fn test_no_access_token() { + let appservice = appservice().await; + let app = test::init_service(App::new().service(appservice.actix_service())).await; + + let transactions = r#"{ + "events": [ + { + "content": {}, + "type": "m.dummy" + } + ] + }"#; + + let transactions: serde_json::Value = serde_json::from_str(transactions).unwrap(); + + let req = test::TestRequest::put() + .uri("/_matrix/app/v1/transactions/1") + .set_json(&transactions) + .to_request(); + + let resp = test::call_service(&app, req).await; + + // TODO: this should actually return a 401 but is 500 because something in the extractor fails + assert_eq!(resp.status(), 500); + } +} diff --git a/matrix_sdk_appservice/tests/registration.yaml b/matrix_sdk_appservice/tests/registration.yaml new file mode 100644 index 00000000..eb51a814 --- /dev/null +++ b/matrix_sdk_appservice/tests/registration.yaml @@ -0,0 +1,13 @@ +id: appservice +url: http://localhost:9009 +as_token: as_token +hs_token: hs_token +sender_localpart: _appservice +namespaces: + aliases: [] + rooms: [] + users: + - exclusive: true + regex: '@_appservice_.*' +rate_limited: false +protocols: [] diff --git a/matrix_sdk_appservice/tests/tests.rs b/matrix_sdk_appservice/tests/tests.rs new file mode 100644 index 00000000..9b81b81c --- /dev/null +++ b/matrix_sdk_appservice/tests/tests.rs @@ -0,0 +1,157 @@ +use std::env; + +use matrix_sdk::{ + api_appservice, + api_appservice::Registration, + async_trait, + events::{room::member::MemberEventContent, AnyEvent, AnyStateEvent, SyncStateEvent}, + room::Room, + EventHandler, Raw, +}; +use matrix_sdk_appservice::*; +use matrix_sdk_test::async_test; +use serde_json::json; + +fn registration_string() -> String { + include_str!("../tests/registration.yaml").to_owned() +} + +async fn appservice(registration: Option) -> Result { + env::set_var("RUST_LOG", "mockito=debug,matrix_sdk=debug"); + let _ = tracing_subscriber::fmt::try_init(); + + let registration = match registration { + Some(registration) => registration.into(), + None => AppserviceRegistration::try_from_yaml_str(registration_string()).unwrap(), + }; + + let homeserver_url = mockito::server_url(); + let server_name = "localhost"; + + Ok(Appservice::new(homeserver_url.as_ref(), server_name, registration).await?) +} + +fn member_json() -> serde_json::Value { + json!({ + "content": { + "avatar_url": null, + "displayname": "example", + "membership": "join" + }, + "event_id": "$151800140517rfvjc:localhost", + "membership": "join", + "origin_server_ts": 151800140, + "room_id": "!ahpSDaDUPCCqktjUEF:localhost", + "sender": "@example:localhost", + "state_key": "@example:localhost", + "type": "m.room.member", + "prev_content": { + "avatar_url": null, + "displayname": "example", + "membership": "invite" + }, + "unsigned": { + "age": 297036, + "replaces_state": "$151800111315tsynI:localhost" + } + }) +} + +#[async_test] +async fn test_event_handler() -> Result<()> { + let appservice = appservice(None).await?; + + struct Example {} + + impl Example { + pub fn new() -> Self { + Self {} + } + } + + #[async_trait] + impl EventHandler for Example { + async fn on_state_member(&self, room: Room, event: &SyncStateEvent) { + dbg!(room, event); + } + } + + appservice + .client() + .set_event_handler(Box::new(Example::new())) + .await; + + let event = serde_json::from_value::(member_json()).unwrap(); + let event: Raw = AnyEvent::State(event).into(); + let events = vec![event]; + + let incoming = api_appservice::event::push_events::v1::IncomingRequest::new( + "any_txn_id".to_owned(), + events, + ); + + appservice.client().receive_transaction(incoming).await?; + + Ok(()) +} + +#[async_test] +async fn test_transaction() -> Result<()> { + let appservice = appservice(None).await?; + + let event = serde_json::from_value::(member_json()).unwrap(); + let event: Raw = AnyEvent::State(event).into(); + let events = vec![event]; + + let incoming = api_appservice::event::push_events::v1::IncomingRequest::new( + "any_txn_id".to_owned(), + events, + ); + + appservice.client().receive_transaction(incoming).await?; + + Ok(()) +} + +#[async_test] +async fn test_verify_hs_token() -> Result<()> { + let appservice = appservice(None).await?; + + let registration = appservice.registration(); + + assert!(appservice.verify_hs_token(®istration.hs_token)); + + Ok(()) +} + +mod registration { + use super::*; + + #[test] + fn test_registration() -> Result<()> { + let registration: Registration = serde_yaml::from_str(®istration_string())?; + let registration: AppserviceRegistration = registration.into(); + + assert_eq!(registration.id, "appservice"); + + Ok(()) + } + + #[test] + fn test_registration_from_yaml_file() -> Result<()> { + let registration = AppserviceRegistration::try_from_yaml_file("./tests/registration.yaml")?; + + assert_eq!(registration.id, "appservice"); + + Ok(()) + } + + #[test] + fn test_registration_from_yaml_str() -> Result<()> { + let registration = AppserviceRegistration::try_from_yaml_str(registration_string())?; + + assert_eq!(registration.id, "appservice"); + + Ok(()) + } +} diff --git a/matrix_sdk_common/Cargo.toml b/matrix_sdk_common/Cargo.toml index 0f258e78..21d0c00c 100644 --- a/matrix_sdk_common/Cargo.toml +++ b/matrix_sdk_common/Cargo.toml @@ -12,6 +12,7 @@ version = "0.2.0" [features] markdown = ["ruma/markdown"] +appservice = ["ruma/appservice-api", "ruma/appservice-api-helper", "ruma/rand"] [dependencies] instant = { version = "0.1.9", features = ["wasm-bindgen", "now"] } diff --git a/matrix_sdk_common/src/lib.rs b/matrix_sdk_common/src/lib.rs index fd0e43d9..9e10d525 100644 --- a/matrix_sdk_common/src/lib.rs +++ b/matrix_sdk_common/src/lib.rs @@ -1,5 +1,10 @@ pub use async_trait::async_trait; pub use instant; +#[cfg(feature = "appservice")] +pub use ruma::{ + api::{appservice as api_appservice, IncomingRequest, OutgoingRequestAppserviceExt}, + serde::{exports::serde::de::value::Error as SerdeError, urlencoded}, +}; pub use ruma::{ api::{ client as api,