use askama::Template; use axum::{ Form, Router, extract::State, response::{Html, Redirect}, routing::{get, post}, }; use serde::Deserialize; use crate::auth::CurrentUser; use crate::routes::AppState; use crate::sanitize; #[derive(Template)] #[template(path = "players/manage.html")] struct PlayerManagementTemplate { username: String, is_superuser: bool, password_change_required: bool, active_instance_name: Option, whitelist: Vec, ops: Vec, bans: Vec, } #[derive(Debug, Deserialize)] pub struct PlayerAction { pub username: String, } pub fn router() -> Router { Router::new() .route("/players", get(manage_players)) .route("/players/whitelist/add", post(add_whitelist)) .route("/players/whitelist/remove", post(remove_whitelist)) .route("/players/op/add", post(add_op)) .route("/players/op/remove", post(remove_op)) .route("/players/ban/add", post(add_ban)) .route("/players/ban/remove", post(remove_ban)) } async fn manage_players( State(state): State, CurrentUser(account): CurrentUser, ) -> Result, axum::response::Redirect> { let active = get_active_instance(&state.db); let (instance_name, whitelist, ops, bans) = if let Some((_id, inst_name, _rcon_pw, _container_state)) = active { // Sanitize instance name to prevent path traversal in file reads let safe_name = sanitize::sanitize_instance_name(&inst_name) .unwrap_or_else(|| inst_name.clone()); let data_dir = state .config .server_directory .join("instances") .join(&safe_name) .join("data"); let w = read_json_list(&data_dir.join("whitelist.json")); let o = read_json_list(&data_dir.join("ops.json")); let b = read_json_list(&data_dir.join("banned-players.json")); (Some(inst_name), w, o, b) } else { (None, vec![], vec![], vec![]) }; let template = PlayerManagementTemplate { username: account.username, is_superuser: account.is_superuser, password_change_required: account.password_change_required, active_instance_name: instance_name, whitelist, ops, bans, }; Ok(Html(template.render().unwrap_or_default())) } fn read_json_list(path: &std::path::Path) -> Vec { if let Ok(contents) = std::fs::read_to_string(path) && let Ok(entries) = serde_json::from_str::>(&contents) { return entries .iter() .filter_map(|e| e["name"].as_str().map(String::from)) .collect(); } vec![] } macro_rules! player_action { ($name:ident, $command:expr) => { async fn $name( State(state): State, CurrentUser(_user): CurrentUser, Form(form): Form, ) -> Result { // Validate Minecraft username to prevent RCON command injection // Minecraft usernames are 3-16 alphanumeric + underscore chars let safe_username = form.username.trim(); if !sanitize::validate_username(safe_username) { tracing::warn!( "Rejected invalid username for '{}': {safe_username:?}", stringify!($name) ); return Ok(Redirect::to("/players")); } if let Some((_, _, rcon_pw, _)) = get_active_instance(&state.db) { let client = crate::rcon::RconClient::new(rcon_pw); let cmd = format!($command, safe_username); match client.send_command_async(cmd.clone()).await { Ok(resp) => tracing::info!("RCON '{}' -> {}", cmd, resp.trim()), Err(e) => tracing::error!("RCON '{}' failed: {}", cmd, e), } } else { tracing::warn!( "Player action '{}' called but no active instance", stringify!($name) ); } Ok(Redirect::to("/players")) } }; } player_action!(add_whitelist, "whitelist add {}"); player_action!(remove_whitelist, "whitelist remove {}"); player_action!(add_op, "op {}"); player_action!(remove_op, "deop {}"); player_action!(add_ban, "ban {}"); player_action!(remove_ban, "pardon {}"); fn get_active_instance(db: &crate::db::Database) -> Option<(i64, String, String, String)> { db.conn() .query_row( "SELECT id, name, rcon_password, container_state FROM instances WHERE is_active = 1", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) .ok() }