Compare commits

...

8 Commits

19 changed files with 3639 additions and 4 deletions

4
.gitignore vendored
View File

@@ -11,4 +11,6 @@ target/
*.pdb
.idea/
.vscode
.vscode
server/data

2375
server/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,16 @@
[package]
name = "server"
version = "0.1.0"
edition = "2024"
edition = "2021"
[dependencies]
axum = "0.8.4"
tokio = { version = "1.47.1", features = ["full"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
bcrypt = "0.17.1"
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tower-http = { version = "0.6.6", features = ["cors"] }
anyhow = "1.0"

View File

@@ -0,0 +1,106 @@
use crate::controllers::users::UsersController;
use crate::utils::{error::*, models::*, DbPool};
use chrono::{Duration, Utc};
use sqlx::Row;
use uuid::Uuid;
pub struct AuthController;
impl AuthController {
pub async fn login(pool: &DbPool, username: &str, password: &str) -> AppResult<LoginResponse> {
let user = UsersController::verify_user_credentials(pool, username, password).await?;
let session = Self::create_session(pool, user.id).await?;
Ok(LoginResponse {
token: session.token,
role: user.role,
})
}
pub async fn logout(pool: &DbPool, user_id: i64) -> AppResult<()> {
sqlx::query("DELETE FROM sessions WHERE user_id = ?")
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn create_session(pool: &DbPool, user_id: i64) -> AppResult<Session> {
let token = Self::generate_session_token();
let expires_at = Utc::now() + Duration::days(30);
let result = sqlx::query(
r#"
INSERT INTO sessions (user_id, token, expires_at)
VALUES (?, ?, ?)
"#,
)
.bind(user_id)
.bind(&token)
.bind(expires_at)
.execute(pool)
.await?;
Ok(Session {
id: result.last_insert_rowid(),
user_id,
token,
created_at: Utc::now(),
expires_at,
})
}
pub async fn get_session_by_token(pool: &DbPool, token: &str) -> AppResult<Option<Session>> {
let row = sqlx::query(
r#"
SELECT id, user_id, token, created_at, expires_at
FROM sessions WHERE token = ? AND expires_at > datetime('now')
"#,
)
.bind(token)
.fetch_optional(pool)
.await?;
if let Some(row) = row {
Ok(Some(Session {
id: row.get("id"),
user_id: row.get("user_id"),
token: row.get("token"),
created_at: row.get("created_at"),
expires_at: row.get("expires_at"),
}))
} else {
Ok(None)
}
}
pub async fn authenticate_user(pool: &DbPool, token: &str) -> AppResult<User> {
let session = Self::get_session_by_token(pool, token)
.await?
.ok_or_else(|| auth_error("Invalid or expired session"))?;
let user = UsersController::get_user_by_id(pool, session.user_id).await?;
Ok(user)
}
pub async fn cleanup_expired_sessions(pool: &DbPool) -> AppResult<()> {
sqlx::query("DELETE FROM sessions WHERE expires_at <= datetime('now')")
.execute(pool)
.await?;
Ok(())
}
fn generate_session_token() -> String {
Uuid::new_v4().to_string()
}
#[allow(dead_code)]
pub async fn delete_session_by_token(pool: &DbPool, token: &str) -> AppResult<()> {
sqlx::query("DELETE FROM sessions WHERE token = ?")
.bind(token)
.execute(pool)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,208 @@
use crate::utils::{error::*, models::*, DbPool};
use chrono::Utc;
use sqlx::Row;
use uuid::Uuid;
pub struct MachinesController;
impl MachinesController {
pub async fn register_machine(
pool: &DbPool,
code: &str,
uuid: &Uuid,
name: &str,
) -> AppResult<Machine> {
Self::validate_machine_input(name)?;
let provisioning_code = Self::get_provisioning_code(pool, code)
.await?
.ok_or_else(|| validation_error("Invalid provisioning code"))?;
if provisioning_code.used {
return Err(validation_error("Provisioning code already used"));
}
if provisioning_code.expires_at < Utc::now() {
return Err(validation_error("Provisioning code expired"));
}
if Self::machine_exists_by_uuid(pool, uuid).await? {
return Err(conflict_error("Machine with this UUID already exists"));
}
let machine = Self::create_machine(pool, provisioning_code.user_id, uuid, name).await?;
Self::mark_provisioning_code_used(pool, code).await?;
Ok(machine)
}
pub async fn get_machines_for_user(pool: &DbPool, user: &User) -> AppResult<Vec<Machine>> {
if user.role == UserRole::Admin {
Self::get_all_machines(pool).await
} else {
Self::get_machines_by_user_id(pool, user.id).await
}
}
pub async fn delete_machine(pool: &DbPool, machine_id: i64, user: &User) -> AppResult<()> {
let machine = Self::get_machine_by_id(pool, machine_id).await?;
if user.role != UserRole::Admin && machine.user_id != user.id {
return Err(forbidden_error("Access denied"));
}
Self::delete_machine_by_id(pool, machine_id).await
}
pub async fn get_machine_by_id(pool: &DbPool, id: i64) -> AppResult<Machine> {
let row = sqlx::query(
r#"
SELECT id, user_id, uuid, name, created_at
FROM machines WHERE id = ?
"#,
)
.bind(id)
.fetch_one(pool)
.await?;
Ok(Machine {
id: row.get("id"),
user_id: row.get("user_id"),
uuid: Uuid::parse_str(&row.get::<String, _>("uuid")).unwrap(),
name: row.get("name"),
created_at: row.get("created_at"),
})
}
async fn get_all_machines(pool: &DbPool) -> AppResult<Vec<Machine>> {
let rows = sqlx::query(
r#"
SELECT id, user_id, uuid, name, created_at
FROM machines ORDER BY created_at DESC
"#,
)
.fetch_all(pool)
.await?;
let mut machines = Vec::new();
for row in rows {
machines.push(Machine {
id: row.get("id"),
user_id: row.get("user_id"),
uuid: Uuid::parse_str(&row.get::<String, _>("uuid")).unwrap(),
name: row.get("name"),
created_at: row.get("created_at"),
});
}
Ok(machines)
}
async fn get_machines_by_user_id(pool: &DbPool, user_id: i64) -> AppResult<Vec<Machine>> {
let rows = sqlx::query(
r#"
SELECT id, user_id, uuid, name, created_at
FROM machines WHERE user_id = ? ORDER BY created_at DESC
"#,
)
.bind(user_id)
.fetch_all(pool)
.await?;
let mut machines = Vec::new();
for row in rows {
machines.push(Machine {
id: row.get("id"),
user_id: row.get("user_id"),
uuid: Uuid::parse_str(&row.get::<String, _>("uuid")).unwrap(),
name: row.get("name"),
created_at: row.get("created_at"),
});
}
Ok(machines)
}
async fn create_machine(
pool: &DbPool,
user_id: i64,
uuid: &Uuid,
name: &str,
) -> AppResult<Machine> {
let result = sqlx::query(
r#"
INSERT INTO machines (user_id, uuid, name)
VALUES (?, ?, ?)
"#,
)
.bind(user_id)
.bind(uuid.to_string())
.bind(name)
.execute(pool)
.await?;
Self::get_machine_by_id(pool, result.last_insert_rowid()).await
}
async fn machine_exists_by_uuid(pool: &DbPool, uuid: &Uuid) -> AppResult<bool> {
let row = sqlx::query("SELECT COUNT(*) as count FROM machines WHERE uuid = ?")
.bind(uuid.to_string())
.fetch_one(pool)
.await?;
let count: i64 = row.get("count");
Ok(count > 0)
}
async fn delete_machine_by_id(pool: &DbPool, id: i64) -> AppResult<()> {
sqlx::query("DELETE FROM machines WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
async fn get_provisioning_code(
pool: &DbPool,
code: &str,
) -> AppResult<Option<ProvisioningCode>> {
let row = sqlx::query(
r#"
SELECT id, user_id, code, created_at, expires_at, used
FROM provisioning_codes WHERE code = ?
"#,
)
.bind(code)
.fetch_optional(pool)
.await?;
if let Some(row) = row {
Ok(Some(ProvisioningCode {
id: row.get("id"),
user_id: row.get("user_id"),
code: row.get("code"),
created_at: row.get("created_at"),
expires_at: row.get("expires_at"),
used: row.get("used"),
}))
} else {
Ok(None)
}
}
async fn mark_provisioning_code_used(pool: &DbPool, code: &str) -> AppResult<()> {
sqlx::query("UPDATE provisioning_codes SET used = 1 WHERE code = ?")
.bind(code)
.execute(pool)
.await?;
Ok(())
}
fn validate_machine_input(name: &str) -> AppResult<()> {
if name.trim().is_empty() {
return Err(validation_error("Machine name cannot be empty"));
}
Ok(())
}
}

View File

@@ -0,0 +1,3 @@
pub mod auth;
pub mod machines;
pub mod users;

View File

@@ -0,0 +1,202 @@
use crate::utils::{error::*, models::*, DbPool};
use bcrypt::{hash, verify, DEFAULT_COST};
use sqlx::Row;
use std::str::FromStr;
pub struct UsersController;
impl UsersController {
pub async fn create_user(
pool: &DbPool,
username: &str,
password: &str,
role: UserRole,
storage_limit_gb: i64,
) -> AppResult<User> {
Self::validate_user_input(username, password)?;
let password_hash = hash(password, DEFAULT_COST)?;
let result = sqlx::query(
r#"
INSERT INTO users (username, password_hash, role, storage_limit_gb)
VALUES (?, ?, ?, ?)
"#,
)
.bind(username)
.bind(&password_hash)
.bind(role.to_string())
.bind(storage_limit_gb)
.execute(pool)
.await?;
Self::get_user_by_id(pool, result.last_insert_rowid()).await
}
pub async fn get_user_by_username(pool: &DbPool, username: &str) -> AppResult<Option<User>> {
let row = sqlx::query(
r#"
SELECT id, username, password_hash, role, storage_limit_gb, created_at
FROM users WHERE username = ?
"#,
)
.bind(username)
.fetch_optional(pool)
.await?;
if let Some(row) = row {
Ok(Some(User {
id: row.get("id"),
username: row.get("username"),
password_hash: row.get("password_hash"),
role: UserRole::from_str(&row.get::<String, _>("role")).unwrap(),
storage_limit_gb: row.get("storage_limit_gb"),
created_at: row.get("created_at"),
}))
} else {
Ok(None)
}
}
pub async fn get_user_by_id(pool: &DbPool, id: i64) -> AppResult<User> {
let row = sqlx::query(
r#"
SELECT id, username, password_hash, role, storage_limit_gb, created_at
FROM users WHERE id = ?
"#,
)
.bind(id)
.fetch_one(pool)
.await?;
Ok(User {
id: row.get("id"),
username: row.get("username"),
password_hash: row.get("password_hash"),
role: UserRole::from_str(&row.get::<String, _>("role")).unwrap(),
storage_limit_gb: row.get("storage_limit_gb"),
created_at: row.get("created_at"),
})
}
pub async fn get_all_users(pool: &DbPool) -> AppResult<Vec<User>> {
let rows = sqlx::query(
r#"
SELECT id, username, password_hash, role, storage_limit_gb, created_at
FROM users ORDER BY created_at DESC
"#,
)
.fetch_all(pool)
.await?;
let mut users = Vec::new();
for row in rows {
users.push(User {
id: row.get("id"),
username: row.get("username"),
password_hash: row.get("password_hash"),
role: UserRole::from_str(&row.get::<String, _>("role")).unwrap(),
storage_limit_gb: row.get("storage_limit_gb"),
created_at: row.get("created_at"),
});
}
Ok(users)
}
pub async fn update_user(
pool: &DbPool,
id: i64,
request: UpdateUserRequest,
) -> AppResult<User> {
let current_user = Self::get_user_by_id(pool, id).await?;
let username = request.username.clone().unwrap_or(current_user.username);
let role = request.role.unwrap_or(current_user.role);
let storage_limit_gb = request
.storage_limit_gb
.unwrap_or(current_user.storage_limit_gb);
if request.username.is_some() || request.password.is_some() {
Self::validate_user_input(
&username,
request.password.as_deref().unwrap_or("validpassword"),
)?;
}
if let Some(password) = request.password {
let password_hash = hash(&password, DEFAULT_COST)?;
sqlx::query(
r#"
UPDATE users
SET username = ?, password_hash = ?, role = ?, storage_limit_gb = ?
WHERE id = ?
"#,
)
.bind(&username)
.bind(&password_hash)
.bind(role.to_string())
.bind(storage_limit_gb)
.bind(id)
.execute(pool)
.await?;
} else {
sqlx::query(
r#"
UPDATE users
SET username = ?, role = ?, storage_limit_gb = ?
WHERE id = ?
"#,
)
.bind(&username)
.bind(role.to_string())
.bind(storage_limit_gb)
.bind(id)
.execute(pool)
.await?;
}
Self::get_user_by_id(pool, id).await
}
pub async fn delete_user(pool: &DbPool, id: i64) -> AppResult<()> {
Self::get_user_by_id(pool, id).await?;
sqlx::query("DELETE FROM users WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn verify_user_credentials(
pool: &DbPool,
username: &str,
password: &str,
) -> AppResult<User> {
let user = Self::get_user_by_username(pool, username)
.await?
.ok_or_else(|| auth_error("Invalid credentials"))?;
let is_valid = verify(password, &user.password_hash)?;
if !is_valid {
return Err(auth_error("Invalid credentials"));
}
Ok(user)
}
fn validate_user_input(username: &str, password: &str) -> AppResult<()> {
if username.trim().is_empty() {
return Err(validation_error("Username cannot be empty"));
}
if password.len() < 8 {
return Err(validation_error(
"Password must be at least 8 characters long",
));
}
Ok(())
}
}

View File

@@ -1,3 +1,44 @@
fn main() {
println!("Hello, world!");
mod controllers;
mod routes;
mod utils;
use utils::init_database;
use anyhow::Result;
use axum::{
routing::{delete, get, post, put},
Router,
};
use routes::{admin, auth as auth_routes, machines, setup};
use tower_http::cors::CorsLayer;
#[tokio::main]
async fn main() -> Result<()> {
let pool = init_database().await?;
let app = Router::new()
.route("/setup/status", get(setup::get_setup_status))
.route("/setup/init", post(setup::init_setup))
.route("/auth/login", post(auth_routes::login))
.route("/auth/logout", post(auth_routes::logout))
.route("/admin/users", get(admin::get_users))
.route("/admin/users", post(admin::create_user_handler))
.route("/admin/users/{id}", put(admin::update_user_handler))
.route("/admin/users/{id}", delete(admin::delete_user_handler))
.route("/machines/register", post(machines::register_machine))
.route("/machines", get(machines::get_machines))
.route("/machines/{id}", delete(machines::delete_machine))
.layer(CorsLayer::permissive())
.with_state(pool);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8379").await?;
println!("Server running on http://0.0.0.0:8379");
axum::serve(listener, app).await?;
Ok(())
}

View File

@@ -0,0 +1,53 @@
use crate::controllers::users::UsersController;
use crate::utils::{auth::*, error::*, models::*, DbPool};
use axum::{
extract::{Path, State},
response::Json,
};
pub async fn get_users(
_admin: AdminUser,
State(pool): State<DbPool>,
) -> Result<Json<Vec<User>>, AppError> {
let users = UsersController::get_all_users(&pool).await?;
Ok(success_response(users))
}
pub async fn create_user_handler(
_admin: AdminUser,
State(pool): State<DbPool>,
Json(request): Json<CreateUserRequest>,
) -> Result<Json<User>, AppError> {
let role = request.role.unwrap_or(UserRole::User);
let storage_limit_gb = request.storage_limit_gb.unwrap_or(0);
let user = UsersController::create_user(
&pool,
&request.username,
&request.password,
role,
storage_limit_gb,
)
.await?;
Ok(success_response(user))
}
pub async fn update_user_handler(
_admin: AdminUser,
State(pool): State<DbPool>,
Path(user_id): Path<i64>,
Json(request): Json<UpdateUserRequest>,
) -> Result<Json<User>, AppError> {
let updated_user = UsersController::update_user(&pool, user_id, request).await?;
Ok(success_response(updated_user))
}
pub async fn delete_user_handler(
_admin: AdminUser,
State(pool): State<DbPool>,
Path(user_id): Path<i64>,
) -> Result<Json<serde_json::Value>, AppError> {
UsersController::delete_user(&pool, user_id).await?;
Ok(success_message("User deleted successfully"))
}

19
server/src/routes/auth.rs Normal file
View File

@@ -0,0 +1,19 @@
use crate::utils::{auth::*, error::*, models::*, DbPool};
use crate::controllers::auth::AuthController;
use axum::{extract::State, response::Json};
pub async fn login(
State(pool): State<DbPool>,
Json(request): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, AppError> {
let response = AuthController::login(&pool, &request.username, &request.password).await?;
Ok(success_response(response))
}
pub async fn logout(
auth_user: AuthUser,
State(pool): State<DbPool>,
) -> Result<Json<serde_json::Value>, AppError> {
AuthController::logout(&pool, auth_user.user.id).await?;
Ok(success_message("Logged out successfully"))
}

View File

@@ -0,0 +1,38 @@
use crate::utils::{auth::*, error::*, models::*, DbPool};
use crate::controllers::machines::MachinesController;
use axum::{
extract::{Path, State},
response::Json,
};
pub async fn register_machine(
State(pool): State<DbPool>,
Json(request): Json<RegisterMachineRequest>,
) -> Result<Json<Machine>, AppError> {
let machine = MachinesController::register_machine(
&pool,
&request.code,
&request.uuid,
&request.name,
)
.await?;
Ok(success_response(machine))
}
pub async fn get_machines(
auth_user: AuthUser,
State(pool): State<DbPool>,
) -> Result<Json<Vec<Machine>>, AppError> {
let machines = MachinesController::get_machines_for_user(&pool, &auth_user.user).await?;
Ok(success_response(machines))
}
pub async fn delete_machine(
auth_user: AuthUser,
State(pool): State<DbPool>,
Path(machine_id): Path<i64>,
) -> Result<Json<serde_json::Value>, AppError> {
MachinesController::delete_machine(&pool, machine_id, &auth_user.user).await?;
Ok(success_message("Machine deleted successfully"))
}

4
server/src/routes/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod admin;
pub mod auth;
pub mod machines;
pub mod setup;

View File

@@ -0,0 +1,32 @@
use crate::controllers::users::UsersController;
use crate::utils::{database::*, error::*, models::*};
use axum::{extract::State, response::Json};
pub async fn get_setup_status(
State(pool): State<DbPool>,
) -> Result<Json<SetupStatusResponse>, AppError> {
let first_user_exists = check_first_user_exists(&pool).await?;
Ok(success_response(SetupStatusResponse { first_user_exists }))
}
pub async fn init_setup(
State(pool): State<DbPool>,
Json(request): Json<InitSetupRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let first_user_exists = check_first_user_exists(&pool).await?;
if first_user_exists {
return Err(validation_error("Setup already completed"));
}
UsersController::create_user(
&pool,
&request.username,
&request.password,
UserRole::Admin,
0,
)
.await?;
Ok(success_message("Setup completed successfully"))
}

67
server/src/utils/auth.rs Normal file
View File

@@ -0,0 +1,67 @@
use crate::controllers::auth::AuthController;
use crate::utils::{models::*, DbPool};
use axum::{
extract::FromRequestParts,
http::{header::AUTHORIZATION, request::Parts, StatusCode},
};
#[derive(Clone)]
pub struct AuthUser {
pub user: User,
}
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get(AUTHORIZATION)
.and_then(|header| header.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
if !auth_header.starts_with("Bearer ") {
return Err(StatusCode::UNAUTHORIZED);
}
let token = &auth_header[7..];
let pool = parts
.extensions
.get::<DbPool>()
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
let user = AuthController::authenticate_user(pool, token)
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
Ok(AuthUser { user })
}
}
#[derive(Clone)]
pub struct AdminUser {
#[allow(dead_code)]
pub user: User,
}
impl<S> FromRequestParts<S> for AdminUser
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let auth_user = AuthUser::from_request_parts(parts, _state).await?;
if auth_user.user.role != UserRole::Admin {
return Err(StatusCode::FORBIDDEN);
}
Ok(AdminUser {
user: auth_user.user,
})
}
}

View File

@@ -0,0 +1,111 @@
use crate::utils::{ensure_data_directories, get_database_path, AppResult};
use sqlx::{sqlite::SqlitePool, Pool, Row, Sqlite};
use std::path::Path;
pub type DbPool = Pool<Sqlite>;
pub async fn init_database() -> AppResult<DbPool> {
ensure_data_directories()?;
let db_path = get_database_path()?;
if !Path::new(&db_path).exists() {
std::fs::File::create(&db_path)?;
}
let database_url = format!("sqlite://{}", db_path);
let pool = SqlitePool::connect(&database_url).await?;
run_migrations(&pool).await?;
Ok(pool)
}
async fn run_migrations(pool: &DbPool) -> AppResult<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT CHECK(role IN ('admin','user')) NOT NULL,
storage_limit_gb INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS machines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
uuid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS provisioning_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_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
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
machine_id INTEGER NOT NULL,
snapshot_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(machine_id) REFERENCES machines(id) ON DELETE CASCADE
)
"#,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn check_first_user_exists(pool: &DbPool) -> AppResult<bool> {
let row = sqlx::query("SELECT COUNT(*) as count FROM users")
.fetch_one(pool)
.await?;
let count: i64 = row.get("count");
Ok(count > 0)
}

View File

@@ -0,0 +1,76 @@
use crate::utils::error::{internal_error, AppResult};
use std::fs;
pub fn get_database_path() -> AppResult<String> {
let db_dir = "data/db";
let db_path = format!("{}/arkendro.db", db_dir);
if let Err(e) = fs::create_dir_all(db_dir) {
return Err(internal_error(&format!(
"Failed to create database directory: {}",
e
)));
}
Ok(db_path)
}
pub fn ensure_data_directories() -> AppResult<()> {
let directories = ["data", "data/db", "data/backups", "data/logs"];
for dir in directories.iter() {
if let Err(e) = fs::create_dir_all(dir) {
return Err(internal_error(&format!(
"Failed to create directory '{}': {}",
dir, e
)));
}
}
Ok(())
}
pub fn get_data_path(filename: &str) -> String {
format!("data/{}", filename)
}
pub fn get_backup_path(filename: &str) -> String {
format!("data/backups/{}", filename)
}
pub fn get_log_path(filename: &str) -> String {
format!("data/logs/{}", filename)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
#[test]
fn test_database_path_creation() {
let _ = fs::remove_dir_all("data");
let db_path = get_database_path().expect("Should create database path");
assert_eq!(db_path, "data/db/arkendro.db");
assert!(Path::new("data/db").exists());
let _ = fs::remove_dir_all("data");
}
#[test]
fn test_ensure_data_directories() {
let _ = fs::remove_dir_all("data");
ensure_data_directories().expect("Should create all directories");
assert!(Path::new("data").exists());
assert!(Path::new("data/db").exists());
assert!(Path::new("data/backups").exists());
assert!(Path::new("data/logs").exists());
let _ = fs::remove_dir_all("data");
}
}

142
server/src/utils/error.rs Normal file
View File

@@ -0,0 +1,142 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use serde_json::json;
use std::fmt;
#[derive(Debug)]
pub enum AppError {
DatabaseError(String),
ValidationError(String),
AuthenticationError(String),
AuthorizationError(String),
NotFoundError(String),
ConflictError(String),
InternalError(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
AppError::AuthenticationError(msg) => write!(f, "Authentication error: {}", msg),
AppError::AuthorizationError(msg) => write!(f, "Authorization error: {}", msg),
AppError::NotFoundError(msg) => write!(f, "Not found: {}", msg),
AppError::ConflictError(msg) => write!(f, "Conflict: {}", msg),
AppError::InternalError(msg) => write!(f, "Internal error: {}", msg),
}
}
}
impl std::error::Error for AppError {}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
AppError::DatabaseError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Database error"),
AppError::ValidationError(ref msg) => (StatusCode::BAD_REQUEST, msg.as_str()),
AppError::AuthenticationError(ref msg) => (StatusCode::UNAUTHORIZED, msg.as_str()),
AppError::AuthorizationError(ref msg) => (StatusCode::FORBIDDEN, msg.as_str()),
AppError::NotFoundError(ref msg) => (StatusCode::NOT_FOUND, msg.as_str()),
AppError::ConflictError(ref msg) => (StatusCode::CONFLICT, msg.as_str()),
AppError::InternalError(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error")
}
};
let body = Json(json!({
"error": error_message
}));
(status, body).into_response()
}
}
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
if let Some(sqlx_err) = err.downcast_ref::<sqlx::Error>() {
match sqlx_err {
sqlx::Error::RowNotFound => {
AppError::NotFoundError("Resource not found".to_string())
}
sqlx::Error::Database(db_err) => {
if db_err.message().contains("UNIQUE constraint failed") {
AppError::ConflictError("Resource already exists".to_string())
} else {
AppError::DatabaseError(db_err.message().to_string())
}
}
_ => AppError::DatabaseError("Database operation failed".to_string()),
}
} else {
AppError::InternalError(err.to_string())
}
}
}
impl From<bcrypt::BcryptError> for AppError {
fn from(_: bcrypt::BcryptError) -> Self {
AppError::InternalError("Password hashing error".to_string())
}
}
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
match err {
sqlx::Error::RowNotFound => AppError::NotFoundError("Resource not found".to_string()),
sqlx::Error::Database(db_err) => {
if db_err.message().contains("UNIQUE constraint failed") {
AppError::ConflictError("Resource already exists".to_string())
} else {
AppError::DatabaseError(db_err.message().to_string())
}
}
_ => AppError::DatabaseError("Database operation failed".to_string()),
}
}
}
impl From<std::io::Error> for AppError {
fn from(err: std::io::Error) -> Self {
AppError::InternalError(format!("IO error: {}", err))
}
}
pub type AppResult<T> = Result<T, AppError>;
pub fn validation_error(msg: &str) -> AppError {
AppError::ValidationError(msg.to_string())
}
pub fn auth_error(msg: &str) -> AppError {
AppError::AuthenticationError(msg.to_string())
}
pub fn forbidden_error(msg: &str) -> AppError {
AppError::AuthorizationError(msg.to_string())
}
pub fn not_found_error(msg: &str) -> AppError {
AppError::NotFoundError(msg.to_string())
}
pub fn conflict_error(msg: &str) -> AppError {
AppError::ConflictError(msg.to_string())
}
pub fn internal_error(msg: &str) -> AppError {
AppError::InternalError(msg.to_string())
}
pub fn success_response<T>(data: T) -> Json<T>
where
T: serde::Serialize,
{
Json(data)
}
pub fn success_message(msg: &str) -> Json<serde_json::Value> {
Json(json!({ "message": msg }))
}

