diff --git a/Cargo.lock b/Cargo.lock index 0594301..8a2a94b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1616,7 +1616,9 @@ dependencies = [ "rust-embed", "rust-s3", "serde", + "serde_json", "thiserror 2.0.11", + "urlencoding", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 815ba8e..c201a4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,8 @@ clap = { version = "4.5.26", features = ["derive", "env"] } lazy_static = "1.5.0" anyhow = "1.0.95" serde = { version = "1.0.217", features = ["derive"] } -rust-s3 = "0.36.0-beta.2" +serde_json = "1.0.137" +rust-s3 = { version = "0.36.0-beta.2", features = ["tokio"] } actix-web = "4" actix-session = { version = "0.10.1", features = ["redis-session"] } light-openid = "1.0.2" @@ -18,4 +19,5 @@ thiserror = "2.0.11" rand = "0.9.0-beta.3" rust-embed = "8.5.0" mime_guess = "2.0.5" -askama = "0.12.1" \ No newline at end of file +askama = "0.12.1" +urlencoding = "2.1.3" \ No newline at end of file diff --git a/src/app_config.rs b/src/app_config.rs index 58c11f0..8eb8789 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -75,11 +75,11 @@ pub struct AppConfig { s3_endpoint: String, /// S3 access key - #[arg(long, env, default_value = "topsecret")] + #[arg(long, env, default_value = "minioadmin")] s3_access_key: String, /// S3 secret key - #[arg(long, env, default_value = "topsecret")] + #[arg(long, env, default_value = "minioadmin")] s3_secret_key: String, /// S3 skip auto create bucket if not existing diff --git a/src/main.rs b/src/main.rs index 5aa8672..b5d0005 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,11 +4,16 @@ use actix_web::cookie::Key; use actix_web::{web, App, HttpServer}; use matrix_gateway::app_config::AppConfig; use matrix_gateway::server::web_ui; +use matrix_gateway::user::UserConfig; #[actix_web::main] async fn main() -> std::io::Result<()> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + UserConfig::create_bucket_if_required() + .await + .expect("Failed to create bucket!"); + // FIXME : not scalable let secret_key = Key::generate(); @@ -33,6 +38,7 @@ async fn main() -> std::io::Result<()> { // Web configuration routes .route("/assets/{tail:.*}", web::get().to(web_ui::static_file)) .route("/", web::get().to(web_ui::home)) + .route("/", web::post().to(web_ui::home)) .route("/oidc_cb", web::get().to(web_ui::oidc_cb)) .route("/sign_out", web::get().to(web_ui::sign_out)) diff --git a/src/server/mod.rs b/src/server/mod.rs index 4b2390e..a60ec5c 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -16,6 +16,8 @@ pub enum HttpFailure { SessionError(#[from] actix_session::SessionGetError), #[error("an unspecified open id error occurred: {0}")] OpenID(Box), + #[error("an error occurred while fetching user configuration: {0}")] + FetchUserConfig(anyhow::Error), #[error("an unspecified internal error occurred: {0}")] InternalError(#[from] anyhow::Error), } diff --git a/src/server/web_ui.rs b/src/server/web_ui.rs index 7769c32..d9875a8 100644 --- a/src/server/web_ui.rs +++ b/src/server/web_ui.rs @@ -1,7 +1,7 @@ use crate::app_config::AppConfig; use crate::constants::{STATE_KEY, USER_SESSION_KEY}; use crate::server::{HttpFailure, HttpResult}; -use crate::user::{User, UserID}; +use crate::user::{User, UserConfig, UserID}; use crate::utils; use actix_session::Session; use actix_web::{web, HttpResponse}; @@ -32,10 +32,21 @@ pub async fn static_file(path: web::Path) -> HttpResult { struct HomeTemplate { name: String, matrix_token: String, + success_message: Option, + error_message: Option, +} + +/// Update matrix token request +#[derive(serde::Deserialize)] +pub struct UpdateMatrixToken { + new_matrix_token: Option, } /// Main route -pub async fn home(session: Session) -> HttpResult { +pub async fn home( + session: Session, + update_matrix_token: Option>, +) -> HttpResult { // Get user information, requesting authentication if information is missing let Some(user): Option = session.get(USER_SESSION_KEY)? else { // Generate auth state @@ -54,12 +65,37 @@ pub async fn home(session: Session) -> HttpResult { .finish()); }; + let mut success_message = None; + let mut error_message = None; + + // Retrieve user configuration + let mut config = UserConfig::load(&user.id) + .await + .map_err(HttpFailure::FetchUserConfig)?; + + // Update matrix token, if requested + if let Some(update_matrix_token) = update_matrix_token { + if let Some(t) = update_matrix_token.0.new_matrix_token { + if t.len() < 3 { + error_message = Some("Specified Matrix token is too short!".to_string()); + } else { + // TODO : invalidate all existing connections + config.matrix_token = t; + config.save().await?; + success_message = Some("Matrix token was successfully updated!".to_string()); + } + } + } + + // Render page Ok(HttpResponse::Ok() .insert_header(("content-type", "text/html")) .body( HomeTemplate { name: user.name, - matrix_token: "TODO".to_string(), + matrix_token: config.obfuscated_matrix_token(), + success_message, + error_message, } .render() .unwrap(), diff --git a/src/user.rs b/src/user.rs index 52a3149..a7e7ba8 100644 --- a/src/user.rs +++ b/src/user.rs @@ -1,9 +1,135 @@ +use crate::app_config::AppConfig; +use crate::utils::curr_time; +use s3::error::S3Error; +use s3::request::ResponseData; +use s3::{Bucket, BucketConfiguration}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum UserError { + #[error("failed to fetch user configuration: {0}")] + FetchUserConfig(S3Error), +} + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] pub struct UserID(pub String); +impl UserID { + fn conf_path_in_bucket(&self) -> String { + format!("confs/{}.json", urlencoding::encode(&self.0)) + } +} + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] pub struct User { pub id: UserID, pub name: String, pub email: String, } + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct UserConfig { + /// Target user ID + pub user_id: UserID, + + /// Configuration creation time + pub created: u64, + + /// Configuration last update time + pub updated: u64, + + /// Current user matrix token + pub matrix_token: String, +} + +impl UserConfig { + /// Create S3 bucket if required + pub async fn create_bucket_if_required() -> anyhow::Result<()> { + if AppConfig::get().s3_skip_auto_create_bucket { + log::debug!("Skipping bucket existence check"); + return Ok(()); + } + + let bucket = AppConfig::get().s3_bucket()?; + + match bucket.location().await { + Ok(_) => { + log::debug!("The bucket already exists."); + return Ok(()); + } + Err(S3Error::HttpFailWithBody(404, s)) if s.contains("NoSuchKey") => { + log::warn!("Failed to fetch bucket location, but it seems that bucket exists."); + return Ok(()); + } + Err(S3Error::HttpFailWithBody(404, s)) if s.contains("NoSuchBucket") => { + log::warn!("The bucket does not seem to exists, trying to create it!") + } + Err(e) => { + log::error!("Got unexpected error when querying bucket info: {}", e); + return Err(e.into()); + } + } + + Bucket::create_with_path_style( + &bucket.name, + bucket.region, + AppConfig::get().s3_credentials()?, + BucketConfiguration::private(), + ) + .await?; + + Ok(()) + } + + /// Get current user configuration + pub async fn load(user_id: &UserID) -> anyhow::Result { + let res: Result = AppConfig::get() + .s3_bucket()? + .get_object(user_id.conf_path_in_bucket()) + .await; + + match res { + Ok(res) => Ok(serde_json::from_slice(res.as_slice())?), + Err(S3Error::HttpFailWithBody(404, _)) => { + log::warn!("User configuration does not exists, generating a new one..."); + Ok(Self { + user_id: user_id.clone(), + created: curr_time()?, + updated: curr_time()?, + matrix_token: "".to_string(), + }) + } + Err(e) => Err(UserError::FetchUserConfig(e).into()), + } + } + + /// Set user configuration + pub async fn save(&mut self) -> anyhow::Result<()> { + log::info!("Saving new configuration for user {:?}", self.user_id); + + self.updated = curr_time()?; + + // Save updated configuration + AppConfig::get() + .s3_bucket()? + .put_object( + self.user_id.conf_path_in_bucket(), + &serde_json::to_vec(self)?, + ) + .await?; + + Ok(()) + } + + /// Get current user matrix token, in an obfuscated form + pub fn obfuscated_matrix_token(&self) -> String { + self.matrix_token + .chars() + .enumerate() + .map(|(num, c)| match num { + 0 | 1 => c, + _ => 'X', + }) + .collect() + } +} diff --git a/src/utils.rs b/src/utils.rs index ac8f50e..15c9799 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,14 @@ use rand::distr::{Alphanumeric, SampleString}; +use std::time::{SystemTime, UNIX_EPOCH}; -// Generate a random string of a given size +/// Generate a random string of a given size pub fn rand_str(len: usize) -> String { Alphanumeric.sample_string(&mut rand::rng(), len) } + +/// Get current time +pub fn curr_time() -> anyhow::Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|t| t.as_secs())?) +} diff --git a/templates/index.html b/templates/index.html index f6eaf79..757e92b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -32,6 +32,20 @@
+ + {% if let Some(msg) = success_message %} +
+ {{ msg }} +
+ {% endif %} + + + {% if let Some(msg) = error_message %} +
+ {{ msg }} +
+ {% endif %} +
Matrix authentication token
@@ -50,11 +64,11 @@

Tip: you can rename the session to easily identify it among all your other sessions!

-
+
+ placeholder="{{ matrix_token }}" required minlength="2" name="new_matrix_token"/> Changing this value will reset all active connections to Matrix GW.