postgress

This commit is contained in:
MythEclipse
2025-12-01 00:20:42 +07:00
parent 6fe495eed1
commit b429b3a9c7
325 changed files with 35728 additions and 50259 deletions
-14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+30 -30
View File
@@ -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
}
}
+87 -87
View File
@@ -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);
}
}
-29
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
-14
View File
@@ -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());
}
}
-375
View File
@@ -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')"
);
}
}
-146
View File
@@ -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,
}),
})
}
}
+88 -73
View File
@@ -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
View File
@@ -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, "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;");
/// ```
pub fn sanitize_html(input: &str) -> String {
input
.chars()
.map(|c| match c {
'<' => "&lt;".to_string(),
'>' => "&gt;".to_string(),
'"' => "&quot;".to_string(),
'\'' => "&#39;".to_string(),
'&' => "&amp;".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>"),
"&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
);
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, "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;");
/// ```
pub fn sanitize_html(input: &str) -> String {
input
.chars()
.map(|c| match c {
'<' => "&lt;".to_string(),
'>' => "&gt;".to_string(),
'"' => "&quot;".to_string(),
'\'' => "&#39;".to_string(),
'&' => "&amp;".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>"),
"&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
);
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"
);
}
}
-124
View File
@@ -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))
}
+28 -28
View File
@@ -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(())
}