mirror of
				https://github.com/MindWorkAI/AI-Studio.git
				synced 2025-10-31 08:40:21 +00:00 
			
		
		
		
	Refactored encryption-related code
This commit is contained in:
		
							parent
							
								
									f1104c5e09
								
							
						
					
					
						commit
						74522dc22a
					
				
							
								
								
									
										165
									
								
								runtime/src/encryption.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								runtime/src/encryption.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,165 @@ | |||||||
|  | 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<aes::Aes256>; | ||||||
|  | 
 | ||||||
|  | type Aes256CbcDec = cbc::Decryptor<aes::Aes256>; | ||||||
|  | 
 | ||||||
|  | type DataOutcome<'r, T> = data::Outcome<'r, T>; | ||||||
|  | 
 | ||||||
|  | pub static ENCRYPTION: Lazy<Encryption> = 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() | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | 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; | ||||||
|  | 
 | ||||||
|  |     pub fn new(secret_password: &[u8], secret_key_salt: &[u8]) -> Result<Self, String> { | ||||||
|  |         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::<Hmac<Sha512>>(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<EncryptedText, String> { | ||||||
|  |         let cipher = Aes256CbcEnc::new(&self.key.into(), &self.iv.into()); | ||||||
|  |         let encrypted = cipher.encrypt_padded_vec_mut::<Pkcs7>(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<String, String> { | ||||||
|  |         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::<Pkcs7>(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)) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										1
									
								
								runtime/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								runtime/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | |||||||
|  | pub mod encryption; | ||||||
| @ -5,16 +5,14 @@ extern crate rocket; | |||||||
| extern crate core; | extern crate core; | ||||||
| 
 | 
 | ||||||
| use std::collections::{BTreeMap, HashMap, HashSet}; | use std::collections::{BTreeMap, HashMap, HashSet}; | ||||||
| use std::fmt; |  | ||||||
| use std::net::TcpListener; | use std::net::TcpListener; | ||||||
| use std::sync::{Arc, Mutex, OnceLock}; | use std::sync::{Arc, Mutex, OnceLock}; | ||||||
| use std::time::{Duration, Instant}; | use std::time::Duration; | ||||||
| use once_cell::sync::Lazy; | use once_cell::sync::Lazy; | ||||||
| 
 | 
 | ||||||
| use arboard::Clipboard; | use arboard::Clipboard; | ||||||
| use base64::Engine; | use base64::Engine; | ||||||
| use base64::prelude::BASE64_STANDARD; | use base64::prelude::BASE64_STANDARD; | ||||||
| use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; |  | ||||||
| use keyring::Entry; | use keyring::Entry; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use tauri::{Manager, Url, Window}; | use tauri::{Manager, Url, Window}; | ||||||
| @ -22,29 +20,21 @@ use tauri::api::process::{Command, CommandChild, CommandEvent}; | |||||||
| use tokio::time; | use tokio::time; | ||||||
| use flexi_logger::{DeferredNow, Duplicate, FileSpec, Logger}; | use flexi_logger::{DeferredNow, Duplicate, FileSpec, Logger}; | ||||||
| use flexi_logger::writers::FileLogWriter; | use flexi_logger::writers::FileLogWriter; | ||||||
| use hmac::Hmac; |  | ||||||
| use keyring::error::Error::NoEntry; | use keyring::error::Error::NoEntry; | ||||||
| use log::{debug, error, info, kv, warn}; | use log::{debug, error, info, kv, warn}; | ||||||
| use log::kv::{Key, Value, VisitSource}; | use log::kv::{Key, Value, VisitSource}; | ||||||
| use pbkdf2::pbkdf2; |  | ||||||
| use rand::{RngCore, SeedableRng}; | use rand::{RngCore, SeedableRng}; | ||||||
| use rcgen::generate_simple_self_signed; | use rcgen::generate_simple_self_signed; | ||||||
| use rocket::figment::Figment; | use rocket::figment::Figment; | ||||||
| use rocket::{data, get, post, routes, Data, Request}; | use rocket::{get, post, routes, Request}; | ||||||
| use rocket::config::{Shutdown}; | use rocket::config::{Shutdown}; | ||||||
| use rocket::data::{ToByteUnit}; |  | ||||||
| use rocket::http::Status; | use rocket::http::Status; | ||||||
| use rocket::request::{FromRequest}; | use rocket::request::{FromRequest}; | ||||||
| use rocket::serde::json::Json; | use rocket::serde::json::Json; | ||||||
| use sha2::{Sha256, Sha512, Digest}; | use sha2::{Sha256, Digest}; | ||||||
| use tauri::updater::UpdateResponse; | use tauri::updater::UpdateResponse; | ||||||
| use tokio::io::AsyncReadExt; |  | ||||||
| 
 | 
 | ||||||
| type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>; | use mindwork_ai_studio::encryption::{EncryptedText, ENCRYPTION}; | ||||||
| 
 |  | ||||||
| type Aes256CbcDec = cbc::Decryptor<aes::Aes256>; |  | ||||||
| 
 |  | ||||||
| type DataOutcome<'r, T> = data::Outcome<'r, T>; |  | ||||||
| 
 | 
 | ||||||
| type RequestOutcome<R, T> = rocket::request::Outcome<R, T>; | type RequestOutcome<R, T> = rocket::request::Outcome<R, T>; | ||||||
| 
 | 
 | ||||||
| @ -75,25 +65,6 @@ static MAIN_WINDOW: Lazy<Mutex<Option<Window>>> = Lazy::new(|| Mutex::new(None)) | |||||||
| // The update response coming from the Tauri updater.
 | // The update response coming from the Tauri updater.
 | ||||||
| static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<UpdateResponse<tauri::Wry>>>> = Lazy::new(|| Mutex::new(None)); | static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<UpdateResponse<tauri::Wry>>>> = Lazy::new(|| Mutex::new(None)); | ||||||
| 
 | 
 | ||||||
| static ENCRYPTION: Lazy<Encryption> = 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<APIToken> = Lazy::new(|| { | static API_TOKEN: Lazy<APIToken> = Lazy::new(|| { | ||||||
|     let mut token = [0u8; 32]; |     let mut token = [0u8; 32]; | ||||||
|     let mut rng = rand_chacha::ChaChaRng::from_entropy(); |     let mut rng = rand_chacha::ChaChaRng::from_entropy(); | ||||||
| @ -590,130 +561,6 @@ pub fn file_logger_format( | |||||||
|     write!(w, "{}", &record.args()) |     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<Self, String> { |  | ||||||
|         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::<Hmac<Sha512>>(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<EncryptedText, String> { |  | ||||||
|         let cipher = Aes256CbcEnc::new(&self.key.into(), &self.iv.into()); |  | ||||||
|         let encrypted = cipher.encrypt_padded_vec_mut::<Pkcs7>(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<String, String> { |  | ||||||
|         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::<Pkcs7>(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")] | #[get("/system/dotnet/port")] | ||||||
| fn dotnet_port(_token: APIToken) -> String { | fn dotnet_port(_token: APIToken) -> String { | ||||||
|     let dotnet_server_port = *DOTNET_SERVER_PORT; |     let dotnet_server_port = *DOTNET_SERVER_PORT; | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user