Add config controller in server

This commit is contained in:
2025-09-09 19:06:03 +02:00
parent 7a7a909440
commit 88e5f3d694
7 changed files with 285 additions and 3 deletions

1
server/Cargo.lock generated
View File

@@ -1363,6 +1363,7 @@ dependencies = [
"axum",
"bcrypt",
"chrono",
"rand",
"serde",
"serde_json",
"sqlx",

View File

@@ -14,3 +14,4 @@ uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tower-http = { version = "0.6.6", features = ["cors", "fs"] }
anyhow = "1.0"
rand = "0.8"

113
server/src/routes/config.rs Normal file
View File

@@ -0,0 +1,113 @@
use crate::utils::{auth::*, config::ConfigManager, error::*, DbPool};
use axum::{extract::State, response::Json};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct ConfigRequest {
pub key: String,
pub value: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ConfigResponse {
pub key: String,
pub value: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ConfigDefinition {
pub key: String,
pub description: String,
pub value: Option<String>,
pub default_value: Option<String>,
pub required: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ConfigListResponse {
pub configs: Vec<ConfigDefinition>,
}
pub async fn get_all_configs(
auth_user: AuthUser,
State(pool): State<DbPool>,
) -> Result<Json<ConfigListResponse>, AppError> {
if auth_user.user.role != crate::utils::models::UserRole::Admin {
return Err(forbidden_error("Admin access required"));
}
let allowed_configs = vec![
ConfigDefinition {
key: "EXTERNAL_URL".to_string(),
description: "The external URL used for provisioning codes. This should be the public URL where this server can be reached.".to_string(),
value: ConfigManager::get_config(&pool, "EXTERNAL_URL").await?,
default_value: Some("https://your-domain.com".to_string()),
required: true,
},
ConfigDefinition {
key: "SESSION_TIMEOUT_HOURS".to_string(),
description: "Number of hours before user sessions expire and require re-authentication.".to_string(),
value: ConfigManager::get_config(&pool, "SESSION_TIMEOUT_HOURS").await?,
default_value: Some("24".to_string()),
required: false,
},
];
Ok(success_response(ConfigListResponse {
configs: allowed_configs,
}))
}
pub async fn set_config(
auth_user: AuthUser,
State(pool): State<DbPool>,
Json(request): Json<ConfigRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
if auth_user.user.role != crate::utils::models::UserRole::Admin {
return Err(forbidden_error("Admin access required"));
}
let allowed_keys = vec!["EXTERNAL_URL", "SESSION_TIMEOUT_HOURS"];
if !allowed_keys.contains(&request.key.as_str()) {
return Err(validation_error("Invalid configuration key"));
}
match request.key.as_str() {
"EXTERNAL_URL" => {
if request.value.trim().is_empty() {
return Err(validation_error("External URL cannot be empty"));
}
if !request.value.starts_with("http://") && !request.value.starts_with("https://") {
return Err(validation_error(
"External URL must start with http:// or https://",
));
}
}
"SESSION_TIMEOUT_HOURS" => {
if request.value.parse::<i32>().is_err() || request.value.parse::<i32>().unwrap() <= 0 {
return Err(validation_error("Value must be a positive number"));
}
}
_ => {}
}
ConfigManager::set_config(&pool, &request.key, &request.value).await?;
Ok(success_message("Configuration updated successfully"))
}
pub async fn get_config(
auth_user: AuthUser,
State(pool): State<DbPool>,
axum::extract::Path(key): axum::extract::Path<String>,
) -> Result<Json<ConfigResponse>, AppError> {
if auth_user.user.role != crate::utils::models::UserRole::Admin {
return Err(forbidden_error("Admin access required"));
}
let value = ConfigManager::get_config(&pool, &key)
.await?
.ok_or_else(|| not_found_error("Configuration key not found"))?;
Ok(success_response(ConfigResponse { key, value }))
}

108
server/src/utils/base62.rs Normal file
View File

@@ -0,0 +1,108 @@
const CHARS: &str = "rYTSJ96O2ntiEBkuwQq0vdslyfI8Ph51bpae3LgHoFZAxj7WmzUNCGXcR4MDKV";
pub struct Base62;
impl Base62 {
pub fn encode(input: &str) -> String {
if input.is_empty() {
return String::new();
}
let bytes = input.as_bytes();
let alphabet_chars: Vec<char> = CHARS.chars().collect();
let mut number = bytes.iter().fold(String::from("0"), |acc, &byte| {
Self::multiply_and_add(&acc, 256, byte as u32)
});
if number == "0" {
return "0".to_string();
}
let mut result = String::new();
while number != "0" {
let (new_number, remainder) = Self::divide_by(&number, 62);
result.push(alphabet_chars[remainder as usize]);
number = new_number;
}
result.chars().rev().collect()
}
pub fn decode(encoded: &str) -> Option<String> {
if encoded.is_empty() {
return Some(String::new());
}
let char_to_value: std::collections::HashMap<char, u32> = CHARS
.chars()
.enumerate()
.map(|(i, c)| (c, i as u32))
.collect();
let mut number = String::from("0");
for c in encoded.chars() {
let value = *char_to_value.get(&c)?;
number = Self::multiply_and_add(&number, 62, value);
}
if number == "0" {
return Some(String::new());
}
let mut bytes = Vec::new();
while number != "0" {
let (new_number, remainder) = Self::divide_by(&number, 256);
bytes.push(remainder as u8);
number = new_number;
}
bytes.reverse();
String::from_utf8(bytes).ok()
}
fn multiply_and_add(num_str: &str, base: u32, add: u32) -> String {
let mut result = Vec::new();
let mut carry = add;
for c in num_str.chars().rev() {
let digit = c.to_digit(10).unwrap_or(0);
let product = digit * base + carry;
result.push((product % 10).to_string());
carry = product / 10;
}
while carry > 0 {
result.push((carry % 10).to_string());
carry /= 10;
}
if result.is_empty() {
"0".to_string()
} else {
result.into_iter().rev().collect()
}
}
fn divide_by(num_str: &str, base: u32) -> (String, u32) {
let mut quotient = String::new();
let mut remainder = 0u32;
for c in num_str.chars() {
let digit = c.to_digit(10).unwrap_or(0);
let current = remainder * 10 + digit;
let q = current / base;
remainder = current % base;
if !quotient.is_empty() || q > 0 {
quotient.push_str(&q.to_string());
}
}
if quotient.is_empty() {
quotient = "0".to_string();
}
(quotient, remainder)
}
}

View File

@@ -0,0 +1,44 @@
use crate::utils::{error::*, DbPool};
use sqlx::Row;
pub struct ConfigManager;
impl ConfigManager {
pub async fn get_config(pool: &DbPool, key: &str) -> AppResult<Option<String>> {
let row = sqlx::query("SELECT value FROM config WHERE key = ?")
.bind(key)
.fetch_optional(pool)
.await?;
if let Some(row) = row {
Ok(Some(row.get("value")))
} else {
Ok(None)
}
}
pub async fn set_config(pool: &DbPool, key: &str, value: &str) -> AppResult<()> {
sqlx::query(
r#"
INSERT INTO config (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP
"#,
)
.bind(key)
.bind(value)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_external_url(pool: &DbPool) -> AppResult<String> {
match Self::get_config(pool, "EXTERNAL_URL").await? {
Some(url) => Ok(url),
None => Err(internal_error("EXTERNAL_URL not configured")),
}
}
}

View File

@@ -72,12 +72,12 @@ async fn run_migrations(pool: &DbPool) -> AppResult<()> {
r#"
CREATE TABLE IF NOT EXISTS provisioning_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
machine_id INTEGER NOT NULL,
code TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
used BOOLEAN DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
FOREIGN KEY(machine_id) REFERENCES machines(id) ON DELETE CASCADE
)
"#,
)
@@ -98,6 +98,19 @@ async fn run_migrations(pool: &DbPool) -> AppResult<()> {
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"#,
)
.execute(pool)
.await?;
Ok(())
}

View File

@@ -1,4 +1,6 @@
pub mod auth;
pub mod base62;
pub mod config;
pub mod database;
pub mod db_path;
pub mod error;