feat(frontend): request logs; refactor frontend components
This commit is contained in:
@@ -7,7 +7,10 @@ use axum::{
|
||||
};
|
||||
use tracing::error;
|
||||
|
||||
use crate::{AppState, auth, crypto, db};
|
||||
use crate::{
|
||||
AppState, auth,
|
||||
db::{self, models::RequestLog},
|
||||
};
|
||||
|
||||
use super::models::*;
|
||||
|
||||
@@ -348,3 +351,19 @@ pub async fn update_config(
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
// Log handlers
|
||||
pub async fn list_logs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<RequestLog>>, (StatusCode, Json<ApiError>)> {
|
||||
match db::repositories::logs::list_all(&state.db).await {
|
||||
Ok(logs) => Ok(Json(logs)),
|
||||
Err(e) => {
|
||||
error!("Failed to list logs: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError::new("Failed to fetch logs")),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,3 +99,21 @@ impl ApiError {
|
||||
Self { error: msg.into() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RequestDetails {
|
||||
pub headers: Vec<Header>,
|
||||
pub body: Body,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Body {
|
||||
pub value: Value,
|
||||
pub modified: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Header {
|
||||
pub value: String,
|
||||
pub modified: bool,
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ pub fn admin_routes(state: Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
.route("/api/commands/{:id}", post(handlers::verify_command))
|
||||
.route("/api/config", get(handlers::get_config))
|
||||
.route("/api/config", put(handlers::update_config))
|
||||
.route("/api/logs", get(handlers::list_logs))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state,
|
||||
auth_middleware::auth_middleware,
|
||||
|
||||
@@ -2,12 +2,25 @@ pub mod models;
|
||||
pub mod repositories;
|
||||
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||
use std::str::FromStr;
|
||||
use tracing::info;
|
||||
use std::{path::Path, str::FromStr};
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub async fn init_db(database_url: &str) -> anyhow::Result<SqlitePool> {
|
||||
info!("Initializing database at: {}", database_url);
|
||||
|
||||
// Create directories
|
||||
{
|
||||
// remove scheme from the URL
|
||||
let url = database_url
|
||||
.trim_start_matches("sqlite://")
|
||||
.trim_start_matches("sqlite:");
|
||||
let path = url.split('?').next().unwrap_or_default();
|
||||
if let Some(dir) = Path::new(path).parent()
|
||||
&& let Err(err) = tokio::fs::create_dir_all(dir).await
|
||||
{
|
||||
warn!("Failed to create directories for database ({database_url}): {err}")
|
||||
}
|
||||
}
|
||||
// Parse connection options
|
||||
let options = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct InterceptionRule {
|
||||
@@ -29,23 +30,13 @@ pub struct Config {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct ResponseTemplate {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub method_name: String,
|
||||
pub response_json: String,
|
||||
pub description: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct RequestLog {
|
||||
pub id: i64,
|
||||
pub method: Option<String>,
|
||||
pub path: String,
|
||||
pub request_body: String,
|
||||
pub response_body: String,
|
||||
pub status_code: i32,
|
||||
pub timestamp: String,
|
||||
pub method: String,
|
||||
pub request_body: Value,
|
||||
pub response_body: Value,
|
||||
pub request_interception_action: String,
|
||||
pub response_interception_action: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
39
src/db/repositories/logs.rs
Normal file
39
src/db/repositories/logs.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use crate::db::models::RequestLog;
|
||||
use serde_json::Value;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub async fn list_all(pool: &SqlitePool) -> anyhow::Result<Vec<RequestLog>> {
|
||||
Ok(
|
||||
sqlx::query_as::<_, RequestLog>("SELECT * FROM request_logs ORDER BY created_at")
|
||||
.fetch_all(pool)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
method: String,
|
||||
request_body: Value,
|
||||
response_body: Value,
|
||||
request_interception_action: String,
|
||||
response_interception_action: String,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO request_logs (
|
||||
method,
|
||||
request_body,
|
||||
response_body,
|
||||
request_interception_action,
|
||||
response_interception_action
|
||||
) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(method)
|
||||
.bind(request_body)
|
||||
.bind(response_body)
|
||||
.bind(request_interception_action)
|
||||
.bind(response_interception_action)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.last_insert_rowid())
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod logs;
|
||||
pub mod rules;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{Router, routing::any};
|
||||
use axum::handler::Handler;
|
||||
use axum::{Router, routing::any};
|
||||
use dotenvy::dotenv;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tracing::{error, info, level_filters::LevelFilter};
|
||||
@@ -88,7 +88,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Admin routes
|
||||
.nest("/admin", admin::routes::admin_routes(state.clone()))
|
||||
// Proxy Linspirer APIs
|
||||
.route("/public-interface.php", any(proxy::proxy_handler.layer(proxy_middleware)))
|
||||
.route(
|
||||
"/public-interface.php",
|
||||
any(proxy::proxy_handler.layer(proxy_middleware)),
|
||||
)
|
||||
.layer(CompressionLayer::new().gzip(true))
|
||||
.with_state(state);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::str;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{OriginalUri, State},
|
||||
extract::State,
|
||||
http::{Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
@@ -11,7 +11,7 @@ use http_body_util::BodyExt;
|
||||
use serde_json::Value;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::{AppState, crypto};
|
||||
use crate::{AppState, crypto, db};
|
||||
|
||||
enum ResponseBody {
|
||||
Original(String),
|
||||
@@ -20,7 +20,6 @@ enum ResponseBody {
|
||||
|
||||
pub async fn middleware(
|
||||
State(state): State<Arc<AppState>>,
|
||||
OriginalUri(uri): OriginalUri,
|
||||
req: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> impl IntoResponse {
|
||||
@@ -37,9 +36,7 @@ pub async fn middleware(
|
||||
}
|
||||
};
|
||||
|
||||
let path = uri.path();
|
||||
|
||||
let (decrypted_request_for_log, method) = match str::from_utf8(&body_bytes)
|
||||
let (decrypted_request, method) = match str::from_utf8(&body_bytes)
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(|body| process_and_log_request(body, &state.key, &state.iv))
|
||||
{
|
||||
@@ -78,9 +75,8 @@ pub async fn middleware(
|
||||
let resp_body_text = String::from_utf8(body_bytes.clone().to_vec()).unwrap_or_default();
|
||||
|
||||
// Check for generic method interception first
|
||||
let response_body_to_log = if let Some(method_str) = &method {
|
||||
if let Ok(Some(intercepted)) =
|
||||
maybe_intercept_response(method_str, &resp_body_text, &state).await
|
||||
let response_body = if let Some(method_str) = &method {
|
||||
if let Ok(Some(intercepted)) = intercept_response(method_str, &resp_body_text, &state).await
|
||||
{
|
||||
info!("Intercepting response for method: {}", method_str);
|
||||
ResponseBody::Original(intercepted)
|
||||
@@ -108,28 +104,46 @@ pub async fn middleware(
|
||||
ResponseBody::Original(resp_body_text.clone())
|
||||
};
|
||||
|
||||
let (decrypted_response_for_log, final_response_body) = match response_body_to_log {
|
||||
let (decrypted_response, final_response_body) = match response_body {
|
||||
ResponseBody::Original(body_text) => {
|
||||
let decrypted = decrypt_and_format(&body_text, &state.key, &state.iv)
|
||||
.unwrap_or_else(|_| "Could not decrypt or format response".to_string());
|
||||
let decrypted =
|
||||
decrypt_and_format(&body_text, &state.key, &state.iv).unwrap_or_else(|_| {
|
||||
Value::String("Could not decrypt or format response".to_string())
|
||||
});
|
||||
(decrypted, body_text)
|
||||
}
|
||||
ResponseBody::Modified(body_value) => {
|
||||
let pretty_printed = serde_json::to_string_pretty(&body_value).unwrap_or_default();
|
||||
ResponseBody::Modified(response_body_value) => {
|
||||
let pretty_printed =
|
||||
serde_json::to_string_pretty(&response_body_value).unwrap_or_default();
|
||||
let encrypted = crypto::encrypt(&pretty_printed, &state.key, &state.iv)
|
||||
.unwrap_or_else(|_| "Failed to encrypt modified response".to_string());
|
||||
(pretty_printed, encrypted)
|
||||
(response_body_value, encrypted)
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
"{}\nRequest:\n{}\nResponse:\n{}\n{}",
|
||||
path,
|
||||
serde_json::to_string_pretty(&decrypted_request_for_log).unwrap_or_default(),
|
||||
decrypted_response_for_log,
|
||||
"\nRequest:\n{}\nResponse:\n{}\n{}",
|
||||
serde_json::to_string_pretty(&decrypted_request).unwrap_or_default(),
|
||||
serde_json::to_string_pretty(&decrypted_response).unwrap_or_default(),
|
||||
"-".repeat(80),
|
||||
);
|
||||
|
||||
if let Some(method) = method {
|
||||
// TODO: interception action
|
||||
if let Err(e) = db::repositories::logs::create(
|
||||
&state.db,
|
||||
method,
|
||||
decrypted_request,
|
||||
decrypted_response,
|
||||
"".into(),
|
||||
"".into(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to log request: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let mut response_builder = Response::builder().status(resp_parts.status);
|
||||
if !resp_parts.headers.is_empty() {
|
||||
*response_builder.headers_mut().unwrap() = resp_parts.headers;
|
||||
@@ -220,13 +234,12 @@ async fn handle_getcommand_response(
|
||||
Ok(response_json)
|
||||
}
|
||||
|
||||
fn decrypt_and_format(body_text: &str, key: &str, iv: &str) -> anyhow::Result<String> {
|
||||
fn decrypt_and_format(body_text: &str, key: &str, iv: &str) -> anyhow::Result<Value> {
|
||||
let decrypted = crypto::decrypt(body_text, key, iv)?;
|
||||
let formatted: Value = serde_json::from_str(&decrypted)?;
|
||||
Ok(serde_json::to_string_pretty(&formatted)?)
|
||||
Ok(serde_json::from_str(&decrypted)?)
|
||||
}
|
||||
|
||||
async fn maybe_intercept_response(
|
||||
async fn intercept_response(
|
||||
method: &str,
|
||||
_original_response: &str,
|
||||
state: &Arc<AppState>,
|
||||
|
||||
Reference in New Issue
Block a user