feat: web frontend; middleware; serde (WIP?)

This commit is contained in:
2025-11-30 09:41:37 +08:00
parent be35040e26
commit 531ac029af
45 changed files with 6806 additions and 82 deletions

View 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
View 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, &current_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
View 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
View 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
View 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
View 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()
}
}
}