9
server/src/utils/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
pub mod auth;
pub mod database;
pub mod db_path;
pub mod error;
pub mod models;
pub use database::*;
pub use db_path::*;
pub use error::*;

137
server/src/utils/models.rs Normal file
View File

@@ -0,0 +1,137 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct User {
pub id: i64,
pub username: String,
#[serde(skip_serializing)]
pub password_hash: String,
pub role: UserRole,
pub storage_limit_gb: i64,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum UserRole {
Admin,
User,
}
impl std::fmt::Display for UserRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UserRole::Admin => write!(f, "admin"),
UserRole::User => write!(f, "user"),
}
}
}
impl std::str::FromStr for UserRole {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"admin" => Ok(UserRole::Admin),
"user" => Ok(UserRole::User),
_ => Err("Invalid role"),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateUserRequest {
pub username: String,
pub password: String,
pub role: Option<UserRole>,
pub storage_limit_gb: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateUserRequest {
pub username: Option<String>,
pub password: Option<String>,
pub role: Option<UserRole>,
pub storage_limit_gb: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Session {
pub id: i64,
pub user_id: i64,
pub token: String,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginResponse {
pub token: String,
pub role: UserRole,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Machine {
pub id: i64,
pub user_id: i64,
pub uuid: Uuid,
pub name: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterMachineRequest {
pub code: String,
pub uuid: Uuid,
pub name: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProvisioningCode {
pub id: i64,
pub user_id: i64,
pub code: String,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub used: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Snapshot {
pub id: i64,
pub machine_id: i64,
pub snapshot_hash: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SetupStatusResponse {
pub first_user_exists: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InitSetupRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorResponse {
pub error: String,
}
impl ErrorResponse {
pub fn new(message: &str) -> Self {
Self {
error: message.to_string(),
}
}
}