postgress
This commit is contained in:
@@ -1,14 +0,0 @@
|
||||
use tracing::{info};
|
||||
use surrealdb::engine::any;
|
||||
use surrealdb::method::Query;
|
||||
|
||||
/// Binds a filter value to the query under the key "filter".
|
||||
pub fn bind_filter_value(
|
||||
query: Query<'_, any::Any>,
|
||||
val: String,
|
||||
) -> Query<'_, any::Any> {
|
||||
info!(?val, "bind_filter_value called with arguments");
|
||||
let result = query.bind(("filter", val.clone()));
|
||||
info!(?val, "bind_filter_value returning query with bound filter");
|
||||
result
|
||||
}
|
||||
+225
-225
@@ -1,226 +1,226 @@
|
||||
//! CSRF token generation and validation utilities.
|
||||
//!
|
||||
//! This module provides stateless CSRF token management using signed tokens
|
||||
//! with timestamp validation to prevent cross-site request forgery attacks.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use sha2::{Sha256, Digest};
|
||||
use imphnen_entities::error_dto::error::Error;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CsrfPayload {
|
||||
pub timestamp: u64,
|
||||
pub random: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct OAuthCsrfPayload {
|
||||
pub timestamp: u64,
|
||||
pub random: String,
|
||||
pub pkce_verifier: String,
|
||||
}
|
||||
|
||||
/// Generate a signed CSRF token that can be validated without server-side storage
|
||||
pub fn generate_csrf_token(secret: &str) -> Result<String, Error> {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| Error::Auth("Failed to get timestamp".to_string()))?
|
||||
.as_secs();
|
||||
|
||||
let random = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let payload = CsrfPayload {
|
||||
timestamp,
|
||||
random,
|
||||
};
|
||||
|
||||
let payload_json = serde_json::to_string(&payload)
|
||||
.map_err(|e| {
|
||||
error!("CSRF Token Generation: Failed to serialize CSRF payload: {:?}", e);
|
||||
Error::Auth("Failed to serialize CSRF payload".to_string())
|
||||
})?;
|
||||
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
|
||||
|
||||
// Create signature
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(payload_b64.as_bytes());
|
||||
hasher.update(secret.as_bytes());
|
||||
let signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
|
||||
|
||||
Ok(format!("{}.{}", payload_b64, signature))
|
||||
}
|
||||
|
||||
/// Generate a signed OAuth CSRF token with PKCE verifier
|
||||
pub fn generate_oauth_csrf_token(secret: &str, pkce_verifier: &str) -> Result<String, Error> {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| Error::Auth("Failed to get timestamp".to_string()))?
|
||||
.as_secs();
|
||||
|
||||
let random = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let payload = OAuthCsrfPayload {
|
||||
timestamp,
|
||||
random,
|
||||
pkce_verifier: pkce_verifier.to_string(),
|
||||
};
|
||||
|
||||
let payload_json = serde_json::to_string(&payload)
|
||||
.map_err(|e| {
|
||||
error!("OAuth CSRF Token Generation: Failed to serialize payload: {:?}", e);
|
||||
Error::Auth("Failed to serialize OAuth CSRF payload".to_string())
|
||||
})?;
|
||||
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
|
||||
|
||||
// Create signature
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(payload_b64.as_bytes());
|
||||
hasher.update(secret.as_bytes());
|
||||
let signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
|
||||
|
||||
Ok(format!("{}.{}", payload_b64, signature))
|
||||
}
|
||||
|
||||
/// Validate a CSRF token
|
||||
pub fn validate_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result<(), Error> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(Error::Auth("Invalid CSRF token format".to_string()));
|
||||
}
|
||||
|
||||
let payload_b64 = parts[0];
|
||||
let provided_signature = parts[1];
|
||||
|
||||
// Verify signature
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(payload_b64.as_bytes());
|
||||
hasher.update(secret.as_bytes());
|
||||
let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
|
||||
|
||||
if provided_signature != expected_signature {
|
||||
return Err(Error::Auth("Invalid CSRF token signature".to_string()));
|
||||
}
|
||||
|
||||
// Decode and validate payload
|
||||
let payload_json = URL_SAFE_NO_PAD.decode(payload_b64)
|
||||
.map_err(|_| Error::Auth("Failed to decode CSRF token".to_string()))?;
|
||||
|
||||
let payload_str = String::from_utf8(payload_json)
|
||||
.map_err(|_| Error::Auth("Invalid CSRF token encoding".to_string()))?;
|
||||
|
||||
let payload: CsrfPayload = serde_json::from_str(&payload_str)
|
||||
.map_err(|_| Error::Auth("Failed to parse CSRF token".to_string()))?;
|
||||
|
||||
// Check timestamp
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))?
|
||||
.as_secs();
|
||||
|
||||
if now > payload.timestamp + max_age_seconds {
|
||||
return Err(Error::Auth("CSRF token has expired".to_string()));
|
||||
}
|
||||
|
||||
if payload.timestamp > now + 60 { // Allow 1 minute clock skew
|
||||
return Err(Error::Auth("CSRF token timestamp is in the future".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate OAuth CSRF token and extract PKCE verifier
|
||||
pub fn validate_oauth_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result<String, Error> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(Error::Auth("Invalid OAuth CSRF token format".to_string()));
|
||||
}
|
||||
|
||||
let payload_b64 = parts[0];
|
||||
let provided_signature = parts[1];
|
||||
|
||||
// Verify signature
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(payload_b64.as_bytes());
|
||||
hasher.update(secret.as_bytes());
|
||||
let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
|
||||
|
||||
if provided_signature != expected_signature {
|
||||
return Err(Error::Auth("Invalid OAuth CSRF token signature".to_string()));
|
||||
}
|
||||
|
||||
// Decode and validate payload
|
||||
let payload_json = URL_SAFE_NO_PAD.decode(payload_b64)
|
||||
.map_err(|_| Error::Auth("Failed to decode OAuth CSRF token".to_string()))?;
|
||||
|
||||
let payload_str = String::from_utf8(payload_json)
|
||||
.map_err(|_| Error::Auth("Invalid OAuth CSRF token encoding".to_string()))?;
|
||||
|
||||
let payload: OAuthCsrfPayload = serde_json::from_str(&payload_str)
|
||||
.map_err(|_| Error::Auth("Failed to parse OAuth CSRF token".to_string()))?;
|
||||
|
||||
// Check timestamp
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))?
|
||||
.as_secs();
|
||||
|
||||
if now > payload.timestamp + max_age_seconds {
|
||||
return Err(Error::Auth("OAuth CSRF token has expired".to_string()));
|
||||
}
|
||||
|
||||
if payload.timestamp > now + 60 { // Allow 1 minute clock skew
|
||||
return Err(Error::Auth("OAuth CSRF token timestamp is in the future".to_string()));
|
||||
}
|
||||
|
||||
Ok(payload.pkce_verifier)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_csrf_token_generation_and_validation() {
|
||||
let secret = "test_secret";
|
||||
|
||||
// Generate token
|
||||
let token = generate_csrf_token(secret).unwrap();
|
||||
|
||||
// Validate token (should pass)
|
||||
assert!(validate_csrf_token(&token, secret, 300).is_ok());
|
||||
|
||||
// Validate with wrong secret (should fail)
|
||||
assert!(validate_csrf_token(&token, "wrong_secret", 300).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csrf_token_expiration() {
|
||||
let secret = "test_secret";
|
||||
let token = generate_csrf_token(secret).unwrap();
|
||||
|
||||
// Add a 2 second delay to ensure the token expires when max_age is 1 second
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
|
||||
// Should fail with 1 second max age (token is now 2 seconds old)
|
||||
assert!(validate_csrf_token(&token, secret, 1).is_err());
|
||||
|
||||
// Should still work with a large max age
|
||||
assert!(validate_csrf_token(&token, secret, 300).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_csrf_token_format() {
|
||||
let secret = "test_secret";
|
||||
|
||||
// Invalid format (no dot)
|
||||
assert!(validate_csrf_token("invalid_token", secret, 300).is_err());
|
||||
|
||||
// Invalid format (too many dots)
|
||||
assert!(validate_csrf_token("a.b.c", secret, 300).is_err());
|
||||
}
|
||||
//! CSRF token generation and validation utilities.
|
||||
//!
|
||||
//! This module provides stateless CSRF token management using signed tokens
|
||||
//! with timestamp validation to prevent cross-site request forgery attacks.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use sha2::{Sha256, Digest};
|
||||
use imphnen_entities::error_dto::error::Error;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CsrfPayload {
|
||||
pub timestamp: u64,
|
||||
pub random: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct OAuthCsrfPayload {
|
||||
pub timestamp: u64,
|
||||
pub random: String,
|
||||
pub pkce_verifier: String,
|
||||
}
|
||||
|
||||
/// Generate a signed CSRF token that can be validated without server-side storage
|
||||
pub fn generate_csrf_token(secret: &str) -> Result<String, Error> {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| Error::Auth("Failed to get timestamp".to_string()))?
|
||||
.as_secs();
|
||||
|
||||
let random = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let payload = CsrfPayload {
|
||||
timestamp,
|
||||
random,
|
||||
};
|
||||
|
||||
let payload_json = serde_json::to_string(&payload)
|
||||
.map_err(|e| {
|
||||
error!("CSRF Token Generation: Failed to serialize CSRF payload: {:?}", e);
|
||||
Error::Auth("Failed to serialize CSRF payload".to_string())
|
||||
})?;
|
||||
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
|
||||
|
||||
// Create signature
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(payload_b64.as_bytes());
|
||||
hasher.update(secret.as_bytes());
|
||||
let signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
|
||||
|
||||
Ok(format!("{payload_b64}.{signature}"))
|
||||
}
|
||||
|
||||
/// Generate a signed OAuth CSRF token with PKCE verifier
|
||||
pub fn generate_oauth_csrf_token(secret: &str, pkce_verifier: &str) -> Result<String, Error> {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| Error::Auth("Failed to get timestamp".to_string()))?
|
||||
.as_secs();
|
||||
|
||||
let random = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let payload = OAuthCsrfPayload {
|
||||
timestamp,
|
||||
random,
|
||||
pkce_verifier: pkce_verifier.to_string(),
|
||||
};
|
||||
|
||||
let payload_json = serde_json::to_string(&payload)
|
||||
.map_err(|e| {
|
||||
error!("OAuth CSRF Token Generation: Failed to serialize payload: {:?}", e);
|
||||
Error::Auth("Failed to serialize OAuth CSRF payload".to_string())
|
||||
})?;
|
||||
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
|
||||
|
||||
// Create signature
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(payload_b64.as_bytes());
|
||||
hasher.update(secret.as_bytes());
|
||||
let signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
|
||||
|
||||
Ok(format!("{payload_b64}.{signature}"))
|
||||
}
|
||||
|
||||
/// Validate a CSRF token
|
||||
pub fn validate_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result<(), Error> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(Error::Auth("Invalid CSRF token format".to_string()));
|
||||
}
|
||||
|
||||
let payload_b64 = parts[0];
|
||||
let provided_signature = parts[1];
|
||||
|
||||
// Verify signature
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(payload_b64.as_bytes());
|
||||
hasher.update(secret.as_bytes());
|
||||
let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
|
||||
|
||||
if provided_signature != expected_signature {
|
||||
return Err(Error::Auth("Invalid CSRF token signature".to_string()));
|
||||
}
|
||||
|
||||
// Decode and validate payload
|
||||
let payload_json = URL_SAFE_NO_PAD.decode(payload_b64)
|
||||
.map_err(|_| Error::Auth("Failed to decode CSRF token".to_string()))?;
|
||||
|
||||
let payload_str = String::from_utf8(payload_json)
|
||||
.map_err(|_| Error::Auth("Invalid CSRF token encoding".to_string()))?;
|
||||
|
||||
let payload: CsrfPayload = serde_json::from_str(&payload_str)
|
||||
.map_err(|_| Error::Auth("Failed to parse CSRF token".to_string()))?;
|
||||
|
||||
// Check timestamp
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))?
|
||||
.as_secs();
|
||||
|
||||
if now > payload.timestamp + max_age_seconds {
|
||||
return Err(Error::Auth("CSRF token has expired".to_string()));
|
||||
}
|
||||
|
||||
if payload.timestamp > now + 60 { // Allow 1 minute clock skew
|
||||
return Err(Error::Auth("CSRF token timestamp is in the future".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate OAuth CSRF token and extract PKCE verifier
|
||||
pub fn validate_oauth_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result<String, Error> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(Error::Auth("Invalid OAuth CSRF token format".to_string()));
|
||||
}
|
||||
|
||||
let payload_b64 = parts[0];
|
||||
let provided_signature = parts[1];
|
||||
|
||||
// Verify signature
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(payload_b64.as_bytes());
|
||||
hasher.update(secret.as_bytes());
|
||||
let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
|
||||
|
||||
if provided_signature != expected_signature {
|
||||
return Err(Error::Auth("Invalid OAuth CSRF token signature".to_string()));
|
||||
}
|
||||
|
||||
// Decode and validate payload
|
||||
let payload_json = URL_SAFE_NO_PAD.decode(payload_b64)
|
||||
.map_err(|_| Error::Auth("Failed to decode OAuth CSRF token".to_string()))?;
|
||||
|
||||
let payload_str = String::from_utf8(payload_json)
|
||||
.map_err(|_| Error::Auth("Invalid OAuth CSRF token encoding".to_string()))?;
|
||||
|
||||
let payload: OAuthCsrfPayload = serde_json::from_str(&payload_str)
|
||||
.map_err(|_| Error::Auth("Failed to parse OAuth CSRF token".to_string()))?;
|
||||
|
||||
// Check timestamp
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))?
|
||||
.as_secs();
|
||||
|
||||
if now > payload.timestamp + max_age_seconds {
|
||||
return Err(Error::Auth("OAuth CSRF token has expired".to_string()));
|
||||
}
|
||||
|
||||
if payload.timestamp > now + 60 { // Allow 1 minute clock skew
|
||||
return Err(Error::Auth("OAuth CSRF token timestamp is in the future".to_string()));
|
||||
}
|
||||
|
||||
Ok(payload.pkce_verifier)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_csrf_token_generation_and_validation() {
|
||||
let secret = "test_secret";
|
||||
|
||||
// Generate token
|
||||
let token = generate_csrf_token(secret).unwrap();
|
||||
|
||||
// Validate token (should pass)
|
||||
assert!(validate_csrf_token(&token, secret, 300).is_ok());
|
||||
|
||||
// Validate with wrong secret (should fail)
|
||||
assert!(validate_csrf_token(&token, "wrong_secret", 300).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csrf_token_expiration() {
|
||||
let secret = "test_secret";
|
||||
let token = generate_csrf_token(secret).unwrap();
|
||||
|
||||
// Add a 2 second delay to ensure the token expires when max_age is 1 second
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
|
||||
// Should fail with 1 second max age (token is now 2 seconds old)
|
||||
assert!(validate_csrf_token(&token, secret, 1).is_err());
|
||||
|
||||
// Should still work with a large max age
|
||||
assert!(validate_csrf_token(&token, secret, 300).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_csrf_token_format() {
|
||||
let secret = "test_secret";
|
||||
|
||||
// Invalid format (no dot)
|
||||
assert!(validate_csrf_token("invalid_token", secret, 300).is_err());
|
||||
|
||||
// Invalid format (too many dots)
|
||||
assert!(validate_csrf_token("a.b.c", secret, 300).is_err());
|
||||
}
|
||||
}
|
||||
+94
-70
@@ -1,71 +1,95 @@
|
||||
use axum::http::StatusCode;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub enum AppError {
|
||||
ValidationError(String),
|
||||
AuthenticationError(String),
|
||||
AuthorizationError(String),
|
||||
NotFoundError(String),
|
||||
ConflictError(String),
|
||||
InternalServerError(String),
|
||||
BadRequestError(String),
|
||||
ForbiddenError(String),
|
||||
PaymentRequiredError(String),
|
||||
MethodNotAllowedError(String),
|
||||
NotAcceptableError(String),
|
||||
RequestTimeoutError(String),
|
||||
TooManyRequestsError(String),
|
||||
GatewayTimeoutError(String),
|
||||
ServiceUnavailableError(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AppError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
|
||||
AppError::AuthenticationError(msg) => write!(f, "Authentication failed: {}", msg),
|
||||
AppError::AuthorizationError(msg) => write!(f, "Authorization failed: {}", msg),
|
||||
AppError::NotFoundError(msg) => write!(f, "Resource not found: {}", msg),
|
||||
AppError::ConflictError(msg) => write!(f, "Conflict error: {}", msg),
|
||||
AppError::InternalServerError(msg) => write!(f, "Internal server error: {}", msg),
|
||||
AppError::BadRequestError(msg) => write!(f, "Bad request: {}", msg),
|
||||
AppError::ForbiddenError(msg) => write!(f, "Forbidden: {}", msg),
|
||||
AppError::PaymentRequiredError(msg) => write!(f, "Payment required: {}", msg),
|
||||
AppError::MethodNotAllowedError(msg) => write!(f, "Method not allowed: {}", msg),
|
||||
AppError::NotAcceptableError(msg) => write!(f, "Not acceptable: {}", msg),
|
||||
AppError::RequestTimeoutError(msg) => write!(f, "Request timeout: {}", msg),
|
||||
AppError::TooManyRequestsError(msg) => write!(f, "Too many requests: {}", msg),
|
||||
AppError::GatewayTimeoutError(msg) => write!(f, "Gateway timeout: {}", msg),
|
||||
AppError::ServiceUnavailableError(msg) => write!(f, "Service unavailable: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
AppError::ValidationError(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::AuthenticationError(_) => StatusCode::UNAUTHORIZED,
|
||||
AppError::AuthorizationError(_) => StatusCode::FORBIDDEN,
|
||||
AppError::NotFoundError(_) => StatusCode::NOT_FOUND,
|
||||
AppError::ConflictError(_) => StatusCode::CONFLICT,
|
||||
AppError::InternalServerError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AppError::BadRequestError(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::ForbiddenError(_) => StatusCode::FORBIDDEN,
|
||||
AppError::PaymentRequiredError(_) => StatusCode::PAYMENT_REQUIRED,
|
||||
AppError::MethodNotAllowedError(_) => StatusCode::METHOD_NOT_ALLOWED,
|
||||
AppError::NotAcceptableError(_) => StatusCode::NOT_ACCEPTABLE,
|
||||
AppError::RequestTimeoutError(_) => StatusCode::REQUEST_TIMEOUT,
|
||||
AppError::TooManyRequestsError(_) => StatusCode::TOO_MANY_REQUESTS,
|
||||
AppError::GatewayTimeoutError(_) => StatusCode::GATEWAY_TIMEOUT,
|
||||
AppError::ServiceUnavailableError(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(&self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub enum AppError {
|
||||
ValidationError(String),
|
||||
AuthenticationError(String),
|
||||
AuthorizationError(String),
|
||||
NotFoundError(String),
|
||||
ConflictError(String),
|
||||
InternalServerError(String),
|
||||
BadRequestError(String),
|
||||
ForbiddenError(String),
|
||||
PaymentRequiredError(String),
|
||||
MethodNotAllowedError(String),
|
||||
NotAcceptableError(String),
|
||||
RequestTimeoutError(String),
|
||||
TooManyRequestsError(String),
|
||||
GatewayTimeoutError(String),
|
||||
ServiceUnavailableError(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AppError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
|
||||
AppError::AuthenticationError(msg) => write!(f, "Authentication failed: {}", msg),
|
||||
AppError::AuthorizationError(msg) => write!(f, "Authorization failed: {}", msg),
|
||||
AppError::NotFoundError(msg) => write!(f, "Resource not found: {}", msg),
|
||||
AppError::ConflictError(msg) => write!(f, "Conflict error: {}", msg),
|
||||
AppError::InternalServerError(msg) => write!(f, "Internal server error: {}", msg),
|
||||
AppError::BadRequestError(msg) => write!(f, "Bad request: {}", msg),
|
||||
AppError::ForbiddenError(msg) => write!(f, "Forbidden: {}", msg),
|
||||
AppError::PaymentRequiredError(msg) => write!(f, "Payment required: {}", msg),
|
||||
AppError::MethodNotAllowedError(msg) => write!(f, "Method not allowed: {}", msg),
|
||||
AppError::NotAcceptableError(msg) => write!(f, "Not acceptable: {}", msg),
|
||||
AppError::RequestTimeoutError(msg) => write!(f, "Request timeout: {}", msg),
|
||||
AppError::TooManyRequestsError(msg) => write!(f, "Too many requests: {}", msg),
|
||||
AppError::GatewayTimeoutError(msg) => write!(f, "Gateway timeout: {}", msg),
|
||||
AppError::ServiceUnavailableError(msg) => write!(f, "Service unavailable: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
AppError::ValidationError(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::AuthenticationError(_) => StatusCode::UNAUTHORIZED,
|
||||
AppError::AuthorizationError(_) => StatusCode::FORBIDDEN,
|
||||
AppError::NotFoundError(_) => StatusCode::NOT_FOUND,
|
||||
AppError::ConflictError(_) => StatusCode::CONFLICT,
|
||||
AppError::InternalServerError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AppError::BadRequestError(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::ForbiddenError(_) => StatusCode::FORBIDDEN,
|
||||
AppError::PaymentRequiredError(_) => StatusCode::PAYMENT_REQUIRED,
|
||||
AppError::MethodNotAllowedError(_) => StatusCode::METHOD_NOT_ALLOWED,
|
||||
AppError::NotAcceptableError(_) => StatusCode::NOT_ACCEPTABLE,
|
||||
AppError::RequestTimeoutError(_) => StatusCode::REQUEST_TIMEOUT,
|
||||
AppError::TooManyRequestsError(_) => StatusCode::TOO_MANY_REQUESTS,
|
||||
AppError::GatewayTimeoutError(_) => StatusCode::GATEWAY_TIMEOUT,
|
||||
AppError::ServiceUnavailableError(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message(&self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sea_orm::DbErr> for AppError {
|
||||
fn from(err: sea_orm::DbErr) -> Self {
|
||||
AppError::InternalServerError(format!("Database error: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
AppError::InternalServerError(format!("Error: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chrono::ParseError> for AppError {
|
||||
fn from(err: chrono::ParseError) -> Self {
|
||||
AppError::BadRequestError(format!("Date parsing error: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<uuid::Error> for AppError {
|
||||
fn from(err: uuid::Error) -> Self {
|
||||
AppError::BadRequestError(format!("UUID parsing error: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T, E = AppError> = std::result::Result<T, E>;
|
||||
+154
-154
@@ -1,155 +1,155 @@
|
||||
//! Email extraction utilities from authentication tokens.
|
||||
//!
|
||||
//! This module provides functions to extract email addresses from JWT tokens
|
||||
//! and Google OAuth access tokens, supporting both synchronous and asynchronous
|
||||
//! validation methods.
|
||||
|
||||
use tracing::{error, info};
|
||||
use crate::decode_access_token;
|
||||
use axum::http::{HeaderMap, header::AUTHORIZATION};
|
||||
|
||||
/// Extracts the email from the Authorization header, if present and valid.
|
||||
/// Supports both our internal JWT tokens and Google access tokens.
|
||||
pub fn extract_email(headers: &HeaderMap) -> Option<String> {
|
||||
let auth_header = match headers.get(AUTHORIZATION) {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
error!("Authorization header missing in extract_email");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let auth_str = match auth_header.to_str() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Failed to convert Authorization header to str in extract_email");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let token = match auth_str.strip_prefix("Bearer ") {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
error!(auth_str, "Authorization header does not start with 'Bearer ' in extract_email");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// First try to decode as our internal JWT token
|
||||
match decode_access_token(token) {
|
||||
Ok(data) => {
|
||||
Some(data.claims.sub)
|
||||
}
|
||||
Err(_) => {
|
||||
// If it fails, it might be a Google access token
|
||||
// For Google tokens, we need async validation, so we'll return None here
|
||||
// and handle Google tokens separately in the calling code
|
||||
error!("Token is not a valid internal JWT. If this is a Google token, please use extract_email_async or handle Google OAuth flow properly.");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Async version that can handle Google access tokens
|
||||
pub async fn extract_email_async(headers: &HeaderMap) -> Option<String> {
|
||||
let auth_header = match headers.get(AUTHORIZATION) {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
error!("Authorization header missing in extract_email_async");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let auth_str = match auth_header.to_str() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Failed to convert Authorization header to str in extract_email_async");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let token = match auth_str.strip_prefix("Bearer ") {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
error!(auth_str, "Authorization header does not start with 'Bearer ' in extract_email_async");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// First try to decode as our internal JWT token
|
||||
match decode_access_token(token) {
|
||||
Ok(data) => {
|
||||
Some(data.claims.sub)
|
||||
}
|
||||
Err(_) => {
|
||||
// If it fails, try to validate as Google access token
|
||||
extract_email_from_google_token(token).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts email from Google access token by calling Google's tokeninfo endpoint
|
||||
async fn extract_email_from_google_token(token: &str) -> Option<String> {
|
||||
use serde_json::Value;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let tokeninfo_url = format!("https://oauth2.googleapis.com/tokeninfo?access_token={}", token);
|
||||
|
||||
match client.get(&tokeninfo_url).send().await {
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
match response.json::<Value>().await {
|
||||
Ok(token_info) => {
|
||||
if let Some(email) = token_info.get("email").and_then(|e| e.as_str()) {
|
||||
info!(email = %email, "Successfully extracted email from Google token");
|
||||
Some(email.to_string())
|
||||
} else {
|
||||
error!("Email not found in Google token info response");
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Failed to parse Google token info response");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!(status = %response.status(), "Google token validation failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Failed to validate Google token");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the email from a JWT token string.
|
||||
/// Supports both our internal JWT tokens and Google access tokens.
|
||||
pub fn extract_email_token(token: String) -> Option<String> {
|
||||
match decode_access_token(&token) {
|
||||
Ok(data) => {
|
||||
Some(data.claims.sub)
|
||||
}
|
||||
Err(_) => {
|
||||
// If it fails, it might be a Google access token
|
||||
// For Google tokens, we need async validation, so we'll return None here
|
||||
// and handle Google tokens separately in the calling code
|
||||
error!("Token is not a valid internal JWT. If this is a Google token, please use extract_email_token_async or handle Google OAuth flow properly.");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple helper to check if a token string looks like a JWT.
|
||||
fn is_jwt(token: &str) -> bool {
|
||||
let parts: Vec<_> = token.split('.').collect();
|
||||
parts.len() == 3
|
||||
}
|
||||
|
||||
/// Async version of extract_email_token that can handle Google access tokens
|
||||
pub async fn extract_email_token_async(token: String) -> Option<String> {
|
||||
if is_jwt(&token) && let Ok(data) = decode_access_token(&token) {
|
||||
return Some(data.claims.sub);
|
||||
}
|
||||
|
||||
// If it's not a valid internal JWT, try to validate as Google access token
|
||||
extract_email_from_google_token(&token).await
|
||||
//! Email extraction utilities from authentication tokens.
|
||||
//!
|
||||
//! This module provides functions to extract email addresses from JWT tokens
|
||||
//! and Google OAuth access tokens, supporting both synchronous and asynchronous
|
||||
//! validation methods.
|
||||
|
||||
use tracing::{error, info};
|
||||
use imphnen_libs::jsonwebtoken::decode_access_token;
|
||||
use axum::http::{HeaderMap, header::AUTHORIZATION};
|
||||
|
||||
/// Extracts the email from the Authorization header, if present and valid.
|
||||
/// Supports both our internal JWT tokens and Google access tokens.
|
||||
pub fn extract_email(headers: &HeaderMap) -> Option<String> {
|
||||
let auth_header = match headers.get(AUTHORIZATION) {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
error!("Authorization header missing in extract_email");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let auth_str = match auth_header.to_str() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Failed to convert Authorization header to str in extract_email");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let token = match auth_str.strip_prefix("Bearer ") {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
error!(auth_str, "Authorization header does not start with 'Bearer ' in extract_email");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// First try to decode as our internal JWT token
|
||||
match decode_access_token(token) {
|
||||
Ok(data) => {
|
||||
Some(data.claims.sub)
|
||||
}
|
||||
Err(_) => {
|
||||
// If it fails, it might be a Google access token
|
||||
// For Google tokens, we need async validation, so we'll return None here
|
||||
// and handle Google tokens separately in the calling code
|
||||
error!("Token is not a valid internal JWT. If this is a Google token, please use extract_email_async or handle Google OAuth flow properly.");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Async version that can handle Google access tokens
|
||||
pub async fn extract_email_async(headers: &HeaderMap) -> Option<String> {
|
||||
let auth_header = match headers.get(AUTHORIZATION) {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
error!("Authorization header missing in extract_email_async");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let auth_str = match auth_header.to_str() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Failed to convert Authorization header to str in extract_email_async");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let token = match auth_str.strip_prefix("Bearer ") {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
error!(auth_str, "Authorization header does not start with 'Bearer ' in extract_email_async");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// First try to decode as our internal JWT token
|
||||
match decode_access_token(token) {
|
||||
Ok(data) => {
|
||||
Some(data.claims.sub)
|
||||
}
|
||||
Err(_) => {
|
||||
// If it fails, try to validate as Google access token
|
||||
extract_email_from_google_token(token).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts email from Google access token by calling Google's tokeninfo endpoint
|
||||
async fn extract_email_from_google_token(token: &str) -> Option<String> {
|
||||
use serde_json::Value;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let tokeninfo_url = format!("https://oauth2.googleapis.com/tokeninfo?access_token={token}");
|
||||
|
||||
match client.get(&tokeninfo_url).send().await {
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
match response.json::<Value>().await {
|
||||
Ok(token_info) => {
|
||||
if let Some(email) = token_info.get("email").and_then(|e| e.as_str()) {
|
||||
info!(email = %email, "Successfully extracted email from Google token");
|
||||
Some(email.to_string())
|
||||
} else {
|
||||
error!("Email not found in Google token info response");
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Failed to parse Google token info response");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!(status = %response.status(), "Google token validation failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Failed to validate Google token");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the email from a JWT token string.
|
||||
/// Supports both our internal JWT tokens and Google access tokens.
|
||||
pub fn extract_email_token(token: String) -> Option<String> {
|
||||
match decode_access_token(&token) {
|
||||
Ok(data) => {
|
||||
Some(data.claims.sub)
|
||||
}
|
||||
Err(_) => {
|
||||
// If it fails, it might be a Google access token
|
||||
// For Google tokens, we need async validation, so we'll return None here
|
||||
// and handle Google tokens separately in the calling code
|
||||
error!("Token is not a valid internal JWT. If this is a Google token, please use extract_email_token_async or handle Google OAuth flow properly.");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple helper to check if a token string looks like a JWT.
|
||||
fn is_jwt(token: &str) -> bool {
|
||||
let parts: Vec<_> = token.split('.').collect();
|
||||
parts.len() == 3
|
||||
}
|
||||
|
||||
/// Async version of extract_email_token that can handle Google access tokens
|
||||
pub async fn extract_email_token_async(token: String) -> Option<String> {
|
||||
if is_jwt(&token) && let Ok(data) = decode_access_token(&token) {
|
||||
return Some(data.claims.sub);
|
||||
}
|
||||
|
||||
// If it's not a valid internal JWT, try to validate as Google access token
|
||||
extract_email_from_google_token(&token).await
|
||||
}
|
||||
+143
-143
@@ -1,144 +1,144 @@
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
/// Extract real client IP address from various headers commonly used in proxies
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. X-Forwarded-For (first IP in the list)
|
||||
/// 2. X-Real-IP
|
||||
/// 3. CF-Connecting-IP (Cloudflare)
|
||||
/// 4. True-Client-IP (Akamai and others)
|
||||
/// 5. X-Cluster-Client-IP
|
||||
/// 6. Forwarded (standard header)
|
||||
/// 7. Direct connection IP (if available)
|
||||
pub fn extract_real_ip(headers: &HeaderMap) -> Option<String> {
|
||||
// Try different headers in priority order
|
||||
if let Some(ip) = extract_from_x_forwarded_for(headers) {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(ip) = extract_header_value(headers, "x-real-ip") {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(ip) = extract_header_value(headers, "cf-connecting-ip") {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(ip) = extract_header_value(headers, "true-client-ip") {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(ip) = extract_header_value(headers, "x-cluster-client-ip") {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(ip) = extract_from_forwarded_header(headers) {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the first IP from X-Forwarded-For header
|
||||
fn extract_from_x_forwarded_for(headers: &HeaderMap) -> Option<String> {
|
||||
let header_value = headers.get("x-forwarded-for")?;
|
||||
let header_str = header_value.to_str().ok()?;
|
||||
|
||||
// X-Forwarded-For can contain multiple IPs separated by commas
|
||||
// We take the first one (the original client IP)
|
||||
header_str.split(',').next()
|
||||
.map(|ip| ip.trim().to_string())
|
||||
.filter(|ip| is_valid_ip(ip))
|
||||
}
|
||||
|
||||
/// Extract IP from Forwarded header (RFC 7239)
|
||||
fn extract_from_forwarded_header(headers: &HeaderMap) -> Option<String> {
|
||||
let header_value = headers.get("forwarded")?;
|
||||
let header_str = header_value.to_str().ok()?;
|
||||
|
||||
// Parse Forwarded header: for=192.0.2.60;proto=http;by=203.0.113.43
|
||||
for part in header_str.split(';') {
|
||||
if part.trim().starts_with("for=") {
|
||||
let ip = part.trim().trim_start_matches("for=");
|
||||
// Remove quotes and brackets if present
|
||||
let ip = ip.trim_matches('"').trim_matches('[').trim_matches(']');
|
||||
if is_valid_ip(ip) {
|
||||
return Some(ip.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract value from a specific header
|
||||
fn extract_header_value(headers: &HeaderMap, header_name: &str) -> Option<String> {
|
||||
let header_value = headers.get(header_name)?;
|
||||
let value_str = header_value.to_str().ok()?;
|
||||
|
||||
if is_valid_ip(value_str) {
|
||||
Some(value_str.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Basic IP validation
|
||||
fn is_valid_ip(ip: &str) -> bool {
|
||||
// Simple validation - check if it looks like an IP address
|
||||
if ip.is_empty() || ip == "unknown" || ip == "undefined" {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for IPv4 pattern
|
||||
if ip.split('.').count() == 4 && ip.chars().all(|c| c.is_ascii_digit() || c == '.') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for IPv6 pattern (simplified)
|
||||
if ip.contains(':') {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::HeaderValue;
|
||||
|
||||
#[test]
|
||||
fn test_extract_from_x_forwarded_for() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("192.168.1.1, 10.0.0.1"));
|
||||
|
||||
assert_eq!(extract_from_x_forwarded_for(&headers), Some("192.168.1.1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_from_forwarded_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("forwarded", HeaderValue::from_static("for=192.168.1.1;proto=https"));
|
||||
|
||||
assert_eq!(extract_from_forwarded_header(&headers), Some("192.168.1.1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_real_ip_priority() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("192.168.1.1"));
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.1"));
|
||||
|
||||
// Should prefer x-forwarded-for
|
||||
assert_eq!(extract_real_ip(&headers), Some("192.168.1.1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_ip_rejection() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("unknown"));
|
||||
|
||||
assert_eq!(extract_real_ip(&headers), None);
|
||||
}
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
/// Extract real client IP address from various headers commonly used in proxies
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. X-Forwarded-For (first IP in the list)
|
||||
/// 2. X-Real-IP
|
||||
/// 3. CF-Connecting-IP (Cloudflare)
|
||||
/// 4. True-Client-IP (Akamai and others)
|
||||
/// 5. X-Cluster-Client-IP
|
||||
/// 6. Forwarded (standard header)
|
||||
/// 7. Direct connection IP (if available)
|
||||
pub fn extract_real_ip(headers: &HeaderMap) -> Option<String> {
|
||||
// Try different headers in priority order
|
||||
if let Some(ip) = extract_from_x_forwarded_for(headers) {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(ip) = extract_header_value(headers, "x-real-ip") {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(ip) = extract_header_value(headers, "cf-connecting-ip") {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(ip) = extract_header_value(headers, "true-client-ip") {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(ip) = extract_header_value(headers, "x-cluster-client-ip") {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(ip) = extract_from_forwarded_header(headers) {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the first IP from X-Forwarded-For header
|
||||
fn extract_from_x_forwarded_for(headers: &HeaderMap) -> Option<String> {
|
||||
let header_value = headers.get("x-forwarded-for")?;
|
||||
let header_str = header_value.to_str().ok()?;
|
||||
|
||||
// X-Forwarded-For can contain multiple IPs separated by commas
|
||||
// We take the first one (the original client IP)
|
||||
header_str.split(',').next()
|
||||
.map(|ip| ip.trim().to_string())
|
||||
.filter(|ip| is_valid_ip(ip))
|
||||
}
|
||||
|
||||
/// Extract IP from Forwarded header (RFC 7239)
|
||||
fn extract_from_forwarded_header(headers: &HeaderMap) -> Option<String> {
|
||||
let header_value = headers.get("forwarded")?;
|
||||
let header_str = header_value.to_str().ok()?;
|
||||
|
||||
// Parse Forwarded header: for=192.0.2.60;proto=http;by=203.0.113.43
|
||||
for part in header_str.split(';') {
|
||||
if part.trim().starts_with("for=") {
|
||||
let ip = part.trim().trim_start_matches("for=");
|
||||
// Remove quotes and brackets if present
|
||||
let ip = ip.trim_matches('"').trim_matches('[').trim_matches(']');
|
||||
if is_valid_ip(ip) {
|
||||
return Some(ip.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract value from a specific header
|
||||
fn extract_header_value(headers: &HeaderMap, header_name: &str) -> Option<String> {
|
||||
let header_value = headers.get(header_name)?;
|
||||
let value_str = header_value.to_str().ok()?;
|
||||
|
||||
if is_valid_ip(value_str) {
|
||||
Some(value_str.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Basic IP validation
|
||||
fn is_valid_ip(ip: &str) -> bool {
|
||||
// Simple validation - check if it looks like an IP address
|
||||
if ip.is_empty() || ip == "unknown" || ip == "undefined" {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for IPv4 pattern
|
||||
if ip.split('.').count() == 4 && ip.chars().all(|c| c.is_ascii_digit() || c == '.') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for IPv6 pattern (simplified)
|
||||
if ip.contains(':') {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::HeaderValue;
|
||||
|
||||
#[test]
|
||||
fn test_extract_from_x_forwarded_for() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("192.168.1.1, 10.0.0.1"));
|
||||
|
||||
assert_eq!(extract_from_x_forwarded_for(&headers), Some("192.168.1.1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_from_forwarded_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("forwarded", HeaderValue::from_static("for=192.168.1.1;proto=https"));
|
||||
|
||||
assert_eq!(extract_from_forwarded_header(&headers), Some("192.168.1.1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_real_ip_priority() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("192.168.1.1"));
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.1"));
|
||||
|
||||
// Should prefer x-forwarded-for
|
||||
assert_eq!(extract_real_ip(&headers), Some("192.168.1.1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_ip_rejection() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("unknown"));
|
||||
|
||||
assert_eq!(extract_real_ip(&headers), None);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,30 @@
|
||||
use tracing::{info};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Returns the current UTC date/time as an RFC3339 string.
|
||||
pub fn get_iso_date() -> String {
|
||||
info!("get_iso_date called");
|
||||
let now: DateTime<Utc> = Utc::now();
|
||||
let date_str = now.to_rfc3339();
|
||||
info!(date_str = %date_str, "get_iso_date returning RFC3339 date string");
|
||||
date_str
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::DateTime;
|
||||
|
||||
#[test]
|
||||
fn test_get_iso_date() {
|
||||
let date_str = get_iso_date();
|
||||
// Should be valid RFC3339
|
||||
let parsed = DateTime::parse_from_rfc3339(&date_str);
|
||||
assert!(parsed.is_ok());
|
||||
// Should be recent (within last second)
|
||||
let now = Utc::now();
|
||||
let parsed = parsed.unwrap().with_timezone(&Utc);
|
||||
let diff = (now - parsed).num_milliseconds().abs();
|
||||
assert!(diff < 1000); // Within 1 second
|
||||
}
|
||||
}
|
||||
use tracing::{info};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Returns the current UTC date/time as an RFC3339 string.
|
||||
pub fn get_iso_date() -> String {
|
||||
info!("get_iso_date called");
|
||||
let now: DateTime<Utc> = Utc::now();
|
||||
let date_str = now.to_rfc3339();
|
||||
info!(date_str = %date_str, "get_iso_date returning RFC3339 date string");
|
||||
date_str
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::DateTime;
|
||||
|
||||
#[test]
|
||||
fn test_get_iso_date() {
|
||||
let date_str = get_iso_date();
|
||||
// Should be valid RFC3339
|
||||
let parsed = DateTime::parse_from_rfc3339(&date_str);
|
||||
assert!(parsed.is_ok());
|
||||
// Should be recent (within last second)
|
||||
let now = Utc::now();
|
||||
let parsed = parsed.unwrap().with_timezone(&Utc);
|
||||
let diff = (now - parsed).num_milliseconds().abs();
|
||||
assert!(diff < 1000); // Within 1 second
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +1,87 @@
|
||||
//! OTP generation utilities with time-based expiration and secure hashing.
|
||||
//!
|
||||
//! This module provides functionality to generate one-time passwords (OTPs) with
|
||||
//! a 5-minute expiration time and SHA256 hashing for secure storage and validation,
|
||||
//! preventing replay attacks.
|
||||
|
||||
use rand::{Rng, rng};
|
||||
use sha2::{Sha256, Digest};
|
||||
use chrono::{DateTime, Utc, Duration};
|
||||
|
||||
/// Represents an OTP with its code, hashed value and expiration time
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OtpData {
|
||||
pub code: u32,
|
||||
pub hash: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct OtpManager;
|
||||
|
||||
impl OtpManager {
|
||||
/// Generates a new OTP with a 5-minute expiration and SHA256 hash for secure storage
|
||||
pub fn generate_otp() -> OtpData {
|
||||
let code = rng().random_range(100_000..1_000_000);
|
||||
let otp_str = code.to_string();
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(otp_str.as_bytes());
|
||||
let hash = format!("{:x}", hasher.finalize());
|
||||
let expires_at = Utc::now() + Duration::minutes(5);
|
||||
OtpData { code, hash, expires_at }
|
||||
}
|
||||
|
||||
/// Validates the user-provided OTP against the stored OTP data
|
||||
/// Checks both hash match and expiration
|
||||
pub fn validate_otp(stored: &OtpData, user_otp: u32) -> bool {
|
||||
if Utc::now() > stored.expires_at {
|
||||
return false;
|
||||
}
|
||||
let user_otp_str = user_otp.to_string();
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(user_otp_str.as_bytes());
|
||||
let user_hash = format!("{:x}", hasher.finalize());
|
||||
user_hash == stored.hash
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_otp() {
|
||||
let otp = OtpManager::generate_otp();
|
||||
assert!(otp.code >= 100_000 && otp.code < 1_000_000);
|
||||
assert!(!otp.hash.is_empty());
|
||||
assert!(otp.expires_at > Utc::now());
|
||||
assert!(otp.expires_at <= Utc::now() + chrono::Duration::minutes(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_otp_valid() {
|
||||
let otp = OtpManager::generate_otp();
|
||||
assert!(OtpManager::validate_otp(&otp, otp.code));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_otp_invalid_code() {
|
||||
let otp = OtpManager::generate_otp();
|
||||
assert!(!OtpManager::validate_otp(&otp, 123456)); // Wrong code
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_otp_expired() {
|
||||
let mut otp = OtpManager::generate_otp();
|
||||
otp.expires_at = Utc::now() - chrono::Duration::seconds(1); // Expired
|
||||
assert!(!OtpManager::validate_otp(&otp, otp.code));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_otp_uniqueness() {
|
||||
let otp1 = OtpManager::generate_otp();
|
||||
let otp2 = OtpManager::generate_otp();
|
||||
// Codes should be different (high probability)
|
||||
assert_ne!(otp1.code, otp2.code);
|
||||
assert_ne!(otp1.hash, otp2.hash);
|
||||
}
|
||||
}
|
||||
//! OTP generation utilities with time-based expiration and secure hashing.
|
||||
//!
|
||||
//! This module provides functionality to generate one-time passwords (OTPs) with
|
||||
//! a 5-minute expiration time and SHA256 hashing for secure storage and validation,
|
||||
//! preventing replay attacks.
|
||||
|
||||
use rand::{Rng, rng};
|
||||
use sha2::{Sha256, Digest};
|
||||
use chrono::{DateTime, Utc, Duration};
|
||||
|
||||
/// Represents an OTP with its code, hashed value and expiration time
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OtpData {
|
||||
pub code: u32,
|
||||
pub hash: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct OtpManager;
|
||||
|
||||
impl OtpManager {
|
||||
/// Generates a new OTP with a 5-minute expiration and SHA256 hash for secure storage
|
||||
pub fn generate_otp() -> OtpData {
|
||||
let code = rng().random_range(100_000..1_000_000);
|
||||
let otp_str = code.to_string();
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(otp_str.as_bytes());
|
||||
let hash = format!("{:x}", hasher.finalize());
|
||||
let expires_at = Utc::now() + Duration::minutes(5);
|
||||
OtpData { code, hash, expires_at }
|
||||
}
|
||||
|
||||
/// Validates the user-provided OTP against the stored OTP data
|
||||
/// Checks both hash match and expiration
|
||||
pub fn validate_otp(stored: &OtpData, user_otp: u32) -> bool {
|
||||
if Utc::now() > stored.expires_at {
|
||||
return false;
|
||||
}
|
||||
let user_otp_str = user_otp.to_string();
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(user_otp_str.as_bytes());
|
||||
let user_hash = format!("{:x}", hasher.finalize());
|
||||
user_hash == stored.hash
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_otp() {
|
||||
let otp = OtpManager::generate_otp();
|
||||
assert!(otp.code >= 100_000 && otp.code < 1_000_000);
|
||||
assert!(!otp.hash.is_empty());
|
||||
assert!(otp.expires_at > Utc::now());
|
||||
assert!(otp.expires_at <= Utc::now() + chrono::Duration::minutes(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_otp_valid() {
|
||||
let otp = OtpManager::generate_otp();
|
||||
assert!(OtpManager::validate_otp(&otp, otp.code));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_otp_invalid_code() {
|
||||
let otp = OtpManager::generate_otp();
|
||||
assert!(!OtpManager::validate_otp(&otp, 123456)); // Wrong code
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_otp_expired() {
|
||||
let mut otp = OtpManager::generate_otp();
|
||||
otp.expires_at = Utc::now() - chrono::Duration::seconds(1); // Expired
|
||||
assert!(!OtpManager::validate_otp(&otp, otp.code));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_otp_uniqueness() {
|
||||
let otp1 = OtpManager::generate_otp();
|
||||
let otp2 = OtpManager::generate_otp();
|
||||
// Codes should be different (high probability)
|
||||
assert_ne!(otp1.code, otp2.code);
|
||||
assert_ne!(otp1.hash, otp2.hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
use tracing::{info, error};
|
||||
use anyhow::{Result, bail};
|
||||
use surrealdb::sql::Thing;
|
||||
|
||||
/// Extracts the table and id from a Thing, returning (&str, &str).
|
||||
pub fn get_id(thing: &Thing) -> Result<(&str, &str)> {
|
||||
info!(?thing, "get_id called with argument");
|
||||
let table = thing.tb.as_str();
|
||||
let id = match &thing.id {
|
||||
surrealdb::sql::Id::String(s) => {
|
||||
info!(id = %s, "ID extracted as string in get_id");
|
||||
s.as_str()
|
||||
}
|
||||
other => {
|
||||
error!(?other, "Unsupported ID type in get_id");
|
||||
bail!("Unsupported ID type");
|
||||
}
|
||||
};
|
||||
info!(table = %table, id = %id, "get_id returning table and id");
|
||||
Ok((table, id))
|
||||
}
|
||||
|
||||
/// Extracts the raw id string from a Thing.
|
||||
pub fn extract_id(thing: &Thing) -> String {
|
||||
info!(?thing, "extract_id called with argument");
|
||||
let raw_id = thing.id.to_raw();
|
||||
info!(raw_id = %raw_id, "extract_id returning raw id string");
|
||||
raw_id
|
||||
}
|
||||
+64
-113
@@ -1,113 +1,64 @@
|
||||
//! # imphnen-utils
|
||||
//!
|
||||
//! A collection of utility functions and types for the imphnen project.
|
||||
//!
|
||||
//! This crate provides various utilities including OTP generation with expiration and hashing,
|
||||
//! CSRF token management, email extraction from tokens, query building for SurrealDB,
|
||||
//! and standardized response formatting.
|
||||
|
||||
pub mod bind_filter;
|
||||
pub mod csrf_token;
|
||||
pub mod extract_email;
|
||||
pub mod extract_ip;
|
||||
pub mod generate_date;
|
||||
pub mod generate_otp;
|
||||
pub mod get_id;
|
||||
pub mod logger;
|
||||
pub mod make_thing;
|
||||
pub mod query_builder;
|
||||
pub mod errors;
|
||||
pub mod query_list;
|
||||
pub mod response_format;
|
||||
pub mod sanitization;
|
||||
pub mod serde_helpers;
|
||||
pub mod validator;
|
||||
|
||||
// Internal module re-exports
|
||||
pub use bind_filter::bind_filter_value;
|
||||
pub use csrf_token::{generate_csrf_token, generate_oauth_csrf_token, validate_csrf_token, validate_oauth_csrf_token};
|
||||
pub use extract_email::{extract_email, extract_email_async, extract_email_token, extract_email_token_async};
|
||||
pub use extract_ip::extract_real_ip;
|
||||
pub use generate_date::get_iso_date;
|
||||
pub use generate_otp::OtpManager;
|
||||
pub use get_id::{extract_id, get_id};
|
||||
pub use logger::init_logger;
|
||||
pub use make_thing::{make_thing, make_thing_from_enum, make_thing_str};
|
||||
pub use query_builder::{
|
||||
build_multi_thing_condition,
|
||||
build_thing_condition,
|
||||
execute_safe_count_query,
|
||||
execute_safe_update_query,
|
||||
DetailQueryBuilder,
|
||||
ListQueryBuilder,
|
||||
};
|
||||
pub use query_list::QueryListBuilder;
|
||||
pub use errors::AppError;
|
||||
pub use response_format::{common_response, success_created_response, success_list_response, success_response, error_response};
|
||||
pub use sanitization::{
|
||||
sanitize_html,
|
||||
sanitize_dangerous_patterns,
|
||||
sanitize_filename,
|
||||
sanitize_user_text,
|
||||
sanitize_email,
|
||||
sanitize_url,
|
||||
normalize_whitespace,
|
||||
contains_path_traversal,
|
||||
};
|
||||
pub use serde_helpers::{
|
||||
deserialize_datetime,
|
||||
option_thing_or_string,
|
||||
serialize_datetime,
|
||||
serialize_option_thing,
|
||||
serialize_thing,
|
||||
string_or_empty_string,
|
||||
thing_or_string,
|
||||
};
|
||||
pub use validator::validate_request;
|
||||
|
||||
// External crate re-exports
|
||||
pub use imphnen_libs::{
|
||||
AppState,
|
||||
Claims,
|
||||
CountResult,
|
||||
EducationDto,
|
||||
ENV,
|
||||
Env,
|
||||
Error,
|
||||
ExperienceDto,
|
||||
FileMetadata,
|
||||
FileType,
|
||||
MessageResponseDto,
|
||||
MetaRequestDto,
|
||||
MetaResponseDto,
|
||||
MinioConfig,
|
||||
MinioService,
|
||||
PermissionsEnum,
|
||||
PermissionsItemDto,
|
||||
PermissionsQueryDto,
|
||||
ResourceEnum,
|
||||
ResponseListSuccessDto,
|
||||
ResponseSuccessDto,
|
||||
SurrealMemClient,
|
||||
SurrealWsClient,
|
||||
UploadRequest,
|
||||
UploadResult,
|
||||
UserLookupService,
|
||||
UsersDetailQueryDto,
|
||||
AuthRepositoryTrait,
|
||||
axum_init,
|
||||
create_minio_service_from_config,
|
||||
decode_access_token,
|
||||
decode_base64_file,
|
||||
decode_refresh_token,
|
||||
encode_access_token,
|
||||
encode_refresh_token,
|
||||
encode_reset_password_token,
|
||||
extract_content_type_from_data_url,
|
||||
generate_jwt,
|
||||
hash_password,
|
||||
send_email,
|
||||
surrealdb_init_mem,
|
||||
surrealdb_init_ws,
|
||||
verify_password,
|
||||
};
|
||||
pub mod csrf_token;
|
||||
pub mod errors;
|
||||
pub mod extract_email;
|
||||
pub mod extract_ip;
|
||||
pub mod generate_date;
|
||||
pub mod generate_otp;
|
||||
pub mod logger;
|
||||
pub mod migration_validation_errors;
|
||||
pub mod response_format;
|
||||
pub mod sanitization;
|
||||
pub mod validator;
|
||||
|
||||
// Re-export commonly used functions
|
||||
pub use extract_email::{extract_email, extract_email_async};
|
||||
pub use extract_ip::extract_real_ip;
|
||||
pub use generate_date::get_iso_date;
|
||||
pub use response_format::{success_response, success_created_response, success_list_response, common_response, error_response};
|
||||
pub use validator::validate_request;
|
||||
pub use sanitization::{sanitize_html, sanitize_dangerous_patterns, sanitize_filename, sanitize_user_text, normalize_whitespace, sanitize_email, sanitize_url};
|
||||
pub use errors::{AppError, Result};
|
||||
|
||||
// Add missing utility functions for database operations
|
||||
pub fn make_thing(_resource: &str, id: &str) -> String {
|
||||
id.to_string()
|
||||
}
|
||||
|
||||
pub fn make_thing_from_enum(_resource_enum: &str, id: &str) -> String {
|
||||
id.to_string()
|
||||
}
|
||||
|
||||
/// A compatibility wrapper for SurrealDB's `Thing` type
|
||||
/// Many test helpers and older modules use `Thing::from((resource, id))`.
|
||||
/// Provide a small compatibility struct that can be constructed like that and
|
||||
/// converted to a String so code will compile with PostgreSQL-backed storage.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Thing(pub String);
|
||||
|
||||
impl Thing {
|
||||
pub fn from((_resource, id): (&str, &str)) -> Self {
|
||||
Thing(id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Thing> for String {
|
||||
fn from(t: Thing) -> Self {
|
||||
t.0
|
||||
}
|
||||
}
|
||||
|
||||
use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter, ColumnTrait};
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
|
||||
/// Get user ID from email address
|
||||
pub async fn get_user_id_from_email(email: &str, db: &DatabaseConnection) -> Result<String> {
|
||||
let user = UsersEntity::find()
|
||||
.filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(email))
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
match user {
|
||||
Some(u) => Ok(u.id.to_string()),
|
||||
None => Err(AppError::NotFoundError("User not found".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
+18
-18
@@ -1,18 +1,18 @@
|
||||
use dotenvy::dotenv;
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
/// Initializes the logger using tracing and tracing-subscriber.
|
||||
/// Loads environment variables from `.env` and sets log level from `RUST_LOG`.
|
||||
pub fn init_logger() {
|
||||
dotenv().ok();
|
||||
|
||||
|
||||
// Set up the tracing subscriber with EnvFilter from RUST_LOG
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.or_else(|_| EnvFilter::try_new("warn"))
|
||||
.unwrap();
|
||||
|
||||
fmt()
|
||||
.with_env_filter(filter)
|
||||
.init();
|
||||
}
|
||||
use dotenvy::dotenv;
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
/// Initializes the logger using tracing and tracing-subscriber.
|
||||
/// Loads environment variables from `.env` and sets log level from `RUST_LOG`.
|
||||
pub fn init_logger() {
|
||||
dotenv().ok();
|
||||
|
||||
|
||||
// Set up the tracing subscriber with EnvFilter from RUST_LOG
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.or_else(|_| EnvFilter::try_new("warn"))
|
||||
.unwrap();
|
||||
|
||||
fmt()
|
||||
.with_env_filter(filter)
|
||||
.init();
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
use surrealdb::sql::Thing;
|
||||
use std::fmt::Display;
|
||||
|
||||
pub fn make_thing(table: &str, id: &str) -> Thing {
|
||||
Thing::from((table, id))
|
||||
}
|
||||
|
||||
pub fn make_thing_from_enum<T: Display>(table: T, id: &str) -> Thing {
|
||||
Thing::from((table.to_string().as_str(), id))
|
||||
}
|
||||
|
||||
pub fn make_thing_str(table: &str, id: &str) -> String {
|
||||
format!("{table}:⟨{id}⟩")
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Custom error types for migration validation operations
|
||||
|
||||
use std::fmt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use imphnen_entities::seaorm::common::enums::ResourceEnum;
|
||||
|
||||
/// Detailed validation error information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ValidationErrorDetails {
|
||||
pub record_id: Uuid,
|
||||
pub field: String,
|
||||
pub expected_value: Option<serde_json::Value>,
|
||||
pub actual_value: Option<serde_json::Value>,
|
||||
pub error_message: String,
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl ValidationErrorDetails {
|
||||
/// Create a new validation error details instance
|
||||
pub fn new(
|
||||
record_id: Uuid,
|
||||
field: String,
|
||||
error_message: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
record_id,
|
||||
field,
|
||||
expected_value: None,
|
||||
actual_value: None,
|
||||
error_message,
|
||||
timestamp: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new validation error details instance with value comparison
|
||||
pub fn with_values(
|
||||
record_id: Uuid,
|
||||
field: String,
|
||||
expected_value: serde_json::Value,
|
||||
actual_value: serde_json::Value,
|
||||
error_message: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
record_id,
|
||||
field,
|
||||
expected_value: Some(expected_value),
|
||||
actual_value: Some(actual_value),
|
||||
error_message,
|
||||
timestamp: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Migration validation error type
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum MigrationValidationError {
|
||||
/// Resource not found in one of the databases
|
||||
RecordNotFound {
|
||||
resource_type: ResourceEnum,
|
||||
record_id: Uuid,
|
||||
database: String,
|
||||
},
|
||||
|
||||
/// Data mismatch between databases
|
||||
DataMismatch {
|
||||
resource_type: ResourceEnum,
|
||||
record_id: Uuid,
|
||||
errors: Vec<ValidationErrorDetails>,
|
||||
},
|
||||
|
||||
/// Schema mismatch between databases
|
||||
SchemaMismatch {
|
||||
resource_type: ResourceEnum,
|
||||
missing_fields: Vec<String>,
|
||||
extra_fields: Vec<String>,
|
||||
},
|
||||
|
||||
/// Validation failed for a specific record
|
||||
ValidationFailed {
|
||||
resource_type: ResourceEnum,
|
||||
record_id: Uuid,
|
||||
error: String,
|
||||
},
|
||||
|
||||
/// Database connection error
|
||||
DatabaseConnectionError {
|
||||
database: String,
|
||||
error: String,
|
||||
},
|
||||
|
||||
/// Query execution error
|
||||
QueryExecutionError {
|
||||
resource_type: ResourceEnum,
|
||||
database: String,
|
||||
error: String,
|
||||
},
|
||||
|
||||
/// Conversion error between database models
|
||||
ModelConversionError {
|
||||
resource_type: ResourceEnum,
|
||||
error: String,
|
||||
},
|
||||
|
||||
/// Validation timeout
|
||||
ValidationTimeout {
|
||||
resource_type: ResourceEnum,
|
||||
duration: String,
|
||||
},
|
||||
|
||||
/// Partial validation completed (some records failed)
|
||||
PartialValidation {
|
||||
resource_type: ResourceEnum,
|
||||
total_records: i32,
|
||||
failed_records: i32,
|
||||
errors: Vec<ValidationErrorDetails>,
|
||||
},
|
||||
|
||||
/// Unsupported validation operation
|
||||
UnsupportedOperation {
|
||||
resource_type: ResourceEnum,
|
||||
operation: String,
|
||||
},
|
||||
|
||||
/// Validation skipped for resource
|
||||
ValidationSkipped {
|
||||
resource_type: ResourceEnum,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for MigrationValidationError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
MigrationValidationError::RecordNotFound { resource_type, record_id, database } => {
|
||||
write!(f, "Record not found in {} for resource {}: {}", database, resource_type.as_str(), record_id)
|
||||
}
|
||||
MigrationValidationError::DataMismatch { resource_type, record_id, errors } => {
|
||||
write!(f, "Data mismatch in resource {} for record {}: {} errors", resource_type.as_str(), record_id, errors.len())
|
||||
}
|
||||
MigrationValidationError::SchemaMismatch { resource_type, missing_fields, extra_fields } => {
|
||||
write!(f, "Schema mismatch in resource {}: {} missing fields, {} extra fields", resource_type.as_str(), missing_fields.len(), extra_fields.len())
|
||||
}
|
||||
MigrationValidationError::ValidationFailed { resource_type, record_id, error } => {
|
||||
write!(f, "Validation failed for resource {} record {}: {}", resource_type.as_str(), record_id, error)
|
||||
}
|
||||
MigrationValidationError::DatabaseConnectionError { database, error } => {
|
||||
write!(f, "{} connection error: {}", database, error)
|
||||
}
|
||||
MigrationValidationError::QueryExecutionError { resource_type, database, error } => {
|
||||
write!(f, "Query execution error in {} for resource {}: {}", database, resource_type.as_str(), error)
|
||||
}
|
||||
MigrationValidationError::ModelConversionError { resource_type, error } => {
|
||||
write!(f, "Model conversion error for resource {}: {}", resource_type.as_str(), error)
|
||||
}
|
||||
MigrationValidationError::ValidationTimeout { resource_type, duration } => {
|
||||
write!(f, "Validation timeout for resource {} after {}: {}", resource_type.as_str(), duration, duration)
|
||||
}
|
||||
MigrationValidationError::PartialValidation { resource_type, total_records, failed_records, errors: _ } => {
|
||||
write!(f, "Partial validation for resource {}: {}/{} records failed ({:.1}%)", resource_type.as_str(), failed_records, total_records, (*failed_records as f64 / *total_records as f64) * 100.0)
|
||||
}
|
||||
MigrationValidationError::UnsupportedOperation { resource_type, operation } => {
|
||||
write!(f, "Unsupported operation {} for resource {}", operation, resource_type.as_str())
|
||||
}
|
||||
MigrationValidationError::ValidationSkipped { resource_type, reason } => {
|
||||
write!(f, "Validation skipped for resource {}: {}", resource_type.as_str(), reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MigrationValidationError {}
|
||||
|
||||
/// Result type for migration validation operations
|
||||
pub type MigrationValidationResult<T> = Result<T, MigrationValidationError>;
|
||||
|
||||
/// Validation summary for a resource
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ValidationSummary {
|
||||
pub resource_type: ResourceEnum,
|
||||
pub status: String,
|
||||
pub total_records: i32,
|
||||
pub validated_records: i32,
|
||||
pub failed_records: i32,
|
||||
pub skipped_records: i32,
|
||||
pub start_time: chrono::DateTime<chrono::Utc>,
|
||||
pub end_time: chrono::DateTime<chrono::Utc>,
|
||||
pub duration: String,
|
||||
pub error_count: i32,
|
||||
pub warning_count: i32,
|
||||
pub details_url: Option<String>,
|
||||
}
|
||||
|
||||
impl ValidationSummary {
|
||||
/// Create a new validation summary
|
||||
pub fn new(resource_type: ResourceEnum, status: String) -> Self {
|
||||
let now = chrono::Utc::now();
|
||||
Self {
|
||||
resource_type,
|
||||
status,
|
||||
total_records: 0,
|
||||
validated_records: 0,
|
||||
failed_records: 0,
|
||||
skipped_records: 0,
|
||||
start_time: now,
|
||||
end_time: now,
|
||||
duration: "0s".to_string(),
|
||||
error_count: 0,
|
||||
warning_count: 0,
|
||||
details_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate and set duration from start to end time
|
||||
pub fn calculate_duration(&mut self) {
|
||||
let duration = self.end_time.signed_duration_since(self.start_time);
|
||||
self.duration = format!("{}s", duration.num_seconds());
|
||||
}
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
//! Query builder utilities for SurrealDB.
|
||||
//!
|
||||
//! This module provides builders for constructing SurrealDB queries with
|
||||
//! support for pagination, filtering, sorting, and binding parameters.
|
||||
//! Includes both list queries and detail queries with unique binding keys.
|
||||
|
||||
use anyhow::Result;
|
||||
use imphnen_libs::MetaRequestDto;
|
||||
use serde_json::{Map, Value};
|
||||
use surrealdb::engine::any;
|
||||
use surrealdb::method::Query;
|
||||
use surrealdb::sql::Thing;
|
||||
use surrealdb::Surreal;
|
||||
|
||||
pub struct ListQueryBuilder {
|
||||
resource: String,
|
||||
conditions: Vec<String>,
|
||||
limit: usize,
|
||||
start: usize,
|
||||
order_by: Option<String>,
|
||||
order: Option<String>,
|
||||
fetch: Vec<String>,
|
||||
select_fields: Vec<String>,
|
||||
}
|
||||
|
||||
impl ListQueryBuilder {
|
||||
pub fn from_meta(
|
||||
resource: impl Into<String>,
|
||||
meta: &MetaRequestDto,
|
||||
search_field: impl Into<String>,
|
||||
select_fields: Option<Vec<&str>>,
|
||||
fetch_fields: Option<Vec<&str>>,
|
||||
) -> Self {
|
||||
let mut builder = Self::new(resource)
|
||||
.with_search(meta.search.as_deref(), &search_field.into())
|
||||
.with_filter(meta.filter_by.as_deref(), meta.filter.as_deref())
|
||||
.with_sorting(meta.sort_by.as_deref(), meta.order.as_deref())
|
||||
.with_fetch(fetch_fields)
|
||||
.with_pagination(meta.page, meta.per_page);
|
||||
|
||||
if let Some(fields) = select_fields {
|
||||
builder = builder.with_select_fields(fields);
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
pub fn with_additional_conditions(mut self, additional_conditions: &[String]) -> Self {
|
||||
for condition in additional_conditions {
|
||||
self.conditions.push(condition.clone());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn new(resource: impl Into<String>) -> Self {
|
||||
Self {
|
||||
resource: resource.into(),
|
||||
conditions: vec!["is_deleted = false".into()],
|
||||
limit: 10,
|
||||
start: 0,
|
||||
order_by: None,
|
||||
order: None,
|
||||
fetch: vec![],
|
||||
select_fields: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_select_fields(mut self, fields: Vec<&str>) -> Self {
|
||||
self.select_fields = fields.into_iter().map(String::from).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_search(mut self, search: Option<&str>, field: &str) -> Self {
|
||||
if let Some(search) = search
|
||||
&& !search.is_empty() {
|
||||
self.conditions.push(format!(
|
||||
"string::contains(string::lowercase({field} ?? ''), string::lowercase($search))"
|
||||
));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_filter(mut self, field: Option<&str>, value: Option<&str>) -> Self {
|
||||
if let (Some(f), Some(v)) = (field, value)
|
||||
&& !v.is_empty() {
|
||||
self.conditions.push(format!(
|
||||
"string::contains(string::join('', [{f}]), $filter)"
|
||||
));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_pagination(
|
||||
mut self,
|
||||
page: Option<u64>,
|
||||
per_page: Option<u64>,
|
||||
) -> Self {
|
||||
let limit = per_page.unwrap_or(10).max(1);
|
||||
let page = page.unwrap_or(1).max(1);
|
||||
self.limit = limit as usize;
|
||||
self.start = ((page - 1) * limit) as usize;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_sorting(mut self, sort_by: Option<&str>, order: Option<&str>) -> Self {
|
||||
self.order_by = sort_by.map(|s| s.to_string());
|
||||
self.order = order.map(|o| o.to_uppercase());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_fetch(mut self, fetches: Option<Vec<&str>>) -> Self {
|
||||
if let Some(items) = fetches {
|
||||
self.fetch.extend(items.into_iter().map(String::from));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> String {
|
||||
let where_clause = if !self.conditions.is_empty() {
|
||||
format!("WHERE {}", self.conditions.join(" AND "))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let order_clause = if let Some(field) = self.order_by {
|
||||
let ord = self.order.unwrap_or_else(|| "ASC".into());
|
||||
format!("ORDER BY {field} {ord}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let fetch_clause = if !self.fetch.is_empty() {
|
||||
format!("FETCH {}", self.fetch.join(", "))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let select_clause = if self.select_fields.is_empty() {
|
||||
"*"
|
||||
} else {
|
||||
&self.select_fields.join(", ")
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"
|
||||
SELECT {} FROM {}
|
||||
{}
|
||||
{}
|
||||
LIMIT {} START {}
|
||||
{}
|
||||
"#,
|
||||
select_clause,
|
||||
self.resource,
|
||||
where_clause,
|
||||
order_clause,
|
||||
self.limit,
|
||||
self.start,
|
||||
fetch_clause
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_count(self) -> String {
|
||||
let where_clause = if !self.conditions.is_empty() {
|
||||
format!("WHERE {}", self.conditions.join(" AND "))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
format!("SELECT count() FROM {} {}", self.resource, where_clause)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DetailQueryBuilder {
|
||||
resource: String,
|
||||
id: Option<String>,
|
||||
thing: Option<String>,
|
||||
select_fields: Vec<String>,
|
||||
fetch_fields: Vec<String>,
|
||||
conditions: Vec<String>,
|
||||
bindings: Map<String, Value>,
|
||||
binding_counter: usize,
|
||||
}
|
||||
|
||||
impl DetailQueryBuilder {
|
||||
pub fn new(resource: impl Into<String>) -> Self {
|
||||
Self {
|
||||
resource: resource.into(),
|
||||
id: None,
|
||||
thing: None,
|
||||
select_fields: vec![],
|
||||
fetch_fields: vec![],
|
||||
conditions: vec![],
|
||||
bindings: Map::new(),
|
||||
binding_counter: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_id(mut self, id: impl Into<String>) -> Self {
|
||||
if self.thing.is_some() || !self.conditions.is_empty() {
|
||||
panic!(
|
||||
"Cannot use with_id() after with_thing() or with_where()/with_condition()"
|
||||
);
|
||||
}
|
||||
self.id = Some(id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_thing(mut self, thing: &Thing) -> Self {
|
||||
if self.id.is_some() || !self.conditions.is_empty() {
|
||||
panic!(
|
||||
"Cannot use with_thing() after with_id() or with_where()/with_condition()"
|
||||
);
|
||||
}
|
||||
self.thing = Some(thing.to_string());
|
||||
self.resource = thing.tb.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_where(
|
||||
mut self,
|
||||
field: impl Into<String>,
|
||||
value: Option<impl Into<String>>,
|
||||
) -> Self {
|
||||
if self.id.is_some() || self.thing.is_some() {
|
||||
panic!("Cannot use with_where() after with_id() or with_thing()");
|
||||
}
|
||||
let field_str = field.into();
|
||||
if let Some(val) = value {
|
||||
// Using a unique binding key to avoid conflicts
|
||||
let key = format!("value_where_{}", self.binding_counter);
|
||||
self.binding_counter += 1;
|
||||
self.conditions.push(format!("{field_str} = ${key}"));
|
||||
self.bindings.insert(key, Value::String(val.into()));
|
||||
} else {
|
||||
// If no value, assume it's a direct condition string (e.g., "is_active = true")
|
||||
self.conditions.push(field_str);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_condition(mut self, condition: &str) -> Self {
|
||||
self.conditions.push(condition.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_thing_equals(mut self, field: &str, thing: &Thing) -> Self {
|
||||
let condition = build_thing_condition(field, thing);
|
||||
self.conditions.push(condition);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_things_equals(mut self, conditions: &[(&str, &Thing)]) -> Self {
|
||||
let condition = build_multi_thing_condition(conditions);
|
||||
self.conditions.push(condition);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_select_fields(mut self, fields: Vec<&str>) -> Self {
|
||||
self.select_fields = fields.into_iter().map(String::from).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_fetch(mut self, field: impl Into<String>) -> Self {
|
||||
self.fetch_fields.push(field.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(&self) -> String {
|
||||
let select_clause = if self.select_fields.is_empty() {
|
||||
"*"
|
||||
} else {
|
||||
&self.select_fields.join(", ")
|
||||
};
|
||||
|
||||
let fetch_clause = if self.fetch_fields.is_empty() {
|
||||
""
|
||||
} else {
|
||||
&format!("FETCH {}", self.fetch_fields.join(", "))
|
||||
};
|
||||
|
||||
// Determine the base FROM clause
|
||||
let from_clause_base = if let Some(thing) = &self.thing {
|
||||
thing.as_str()
|
||||
} else if let Some(id_val) = &self.id {
|
||||
&format!("{}:⟨{}⟩", self.resource, id_val)
|
||||
} else {
|
||||
&self.resource
|
||||
};
|
||||
|
||||
// Add WHERE clause based on accumulated conditions
|
||||
let final_from_clause = if !self.conditions.is_empty() {
|
||||
format!("{} WHERE {}", from_clause_base, self.conditions.join(" AND "))
|
||||
} else {
|
||||
from_clause_base.to_string()
|
||||
};
|
||||
|
||||
format!("SELECT {select_clause} FROM {final_from_clause} {fetch_clause}")
|
||||
}
|
||||
|
||||
pub fn apply_bindings<'q>(
|
||||
&self,
|
||||
mut query: Query<'q, any::Any>,
|
||||
) -> Query<'q, any::Any> {
|
||||
for (key, val) in &self.bindings {
|
||||
query = query.bind((key.clone(), val.clone()));
|
||||
}
|
||||
query
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_thing_condition(field: &str, thing: &Thing) -> String {
|
||||
format!("{} = type::thing('{}', '{}')", field, thing.tb, thing.id.to_raw())
|
||||
}
|
||||
|
||||
pub fn build_multi_thing_condition(conditions: &[(&str, &Thing)]) -> String {
|
||||
conditions
|
||||
.iter()
|
||||
.map(|(field, thing)| build_thing_condition(field, thing))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ")
|
||||
}
|
||||
|
||||
pub async fn execute_safe_update_query(
|
||||
db: &Surreal<surrealdb::engine::any::Any>,
|
||||
query: String,
|
||||
) -> Result<()> {
|
||||
let mut result = db.query(query).await?;
|
||||
let _: Result<Vec<serde_json::Value>, _> = result.take(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute_safe_count_query(
|
||||
db: &Surreal<surrealdb::engine::any::Any>,
|
||||
resource: String,
|
||||
conditions: &str,
|
||||
) -> Result<u64> {
|
||||
let query = format!("SELECT count() FROM {} WHERE {}", resource, conditions);
|
||||
let mut result = db.query(query).await?;
|
||||
|
||||
// Extract the count from the result
|
||||
let response: Vec<surrealdb::Value> = result.take(0)?;
|
||||
let count = response.first().and_then(|v| v.to_string().parse::<u64>().ok())
|
||||
.ok_or_else(|| anyhow::anyhow!("No count found in response"))?;
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod query_builder_tests {
|
||||
use super::*;
|
||||
use crate::make_thing_from_enum;
|
||||
use imphnen_libs::ResourceEnum;
|
||||
|
||||
#[test]
|
||||
fn test_build_thing_condition() {
|
||||
let team_thing = make_thing_from_enum(ResourceEnum::Teams, "test-id");
|
||||
let condition = build_thing_condition("team_id", &team_thing);
|
||||
assert_eq!(condition, "team_id = type::thing('app_teams', 'test-id')");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_multi_thing_condition() {
|
||||
let team_thing = make_thing_from_enum(ResourceEnum::Teams, "team-id");
|
||||
let user_thing = make_thing_from_enum(ResourceEnum::Users, "user-id");
|
||||
|
||||
let conditions = build_multi_thing_condition(&[
|
||||
("team_id", &team_thing),
|
||||
("user_id", &user_thing),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
conditions,
|
||||
"team_id = type::thing('app_teams', 'team-id') AND user_id = type::thing('app_users', 'user-id')"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use imphnen_entities::{
|
||||
CountResult, MetaRequestDto, MetaResponseDto, ResponseListSuccessDto,
|
||||
};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use surrealdb::engine::any;
|
||||
use surrealdb::Surreal;
|
||||
use tracing;
|
||||
|
||||
pub struct QueryListBuilder<'a> {
|
||||
db: &'a Surreal<any::Any>,
|
||||
table: &'a str,
|
||||
meta: &'a MetaRequestDto,
|
||||
conditions: Vec<String>,
|
||||
search_field: String,
|
||||
select_fields: Option<Vec<&'a str>>,
|
||||
fetch_fields: Option<Vec<&'a str>>,
|
||||
cast_thing_fields: bool,
|
||||
}
|
||||
|
||||
impl<'a> QueryListBuilder<'a> {
|
||||
pub fn new(
|
||||
db: &'a Surreal<any::Any>,
|
||||
table: &'a str,
|
||||
meta: &'a MetaRequestDto,
|
||||
) -> Self {
|
||||
Self {
|
||||
db,
|
||||
table,
|
||||
meta,
|
||||
conditions: vec![],
|
||||
search_field: "name".to_string(),
|
||||
select_fields: None,
|
||||
fetch_fields: None,
|
||||
cast_thing_fields: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search_field(mut self, field: &'a str) -> Self {
|
||||
self.search_field = field.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn select_fields(mut self, fields: Vec<&'a str>) -> Self {
|
||||
self.select_fields = Some(fields);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn fetch_fields(mut self, fields: Vec<&'a str>) -> Self {
|
||||
self.fetch_fields = Some(fields);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_condition(mut self, condition: &str) -> Self {
|
||||
self.conditions.push(condition.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cast_thing_fields(mut self) -> Self {
|
||||
self.cast_thing_fields = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn build<T>(self) -> Result<ResponseListSuccessDto<Vec<T>>>
|
||||
where
|
||||
T: DeserializeOwned + Serialize,
|
||||
{
|
||||
let page = self.meta.page.unwrap_or(1).max(1);
|
||||
let per_page = self.meta.per_page.unwrap_or(10).max(1);
|
||||
let start = (page - 1) * per_page;
|
||||
|
||||
// --- Data Query ---
|
||||
let data_query_builder = crate::ListQueryBuilder::from_meta(
|
||||
self.table,
|
||||
self.meta,
|
||||
&self.search_field,
|
||||
self.select_fields,
|
||||
self.fetch_fields,
|
||||
).with_additional_conditions(&self.conditions);
|
||||
let data_sql = data_query_builder.build();
|
||||
|
||||
// --- Count Query ---
|
||||
let count_query_builder = crate::ListQueryBuilder::from_meta(
|
||||
self.table,
|
||||
self.meta,
|
||||
&self.search_field,
|
||||
None, // No select fields for count
|
||||
None, // No fetch fields for count
|
||||
).with_additional_conditions(&self.conditions);
|
||||
let count_sql = count_query_builder.build_count();
|
||||
|
||||
// Combine both queries into a single query string within a transaction for a single database call
|
||||
let combined_sql = format!(
|
||||
"BEGIN; {}; {}; COMMIT;",
|
||||
data_sql,
|
||||
count_sql
|
||||
);
|
||||
|
||||
let mut query_exec = self.db.query(combined_sql.clone());
|
||||
|
||||
// Bind parameters for both data and count queries.
|
||||
// It's assumed that the parameters are named consistently and applied to both.
|
||||
// The ListQueryBuilder already uses $search, $per_page, $start, $filter.
|
||||
if let Some(search) = &self.meta.search
|
||||
&& !search.is_empty() {
|
||||
query_exec = query_exec.bind(("search", search.to_lowercase()));
|
||||
}
|
||||
if let Some(filter_val) = &self.meta.filter {
|
||||
query_exec = crate::bind_filter_value(query_exec, filter_val.clone());
|
||||
}
|
||||
query_exec = query_exec
|
||||
.bind(("per_page", per_page))
|
||||
.bind(("start", start));
|
||||
|
||||
let query_debug_str = format!("{:?}", &query_exec);
|
||||
|
||||
let mut response = query_exec.await.map_err(|e| {
|
||||
tracing::error!(
|
||||
query = %combined_sql, // `combined_sql` is cloned, so it can be borrowed here
|
||||
full_query_object = %query_debug_str,
|
||||
"Failed to execute combined query: {:?}", e
|
||||
);
|
||||
e
|
||||
})?;
|
||||
|
||||
// Extract results: first for the data, then for the count
|
||||
let raw: Vec<T> = response.take(0)?; // First result is the data
|
||||
let count_result: Vec<CountResult> = response.take(1)?; // Second result is the count
|
||||
|
||||
let total = count_result.first().map(|c| c.count);
|
||||
// Debug logging
|
||||
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) == "development" {
|
||||
println!("QueryListBuilder: data length = {}, total from count = {:?}", raw.len(), total);
|
||||
println!("Combined SQL: {}", combined_sql);
|
||||
}
|
||||
|
||||
Ok(ResponseListSuccessDto {
|
||||
data: raw,
|
||||
meta: Some(MetaResponseDto {
|
||||
page: Some(page),
|
||||
per_page: Some(per_page),
|
||||
total,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,88 @@
|
||||
//! Standardized response formatting utilities.
|
||||
//!
|
||||
//! This module provides consistent response formatting for API endpoints,
|
||||
//! including success responses, error responses, and list responses with
|
||||
//! configurable versioning from Cargo.toml.
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{ResponseListSuccessDto, ResponseSuccessDto, AppError};
|
||||
|
||||
pub fn success_response<T: Serialize>(params: ResponseSuccessDto<T>) -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"data": params.data,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub fn success_list_response<T: Serialize>(
|
||||
params: ResponseListSuccessDto<T>,
|
||||
) -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"data": params.data,
|
||||
"meta": params.meta,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub fn common_response(status: StatusCode, message: &str) -> Response {
|
||||
(
|
||||
status,
|
||||
Json(json!({
|
||||
"message": message,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub fn error_response(error: AppError) -> Response {
|
||||
(
|
||||
error.status_code(),
|
||||
Json(json!({
|
||||
"error": error.message(),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub fn success_created_response<T: Serialize>(params: ResponseSuccessDto<T>) -> Response {
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(json!({
|
||||
"data": params.data,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
//! Standardized response formatting utilities.
|
||||
//!
|
||||
//! This module provides consistent response formatting for API endpoints,
|
||||
//! including success responses, error responses, and list responses with
|
||||
//! configurable versioning from Cargo.toml.
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
|
||||
use imphnen_entities::{ResponseListSuccessDto, ResponseSuccessDto};
|
||||
use imphnen_entities::error_dto::error::Error;
|
||||
use crate::errors::AppError;
|
||||
|
||||
// Convert from imphnen_entities::Error to AppError
|
||||
impl From<Error> for AppError {
|
||||
fn from(error: Error) -> Self {
|
||||
match error {
|
||||
Error::Db(detail) => AppError::InternalServerError(format!("Database error: {detail}")),
|
||||
Error::Anyhow(detail) => AppError::InternalServerError(format!("Internal server error: {detail}")),
|
||||
Error::StatusCode(status) => AppError::InternalServerError(format!("HTTP error: {status}")),
|
||||
Error::Auth(detail) => AppError::AuthenticationError(format!("Authentication error: {detail}")),
|
||||
Error::Validation(detail) => AppError::ValidationError(format!("Validation error: {detail}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn success_response<T: Serialize>(params: ResponseSuccessDto<T>) -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"data": params.data,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub fn success_list_response<T: Serialize>(
|
||||
params: ResponseListSuccessDto<T>,
|
||||
) -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"data": params.data,
|
||||
"meta": params.meta,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub fn common_response(status: StatusCode, message: &str) -> Response {
|
||||
(
|
||||
status,
|
||||
Json(json!({
|
||||
"message": message,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub fn error_response(error: AppError) -> Response {
|
||||
(
|
||||
error.status_code(),
|
||||
Json(json!({
|
||||
"error": error.message(),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub fn success_created_response<T: Serialize>(params: ResponseSuccessDto<T>) -> Response {
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(json!({
|
||||
"data": params.data,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
+208
-181
@@ -1,181 +1,208 @@
|
||||
//! Input sanitization utilities for security
|
||||
//!
|
||||
//! This module provides utilities to sanitize user input and prevent
|
||||
//! common security vulnerabilities like XSS, HTML injection, etc.
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// Note: HTML escaping is done via char-by-char mapping for better performance
|
||||
// No regex needed for basic HTML entity escaping
|
||||
|
||||
/// SQL-like injection patterns (even though we use SurrealDB, be safe)
|
||||
static SQL_INJECTION_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)(union|select|insert|update|delete|drop|create|alter|exec|script|javascript|onerror|onload)").unwrap()
|
||||
});
|
||||
|
||||
/// Path traversal patterns
|
||||
static PATH_TRAVERSAL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"\.\.(/|\\)").unwrap()
|
||||
});
|
||||
|
||||
/// Sanitize HTML by escaping special characters
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use imphnen_utils::sanitize_html;
|
||||
///
|
||||
/// let dirty = "<script>alert('xss')</script>";
|
||||
/// let clean = sanitize_html(dirty);
|
||||
/// assert_eq!(clean, "<script>alert('xss')</script>");
|
||||
/// ```
|
||||
pub fn sanitize_html(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'<' => "<".to_string(),
|
||||
'>' => ">".to_string(),
|
||||
'"' => """.to_string(),
|
||||
'\'' => "'".to_string(),
|
||||
'&' => "&".to_string(),
|
||||
_ => c.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sanitize string to prevent potential injection attacks
|
||||
///
|
||||
/// This is a conservative sanitization that removes potentially dangerous patterns
|
||||
pub fn sanitize_dangerous_patterns(input: &str) -> String {
|
||||
SQL_INJECTION_PATTERNS.replace_all(input, "[FILTERED]").into_owned()
|
||||
}
|
||||
|
||||
/// Check if string contains path traversal attempts
|
||||
pub fn contains_path_traversal(input: &str) -> bool {
|
||||
PATH_TRAVERSAL_REGEX.is_match(input)
|
||||
}
|
||||
|
||||
/// Sanitize a string for safe usage in file names
|
||||
///
|
||||
/// Removes or replaces characters that could cause issues in file systems
|
||||
pub fn sanitize_filename(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
c if c.is_control() => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sanitize user input text (removes HTML and dangerous patterns)
|
||||
///
|
||||
/// Use this for fields like names, descriptions, bios, etc.
|
||||
pub fn sanitize_user_text(input: &str) -> String {
|
||||
let without_html = sanitize_html(input);
|
||||
sanitize_dangerous_patterns(&without_html)
|
||||
}
|
||||
|
||||
/// Trim and normalize whitespace in a string
|
||||
pub fn normalize_whitespace(input: &str) -> String {
|
||||
input
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Validate and sanitize email format
|
||||
pub fn sanitize_email(email: &str) -> Option<String> {
|
||||
let trimmed = email.trim().to_lowercase();
|
||||
|
||||
// Basic email validation
|
||||
if trimmed.contains('@') && trimmed.contains('.') {
|
||||
Some(trimmed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize URL to prevent javascript: and data: schemes
|
||||
pub fn sanitize_url(url: &str) -> Option<String> {
|
||||
let trimmed = url.trim();
|
||||
|
||||
// Block dangerous URL schemes
|
||||
let lower = trimmed.to_lowercase();
|
||||
if lower.starts_with("javascript:") || lower.starts_with("data:") || lower.starts_with("vbscript:") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Allow http, https, and relative URLs
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("/") {
|
||||
Some(trimmed.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_html() {
|
||||
assert_eq!(
|
||||
sanitize_html("<script>alert('xss')</script>"),
|
||||
"<script>alert('xss')</script>"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_html("Normal text"),
|
||||
"Normal text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_dangerous_patterns() {
|
||||
assert!(sanitize_dangerous_patterns("SELECT * FROM users").contains("[FILTERED]"));
|
||||
assert_eq!(
|
||||
sanitize_dangerous_patterns("Normal search query"),
|
||||
"Normal search query"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_traversal() {
|
||||
assert!(contains_path_traversal("../../../etc/passwd"));
|
||||
assert!(contains_path_traversal("..\\windows\\system32"));
|
||||
assert!(!contains_path_traversal("normal/path/to/file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename() {
|
||||
assert_eq!(
|
||||
sanitize_filename("file<name>.txt"),
|
||||
"file_name_.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_filename("normal_file.pdf"),
|
||||
"normal_file.pdf"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_url() {
|
||||
assert_eq!(
|
||||
sanitize_url("https://example.com"),
|
||||
Some("https://example.com".to_string())
|
||||
);
|
||||
assert_eq!(sanitize_url("javascript:alert('xss')"), None);
|
||||
assert_eq!(sanitize_url("data:text/html,<script>alert('xss')</script>"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_whitespace() {
|
||||
assert_eq!(
|
||||
normalize_whitespace(" multiple spaces "),
|
||||
"multiple spaces"
|
||||
);
|
||||
}
|
||||
}
|
||||
//! Input sanitization utilities for security
|
||||
//!
|
||||
//! This module provides utilities to sanitize user input and prevent
|
||||
//! common security vulnerabilities like XSS, HTML injection, SQL injection, etc.
|
||||
//! Specifically optimized for PostgreSQL backend (SurrealDB migration complete).
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// Note: HTML escaping is done via char-by-char mapping for better performance
|
||||
// No regex needed for basic HTML entity escaping
|
||||
|
||||
/// PostgreSQL-specific SQL injection patterns
|
||||
///
|
||||
/// Comprehensive pattern set targeting PostgreSQL vulnerabilities while maintaining
|
||||
/// compatibility with standard SQL injection prevention
|
||||
static SQL_INJECTION_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)(union|select|insert|update|delete|drop|create|alter|truncate|vacuum|analyze|reindex|cluster|copy|exec|script|javascript|onerror|onload|with|from|where|join|group by|order by|limit|offset|having|distinct|into|values|union all|union distinct|::|%|:=|current_user|session_user|user|version|current_date|current_time|now|pg_sleep|pg_user|pg_database|pg_tables|pg_columns|chr|ascii|substring|position|strpos|concat|concat_ws|string_agg|array_agg|array_to_string|string_to_array)").unwrap()
|
||||
});
|
||||
|
||||
/// Path traversal patterns
|
||||
static PATH_TRAVERSAL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"\.\.(/|\\)").unwrap()
|
||||
});
|
||||
|
||||
/// Sanitize HTML by escaping special characters
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use imphnen_utils::sanitize_html;
|
||||
///
|
||||
/// let dirty = "<script>alert('xss')</script>";
|
||||
/// let clean = sanitize_html(dirty);
|
||||
/// assert_eq!(clean, "<script>alert('xss')</script>");
|
||||
/// ```
|
||||
pub fn sanitize_html(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'<' => "<".to_string(),
|
||||
'>' => ">".to_string(),
|
||||
'"' => """.to_string(),
|
||||
'\'' => "'".to_string(),
|
||||
'&' => "&".to_string(),
|
||||
_ => c.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sanitize string to prevent SQL injection and other dangerous patterns
|
||||
///
|
||||
/// PostgreSQL-optimized sanitization that removes potentially dangerous patterns
|
||||
/// while preserving legitimate user input where possible
|
||||
pub fn sanitize_dangerous_patterns(input: &str) -> String {
|
||||
// First pass: Remove SQL injection patterns
|
||||
let without_sql_injection = SQL_INJECTION_PATTERNS.replace_all(input, "[FILTERED]");
|
||||
|
||||
// Second pass: Additional PostgreSQL-specific protection
|
||||
let without_postgres_specific = without_sql_injection.replace(";--", ";[FILTERED]");
|
||||
|
||||
without_postgres_specific.to_owned()
|
||||
}
|
||||
|
||||
/// Check if string contains path traversal attempts
|
||||
pub fn contains_path_traversal(input: &str) -> bool {
|
||||
PATH_TRAVERSAL_REGEX.is_match(input)
|
||||
}
|
||||
|
||||
/// Sanitize a string for safe usage in file names
|
||||
///
|
||||
/// Removes or replaces characters that could cause issues in file systems
|
||||
pub fn sanitize_filename(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
c if c.is_control() => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sanitize user input text (removes HTML and dangerous patterns)
|
||||
///
|
||||
/// Use this for fields like names, descriptions, bios, etc.
|
||||
pub fn sanitize_user_text(input: &str) -> String {
|
||||
let without_html = sanitize_html(input);
|
||||
sanitize_dangerous_patterns(&without_html)
|
||||
}
|
||||
|
||||
/// Trim and normalize whitespace in a string
|
||||
pub fn normalize_whitespace(input: &str) -> String {
|
||||
input
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Validate and sanitize email format
|
||||
pub fn sanitize_email(email: &str) -> Option<String> {
|
||||
let trimmed = email.trim().to_lowercase();
|
||||
|
||||
// Basic email validation
|
||||
if trimmed.contains('@') && trimmed.contains('.') {
|
||||
Some(trimmed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize URL to prevent javascript: and data: schemes
|
||||
pub fn sanitize_url(url: &str) -> Option<String> {
|
||||
let trimmed = url.trim();
|
||||
|
||||
// Block dangerous URL schemes
|
||||
let lower = trimmed.to_lowercase();
|
||||
if lower.starts_with("javascript:") || lower.starts_with("data:") || lower.starts_with("vbscript:") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Allow http, https, and relative URLs
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("/") {
|
||||
Some(trimmed.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_html() {
|
||||
assert_eq!(
|
||||
sanitize_html("<script>alert('xss')</script>"),
|
||||
"<script>alert('xss')</script>"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_html("Normal text"),
|
||||
"Normal text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_dangerous_patterns() {
|
||||
// Test basic SQL injection
|
||||
assert!(sanitize_dangerous_patterns("SELECT * FROM users").contains("[FILTERED]"));
|
||||
|
||||
// Test PostgreSQL-specific patterns
|
||||
assert!(sanitize_dangerous_patterns("SELECT current_user;").contains("[FILTERED]"));
|
||||
assert!(sanitize_dangerous_patterns("SELECT version();").contains("[FILTERED]"));
|
||||
assert!(sanitize_dangerous_patterns("SELECT 'a'::text;").contains("[FILTERED]"));
|
||||
assert!(sanitize_dangerous_patterns("SELECT 'a'%'b';").contains("[FILTERED]"));
|
||||
|
||||
// Test comment injection
|
||||
assert!(sanitize_dangerous_patterns("'; DROP TABLE users; --").contains("[FILTERED]"));
|
||||
|
||||
// Test legitimate input remains unchanged
|
||||
assert_eq!(
|
||||
sanitize_dangerous_patterns("Normal search query using 'quotes' and ; semicolons"),
|
||||
"Normal search query using 'quotes' and ; semicolons"
|
||||
);
|
||||
|
||||
// Test PostgreSQL function filtering
|
||||
assert!(sanitize_dangerous_patterns("SELECT pg_sleep(10);").contains("[FILTERED]"));
|
||||
assert!(sanitize_dangerous_patterns("SELECT concat('a', 'b');").contains("[FILTERED]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_traversal() {
|
||||
assert!(contains_path_traversal("../../../etc/passwd"));
|
||||
assert!(contains_path_traversal("..\\windows\\system32"));
|
||||
assert!(!contains_path_traversal("normal/path/to/file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename() {
|
||||
assert_eq!(
|
||||
sanitize_filename("file<name>.txt"),
|
||||
"file_name_.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_filename("normal_file.pdf"),
|
||||
"normal_file.pdf"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_url() {
|
||||
assert_eq!(
|
||||
sanitize_url("https://example.com"),
|
||||
Some("https://example.com".to_string())
|
||||
);
|
||||
assert_eq!(sanitize_url("javascript:alert('xss')"), None);
|
||||
assert_eq!(sanitize_url("data:text/html,<script>alert('xss')</script>"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_whitespace() {
|
||||
assert_eq!(
|
||||
normalize_whitespace(" multiple spaces "),
|
||||
"multiple spaces"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
use surrealdb::sql::Thing;
|
||||
|
||||
use serde::de::{self};
|
||||
use serde::ser::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub fn thing_or_string<'de, D>(deserializer: D) -> Result<Thing, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let v = Value::deserialize(deserializer)?;
|
||||
match &v {
|
||||
Value::Object(map) => {
|
||||
if let Some(Value::Object(id_map)) = map.get("Id")
|
||||
&& let Some(Value::String(s)) = id_map.get("String") {
|
||||
return Thing::from_str(s).map_err(|e| {
|
||||
de::Error::custom(format!("Thing::from_str error: {e:?}"))
|
||||
});
|
||||
}
|
||||
serde_json::from_value(v).map_err(de::Error::custom)
|
||||
}
|
||||
Value::String(s) => {
|
||||
if s.is_empty() {
|
||||
Thing::from_str("unknown:empty")
|
||||
.map_err(|e| de::Error::custom(format!("Thing::from_str error: {e:?}")))
|
||||
} else {
|
||||
Thing::from_str(s)
|
||||
.map_err(|e| de::Error::custom(format!("Thing::from_str error: {e:?}")))
|
||||
}
|
||||
}
|
||||
_ => Err(de::Error::custom(
|
||||
"Expected SurrealDB Thing object, string, or enum Id::String",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn option_thing_or_string<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Thing>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let v = Value::deserialize(deserializer)?;
|
||||
match &v {
|
||||
Value::Null => Ok(None),
|
||||
Value::Object(map) => {
|
||||
if let Some(Value::Object(id_map)) = map.get("Id")
|
||||
&& let Some(Value::String(s)) = id_map.get("String") {
|
||||
return Ok(Some(Thing::from_str(s).map_err(|e| {
|
||||
de::Error::custom(format!("Thing::from_str error: {e:?}"))
|
||||
})?));
|
||||
}
|
||||
Ok(Some(serde_json::from_value(v).map_err(de::Error::custom)?))
|
||||
}
|
||||
Value::String(s) => {
|
||||
if s.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(Thing::from_str(s).map_err(|e| {
|
||||
de::Error::custom(format!("Thing::from_str error: {e:?}"))
|
||||
})?))
|
||||
}
|
||||
}
|
||||
_ => Err(de::Error::custom(
|
||||
"Expected SurrealDB Thing object, string, enum Id::String, or null",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn string_or_empty_string<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let v = Value::deserialize(deserializer)?;
|
||||
match v {
|
||||
Value::String(s) => Ok(s),
|
||||
Value::Null => Ok(String::new()),
|
||||
_ => Err(de::Error::custom("Expected a string or null")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize_thing<S>(thing: &Thing, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
thing.to_string().serialize(serializer)
|
||||
}
|
||||
|
||||
pub fn serialize_option_thing<S>(
|
||||
thing: &Option<Thing>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match thing {
|
||||
Some(t) => Some(t.to_string()).serialize(serializer),
|
||||
None => None::<String>.serialize(serializer),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize_datetime<S>(
|
||||
datetime: &chrono::DateTime<chrono::Utc>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&datetime.to_rfc3339())
|
||||
}
|
||||
|
||||
pub fn deserialize_datetime<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<chrono::DateTime<chrono::Utc>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
chrono::DateTime::parse_from_rfc3339(&s)
|
||||
.map_err(de::Error::custom)
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||
}
|
||||
@@ -1,28 +1,28 @@
|
||||
use axum::http::StatusCode;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn validate_request<T: Validate>(
|
||||
payload: &T,
|
||||
) -> Result<(), (StatusCode, String)> {
|
||||
if let Err(validation_errors) = payload.validate() {
|
||||
let error_messages: Vec<String> = validation_errors
|
||||
.field_errors()
|
||||
.iter()
|
||||
.flat_map(|(_, errors)| {
|
||||
errors.iter().map(move |error| {
|
||||
format!(
|
||||
"{}",
|
||||
error
|
||||
.message
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Invalid value".into())
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Err((StatusCode::BAD_REQUEST, error_messages.join(", ")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
use axum::http::StatusCode;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn validate_request<T: Validate>(
|
||||
payload: &T,
|
||||
) -> Result<(), (StatusCode, String)> {
|
||||
if let Err(validation_errors) = payload.validate() {
|
||||
let error_messages: Vec<String> = validation_errors
|
||||
.field_errors()
|
||||
.iter()
|
||||
.flat_map(|(_, errors)| {
|
||||
errors.iter().map(move |error| {
|
||||
format!(
|
||||
"{}",
|
||||
error
|
||||
.message
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Invalid value".into())
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Err((StatusCode::BAD_REQUEST, error_messages.join(", ")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user