diff --git a/runtime/src/api_token.rs b/runtime/src/api_token.rs new file mode 100644 index 00000000..3fe1f182 --- /dev/null +++ b/runtime/src/api_token.rs @@ -0,0 +1,84 @@ +use log::info; +use once_cell::sync::Lazy; +use rand::{RngCore, SeedableRng}; +use rocket::http::Status; +use rocket::Request; +use rocket::request::FromRequest; + +/// The API token used to authenticate requests. +pub static API_TOKEN: Lazy = Lazy::new(|| { + let mut token = [0u8; 32]; + let mut rng = rand_chacha::ChaChaRng::from_entropy(); + rng.fill_bytes(&mut token); + + let token = APIToken::from_bytes(token.to_vec()); + info!("API token was generated successfully."); + + token +}); + +/// The API token data structure used to authenticate requests. +pub struct APIToken { + hex_text: String, +} + +impl APIToken { + /// Creates a new API token from a byte vector. + fn from_bytes(bytes: Vec) -> Self { + APIToken { + hex_text: bytes.iter().fold(String::new(), |mut result, byte| { + result.push_str(&format!("{:02x}", byte)); + result + }), + } + } + + /// Creates a new API token from a hexadecimal text. + fn from_hex_text(hex_text: &str) -> Self { + APIToken { + hex_text: hex_text.to_string(), + } + } + + pub(crate) fn to_hex_text(&self) -> &str { + self.hex_text.as_str() + } + + /// Validates the received token against the valid token. + fn validate(&self, received_token: &Self) -> bool { + received_token.to_hex_text() == self.to_hex_text() + } +} + +/// The request outcome type used to handle API token requests. +type RequestOutcome = rocket::request::Outcome; + +/// The request outcome implementation for the API token. +#[rocket::async_trait] +impl<'r> FromRequest<'r> for APIToken { + type Error = APITokenError; + + /// Handles the API token requests. + async fn from_request(request: &'r Request<'_>) -> RequestOutcome { + let token = request.headers().get_one("token"); + match token { + Some(token) => { + let received_token = APIToken::from_hex_text(token); + if API_TOKEN.validate(&received_token) { + RequestOutcome::Success(received_token) + } else { + RequestOutcome::Error((Status::Unauthorized, APITokenError::Invalid)) + } + } + + None => RequestOutcome::Error((Status::Unauthorized, APITokenError::Missing)), + } + } +} + +/// The API token error types. +#[derive(Debug)] +pub enum APITokenError { + Missing, + Invalid, +} \ No newline at end of file diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs new file mode 100644 index 00000000..e5a5bc92 --- /dev/null +++ b/runtime/src/app_window.rs @@ -0,0 +1,222 @@ +use std::sync::Mutex; +use std::time::Duration; +use log::{error, info, warn}; +use once_cell::sync::Lazy; +use rocket::get; +use rocket::serde::json::Json; +use rocket::serde::Serialize; +use tauri::updater::UpdateResponse; +use tauri::{Manager, Window}; +use tokio::time; +use crate::api_token::APIToken; +use crate::dotnet::stop_dotnet_server; +use crate::environment::{is_prod, CONFIG_DIRECTORY, DATA_DIRECTORY}; +use crate::log::switch_to_file_logging; + +/// The Tauri main window. +static MAIN_WINDOW: Lazy>> = Lazy::new(|| Mutex::new(None)); + +/// The update response coming from the Tauri updater. +static CHECK_UPDATE_RESPONSE: Lazy>>> = Lazy::new(|| Mutex::new(None)); + +/// Starts the Tauri app. +pub fn start_tauri() { + info!("Starting Tauri app..."); + let app = tauri::Builder::default() + .setup(move |app| { + let window = app.get_window("main").expect("Failed to get main window."); + *MAIN_WINDOW.lock().unwrap() = Some(window); + + info!(Source = "Bootloader Tauri"; "Setup is running."); + let logger_path = app.path_resolver().app_local_data_dir().unwrap(); + let logger_path = logger_path.join("data"); + + DATA_DIRECTORY.set(logger_path.to_str().unwrap().to_string()).map_err(|_| error!("Was not abe to set the data directory.")).unwrap(); + CONFIG_DIRECTORY.set(app.path_resolver().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap(); + + info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {logger_path:?}"); + switch_to_file_logging(logger_path).map_err(|e| error!("Failed to switch logging to file: {e}")).unwrap(); + + Ok(()) + }) + .plugin(tauri_plugin_window_state::Builder::default().build()) + .build(tauri::generate_context!()) + .expect("Error while running Tauri application"); + + app.run(|app_handle, event| match event { + + tauri::RunEvent::WindowEvent { event, label, .. } => { + match event { + tauri::WindowEvent::CloseRequested { .. } => { + warn!(Source = "Tauri"; "Window '{label}': close was requested."); + } + + tauri::WindowEvent::Destroyed => { + warn!(Source = "Tauri"; "Window '{label}': was destroyed."); + } + + tauri::WindowEvent::FileDrop(files) => { + info!(Source = "Tauri"; "Window '{label}': files were dropped: {files:?}"); + } + + _ => (), + } + } + + tauri::RunEvent::Updater(updater_event) => { + match updater_event { + + tauri::UpdaterEvent::UpdateAvailable { body, date, version } => { + let body_len = body.len(); + info!(Source = "Tauri"; "Updater: update available: body size={body_len} time={date:?} version={version}"); + } + + tauri::UpdaterEvent::Pending => { + info!(Source = "Tauri"; "Updater: update is pending!"); + } + + tauri::UpdaterEvent::DownloadProgress { chunk_length, content_length } => { + info!(Source = "Tauri"; "Updater: downloaded {} of {:?}", chunk_length, content_length); + } + + tauri::UpdaterEvent::Downloaded => { + info!(Source = "Tauri"; "Updater: update has been downloaded!"); + warn!(Source = "Tauri"; "Try to stop the .NET server now..."); + stop_dotnet_server(); + } + + tauri::UpdaterEvent::Updated => { + info!(Source = "Tauri"; "Updater: app has been updated"); + warn!(Source = "Tauri"; "Try to restart the app now..."); + app_handle.restart(); + } + + tauri::UpdaterEvent::AlreadyUpToDate => { + info!(Source = "Tauri"; "Updater: app is already up to date"); + } + + tauri::UpdaterEvent::Error(error) => { + warn!(Source = "Tauri"; "Updater: failed to update: {error}"); + } + } + } + + tauri::RunEvent::ExitRequested { .. } => { + warn!(Source = "Tauri"; "Run event: exit was requested."); + } + + tauri::RunEvent::Ready => { + info!(Source = "Tauri"; "Run event: Tauri app is ready."); + } + + _ => {} + }); + + warn!(Source = "Tauri"; "Tauri app was stopped."); + if is_prod() { + warn!("Try to stop the .NET server as well..."); + stop_dotnet_server(); + } +} + +/// Changes the location of the main window to the given URL. +pub async fn change_location_to(url: &str) { + // Try to get the main window. If it is not available yet, wait for it: + let mut main_window_ready = false; + let mut main_window_status_reported = false; + let main_window_spawn_clone = &MAIN_WINDOW; + while !main_window_ready + { + main_window_ready = { + let main_window = main_window_spawn_clone.lock().unwrap(); + main_window.is_some() + }; + + if !main_window_ready { + if !main_window_status_reported { + info!("Waiting for main window to be ready, because .NET was faster than Tauri."); + main_window_status_reported = true; + } + + time::sleep(Duration::from_millis(100)).await; + } + } + + let js_location_change = format!("window.location = '{url}';"); + let main_window = main_window_spawn_clone.lock().unwrap(); + let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str()); + match location_change_result { + Ok(_) => info!("The app location was changed to {url}."), + Err(e) => error!("Failed to change the app location to {url}: {e}."), + } +} + +/// Checks for updates. +#[get("/updates/check")] +pub async fn check_for_update(_token: APIToken) -> Json { + let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().unwrap().app_handle(); + let response = app_handle.updater().check().await; + match response { + Ok(update_response) => match update_response.is_update_available() { + true => { + *CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update_response.clone()); + let new_version = update_response.latest_version(); + info!(Source = "Updater"; "An update to version '{new_version}' is available."); + let changelog = update_response.body(); + Json(CheckUpdateResponse { + update_is_available: true, + error: false, + new_version: new_version.to_string(), + changelog: match changelog { + Some(c) => c.to_string(), + None => String::from(""), + }, + }) + }, + + false => { + info!(Source = "Updater"; "No updates are available."); + Json(CheckUpdateResponse { + update_is_available: false, + error: false, + new_version: String::from(""), + changelog: String::from(""), + }) + }, + }, + + Err(e) => { + warn!(Source = "Updater"; "Failed to check for updates: {e}."); + Json(CheckUpdateResponse { + update_is_available: false, + error: true, + new_version: String::from(""), + changelog: String::from(""), + }) + }, + } +} + +/// The response to the check for update request. +#[derive(Serialize)] +pub struct CheckUpdateResponse { + update_is_available: bool, + error: bool, + new_version: String, + changelog: String, +} + +/// Installs the update. +#[get("/updates/install")] +pub async fn install_update(_token: APIToken) { + let cloned_response_option = CHECK_UPDATE_RESPONSE.lock().unwrap().clone(); + match cloned_response_option { + Some(update_response) => { + update_response.download_and_install().await.unwrap(); + }, + + None => { + error!(Source = "Updater"; "No update available to install. Did you check for updates first?"); + }, + } +} \ No newline at end of file diff --git a/runtime/src/certificate.rs b/runtime/src/certificate.rs new file mode 100644 index 00000000..2f6bc715 --- /dev/null +++ b/runtime/src/certificate.rs @@ -0,0 +1,38 @@ +use std::sync::OnceLock; +use log::info; +use rcgen::generate_simple_self_signed; +use sha2::{Sha256, Digest}; + +/// The certificate used for the runtime API server. +pub static CERTIFICATE: OnceLock> = OnceLock::new(); + +/// The private key used for the certificate of the runtime API server. +pub static CERTIFICATE_PRIVATE_KEY: OnceLock> = OnceLock::new(); + +/// The fingerprint of the certificate used for the runtime API server. +pub static CERTIFICATE_FINGERPRINT: OnceLock = OnceLock::new(); + +/// Generates a TLS certificate for the runtime API server. +pub fn generate_certificate() { + + info!("Try to generate a TLS certificate for the runtime API server..."); + + let subject_alt_names = vec!["localhost".to_string()]; + let certificate_data = generate_simple_self_signed(subject_alt_names).unwrap(); + let certificate_binary_data = certificate_data.cert.der().to_vec(); + + let certificate_fingerprint = Sha256::digest(certificate_binary_data).to_vec(); + let certificate_fingerprint = certificate_fingerprint.iter().fold(String::new(), |mut result, byte| { + result.push_str(&format!("{:02x}", byte)); + result + }); + + let certificate_fingerprint = certificate_fingerprint.to_uppercase(); + + CERTIFICATE_FINGERPRINT.set(certificate_fingerprint.clone()).expect("Could not set the certificate fingerprint."); + CERTIFICATE.set(certificate_data.cert.pem().as_bytes().to_vec()).expect("Could not set the certificate."); + CERTIFICATE_PRIVATE_KEY.set(certificate_data.key_pair.serialize_pem().as_bytes().to_vec()).expect("Could not set the private key."); + + info!("Certificate fingerprint: '{certificate_fingerprint}'."); + info!("Done generating certificate for the runtime API server."); +} \ No newline at end of file diff --git a/runtime/src/clipboard.rs b/runtime/src/clipboard.rs new file mode 100644 index 00000000..b00617f2 --- /dev/null +++ b/runtime/src/clipboard.rs @@ -0,0 +1,62 @@ +use arboard::Clipboard; +use log::{debug, error}; +use rocket::post; +use rocket::serde::json::Json; +use serde::Serialize; +use crate::api_token::APIToken; +use crate::encryption::{EncryptedText, ENCRYPTION}; + +/// Sets the clipboard text to the provided encrypted text. +#[post("/clipboard/set", data = "")] +pub fn set_clipboard(_token: APIToken, encrypted_text: EncryptedText) -> Json { + + // Decrypt this text first: + let decrypted_text = match ENCRYPTION.decrypt(&encrypted_text) { + Ok(text) => text, + Err(e) => { + error!(Source = "Clipboard"; "Failed to decrypt the text: {e}."); + return Json(SetClipboardResponse { + success: false, + issue: e, + }) + }, + }; + + let clipboard_result = Clipboard::new(); + let mut clipboard = match clipboard_result { + Ok(clipboard) => clipboard, + Err(e) => { + error!(Source = "Clipboard"; "Failed to get the clipboard instance: {e}."); + return Json(SetClipboardResponse { + success: false, + issue: e.to_string(), + }) + }, + }; + + let set_text_result = clipboard.set_text(decrypted_text); + match set_text_result { + Ok(_) => { + debug!(Source = "Clipboard"; "Text was set to the clipboard successfully."); + Json(SetClipboardResponse { + success: true, + issue: String::from(""), + }) + }, + + Err(e) => { + error!(Source = "Clipboard"; "Failed to set text to the clipboard: {e}."); + Json(SetClipboardResponse { + success: false, + issue: e.to_string(), + }) + }, + } +} + +/// The response for setting the clipboard text. +#[derive(Serialize)] +pub struct SetClipboardResponse { + success: bool, + issue: String, +} \ No newline at end of file diff --git a/runtime/src/dotnet.rs b/runtime/src/dotnet.rs new file mode 100644 index 00000000..b0c577bc --- /dev/null +++ b/runtime/src/dotnet.rs @@ -0,0 +1,176 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use log::{debug, error, info, warn}; +use once_cell::sync::Lazy; +use rocket::get; +use tauri::api::process::{Command, CommandChild, CommandEvent}; +use tauri::Url; +use crate::api_token::{APIToken, API_TOKEN}; +use crate::app_window::change_location_to; +use crate::certificate::CERTIFICATE_FINGERPRINT; +use crate::encryption::ENCRYPTION; +use crate::environment::is_dev; +use crate::network::get_available_port; +use crate::runtime_api::API_SERVER_PORT; + +// The .NET server is started in a separate process and communicates with this +// runtime process via IPC. However, we do net start the .NET server in +// the development environment. +static DOTNET_SERVER: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(None))); + +// The .NET server port is relevant for the production environment only, sine we +// do not start the server in the development environment. +static DOTNET_SERVER_PORT: Lazy = Lazy::new(|| get_available_port().unwrap()); + +static DOTNET_INITIALIZED: Lazy> = Lazy::new(|| Mutex::new(false)); + +/// Returns the desired port of the .NET server. Our .NET app calls this endpoint to get +/// the port where the .NET server should listen to. +#[get("/system/dotnet/port")] +pub fn dotnet_port(_token: APIToken) -> String { + let dotnet_server_port = *DOTNET_SERVER_PORT; + format!("{dotnet_server_port}") +} + +/// Starts the .NET server in a separate process. +pub fn start_dotnet_server() { + + // Get the secret password & salt and convert it to a base64 string: + let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password); + let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt); + + let dotnet_server_environment = HashMap::from_iter([ + (String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password), + (String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt), + (String::from("AI_STUDIO_CERTIFICATE_FINGERPRINT"), CERTIFICATE_FINGERPRINT.get().unwrap().to_string()), + (String::from("AI_STUDIO_API_TOKEN"), API_TOKEN.to_hex_text().to_string()), + ]); + + info!("Try to start the .NET server..."); + let server_spawn_clone = DOTNET_SERVER.clone(); + tauri::async_runtime::spawn(async move { + let api_port = *API_SERVER_PORT; + let (mut rx, child) = match is_dev() { + true => { + // We are in the development environment, so we try to start a process + // with `dotnet run` in the `../app/MindWork AI Studio` directory. But + // we cannot issue a sidecar because we cannot use any command for the + // sidecar (see Tauri configuration). Thus, we use a standard Rust process: + warn!(Source = "Bootloader .NET"; "Development environment detected; start .NET server using 'dotnet run'."); + Command::new("dotnet") + + // Start the .NET server in the `../app/MindWork AI Studio` directory. + // We provide the runtime API server port to the .NET server: + .args(["run", "--project", "../app/MindWork AI Studio", "--", format!("{api_port}").as_str()]) + + .envs(dotnet_server_environment) + .spawn() + .expect("Failed to spawn .NET server process.") + } + + false => { + Command::new_sidecar("mindworkAIStudioServer") + .expect("Failed to create sidecar") + + // Provide the runtime API server port to the .NET server: + .args([format!("{api_port}").as_str()]) + + .envs(dotnet_server_environment) + .spawn() + .expect("Failed to spawn .NET server process.") + } + }; + + let server_pid = child.pid(); + info!(Source = "Bootloader .NET"; "The .NET server process started with PID={server_pid}."); + + // Save the server process to stop it later: + *server_spawn_clone.lock().unwrap() = Some(child); + + // Log the output of the .NET server: + while let Some(CommandEvent::Stdout(line)) = rx.recv().await { + + // Remove newline characters from the end: + let line = line.trim_end(); + + // Starts the line with '=>'? + if line.starts_with("=>") { + // Yes. This means that the line is a log message from the .NET server. + // The format is: ' [] : '. + // We try to parse this line and log it with the correct log level: + let line = line.trim_start_matches("=>").trim(); + let parts = line.split_once(": ").unwrap(); + let left_part = parts.0.trim(); + let message = parts.1.trim(); + let parts = left_part.split_once("] ").unwrap(); + let level = parts.0.split_once("[").unwrap().1.trim(); + let source = parts.1.trim(); + match level { + "Trace" => debug!(Source = ".NET Server", Comp = source; "{message}"), + "Debug" => debug!(Source = ".NET Server", Comp = source; "{message}"), + "Information" => info!(Source = ".NET Server", Comp = source; "{message}"), + "Warning" => warn!(Source = ".NET Server", Comp = source; "{message}"), + "Error" => error!(Source = ".NET Server", Comp = source; "{message}"), + "Critical" => error!(Source = ".NET Server", Comp = source; "{message}"), + + _ => error!(Source = ".NET Server", Comp = source; "{message} (unknown log level '{level}')"), + } + } else { + let lower_line = line.to_lowercase(); + if lower_line.contains("error") { + error!(Source = ".NET Server"; "{line}"); + } else if lower_line.contains("warning") { + warn!(Source = ".NET Server"; "{line}"); + } else { + info!(Source = ".NET Server"; "{line}"); + } + } + } + }); +} + +/// This endpoint is called by the .NET server to signal that the server is ready. +#[get("/system/dotnet/ready")] +pub async fn dotnet_ready(_token: APIToken) { + + // We create a manual scope for the lock to be released as soon as possible. + // This is necessary because we cannot await any function while the lock is + // held. + { + let mut initialized = DOTNET_INITIALIZED.lock().unwrap(); + if *initialized { + error!("Anyone tried to initialize the runtime twice. This is not intended."); + return; + } + + info!("The .NET server was booted successfully."); + *initialized = true; + } + + let dotnet_server_port = *DOTNET_SERVER_PORT; + let url = match Url::parse(format!("http://localhost:{dotnet_server_port}").as_str()) + { + Ok(url) => url, + Err(msg) => { + error!("Error while parsing URL for navigating to the app: {msg}"); + return; + } + }; + + change_location_to(url.as_str()).await; +} + +/// Stops the .NET server process. +pub fn stop_dotnet_server() { + if let Some(server_process) = DOTNET_SERVER.lock().unwrap().take() { + let server_kill_result = server_process.kill(); + match server_kill_result { + Ok(_) => info!("The .NET server process was stopped."), + Err(e) => error!("Failed to stop the .NET server process: {e}."), + } + } else { + warn!("The .NET server process was not started or is already stopped."); + } +} \ No newline at end of file diff --git a/runtime/src/encryption.rs b/runtime/src/encryption.rs new file mode 100644 index 00000000..d28dbf27 --- /dev/null +++ b/runtime/src/encryption.rs @@ -0,0 +1,177 @@ +use std::fmt; +use std::time::Instant; +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use hmac::Hmac; +use log::info; +use once_cell::sync::Lazy; +use pbkdf2::pbkdf2; +use rand::{RngCore, SeedableRng}; +use rocket::{data, Data, Request}; +use rocket::data::ToByteUnit; +use rocket::http::Status; +use rocket::serde::{Deserialize, Serialize}; +use sha2::Sha512; +use tokio::io::AsyncReadExt; + +type Aes256CbcEnc = cbc::Encryptor; + +type Aes256CbcDec = cbc::Decryptor; + +type DataOutcome<'r, T> = data::Outcome<'r, T>; + +/// The encryption instance used for the IPC channel. +pub static ENCRYPTION: Lazy = Lazy::new(|| { + // + // Generate a secret key & salt for the AES encryption for the IPC channel: + // + let mut secret_key = [0u8; 512]; // 512 bytes = 4096 bits + let mut secret_key_salt = [0u8; 16]; // 16 bytes = 128 bits + + // We use a cryptographically secure pseudo-random number generator + // to generate the secret password & salt. ChaCha20Rng is the algorithm + // of our choice: + let mut rng = rand_chacha::ChaChaRng::from_entropy(); + + // Fill the secret key & salt with random bytes: + rng.fill_bytes(&mut secret_key); + rng.fill_bytes(&mut secret_key_salt); + + info!("Secret password for the IPC channel was generated successfully."); + Encryption::new(&secret_key, &secret_key_salt).unwrap() +}); + +/// The encryption struct used for the IPC channel. +pub struct Encryption { + key: [u8; 32], + iv: [u8; 16], + + pub secret_password: [u8; 512], + pub secret_key_salt: [u8; 16], +} + +impl Encryption { + // The number of iterations to derive the key and IV from the password. For a password + // manager where the user has to enter their primary password, 100 iterations would be + // too few and insecure. Here, the use case is different: We generate a 512-byte long + // and cryptographically secure password at every start. This password already contains + // enough entropy. In our case, we need key and IV primarily because AES, with the + // algorithms we chose, requires a fixed key length, and our password is too long. + const ITERATIONS: u32 = 100; + + /// Initializes the encryption with the given secret password and salt. + pub fn new(secret_password: &[u8], secret_key_salt: &[u8]) -> Result { + if secret_password.len() != 512 { + return Err("The secret password must be 512 bytes long.".to_string()); + } + + if secret_key_salt.len() != 16 { + return Err("The salt must be 16 bytes long.".to_string()); + } + + info!(Source = "Encryption"; "Initializing encryption..."); + let mut encryption = Encryption { + key: [0u8; 32], + iv: [0u8; 16], + + secret_password: [0u8; 512], + secret_key_salt: [0u8; 16], + }; + + encryption.secret_password.copy_from_slice(secret_password); + encryption.secret_key_salt.copy_from_slice(secret_key_salt); + + let start = Instant::now(); + let mut key_iv = [0u8; 48]; + pbkdf2::>(secret_password, secret_key_salt, Self::ITERATIONS, &mut key_iv).map_err(|e| format!("Error while generating key and IV: {e}"))?; + encryption.key.copy_from_slice(&key_iv[0..32]); + encryption.iv.copy_from_slice(&key_iv[32..48]); + + let duration = start.elapsed(); + let duration = duration.as_millis(); + info!(Source = "Encryption"; "Encryption initialized in {duration} milliseconds.", ); + + Ok(encryption) + } + + /// Encrypts the given data. + pub fn encrypt(&self, data: &str) -> Result { + let cipher = Aes256CbcEnc::new(&self.key.into(), &self.iv.into()); + let encrypted = cipher.encrypt_padded_vec_mut::(data.as_bytes()); + let mut result = BASE64_STANDARD.encode(self.secret_key_salt); + result.push_str(&BASE64_STANDARD.encode(&encrypted)); + Ok(EncryptedText::new(result)) + } + + /// Decrypts the given data. + pub fn decrypt(&self, encrypted_data: &EncryptedText) -> Result { + let decoded = BASE64_STANDARD.decode(encrypted_data.get_encrypted()).map_err(|e| format!("Error decoding base64: {e}"))?; + + if decoded.len() < 16 { + return Err("Encrypted data is too short.".to_string()); + } + + let (salt, encrypted) = decoded.split_at(16); + if salt != self.secret_key_salt { + return Err("The salt bytes do not match. The data is corrupted or tampered.".to_string()); + } + + let cipher = Aes256CbcDec::new(&self.key.into(), &self.iv.into()); + let decrypted = cipher.decrypt_padded_vec_mut::(encrypted).map_err(|e| format!("Error decrypting data: {e}"))?; + + String::from_utf8(decrypted).map_err(|e| format!("Error converting decrypted data to string: {}", e)) + } +} + +/// Represents encrypted text. +#[derive(Clone, Serialize, Deserialize)] +pub struct EncryptedText(String); + +impl EncryptedText { + + /// Creates a new encrypted text instance. + pub fn new(encrypted_data: String) -> Self { + EncryptedText(encrypted_data) + } + + /// Returns the encrypted data. + pub fn get_encrypted(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for EncryptedText { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "EncryptedText(**********)") + } +} + +impl fmt::Display for EncryptedText { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "**********") + } +} + +/// Use Case: When we receive encrypted text from the client as body (e.g., in a POST request). +/// We must interpret the body as EncryptedText. +#[rocket::async_trait] +impl<'r> data::FromData<'r> for EncryptedText { + type Error = String; + + /// Parses the data as EncryptedText. + async fn from_data(req: &'r Request<'_>, data: Data<'r>) -> DataOutcome<'r, Self> { + let content_type = req.content_type(); + if content_type.map_or(true, |ct| !ct.is_text()) { + return DataOutcome::Forward((data, Status::Ok)); + } + + let mut stream = data.open(2.mebibytes()); + let mut body = String::new(); + if let Err(e) = stream.read_to_string(&mut body).await { + return DataOutcome::Error((Status::InternalServerError, format!("Failed to read data: {}", e))); + } + + DataOutcome::Success(EncryptedText(body)) + } +} \ No newline at end of file diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs new file mode 100644 index 00000000..53e8f8fe --- /dev/null +++ b/runtime/src/environment.rs @@ -0,0 +1,37 @@ +use std::sync::OnceLock; +use rocket::get; +use crate::api_token::APIToken; + +/// The data directory where the application stores its data. +pub static DATA_DIRECTORY: OnceLock = OnceLock::new(); + +/// The config directory where the application stores its configuration. +pub static CONFIG_DIRECTORY: OnceLock = OnceLock::new(); + +/// Returns the config directory. +#[get("/system/directories/config")] +pub fn get_config_directory(_token: APIToken) -> String { + match CONFIG_DIRECTORY.get() { + Some(config_directory) => config_directory.clone(), + None => String::from(""), + } +} + +/// Returns the data directory. +#[get("/system/directories/data")] +pub fn get_data_directory(_token: APIToken) -> String { + match DATA_DIRECTORY.get() { + Some(data_directory) => data_directory.clone(), + None => String::from(""), + } +} + +/// Returns true if the application is running in development mode. +pub fn is_dev() -> bool { + cfg!(debug_assertions) +} + +/// Returns true if the application is running in production mode. +pub fn is_prod() -> bool { + !is_dev() +} \ No newline at end of file diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs new file mode 100644 index 00000000..7cdf018c --- /dev/null +++ b/runtime/src/lib.rs @@ -0,0 +1,11 @@ +pub mod encryption; +pub mod log; +pub mod environment; +pub mod dotnet; +pub mod network; +pub mod api_token; +pub mod app_window; +pub mod secret; +pub mod clipboard; +pub mod runtime_api; +pub mod certificate; \ No newline at end of file diff --git a/runtime/src/log.rs b/runtime/src/log.rs new file mode 100644 index 00000000..30befd46 --- /dev/null +++ b/runtime/src/log.rs @@ -0,0 +1,163 @@ +use std::collections::BTreeMap; +use std::error::Error; +use std::fmt::Debug; +use std::path::PathBuf; +use std::sync::OnceLock; +use flexi_logger::{DeferredNow, Duplicate, FileSpec, Logger, LoggerHandle}; +use flexi_logger::writers::FileLogWriter; +use log::kv; +use log::kv::{Key, Value, VisitSource}; +use crate::environment::is_dev; + +static LOGGER: OnceLock = OnceLock::new(); + +/// Initialize the logging system. +pub fn init_logging() { + + // + // Configure the LOGGER: + // + let mut log_config = String::new(); + + // Set the log level depending on the environment: + match is_dev() { + true => log_config.push_str("debug, "), + false => log_config.push_str("info, "), + }; + + // Set the log level for the Rocket library: + log_config.push_str("rocket=info, "); + + // Set the log level for the Rocket server: + log_config.push_str("rocket::server=warn, "); + + // Set the log level for the Reqwest library: + log_config.push_str("reqwest::async_impl::client=info"); + + // Configure the initial filename. On Unix systems, the file should start + // with a dot to be hidden. + let log_basename = match cfg!(unix) + { + true => ".AI Studio Events", + false => "AI Studio Events", + }; + + let runtime_logger = Logger::try_with_str(log_config).expect("Cannot create logging") + .log_to_file(FileSpec::default() + .basename(log_basename) + .suppress_timestamp() + .suffix("log")) + .duplicate_to_stdout(Duplicate::All) + .use_utc() + .format_for_files(file_logger_format) + .format_for_stderr(terminal_colored_logger_format) + .format_for_stdout(terminal_colored_logger_format) + .start().expect("Cannot start logging"); + + let runtime_logger = RuntimeLoggerHandle{ + handle: runtime_logger + }; + + LOGGER.set(runtime_logger).expect("Cannot set LOGGER"); +} + +/// Switch the logging system to a file-based output. +pub fn switch_to_file_logging(logger_path: PathBuf) -> Result<(), Box>{ + LOGGER.get().expect("No LOGGER was set").handle.reset_flw(&FileLogWriter::builder( + FileSpec::default() + .directory(logger_path) + .basename("events") + .suppress_timestamp() + .suffix("log")))?; + + Ok(()) +} + +struct RuntimeLoggerHandle { + handle: LoggerHandle +} + +impl Debug for RuntimeLoggerHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "LoggerHandle") + } +} + +// +// Data structure for iterating over key-value pairs of log messages. +// +struct LogKVCollect<'kvs>(BTreeMap, Value<'kvs>>); + +impl<'kvs> VisitSource<'kvs> for LogKVCollect<'kvs> { + fn visit_pair(&mut self, key: Key<'kvs>, value: Value<'kvs>) -> Result<(), kv::Error> { + self.0.insert(key, value); + Ok(()) + } +} + +fn write_kv_pairs(w: &mut dyn std::io::Write, record: &log::Record) -> Result<(), std::io::Error> { + if record.key_values().count() > 0 { + let mut visitor = LogKVCollect(BTreeMap::new()); + record.key_values().visit(&mut visitor).unwrap(); + write!(w, "[")?; + let mut index = 0; + for (key, value) in visitor.0 { + index += 1; + if index > 1 { + write!(w, ", ")?; + } + + write!(w, "{} = {}", key, value)?; + } + write!(w, "] ")?; + } + + Ok(()) +} + +// Custom LOGGER format for the terminal: +fn terminal_colored_logger_format( + w: &mut dyn std::io::Write, + now: &mut DeferredNow, + record: &log::Record, +) -> Result<(), std::io::Error> { + let level = record.level(); + + // Write the timestamp, log level, and module path: + write!( + w, + "[{}] {} [{}] ", + flexi_logger::style(level).paint(now.format(flexi_logger::TS_DASHES_BLANK_COLONS_DOT_BLANK).to_string()), + flexi_logger::style(level).paint(record.level().to_string()), + record.module_path().unwrap_or(""), + )?; + + // Write all key-value pairs: + write_kv_pairs(w, record)?; + + // Write the log message: + write!(w, "{}", flexi_logger::style(level).paint(record.args().to_string())) +} + +/// Custom LOGGER format for the log files: +fn file_logger_format( + w: &mut dyn std::io::Write, + now: &mut DeferredNow, + record: &log::Record, +) -> Result<(), std::io::Error> { + + // Write the timestamp, log level, and module path: + write!( + w, + "[{}] {} [{}] ", + now.format(flexi_logger::TS_DASHES_BLANK_COLONS_DOT_BLANK), + record.level(), + record.module_path().unwrap_or(""), + )?; + + // Write all key-value pairs: + write_kv_pairs(w, record)?; + + // Write the log message: + write!(w, "{}", &record.args()) +} \ No newline at end of file diff --git a/runtime/src/main.rs b/runtime/src/main.rs index 424d9294..3b0026d4 100644 --- a/runtime/src/main.rs +++ b/runtime/src/main.rs @@ -4,108 +4,13 @@ extern crate rocket; extern crate core; -use std::collections::{BTreeMap, HashMap, HashSet}; -use std::fmt; -use std::net::TcpListener; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::{Duration, Instant}; -use once_cell::sync::Lazy; - -use arboard::Clipboard; -use base64::Engine; -use base64::prelude::BASE64_STANDARD; -use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; -use keyring::Entry; -use serde::{Deserialize, Serialize}; -use tauri::{Manager, Url, Window}; -use tauri::api::process::{Command, CommandChild, CommandEvent}; -use tokio::time; -use flexi_logger::{DeferredNow, Duplicate, FileSpec, Logger}; -use flexi_logger::writers::FileLogWriter; -use hmac::Hmac; -use keyring::error::Error::NoEntry; -use log::{debug, error, info, kv, warn}; -use log::kv::{Key, Value, VisitSource}; -use pbkdf2::pbkdf2; -use rand::{RngCore, SeedableRng}; -use rcgen::generate_simple_self_signed; -use rocket::figment::Figment; -use rocket::{data, get, post, routes, Data, Request}; -use rocket::config::{Shutdown}; -use rocket::data::{ToByteUnit}; -use rocket::http::Status; -use rocket::request::{FromRequest}; -use rocket::serde::json::Json; -use sha2::{Sha256, Sha512, Digest}; -use tauri::updater::UpdateResponse; -use tokio::io::AsyncReadExt; - -type Aes256CbcEnc = cbc::Encryptor; - -type Aes256CbcDec = cbc::Decryptor; - -type DataOutcome<'r, T> = data::Outcome<'r, T>; - -type RequestOutcome = rocket::request::Outcome; - -// The .NET server is started in a separate process and communicates with this -// runtime process via IPC. However, we do net start the .NET server in -// the development environment. -static DOTNET_SERVER: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(None))); - -// The .NET server port is relevant for the production environment only, sine we -// do not start the server in the development environment. -static DOTNET_SERVER_PORT: Lazy = Lazy::new(|| get_available_port().unwrap()); - -// The port used for the runtime API server. In the development environment, we use a fixed -// port, in the production environment we use the next available port. This differentiation -// is necessary because we cannot communicate the port to the .NET server in the development -// environment. -static API_SERVER_PORT: Lazy = Lazy::new(|| { - if is_dev() { - 5000 - } else { - get_available_port().unwrap() - } -}); - -// The Tauri main window. -static MAIN_WINDOW: Lazy>> = Lazy::new(|| Mutex::new(None)); - -// The update response coming from the Tauri updater. -static CHECK_UPDATE_RESPONSE: Lazy>>> = Lazy::new(|| Mutex::new(None)); - -static ENCRYPTION: Lazy = Lazy::new(|| { - // - // Generate a secret key & salt for the AES encryption for the IPC channel: - // - let mut secret_key = [0u8; 512]; // 512 bytes = 4096 bits - let mut secret_key_salt = [0u8; 16]; // 16 bytes = 128 bits - - // We use a cryptographically secure pseudo-random number generator - // to generate the secret password & salt. ChaCha20Rng is the algorithm - // of our choice: - let mut rng = rand_chacha::ChaChaRng::from_entropy(); - - // Fill the secret key & salt with random bytes: - rng.fill_bytes(&mut secret_key); - rng.fill_bytes(&mut secret_key_salt); - - Encryption::new(&secret_key, &secret_key_salt).unwrap() -}); - -static API_TOKEN: Lazy = Lazy::new(|| { - let mut token = [0u8; 32]; - let mut rng = rand_chacha::ChaChaRng::from_entropy(); - rng.fill_bytes(&mut token); - APIToken::from_bytes(token.to_vec()) -}); - -static DATA_DIRECTORY: OnceLock = OnceLock::new(); - -static CONFIG_DIRECTORY: OnceLock = OnceLock::new(); - -static DOTNET_INITIALIZED: Lazy> = Lazy::new(|| Mutex::new(false)); +use log::{info, warn}; +use mindwork_ai_studio::app_window::start_tauri; +use mindwork_ai_studio::certificate::{generate_certificate}; +use mindwork_ai_studio::dotnet::start_dotnet_server; +use mindwork_ai_studio::environment::is_dev; +use mindwork_ai_studio::log::init_logging; +use mindwork_ai_studio::runtime_api::start_runtime_api; #[tokio::main] async fn main() { @@ -122,47 +27,9 @@ async fn main() { let tauri_version = metadata_lines.next().unwrap(); let app_commit_hash = metadata_lines.next().unwrap(); - // - // Configure the logger: - // - let mut log_config = String::new(); - - // Set the log level depending on the environment: - match is_dev() { - true => log_config.push_str("debug, "), - false => log_config.push_str("info, "), - }; - - // Set the log level for the Rocket library: - log_config.push_str("rocket=info, "); - - // Set the log level for the Rocket server: - log_config.push_str("rocket::server=warn, "); - - // Set the log level for the Reqwest library: - log_config.push_str("reqwest::async_impl::client=info"); - - // Configure the initial filename. On Unix systems, the file should start - // with a dot to be hidden. - let log_basename = match cfg!(unix) - { - true => ".AI Studio Events", - false => "AI Studio Events", - }; - - let logger = Logger::try_with_str(log_config).expect("Cannot create logging") - .log_to_file(FileSpec::default() - .basename(log_basename) - .suppress_timestamp() - .suffix("log")) - .duplicate_to_stdout(Duplicate::All) - .use_utc() - .format_for_files(file_logger_format) - .format_for_stderr(terminal_colored_logger_format) - .format_for_stdout(terminal_colored_logger_format) - .start().expect("Cannot start logging"); - + init_logging(); info!("Starting MindWork AI Studio:"); + let working_directory = std::env::current_dir().unwrap(); info!(".. The working directory is: '{working_directory:?}'"); @@ -180,912 +47,8 @@ async fn main() { info!("Running in production mode."); } - info!("Try to generate a TLS certificate for the runtime API server..."); - - let subject_alt_names = vec!["localhost".to_string()]; - let certificate_data = generate_simple_self_signed(subject_alt_names).unwrap(); - let certificate_binary_data = certificate_data.cert.der().to_vec(); - let certificate_fingerprint = Sha256::digest(certificate_binary_data).to_vec(); - let certificate_fingerprint = certificate_fingerprint.iter().fold(String::new(), |mut result, byte| { - result.push_str(&format!("{:02x}", byte)); - result - }); - let certificate_fingerprint = certificate_fingerprint.to_uppercase(); - info!("Certificate fingerprint: '{certificate_fingerprint}'."); - info!("Done generating certificate for the runtime API server."); - - let api_port = *API_SERVER_PORT; - info!("Try to start the API server on 'http://localhost:{api_port}'..."); - - // The shutdown configuration for the runtime API server: - let mut shutdown = Shutdown { - // We do not want to use the Ctrl+C signal to stop the server: - ctrlc: false, - - // Everything else is set to default for now: - ..Shutdown::default() - }; - - #[cfg(unix)] - { - // We do not want to use the termination signal to stop the server. - // This option, however, is only available on Unix systems: - shutdown.signals = HashSet::new(); - } - - // Configure the runtime API server: - let figment = Figment::from(rocket::Config::release_default()) - - // We use the next available port which was determined before: - .merge(("port", api_port)) - - // The runtime API server should be accessible only from the local machine: - .merge(("address", "127.0.0.1")) - - // We do not want to use the Ctrl+C signal to stop the server: - .merge(("ctrlc", false)) - - // Set a name for the server: - .merge(("ident", "AI Studio Runtime API")) - - // Set the maximum number of workers and blocking threads: - .merge(("workers", 3)) - .merge(("max_blocking", 12)) - - // No colors and emojis in the log output: - .merge(("cli_colors", false)) - - // Read the TLS certificate and key from the generated certificate data in-memory: - .merge(("tls.certs", certificate_data.cert.pem().as_bytes())) - .merge(("tls.key", certificate_data.key_pair.serialize_pem().as_bytes())) - - // Set the shutdown configuration: - .merge(("shutdown", shutdown)); - - // - // Start the runtime API server in a separate thread. This is necessary - // because the server is blocking, and we need to run the Tauri app in - // parallel: - // - tauri::async_runtime::spawn(async move { - rocket::custom(figment) - .mount("/", routes![ - dotnet_port, dotnet_ready, set_clipboard, check_for_update, install_update, - get_secret, store_secret, delete_secret, get_data_directory, get_config_directory, - ]) - .ignite().await.unwrap() - .launch().await.unwrap(); - }); - - // Get the secret password & salt and convert it to a base64 string: - let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password); - let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt); - - let dotnet_server_environment = HashMap::from_iter([ - (String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password), - (String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt), - (String::from("AI_STUDIO_CERTIFICATE_FINGERPRINT"), certificate_fingerprint), - (String::from("AI_STUDIO_API_TOKEN"), API_TOKEN.to_hex_text().to_string()), - ]); - - info!("Secret password for the IPC channel was generated successfully."); - info!("Try to start the .NET server..."); - let server_spawn_clone = DOTNET_SERVER.clone(); - tauri::async_runtime::spawn(async move { - let api_port = *API_SERVER_PORT; - - let (mut rx, child) = match is_dev() { - true => { - // We are in the development environment, so we try to start a process - // with `dotnet run` in the `../app/MindWork AI Studio` directory. But - // we cannot issue a sidecar because we cannot use any command for the - // sidecar (see Tauri configuration). Thus, we use a standard Rust process: - warn!(Source = "Bootloader .NET"; "Development environment detected; start .NET server using 'dotnet run'."); - Command::new("dotnet") - - // Start the .NET server in the `../app/MindWork AI Studio` directory. - // We provide the runtime API server port to the .NET server: - .args(["run", "--project", "../app/MindWork AI Studio", "--", format!("{api_port}").as_str()]) - - .envs(dotnet_server_environment) - .spawn() - .expect("Failed to spawn .NET server process.") - } - - false => { - Command::new_sidecar("mindworkAIStudioServer") - .expect("Failed to create sidecar") - - // Provide the runtime API server port to the .NET server: - .args([format!("{api_port}").as_str()]) - - .envs(dotnet_server_environment) - .spawn() - .expect("Failed to spawn .NET server process.") - } - }; - - let server_pid = child.pid(); - info!(Source = "Bootloader .NET"; "The .NET server process started with PID={server_pid}."); - - // Save the server process to stop it later: - *server_spawn_clone.lock().unwrap() = Some(child); - - // Log the output of the .NET server: - while let Some(CommandEvent::Stdout(line)) = rx.recv().await { - - // Remove newline characters from the end: - let line = line.trim_end(); - - // Starts the line with '=>'? - if line.starts_with("=>") { - // Yes. This means that the line is a log message from the .NET server. - // The format is: ' [] : '. - // We try to parse this line and log it with the correct log level: - let line = line.trim_start_matches("=>").trim(); - let parts = line.split_once(": ").unwrap(); - let left_part = parts.0.trim(); - let message = parts.1.trim(); - let parts = left_part.split_once("] ").unwrap(); - let level = parts.0.split_once("[").unwrap().1.trim(); - let source = parts.1.trim(); - match level { - "Trace" => debug!(Source = ".NET Server", Comp = source; "{message}"), - "Debug" => debug!(Source = ".NET Server", Comp = source; "{message}"), - "Information" => info!(Source = ".NET Server", Comp = source; "{message}"), - "Warning" => warn!(Source = ".NET Server", Comp = source; "{message}"), - "Error" => error!(Source = ".NET Server", Comp = source; "{message}"), - "Critical" => error!(Source = ".NET Server", Comp = source; "{message}"), - - _ => error!(Source = ".NET Server", Comp = source; "{message} (unknown log level '{level}')"), - } - } else { - let lower_line = line.to_lowercase(); - if lower_line.contains("error") { - error!(Source = ".NET Server"; "{line}"); - } else if lower_line.contains("warning") { - warn!(Source = ".NET Server"; "{line}"); - } else { - info!(Source = ".NET Server"; "{line}"); - } - } - } - }); - - info!("Starting Tauri app..."); - let app = tauri::Builder::default() - .setup(move |app| { - let window = app.get_window("main").expect("Failed to get main window."); - *MAIN_WINDOW.lock().unwrap() = Some(window); - - info!(Source = "Bootloader Tauri"; "Setup is running."); - let logger_path = app.path_resolver().app_local_data_dir().unwrap(); - let logger_path = logger_path.join("data"); - - DATA_DIRECTORY.set(logger_path.to_str().unwrap().to_string()).map_err(|_| error!("Was not abe to set the data directory.")).unwrap(); - CONFIG_DIRECTORY.set(app.path_resolver().app_config_dir().unwrap().to_str().unwrap().to_string()).map_err(|_| error!("Was not able to set the config directory.")).unwrap(); - - info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {logger_path:?}"); - logger.reset_flw(&FileLogWriter::builder( - FileSpec::default() - .directory(logger_path) - .basename("events") - .suppress_timestamp() - .suffix("log")))?; - - Ok(()) - }) - .plugin(tauri_plugin_window_state::Builder::default().build()) - .build(tauri::generate_context!()) - .expect("Error while running Tauri application"); - - app.run(|app_handle, event| match event { - - tauri::RunEvent::WindowEvent { event, label, .. } => { - match event { - tauri::WindowEvent::CloseRequested { .. } => { - warn!(Source = "Tauri"; "Window '{label}': close was requested."); - } - - tauri::WindowEvent::Destroyed => { - warn!(Source = "Tauri"; "Window '{label}': was destroyed."); - } - - tauri::WindowEvent::FileDrop(files) => { - info!(Source = "Tauri"; "Window '{label}': files were dropped: {files:?}"); - } - - _ => (), - } - } - - tauri::RunEvent::Updater(updater_event) => { - match updater_event { - - tauri::UpdaterEvent::UpdateAvailable { body, date, version } => { - let body_len = body.len(); - info!(Source = "Tauri"; "Updater: update available: body size={body_len} time={date:?} version={version}"); - } - - tauri::UpdaterEvent::Pending => { - info!(Source = "Tauri"; "Updater: update is pending!"); - } - - tauri::UpdaterEvent::DownloadProgress { chunk_length, content_length } => { - info!(Source = "Tauri"; "Updater: downloaded {} of {:?}", chunk_length, content_length); - } - - tauri::UpdaterEvent::Downloaded => { - info!(Source = "Tauri"; "Updater: update has been downloaded!"); - warn!(Source = "Tauri"; "Try to stop the .NET server now..."); - stop_servers(); - } - - tauri::UpdaterEvent::Updated => { - info!(Source = "Tauri"; "Updater: app has been updated"); - warn!(Source = "Tauri"; "Try to restart the app now..."); - app_handle.restart(); - } - - tauri::UpdaterEvent::AlreadyUpToDate => { - info!(Source = "Tauri"; "Updater: app is already up to date"); - } - - tauri::UpdaterEvent::Error(error) => { - warn!(Source = "Tauri"; "Updater: failed to update: {error}"); - } - } - } - - tauri::RunEvent::ExitRequested { .. } => { - warn!(Source = "Tauri"; "Run event: exit was requested."); - } - - tauri::RunEvent::Ready => { - info!(Source = "Tauri"; "Run event: Tauri app is ready."); - } - - _ => {} - }); - - warn!(Source = "Tauri"; "Tauri app was stopped."); - if is_prod() { - warn!("Try to stop the .NET server as well..."); - stop_servers(); - } -} - -struct APIToken{ - hex_text: String, -} - -impl APIToken { - fn from_bytes(bytes: Vec) -> Self { - APIToken { - hex_text: bytes.iter().fold(String::new(), |mut result, byte| { - result.push_str(&format!("{:02x}", byte)); - result - }), - } - } - - fn from_hex_text(hex_text: &str) -> Self { - APIToken { - hex_text: hex_text.to_string(), - } - } - - fn to_hex_text(&self) -> &str { - self.hex_text.as_str() - } - - fn validate(&self, received_token: &Self) -> bool { - received_token.to_hex_text() == self.to_hex_text() - } -} - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for APIToken { - type Error = APITokenError; - - async fn from_request(request: &'r Request<'_>) -> RequestOutcome { - let token = request.headers().get_one("token"); - match token { - Some(token) => { - let received_token = APIToken::from_hex_text(token); - if API_TOKEN.validate(&received_token) { - RequestOutcome::Success(received_token) - } else { - RequestOutcome::Error((Status::Unauthorized, APITokenError::Invalid)) - } - } - - None => RequestOutcome::Error((Status::Unauthorized, APITokenError::Missing)), - } - } -} - -#[derive(Debug)] -enum APITokenError { - Missing, - Invalid, -} - -// -// Data structure for iterating over key-value pairs of log messages. -// -struct LogKVCollect<'kvs>(BTreeMap, Value<'kvs>>); - -impl<'kvs> VisitSource<'kvs> for LogKVCollect<'kvs> { - fn visit_pair(&mut self, key: Key<'kvs>, value: Value<'kvs>) -> Result<(), kv::Error> { - self.0.insert(key, value); - Ok(()) - } -} - -pub fn write_kv_pairs(w: &mut dyn std::io::Write, record: &log::Record) -> Result<(), std::io::Error> { - if record.key_values().count() > 0 { - let mut visitor = LogKVCollect(BTreeMap::new()); - record.key_values().visit(&mut visitor).unwrap(); - write!(w, "[")?; - let mut index = 0; - for (key, value) in visitor.0 { - index += 1; - if index > 1 { - write!(w, ", ")?; - } - - write!(w, "{} = {}", key, value)?; - } - write!(w, "] ")?; - } - - Ok(()) -} - -// Custom logger format for the terminal: -pub fn terminal_colored_logger_format( - w: &mut dyn std::io::Write, - now: &mut DeferredNow, - record: &log::Record, -) -> Result<(), std::io::Error> { - let level = record.level(); - - // Write the timestamp, log level, and module path: - write!( - w, - "[{}] {} [{}] ", - flexi_logger::style(level).paint(now.format(flexi_logger::TS_DASHES_BLANK_COLONS_DOT_BLANK).to_string()), - flexi_logger::style(level).paint(record.level().to_string()), - record.module_path().unwrap_or(""), - )?; - - // Write all key-value pairs: - write_kv_pairs(w, record)?; - - // Write the log message: - write!(w, "{}", flexi_logger::style(level).paint(record.args().to_string())) -} - -// Custom logger format for the log files: -pub fn file_logger_format( - w: &mut dyn std::io::Write, - now: &mut DeferredNow, - record: &log::Record, -) -> Result<(), std::io::Error> { - - // Write the timestamp, log level, and module path: - write!( - w, - "[{}] {} [{}] ", - now.format(flexi_logger::TS_DASHES_BLANK_COLONS_DOT_BLANK), - record.level(), - record.module_path().unwrap_or(""), - )?; - - // Write all key-value pairs: - write_kv_pairs(w, record)?; - - // Write the log message: - write!(w, "{}", &record.args()) -} - -pub struct Encryption { - key: [u8; 32], - iv: [u8; 16], - - secret_password: [u8; 512], - secret_key_salt: [u8; 16], -} - -impl Encryption { - // The number of iterations to derive the key and IV from the password. For a password - // manager where the user has to enter their primary password, 100 iterations would be - // too few and insecure. Here, the use case is different: We generate a 512-byte long - // and cryptographically secure password at every start. This password already contains - // enough entropy. In our case, we need key and IV primarily because AES, with the - // algorithms we chose, requires a fixed key length, and our password is too long. - const ITERATIONS: u32 = 100; - - pub fn new(secret_password: &[u8], secret_key_salt: &[u8]) -> Result { - if secret_password.len() != 512 { - return Err("The secret password must be 512 bytes long.".to_string()); - } - - if secret_key_salt.len() != 16 { - return Err("The salt must be 16 bytes long.".to_string()); - } - - info!(Source = "Encryption"; "Initializing encryption..."); - let mut encryption = Encryption { - key: [0u8; 32], - iv: [0u8; 16], - - secret_password: [0u8; 512], - secret_key_salt: [0u8; 16], - }; - - encryption.secret_password.copy_from_slice(secret_password); - encryption.secret_key_salt.copy_from_slice(secret_key_salt); - - let start = Instant::now(); - let mut key_iv = [0u8; 48]; - pbkdf2::>(secret_password, secret_key_salt, Self::ITERATIONS, &mut key_iv).map_err(|e| format!("Error while generating key and IV: {e}"))?; - encryption.key.copy_from_slice(&key_iv[0..32]); - encryption.iv.copy_from_slice(&key_iv[32..48]); - - let duration = start.elapsed(); - let duration = duration.as_millis(); - info!(Source = "Encryption"; "Encryption initialized in {duration} milliseconds.", ); - - Ok(encryption) - } - - pub fn encrypt(&self, data: &str) -> Result { - let cipher = Aes256CbcEnc::new(&self.key.into(), &self.iv.into()); - let encrypted = cipher.encrypt_padded_vec_mut::(data.as_bytes()); - let mut result = BASE64_STANDARD.encode(self.secret_key_salt); - result.push_str(&BASE64_STANDARD.encode(&encrypted)); - Ok(EncryptedText::new(result)) - } - - pub fn decrypt(&self, encrypted_data: &EncryptedText) -> Result { - let decoded = BASE64_STANDARD.decode(encrypted_data.get_encrypted()).map_err(|e| format!("Error decoding base64: {e}"))?; - - if decoded.len() < 16 { - return Err("Encrypted data is too short.".to_string()); - } - - let (salt, encrypted) = decoded.split_at(16); - if salt != self.secret_key_salt { - return Err("The salt bytes do not match. The data is corrupted or tampered.".to_string()); - } - - let cipher = Aes256CbcDec::new(&self.key.into(), &self.iv.into()); - let decrypted = cipher.decrypt_padded_vec_mut::(encrypted).map_err(|e| format!("Error decrypting data: {e}"))?; - - String::from_utf8(decrypted).map_err(|e| format!("Error converting decrypted data to string: {}", e)) - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct EncryptedText(String); - -impl EncryptedText { - pub fn new(encrypted_data: String) -> Self { - EncryptedText(encrypted_data) - } - - pub fn get_encrypted(&self) -> &str { - &self.0 - } -} - -impl fmt::Debug for EncryptedText { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "EncryptedText(**********)") - } -} - -impl fmt::Display for EncryptedText { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "**********") - } -} - -// Use Case: When we receive encrypted text from the client as body (e.g., in a POST request). -// We must interpret the body as EncryptedText. -#[rocket::async_trait] -impl<'r> data::FromData<'r> for EncryptedText { - type Error = String; - async fn from_data(req: &'r Request<'_>, data: Data<'r>) -> DataOutcome<'r, Self> { - let content_type = req.content_type(); - if content_type.map_or(true, |ct| !ct.is_text()) { - return DataOutcome::Forward((data, Status::Ok)); - } - - let mut stream = data.open(2.mebibytes()); - let mut body = String::new(); - if let Err(e) = stream.read_to_string(&mut body).await { - return DataOutcome::Error((Status::InternalServerError, format!("Failed to read data: {}", e))); - } - - DataOutcome::Success(EncryptedText(body)) - } -} - -#[get("/system/dotnet/port")] -fn dotnet_port(_token: APIToken) -> String { - let dotnet_server_port = *DOTNET_SERVER_PORT; - format!("{dotnet_server_port}") -} - -#[get("/system/directories/data")] -fn get_data_directory(_token: APIToken) -> String { - match DATA_DIRECTORY.get() { - Some(data_directory) => data_directory.clone(), - None => String::from(""), - } -} - -#[get("/system/directories/config")] -fn get_config_directory(_token: APIToken) -> String { - match CONFIG_DIRECTORY.get() { - Some(config_directory) => config_directory.clone(), - None => String::from(""), - } -} - -#[get("/system/dotnet/ready")] -async fn dotnet_ready(_token: APIToken) { - - // We create a manual scope for the lock to be released as soon as possible. - // This is necessary because we cannot await any function while the lock is - // held. - { - let mut initialized = DOTNET_INITIALIZED.lock().unwrap(); - if *initialized { - error!("Anyone tried to initialize the runtime twice. This is not intended."); - return; - } - - info!("The .NET server was booted successfully."); - *initialized = true; - } - - // Try to get the main window. If it is not available yet, wait for it: - let mut main_window_ready = false; - let mut main_window_status_reported = false; - let main_window_spawn_clone = &MAIN_WINDOW; - while !main_window_ready - { - main_window_ready = { - let main_window = main_window_spawn_clone.lock().unwrap(); - main_window.is_some() - }; - - if !main_window_ready { - if !main_window_status_reported { - info!("Waiting for main window to be ready, because .NET was faster than Tauri."); - main_window_status_reported = true; - } - - time::sleep(Duration::from_millis(100)).await; - } - } - - let main_window = main_window_spawn_clone.lock().unwrap(); - let dotnet_server_port = *DOTNET_SERVER_PORT; - let url = match Url::parse(format!("http://localhost:{dotnet_server_port}").as_str()) - { - Ok(url) => url, - Err(msg) => { - error!("Error while parsing URL for navigating to the app: {msg}"); - return; - } - }; - - let js_location_change = format!("window.location = '{url}';"); - let location_change_result = main_window.as_ref().unwrap().eval(js_location_change.as_str()); - match location_change_result { - Ok(_) => info!("The app location was changed to {url}."), - Err(e) => error!("Failed to change the app location to {url}: {e}."), - } -} - -pub fn is_dev() -> bool { - cfg!(debug_assertions) -} - -pub fn is_prod() -> bool { - !is_dev() -} - -fn get_available_port() -> Option { - TcpListener::bind(("127.0.0.1", 0)) - .map(|listener| listener.local_addr().unwrap().port()) - .ok() -} - -fn stop_servers() { - if let Some(server_process) = DOTNET_SERVER.lock().unwrap().take() { - let server_kill_result = server_process.kill(); - match server_kill_result { - Ok(_) => info!("The .NET server process was stopped."), - Err(e) => error!("Failed to stop the .NET server process: {e}."), - } - } else { - warn!("The .NET server process was not started or is already stopped."); - } -} - -#[get("/updates/check")] -async fn check_for_update(_token: APIToken) -> Json { - let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().unwrap().app_handle(); - let response = app_handle.updater().check().await; - match response { - Ok(update_response) => match update_response.is_update_available() { - true => { - *CHECK_UPDATE_RESPONSE.lock().unwrap() = Some(update_response.clone()); - let new_version = update_response.latest_version(); - info!(Source = "Updater"; "An update to version '{new_version}' is available."); - let changelog = update_response.body(); - Json(CheckUpdateResponse { - update_is_available: true, - error: false, - new_version: new_version.to_string(), - changelog: match changelog { - Some(c) => c.to_string(), - None => String::from(""), - }, - }) - }, - - false => { - info!(Source = "Updater"; "No updates are available."); - Json(CheckUpdateResponse { - update_is_available: false, - error: false, - new_version: String::from(""), - changelog: String::from(""), - }) - }, - }, - - Err(e) => { - warn!(Source = "Updater"; "Failed to check for updates: {e}."); - Json(CheckUpdateResponse { - update_is_available: false, - error: true, - new_version: String::from(""), - changelog: String::from(""), - }) - }, - } -} - -#[derive(Serialize)] -struct CheckUpdateResponse { - update_is_available: bool, - error: bool, - new_version: String, - changelog: String, -} - -#[get("/updates/install")] -async fn install_update(_token: APIToken) { - let cloned_response_option = CHECK_UPDATE_RESPONSE.lock().unwrap().clone(); - match cloned_response_option { - Some(update_response) => { - update_response.download_and_install().await.unwrap(); - }, - - None => { - error!(Source = "Updater"; "No update available to install. Did you check for updates first?"); - }, - } -} - -#[post("/secrets/store", data = "")] -fn store_secret(_token: APIToken, request: Json) -> Json { - let user_name = request.user_name.as_str(); - let decrypted_text = match ENCRYPTION.decrypt(&request.secret) { - Ok(text) => text, - Err(e) => { - error!(Source = "Secret Store"; "Failed to decrypt the text: {e}."); - return Json(StoreSecretResponse { - success: false, - issue: format!("Failed to decrypt the text: {e}"), - }) - }, - }; - - let service = format!("mindwork-ai-studio::{}", request.destination); - let entry = Entry::new(service.as_str(), user_name).unwrap(); - let result = entry.set_password(decrypted_text.as_str()); - match result { - Ok(_) => { - info!(Source = "Secret Store"; "Secret for {service} and user {user_name} was stored successfully."); - Json(StoreSecretResponse { - success: true, - issue: String::from(""), - }) - }, - - Err(e) => { - error!(Source = "Secret Store"; "Failed to store secret for {service} and user {user_name}: {e}."); - Json(StoreSecretResponse { - success: false, - issue: e.to_string(), - }) - }, - } -} - -#[derive(Deserialize)] -struct StoreSecret { - destination: String, - user_name: String, - secret: EncryptedText, -} - -#[derive(Serialize)] -struct StoreSecretResponse { - success: bool, - issue: String, -} - -#[post("/secrets/get", data = "")] -fn get_secret(_token: APIToken, request: Json) -> Json { - let user_name = request.user_name.as_str(); - let service = format!("mindwork-ai-studio::{}", request.destination); - let entry = Entry::new(service.as_str(), user_name).unwrap(); - let secret = entry.get_password(); - match secret { - Ok(s) => { - info!(Source = "Secret Store"; "Secret for '{service}' and user '{user_name}' was retrieved successfully."); - - // Encrypt the secret: - let encrypted_secret = match ENCRYPTION.encrypt(s.as_str()) { - Ok(e) => e, - Err(e) => { - error!(Source = "Secret Store"; "Failed to encrypt the secret: {e}."); - return Json(RequestedSecret { - success: false, - secret: EncryptedText::new(String::from("")), - issue: format!("Failed to encrypt the secret: {e}"), - }); - }, - }; - - Json(RequestedSecret { - success: true, - secret: encrypted_secret, - issue: String::from(""), - }) - }, - - Err(e) => { - if !request.is_trying { - error!(Source = "Secret Store"; "Failed to retrieve secret for '{service}' and user '{user_name}': {e}."); - } - - Json(RequestedSecret { - success: false, - secret: EncryptedText::new(String::from("")), - issue: format!("Failed to retrieve secret for '{service}' and user '{user_name}': {e}"), - }) - }, - } -} - -#[derive(Deserialize)] -struct RequestSecret { - destination: String, - user_name: String, - is_trying: bool, -} - -#[derive(Serialize)] -struct RequestedSecret { - success: bool, - secret: EncryptedText, - issue: String, -} - -#[post("/secrets/delete", data = "")] -fn delete_secret(_token: APIToken, request: Json) -> Json { - let user_name = request.user_name.as_str(); - let service = format!("mindwork-ai-studio::{}", request.destination); - let entry = Entry::new(service.as_str(), user_name).unwrap(); - let result = entry.delete_credential(); - - match result { - Ok(_) => { - warn!(Source = "Secret Store"; "Secret for {service} and user {user_name} was deleted successfully."); - Json(DeleteSecretResponse { - success: true, - was_entry_found: true, - issue: String::from(""), - }) - }, - - Err(NoEntry) => { - warn!(Source = "Secret Store"; "No secret for {service} and user {user_name} was found."); - Json(DeleteSecretResponse { - success: true, - was_entry_found: false, - issue: String::from(""), - }) - } - - Err(e) => { - error!(Source = "Secret Store"; "Failed to delete secret for {service} and user {user_name}: {e}."); - Json(DeleteSecretResponse { - success: false, - was_entry_found: false, - issue: e.to_string(), - }) - }, - } -} - -#[derive(Serialize)] -struct DeleteSecretResponse { - success: bool, - was_entry_found: bool, - issue: String, -} - -#[post("/clipboard/set", data = "")] -fn set_clipboard(_token: APIToken, encrypted_text: EncryptedText) -> Json { - - // Decrypt this text first: - let decrypted_text = match ENCRYPTION.decrypt(&encrypted_text) { - Ok(text) => text, - Err(e) => { - error!(Source = "Clipboard"; "Failed to decrypt the text: {e}."); - return Json(SetClipboardResponse { - success: false, - issue: e, - }) - }, - }; - - let clipboard_result = Clipboard::new(); - let mut clipboard = match clipboard_result { - Ok(clipboard) => clipboard, - Err(e) => { - error!(Source = "Clipboard"; "Failed to get the clipboard instance: {e}."); - return Json(SetClipboardResponse { - success: false, - issue: e.to_string(), - }) - }, - }; - - let set_text_result = clipboard.set_text(decrypted_text); - match set_text_result { - Ok(_) => { - debug!(Source = "Clipboard"; "Text was set to the clipboard successfully."); - Json(SetClipboardResponse { - success: true, - issue: String::from(""), - }) - }, - - Err(e) => { - error!(Source = "Clipboard"; "Failed to set text to the clipboard: {e}."); - Json(SetClipboardResponse { - success: false, - issue: e.to_string(), - }) - }, - } -} - -#[derive(Serialize)] -struct SetClipboardResponse { - success: bool, - issue: String, + generate_certificate(); + start_runtime_api(); + start_dotnet_server(); + start_tauri(); } \ No newline at end of file diff --git a/runtime/src/network.rs b/runtime/src/network.rs new file mode 100644 index 00000000..0e148a1d --- /dev/null +++ b/runtime/src/network.rs @@ -0,0 +1,8 @@ +use std::net::TcpListener; + +/// Returns an available port on the local machine. +pub fn get_available_port() -> Option { + TcpListener::bind(("127.0.0.1", 0)) + .map(|listener| listener.local_addr().unwrap().port()) + .ok() +} \ No newline at end of file diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs new file mode 100644 index 00000000..4c12871d --- /dev/null +++ b/runtime/src/runtime_api.rs @@ -0,0 +1,96 @@ +use std::collections::HashSet; +use log::info; +use once_cell::sync::Lazy; +use rocket::config::Shutdown; +use rocket::figment::Figment; +use rocket::routes; +use crate::certificate::{CERTIFICATE, CERTIFICATE_PRIVATE_KEY}; +use crate::environment::is_dev; +use crate::network::get_available_port; + +/// The port used for the runtime API server. In the development environment, we use a fixed +/// port, in the production environment we use the next available port. This differentiation +/// is necessary because we cannot communicate the port to the .NET server in the development +/// environment. +pub static API_SERVER_PORT: Lazy = Lazy::new(|| { + if is_dev() { + 5000 + } else { + get_available_port().unwrap() + } +}); + +/// Starts the runtime API server. The server is used to communicate with the .NET server and +/// to provide additional functionality to the Tauri app. +pub fn start_runtime_api() { + let api_port = *API_SERVER_PORT; + info!("Try to start the API server on 'http://localhost:{api_port}'..."); + + // The shutdown configuration for the runtime API server: + let mut shutdown = Shutdown { + // We do not want to use the Ctrl+C signal to stop the server: + ctrlc: false, + + // Everything else is set to default for now: + ..Shutdown::default() + }; + + #[cfg(unix)] + { + // We do not want to use the termination signal to stop the server. + // This option, however, is only available on Unix systems: + shutdown.signals = HashSet::new(); + } + + // Configure the runtime API server: + let figment = Figment::from(rocket::Config::release_default()) + + // We use the next available port which was determined before: + .merge(("port", api_port)) + + // The runtime API server should be accessible only from the local machine: + .merge(("address", "127.0.0.1")) + + // We do not want to use the Ctrl+C signal to stop the server: + .merge(("ctrlc", false)) + + // Set a name for the server: + .merge(("ident", "AI Studio Runtime API")) + + // Set the maximum number of workers and blocking threads: + .merge(("workers", 3)) + .merge(("max_blocking", 12)) + + // No colors and emojis in the log output: + .merge(("cli_colors", false)) + + // Read the TLS certificate and key from the generated certificate data in-memory: + .merge(("tls.certs", CERTIFICATE.get().unwrap())) + .merge(("tls.key", CERTIFICATE_PRIVATE_KEY.get().unwrap())) + + // Set the shutdown configuration: + .merge(("shutdown", shutdown)); + + // + // Start the runtime API server in a separate thread. This is necessary + // because the server is blocking, and we need to run the Tauri app in + // parallel: + // + tauri::async_runtime::spawn(async move { + rocket::custom(figment) + .mount("/", routes![ + crate::dotnet::dotnet_port, + crate::dotnet::dotnet_ready, + crate::clipboard::set_clipboard, + crate::app_window::check_for_update, + crate::app_window::install_update, + crate::secret::get_secret, + crate::secret::store_secret, + crate::secret::delete_secret, + crate::environment::get_data_directory, + crate::environment::get_config_directory, + ]) + .ignite().await.unwrap() + .launch().await.unwrap(); + }); +} \ No newline at end of file diff --git a/runtime/src/secret.rs b/runtime/src/secret.rs new file mode 100644 index 00000000..5ae07c8b --- /dev/null +++ b/runtime/src/secret.rs @@ -0,0 +1,167 @@ +use keyring::Entry; +use log::{error, info, warn}; +use rocket::post; +use rocket::serde::json::Json; +use serde::{Deserialize, Serialize}; +use keyring::error::Error::NoEntry; +use crate::api_token::APIToken; +use crate::encryption::{EncryptedText, ENCRYPTION}; + +/// Stores a secret in the secret store using the operating system's keyring. +#[post("/secrets/store", data = "")] +pub fn store_secret(_token: APIToken, request: Json) -> Json { + let user_name = request.user_name.as_str(); + let decrypted_text = match ENCRYPTION.decrypt(&request.secret) { + Ok(text) => text, + Err(e) => { + error!(Source = "Secret Store"; "Failed to decrypt the text: {e}."); + return Json(StoreSecretResponse { + success: false, + issue: format!("Failed to decrypt the text: {e}"), + }) + }, + }; + + let service = format!("mindwork-ai-studio::{}", request.destination); + let entry = Entry::new(service.as_str(), user_name).unwrap(); + let result = entry.set_password(decrypted_text.as_str()); + match result { + Ok(_) => { + info!(Source = "Secret Store"; "Secret for {service} and user {user_name} was stored successfully."); + Json(StoreSecretResponse { + success: true, + issue: String::from(""), + }) + }, + + Err(e) => { + error!(Source = "Secret Store"; "Failed to store secret for {service} and user {user_name}: {e}."); + Json(StoreSecretResponse { + success: false, + issue: e.to_string(), + }) + }, + } +} + +/// The structure of the request to store a secret. +#[derive(Deserialize)] +pub struct StoreSecret { + destination: String, + user_name: String, + secret: EncryptedText, +} + +/// The structure of the response to storing a secret. +#[derive(Serialize)] +pub struct StoreSecretResponse { + success: bool, + issue: String, +} + +/// Retrieves a secret from the secret store using the operating system's keyring. +#[post("/secrets/get", data = "")] +pub fn get_secret(_token: APIToken, request: Json) -> Json { + let user_name = request.user_name.as_str(); + let service = format!("mindwork-ai-studio::{}", request.destination); + let entry = Entry::new(service.as_str(), user_name).unwrap(); + let secret = entry.get_password(); + match secret { + Ok(s) => { + info!(Source = "Secret Store"; "Secret for '{service}' and user '{user_name}' was retrieved successfully."); + + // Encrypt the secret: + let encrypted_secret = match ENCRYPTION.encrypt(s.as_str()) { + Ok(e) => e, + Err(e) => { + error!(Source = "Secret Store"; "Failed to encrypt the secret: {e}."); + return Json(RequestedSecret { + success: false, + secret: EncryptedText::new(String::from("")), + issue: format!("Failed to encrypt the secret: {e}"), + }); + }, + }; + + Json(RequestedSecret { + success: true, + secret: encrypted_secret, + issue: String::from(""), + }) + }, + + Err(e) => { + if !request.is_trying { + error!(Source = "Secret Store"; "Failed to retrieve secret for '{service}' and user '{user_name}': {e}."); + } + + Json(RequestedSecret { + success: false, + secret: EncryptedText::new(String::from("")), + issue: format!("Failed to retrieve secret for '{service}' and user '{user_name}': {e}"), + }) + }, + } +} + +/// The structure of the request to retrieve a secret. +#[derive(Deserialize)] +pub struct RequestSecret { + destination: String, + user_name: String, + is_trying: bool, +} + +/// The structure of the response to retrieving a secret. +#[derive(Serialize)] +pub struct RequestedSecret { + success: bool, + secret: EncryptedText, + issue: String, +} + +/// Deletes a secret from the secret store using the operating system's keyring. +#[post("/secrets/delete", data = "")] +pub fn delete_secret(_token: APIToken, request: Json) -> Json { + let user_name = request.user_name.as_str(); + let service = format!("mindwork-ai-studio::{}", request.destination); + let entry = Entry::new(service.as_str(), user_name).unwrap(); + let result = entry.delete_credential(); + + match result { + Ok(_) => { + warn!(Source = "Secret Store"; "Secret for {service} and user {user_name} was deleted successfully."); + Json(DeleteSecretResponse { + success: true, + was_entry_found: true, + issue: String::from(""), + }) + }, + + Err(NoEntry) => { + warn!(Source = "Secret Store"; "No secret for {service} and user {user_name} was found."); + Json(DeleteSecretResponse { + success: true, + was_entry_found: false, + issue: String::from(""), + }) + } + + Err(e) => { + error!(Source = "Secret Store"; "Failed to delete secret for {service} and user {user_name}: {e}."); + Json(DeleteSecretResponse { + success: false, + was_entry_found: false, + issue: e.to_string(), + }) + }, + } +} + +/// The structure of the response to deleting a secret. +#[derive(Serialize)] +pub struct DeleteSecretResponse { + success: bool, + was_entry_found: bool, + issue: String, +} \ No newline at end of file