use axum::{ extract::{Path, State}, Json, }; use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; use std::io::Write; use std::process::{Command, Stdio}; use crate::{auth::Claims, error::AppError, state::AppState}; /* ── Types ───────────────────────────────────────────────────────────── */ #[derive(Serialize, sqlx::FromRow)] pub struct Database { pub id: i64, pub account_id: i64, pub db_name: String, pub db_user: String, pub created_at: NaiveDateTime, } #[derive(Serialize, sqlx::FromRow)] pub struct DatabaseWithAccount { pub id: i64, pub account_id: i64, pub db_name: String, pub db_user: String, pub created_at: NaiveDateTime, pub account_username: String, } #[derive(Deserialize)] pub struct CreateDatabaseRequest { /// Hosting account username (admin supplies this; NixClient uses JWT sub) pub username: String, /// Short suffix — final DB name will be `username_db_suffix` pub db_suffix: String, /// Short suffix — final DB user will be `username_user_suffix` (defaults to db_suffix) pub db_user_suffix: Option, pub db_password: String, } /* ── MySQL helpers ───────────────────────────────────────────────────── */ /// Escape a string value safe for use inside a single-quoted MySQL literal. fn escape_mysql(s: &str) -> String { s.replace('\\', "\\\\") .replace('\'', "\\'") .replace('\0', "\\0") .replace('\n', "\\n") .replace('\r', "\\r") } /// Execute one or more SQL statements via `mysql -u root` (unix socket auth). fn mysql_exec(sql: &str) -> Result { let mut child = Command::new("mysql") .args(["-u", "root"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("mysql spawn: {}", e))?; if let Some(mut stdin) = child.stdin.take() { stdin.write_all(sql.as_bytes()).ok(); } let out = child.wait_with_output().map_err(|e| format!("mysql wait: {}", e))?; if out.status.success() { Ok(String::from_utf8_lossy(&out.stdout).to_string()) } else { Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) } } /// Validate an identifier: only alphanumeric + underscore, non-empty. fn valid_ident(s: &str) -> bool { !s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_') } /* ── Handlers ────────────────────────────────────────────────────────── */ /// List all databases across all accounts (admin / NixServer). pub async fn list_all_databases( _claims: Claims, State(state): State, ) -> Result>, AppError> { let rows = sqlx::query_as::<_, DatabaseWithAccount>( "SELECT ad.id, ad.account_id, ad.db_name, ad.db_user, ad.created_at, a.username AS account_username FROM account_databases ad JOIN accounts a ON a.id = ad.account_id ORDER BY ad.created_at DESC", ) .fetch_all(&state.db) .await?; Ok(Json(rows)) } /// List databases for a specific hosting account (NixClient). pub async fn list_account_databases( _claims: Claims, State(state): State, Path(account_username): Path, ) -> Result>, AppError> { let rows = sqlx::query_as::<_, Database>( "SELECT ad.id, ad.account_id, ad.db_name, ad.db_user, ad.created_at FROM account_databases ad JOIN accounts a ON a.id = ad.account_id WHERE a.username = ? ORDER BY ad.created_at DESC", ) .bind(&account_username) .fetch_all(&state.db) .await?; Ok(Json(rows)) } /// Create a MariaDB database + user, then record in nixpanel DB. pub async fn create_database( _claims: Claims, State(state): State, Json(body): Json, ) -> Result, AppError> { // Validate inputs if body.username.is_empty() || body.db_suffix.is_empty() || body.db_password.is_empty() { return Err(AppError::BadRequest( "username, db_suffix, and db_password are required".into(), )); } if !valid_ident(&body.db_suffix) { return Err(AppError::BadRequest( "db_suffix must be alphanumeric with underscores only".into(), )); } let user_suffix = body.db_user_suffix.as_deref().unwrap_or(&body.db_suffix); if !valid_ident(user_suffix) { return Err(AppError::BadRequest( "db_user_suffix must be alphanumeric with underscores only".into(), )); } if body.db_password.len() < 6 { return Err(AppError::BadRequest("Password must be at least 6 characters".into())); } // Build cPanel-style namespaced names let db_name = format!("{}_{}", body.username, body.db_suffix); let db_user = format!("{}_{}", body.username, user_suffix); // MySQL identifier length limits if db_name.len() > 64 { return Err(AppError::BadRequest("Database name too long (max 64 chars)".into())); } if db_user.len() > 32 { return Err(AppError::BadRequest("Database username too long (max 32 chars)".into())); } // Get account_id let account_id: Option = sqlx::query_scalar( "SELECT id FROM accounts WHERE username = ?", ) .bind(&body.username) .fetch_optional(&state.db) .await?; let account_id = account_id.ok_or_else(|| { AppError::BadRequest(format!("Account '{}' not found", body.username)) })?; // Check DB doesn't already exist in our tracking table let exists: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM account_databases WHERE db_name = ?", ) .bind(&db_name) .fetch_one(&state.db) .await?; if exists > 0 { return Err(AppError::BadRequest(format!( "Database '{}' already exists", db_name ))); } // Create database + user in MariaDB let pw = escape_mysql(&body.db_password); let sql = format!( "CREATE DATABASE IF NOT EXISTS `{db_name}` \ CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\n\ CREATE USER IF NOT EXISTS '{db_user}'@'localhost' IDENTIFIED BY '{pw}';\n\ GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{db_user}'@'localhost';\n\ FLUSH PRIVILEGES;\n", db_name = db_name, db_user = db_user, pw = pw, ); mysql_exec(&sql).map_err(|e| AppError::BadRequest(format!("MariaDB: {}", e)))?; // Record in nixpanel tracking table let insert_id: u64 = sqlx::query( "INSERT INTO account_databases (account_id, db_name, db_user) VALUES (?, ?, ?)", ) .bind(account_id) .bind(&db_name) .bind(&db_user) .execute(&state.db) .await? .last_insert_id(); let db = sqlx::query_as::<_, Database>( "SELECT id, account_id, db_name, db_user, created_at FROM account_databases WHERE id = ?", ) .bind(insert_id) .fetch_one(&state.db) .await?; Ok(Json(db)) } /// Drop a MariaDB database and its dedicated user. pub async fn delete_database( _claims: Claims, State(state): State, Path(db_name): Path, ) -> Result, AppError> { // Fetch tracking record let row: Option<(i64, String)> = sqlx::query_as( "SELECT id, db_user FROM account_databases WHERE db_name = ?", ) .bind(&db_name) .fetch_optional(&state.db) .await?; let (db_id, db_user) = row.ok_or(AppError::NotFound)?; // Safety: only allow dropping databases that follow the user_name pattern if !db_name.contains('_') { return Err(AppError::BadRequest( "Will not drop databases without account prefix".into(), )); } // Drop database and user from MariaDB let sql = format!( "DROP DATABASE IF EXISTS `{db_name}`;\n\ DROP USER IF EXISTS '{db_user}'@'localhost';\n\ FLUSH PRIVILEGES;\n", db_name = db_name, db_user = db_user, ); mysql_exec(&sql).map_err(|e| AppError::BadRequest(format!("MariaDB: {}", e)))?; // Remove tracking record sqlx::query("DELETE FROM account_databases WHERE id = ?") .bind(db_id) .execute(&state.db) .await?; Ok(Json(serde_json::json!({ "ok": true, "deleted": db_name }))) }