Files
nixpanel/nixpanel-api/src/routes/databases.rs
T

267 lines
8.6 KiB
Rust
Raw Normal View History

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<String>,
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<String, String> {
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<AppState>,
) -> Result<Json<Vec<DatabaseWithAccount>>, 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<AppState>,
Path(account_username): Path<String>,
) -> Result<Json<Vec<Database>>, 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<AppState>,
Json(body): Json<CreateDatabaseRequest>,
) -> Result<Json<Database>, 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<i64> = 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<AppState>,
Path(db_name): Path<String>,
) -> Result<Json<serde_json::Value>, 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 })))
}