feat: web frontend; middleware; serde (WIP?)
This commit is contained in:
47
src/admin/auth_middleware.rs
Normal file
47
src/admin/auth_middleware.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
use crate::{auth, state::AppState};
|
||||
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<Arc<AppState>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
// Skip authentication for login endpoint
|
||||
if request.uri().path().ends_with("/login") {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
// Skip authentication for static files (non-API routes)
|
||||
if !request.uri().path().starts_with("/api/") {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
// Get Authorization header
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
// Check if it's a Bearer token
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
let token = &auth_header[7..]; // Skip "Bearer "
|
||||
|
||||
// Validate token
|
||||
auth::validate_token(state.jwt_secret.as_bytes(), token)
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
// Token is valid, proceed
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
366
src/admin/handlers.rs
Normal file
366
src/admin/handlers.rs
Normal file
@@ -0,0 +1,366 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use tracing::error;
|
||||
|
||||
use crate::{AppState, auth, crypto, db};
|
||||
|
||||
use super::models::*;
|
||||
|
||||
// Authentication handlers
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, (StatusCode, Json<ApiError>)> {
|
||||
// Get stored password hash from config
|
||||
let password_hash = match db::repositories::config::get(&state.db, "admin_password_hash").await
|
||||
{
|
||||
Ok(Some(hash)) => hash,
|
||||
Ok(None) => {
|
||||
error!("Admin password hash not found in config");
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Authentication not configured")),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get password hash: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Authentication error")),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify password
|
||||
match auth::verify_password(&req.password, &password_hash) {
|
||||
Ok(true) => {
|
||||
// Generate JWT token
|
||||
match auth::generate_token(state.jwt_secret.as_bytes(), "admin") {
|
||||
Ok(token) => Ok(Json(LoginResponse { token })),
|
||||
Err(e) => {
|
||||
error!("Failed to generate token: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to generate token")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ApiError::new("Invalid password")),
|
||||
)),
|
||||
Err(e) => {
|
||||
error!("Password verification error: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Authentication error")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<super::models::ChangePasswordRequest>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ApiError>)> {
|
||||
// Get current password hash from config
|
||||
let current_hash = match db::repositories::config::get(&state.db, "admin_password_hash").await {
|
||||
Ok(Some(hash)) => hash,
|
||||
Ok(None) => {
|
||||
error!("Admin password hash not found in config");
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Authentication not configured")),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get password hash: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to get current password")),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify old password
|
||||
match auth::verify_password(&req.old_password, ¤t_hash) {
|
||||
Ok(true) => {
|
||||
// Hash new password
|
||||
let new_hash = match auth::hash_password(&req.new_password) {
|
||||
Ok(hash) => hash,
|
||||
Err(e) => {
|
||||
error!("Failed to hash new password: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to hash new password")),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Update password hash in config
|
||||
if let Err(e) = db::repositories::config::set(
|
||||
&state.db,
|
||||
"admin_password_hash",
|
||||
&new_hash,
|
||||
Some("Hashed admin password"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to update password hash: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to update password")),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
Ok(false) => Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ApiError::new("Invalid old password")),
|
||||
)),
|
||||
Err(e) => {
|
||||
error!("Password verification error: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Password verification error")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rules handlers
|
||||
pub async fn list_rules(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<RuleResponse>>, (StatusCode, Json<ApiError>)> {
|
||||
match db::repositories::rules::list_all(&state.db).await {
|
||||
Ok(rules) => Ok(Json(rules.into_iter().map(Into::into).collect())),
|
||||
Err(e) => {
|
||||
error!("Failed to list rules: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to fetch rules")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_rule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<CreateRuleRequest>,
|
||||
) -> Result<Json<RuleResponse>, (StatusCode, Json<ApiError>)> {
|
||||
// Validate action
|
||||
if !matches!(req.action.as_str(), "passthrough" | "modify" | "replace") {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError::new(
|
||||
"Invalid action. Must be 'passthrough', 'modify', or 'replace'",
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
// If action is replace, validate and encrypt custom_response
|
||||
let custom_response = if req.action == "replace" {
|
||||
match req.custom_response {
|
||||
Some(resp) => Some(resp),
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError::new(
|
||||
"custom_response is required when action is 'replace'",
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match db::repositories::rules::create(
|
||||
&state.db,
|
||||
&req.method_name,
|
||||
&req.action,
|
||||
custom_response.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(id) => match db::repositories::rules::find_by_id(&state.db, id).await {
|
||||
Ok(Some(rule)) => Ok(Json(rule.into())),
|
||||
_ => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to fetch created rule")),
|
||||
)),
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to create rule: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new(format!("Failed to create rule: {}", e))),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_rule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(req): Json<UpdateRuleRequest>,
|
||||
) -> Result<Json<RuleResponse>, (StatusCode, Json<ApiError>)> {
|
||||
// Validate action if provided
|
||||
if let Some(ref action) = req.action
|
||||
&& !matches!(action.as_str(), "passthrough" | "modify" | "replace")
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError::new("Invalid action")),
|
||||
));
|
||||
}
|
||||
|
||||
// Encrypt custom_response if provided
|
||||
let custom_response = if let Some(resp) = req.custom_response {
|
||||
match crypto::encrypt(&resp, &state.key, &state.iv) {
|
||||
Ok(encrypted) => Some(encrypted),
|
||||
Err(e) => {
|
||||
error!("Failed to encrypt custom response: {}", e);
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError::new("Failed to encrypt custom response")),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match db::repositories::rules::update(
|
||||
&state.db,
|
||||
id,
|
||||
req.method_name.as_deref(),
|
||||
req.action.as_deref(),
|
||||
custom_response.as_deref(),
|
||||
req.is_enabled,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => match db::repositories::rules::find_by_id(&state.db, id).await {
|
||||
Ok(Some(rule)) => Ok(Json(rule.into())),
|
||||
_ => Err((StatusCode::NOT_FOUND, Json(ApiError::new("Rule not found")))),
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to update rule: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to update rule")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_rule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ApiError>)> {
|
||||
match db::repositories::rules::delete(&state.db, id).await {
|
||||
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(e) => {
|
||||
error!("Failed to delete rule: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to delete rule")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commands handlers
|
||||
pub async fn list_commands(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<CommandResponse>>, (StatusCode, Json<ApiError>)> {
|
||||
match db::repositories::commands::list_all(&state.db).await {
|
||||
Ok(commands) => Ok(Json(commands.into_iter().map(Into::into).collect())),
|
||||
Err(e) => {
|
||||
error!("Failed to list commands: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to fetch commands")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_command(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(req): Json<UpdateCommandRequest>,
|
||||
) -> Result<Json<CommandResponse>, (StatusCode, Json<ApiError>)> {
|
||||
match db::repositories::commands::update_status(
|
||||
&state.db,
|
||||
id,
|
||||
&req.status,
|
||||
req.notes.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => match db::repositories::commands::find_by_id(&state.db, id).await {
|
||||
Ok(Some(cmd)) => {
|
||||
// Also update in-memory queue if status is verified
|
||||
if req.status == "verified"
|
||||
&& let Ok(cmd_value) = serde_json::from_str(&cmd.command_json)
|
||||
{
|
||||
state.commands.verified.write().await.push(cmd_value);
|
||||
}
|
||||
Ok(Json(cmd.into()))
|
||||
}
|
||||
_ => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ApiError::new("Command not found")),
|
||||
)),
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to update command: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to update command")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Config handlers
|
||||
pub async fn get_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<std::collections::HashMap<String, String>>, (StatusCode, Json<ApiError>)> {
|
||||
match db::repositories::config::get_all(&state.db).await {
|
||||
Ok(config) => Ok(Json(config)),
|
||||
Err(e) => {
|
||||
error!("Failed to get config: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to fetch configuration")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(config): Json<std::collections::HashMap<String, String>>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ApiError>)> {
|
||||
for (key, value) in config {
|
||||
if let Err(e) = db::repositories::config::set(&state.db, &key, &value, None).await {
|
||||
error!("Failed to update config {}: {}", key, e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new(format!("Failed to update config: {}", e))),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
5
src/admin/mod.rs
Normal file
5
src/admin/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod auth_middleware;
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
pub mod routes;
|
||||
pub mod static_files;
|
||||
101
src/admin/models.rs
Normal file
101
src/admin/models.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
// Authentication models
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub old_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
// Request models
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateRuleRequest {
|
||||
pub method_name: String,
|
||||
pub action: String,
|
||||
pub custom_response: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateRuleRequest {
|
||||
pub method_name: Option<String>,
|
||||
pub action: Option<String>,
|
||||
pub custom_response: Option<String>,
|
||||
pub is_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateCommandRequest {
|
||||
pub status: String,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
// Response models
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RuleResponse {
|
||||
pub id: i64,
|
||||
pub method_name: String,
|
||||
pub action: String,
|
||||
pub custom_response: Option<String>,
|
||||
pub is_enabled: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl From<crate::db::models::InterceptionRule> for RuleResponse {
|
||||
fn from(rule: crate::db::models::InterceptionRule) -> Self {
|
||||
Self {
|
||||
id: rule.id,
|
||||
method_name: rule.method_name,
|
||||
action: rule.action,
|
||||
custom_response: rule.custom_response,
|
||||
is_enabled: rule.is_enabled,
|
||||
created_at: rule.created_at,
|
||||
updated_at: rule.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CommandResponse {
|
||||
pub id: i64,
|
||||
pub command: Value,
|
||||
pub status: String,
|
||||
pub received_at: String,
|
||||
pub processed_at: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
impl From<crate::db::models::Command> for CommandResponse {
|
||||
fn from(cmd: crate::db::models::Command) -> Self {
|
||||
Self {
|
||||
id: cmd.id,
|
||||
command: serde_json::from_str(&cmd.command_json).unwrap_or(Value::Null),
|
||||
status: cmd.status,
|
||||
received_at: cmd.received_at,
|
||||
processed_at: cmd.processed_at,
|
||||
notes: cmd.notes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiError {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn new(msg: impl Into<String>) -> Self {
|
||||
Self { error: msg.into() }
|
||||
}
|
||||
}
|
||||
38
src/admin/routes.rs
Normal file
38
src/admin/routes.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
use super::{auth_middleware, handlers, static_files};
|
||||
|
||||
pub fn admin_routes(state: Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
// Public routes (no authentication required)
|
||||
let public_routes = Router::new().route("/api/login", post(handlers::login));
|
||||
|
||||
// Protected API routes (require authentication)
|
||||
let protected_routes = Router::new()
|
||||
.route("/api/password", put(handlers::change_password))
|
||||
.route("/api/rules", get(handlers::list_rules))
|
||||
.route("/api/rules", post(handlers::create_rule))
|
||||
.route("/api/rules/{:id}", put(handlers::update_rule))
|
||||
.route("/api/rules/{:id}", delete(handlers::delete_rule))
|
||||
.route("/api/commands", get(handlers::list_commands))
|
||||
.route("/api/commands/{:id}", post(handlers::verify_command))
|
||||
.route("/api/config", get(handlers::get_config))
|
||||
.route("/api/config", put(handlers::update_config))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state,
|
||||
auth_middleware::auth_middleware,
|
||||
));
|
||||
|
||||
// Combine routes
|
||||
Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(protected_routes)
|
||||
// Static files (frontend)
|
||||
.fallback(static_files::serve_static)
|
||||
}
|
||||
49
src/admin/static_files.rs
Normal file
49
src/admin/static_files.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{StatusCode, Uri, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use rust_embed::RustEmbed;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "frontend/dist"]
|
||||
pub struct Assets;
|
||||
|
||||
pub async fn serve_static(uri: Uri) -> impl IntoResponse {
|
||||
let mut path = uri.path().trim_start_matches("/admin/").to_string();
|
||||
|
||||
// Default to index.html for root or directories
|
||||
if path.is_empty() || path.ends_with('/') {
|
||||
path = "index.html".to_string();
|
||||
}
|
||||
|
||||
info!("{path}");
|
||||
|
||||
match Assets::get(path.trim_start_matches('/')) {
|
||||
Some(content) => {
|
||||
let mime = mime_guess::from_path(&path).first_or_octet_stream();
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime.as_ref())
|
||||
.body(Body::from(content.data))
|
||||
.unwrap()
|
||||
}
|
||||
None => {
|
||||
// For SPA routing, serve index.html for non-asset paths
|
||||
if !path.contains('.')
|
||||
&& let Some(index) = Assets::get("index.html")
|
||||
{
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/html")
|
||||
.body(Body::from(index.data))
|
||||
.unwrap();
|
||||
}
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("404 Not Found"))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user