feat: v0.3.0 — standardize codebase, centralize infra, merge QR into CMS
- Enforce axum best practices across all 13 workspace crates (max 200 LOC/file, no comments, no unwrap, clean architecture) - Fix domain→infrastructure dependency inversions in imphnen-iam and imphnen-dimentorin - Extract imphnen-storage (MinIO) and imphnen-email (Lettre) as standalone crates - Centralize all config in ENV struct: CDN_URL, CORS_ALLOWED_ORIGINS - Centralize SMTP through imphnen-email; remove dead HackathonConfig - Centralize database: QR crate now shares main DB pool (single DATABASE_URL) - Rename QR users table to qr_users to avoid collision with main users table - Merge imphnen-qr into imphnen-cms/src/qr (13 crates, down from 14) - Restructure imphnen-hackathon flat modules into clean architecture - Remove all stale env vars from .env.example (SurrealDB, QR_JWT, Hackathon infra) - Fix Dockerfile to include all current workspace crates - Bump all crate versions 0.2.0 → 0.3.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
2ae43b3bcc
commit
331a4a4e88
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "imphnen-libs"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -14,18 +14,12 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
zod-rs.workspace = true
|
||||
argon2.workspace = true
|
||||
lettre.workspace = true
|
||||
chrono.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
dotenvy.workspace = true
|
||||
anyhow.workspace = true
|
||||
uuid.workspace = true
|
||||
base64.workspace = true
|
||||
reqwest.workspace = true
|
||||
sha2.workspace = true
|
||||
hmac.workspace = true
|
||||
hex.workspace = true
|
||||
urlencoding.workspace = true
|
||||
async-trait.workspace = true
|
||||
thiserror.workspace = true
|
||||
env_logger.workspace = true
|
||||
|
||||
@@ -1,72 +1,25 @@
|
||||
//! Argon2 password hashing utilities.
|
||||
//!
|
||||
//! This module provides secure password hashing and verification using the Argon2 algorithm.
|
||||
//! The hashing parameters are configured for a balance between security and performance.
|
||||
|
||||
use argon2::{
|
||||
password_hash::{
|
||||
rand_core::OsRng, Error, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||
SaltString,
|
||||
},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
/// Hash a password using Argon2id algorithm.
|
||||
///
|
||||
/// This function generates a cryptographically secure salt and hashes the password
|
||||
/// with predefined parameters optimized for a balance of security and performance.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `password` - The plain text password to hash
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(String)` - The hashed password in PHC string format
|
||||
/// * `Err(Error)` - If hashing fails
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use imphnen_libs::hash_password;
|
||||
///
|
||||
/// let hash = hash_password("my_password")?;
|
||||
/// assert!(hash.starts_with("$argon2id$"));
|
||||
/// # Ok::<(), argon2::password_hash::Error>(())
|
||||
/// ```
|
||||
pub fn hash_password(password: &str) -> Result<String, Error> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)?
|
||||
.to_string();
|
||||
Ok(password_hash)
|
||||
}
|
||||
|
||||
/// Verify a password against its hash.
|
||||
///
|
||||
/// This function checks if the provided password matches the given hash.
|
||||
/// Returns false for both incorrect passwords and invalid hash formats.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `password` - The plain text password to verify
|
||||
/// * `hash` - The hashed password in PHC string format
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(bool)` - true if password matches, false otherwise
|
||||
/// * `Err(Error)` - If hash parsing fails
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use imphnen_libs::{hash_password, verify_password};
|
||||
///
|
||||
/// let hash = hash_password("my_password")?;
|
||||
/// assert!(verify_password("my_password", &hash)?);
|
||||
/// assert!(!verify_password("wrong_password", &hash)?);
|
||||
/// # Ok::<(), argon2::password_hash::Error>(())
|
||||
/// ```
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool, Error> {
|
||||
let parsed_hash = PasswordHash::new(hash)?;
|
||||
let argon2 = Argon2::default();
|
||||
match argon2.verify_password(password.as_bytes(), &parsed_hash) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{
|
||||
Error, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
|
||||
rand_core::OsRng,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn hash_password(password: &str) -> Result<String, Error> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)?
|
||||
.to_string();
|
||||
Ok(password_hash)
|
||||
}
|
||||
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool, Error> {
|
||||
let parsed_hash = PasswordHash::new(hash)?;
|
||||
let argon2 = Argon2::default();
|
||||
match argon2.verify_password(password.as_bytes(), &parsed_hash) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
use crate::postgres::{
|
||||
AppStatePostgresExt, PostgresConfig, PostgresConnection, PostgresError,
|
||||
};
|
||||
use crate::services::{AuthRepositoryTrait, UserLookupService};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct PostgresClients {
|
||||
pub main: Arc<PostgresConnection>,
|
||||
pub read_only: Option<Arc<PostgresConnection>>,
|
||||
pub test: Option<Arc<PostgresConnection>>,
|
||||
}
|
||||
|
||||
impl PostgresClients {
|
||||
pub fn new(main: Arc<PostgresConnection>) -> Self {
|
||||
Self {
|
||||
main,
|
||||
read_only: None,
|
||||
test: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_read_only(mut self, read_only: Arc<PostgresConnection>) -> Self {
|
||||
self.read_only = Some(read_only);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_test(mut self, test: Arc<PostgresConnection>) -> Self {
|
||||
self.test = Some(test);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub postgres_connection: Arc<PostgresConnection>,
|
||||
pub user_lookup_service: Arc<dyn UserLookupService>,
|
||||
pub auth_repository: Arc<dyn AuthRepositoryTrait>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn new(
|
||||
postgres_config: PostgresConfig,
|
||||
user_lookup_service: Arc<dyn UserLookupService>,
|
||||
auth_repository: Arc<dyn AuthRepositoryTrait>,
|
||||
) -> Result<Self, PostgresError> {
|
||||
let postgres_connection = PostgresConnection::new(postgres_config).await?;
|
||||
Ok(Self {
|
||||
postgres_connection: Arc::new(postgres_connection),
|
||||
user_lookup_service,
|
||||
auth_repository,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AppStatePostgresExt for AppState {
|
||||
fn postgres_connection(&self) -> &PostgresConnection {
|
||||
&self.postgres_connection
|
||||
}
|
||||
}
|
||||
+209
-333
@@ -1,333 +1,209 @@
|
||||
//! Axum server initialization utilities.
|
||||
//!
|
||||
//! This module provides utilities for initializing and running an Axum web server
|
||||
//! with PostgreSQL database connections and comprehensive error handling.
|
||||
|
||||
pub mod validated_json;
|
||||
pub mod zod_validate;
|
||||
|
||||
use axum::{Router, serve};
|
||||
use std::{future::Future, net::SocketAddr};
|
||||
use tokio::net::TcpListener;
|
||||
use crate::environment::ENV;
|
||||
use crate::postgres::{PostgresConnection, PostgresConfig, PostgresError};
|
||||
use sea_orm::DbErr;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use validated_json::ValidatedJson;
|
||||
pub use zod_validate::ZodValidate;
|
||||
|
||||
/// PostgreSQL database clients for different connection types
|
||||
pub struct PostgresClients {
|
||||
/// Main PostgreSQL connection for production use
|
||||
pub main: Arc<PostgresConnection>,
|
||||
/// Read-only PostgreSQL connection for read-heavy operations
|
||||
pub read_only: Option<Arc<PostgresConnection>>,
|
||||
/// Test PostgreSQL connection for testing scenarios
|
||||
pub test: Option<Arc<PostgresConnection>>,
|
||||
}
|
||||
|
||||
impl PostgresClients {
|
||||
/// Create new PostgreSQL clients with main connection
|
||||
pub fn new(main: Arc<PostgresConnection>) -> Self {
|
||||
Self {
|
||||
main,
|
||||
read_only: None,
|
||||
test: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add read-only connection
|
||||
pub fn with_read_only(mut self, read_only: Arc<PostgresConnection>) -> Self {
|
||||
self.read_only = Some(read_only);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add test connection
|
||||
pub fn with_test(mut self, test: Arc<PostgresConnection>) -> Self {
|
||||
self.test = Some(test);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Comprehensive server configuration
|
||||
pub struct ServerConfig {
|
||||
/// Server port
|
||||
pub port: u16,
|
||||
/// Server host
|
||||
pub host: String,
|
||||
/// Maximum request body size in bytes
|
||||
pub max_request_size: usize,
|
||||
/// Request timeout in seconds
|
||||
pub request_timeout: u64,
|
||||
/// Number of worker threads
|
||||
pub worker_threads: usize,
|
||||
/// Enable request logging
|
||||
pub enable_logging: bool,
|
||||
/// Enable request tracing
|
||||
pub enable_tracing: bool,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
port: 3000,
|
||||
host: "0.0.0.0".to_string(),
|
||||
max_request_size: 10 * 1024 * 1024, // 10MB
|
||||
request_timeout: 30,
|
||||
worker_threads: std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4),
|
||||
enable_logging: true,
|
||||
enable_tracing: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Server initialization error
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ServerInitError {
|
||||
#[error("Database connection failed: {0}")]
|
||||
DatabaseConnectionFailed(#[from] PostgresError),
|
||||
|
||||
#[error("Network binding failed: {0}")]
|
||||
NetworkBindingFailed(String),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
ConfigurationError(String),
|
||||
|
||||
#[error("Server startup failed: {0}")]
|
||||
ServerStartupFailed(String),
|
||||
}
|
||||
|
||||
/// Initialize and start the Axum server with PostgreSQL connections.
|
||||
///
|
||||
/// This function provides a robust server initialization with comprehensive error handling,
|
||||
/// multiple database connection support, and extensive logging.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `router_fn` - A function that takes PostgreSQL clients and returns a Router
|
||||
/// * `config` - Optional server configuration (uses defaults if None)
|
||||
/// * `postgres_config` - PostgreSQL configuration
|
||||
///
|
||||
/// # Returns
|
||||
/// Result indicating success or detailed error information
|
||||
///
|
||||
/// # Example
|
||||
/// ```no_run
|
||||
/// use axum::Router;
|
||||
/// use imphnen_libs::axum::{axum_init_advanced, PostgresClients, ServerConfig};
|
||||
/// use imphnen_libs::postgres::PostgresConfig;
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// async fn create_router(clients: PostgresClients) -> Router {
|
||||
/// Router::new()
|
||||
/// // Add your routes here
|
||||
/// }
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let postgres_config = PostgresConfig::from_env()?;
|
||||
/// let server_config = ServerConfig::default();
|
||||
///
|
||||
/// axum_init_advanced(create_router, Some(server_config), postgres_config).await?;
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn axum_init_advanced<F, Fut>(
|
||||
router_fn: F,
|
||||
config: Option<ServerConfig>,
|
||||
postgres_config: PostgresConfig,
|
||||
) -> Result<(), ServerInitError>
|
||||
where
|
||||
F: FnOnce(PostgresClients) -> Fut,
|
||||
Fut: Future<Output = Router>,
|
||||
{
|
||||
let server_config = config.unwrap_or_default();
|
||||
let _env = &ENV;
|
||||
|
||||
// Initialize logging if enabled
|
||||
|
||||
|
||||
// Initialize tracing if enabled
|
||||
|
||||
|
||||
log::info!("Starting server initialization with PostgreSQL support");
|
||||
|
||||
// Initialize PostgreSQL connections with retry logic
|
||||
let main_connection = match PostgresConnection::new(postgres_config.clone()).await {
|
||||
Ok(conn) => {
|
||||
log::info!("Main PostgreSQL connection established successfully");
|
||||
Arc::new(conn)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to establish main PostgreSQL connection: {}", e);
|
||||
return Err(ServerInitError::DatabaseConnectionFailed(e));
|
||||
}
|
||||
};
|
||||
|
||||
// Test the connection
|
||||
match test_postgres_connection(&main_connection).await {
|
||||
Ok(()) => log::info!("PostgreSQL connection test passed"),
|
||||
Err(e) => {
|
||||
log::error!("PostgreSQL connection test failed: {}", e);
|
||||
return Err(ServerInitError::DatabaseConnectionFailed(e));
|
||||
}
|
||||
}
|
||||
|
||||
// Create PostgreSQL clients
|
||||
let postgres_clients = PostgresClients::new(main_connection);
|
||||
|
||||
log::info!("PostgreSQL clients initialized successfully");
|
||||
|
||||
// Build the router
|
||||
let router = router_fn(postgres_clients).await;
|
||||
|
||||
// Configure the server
|
||||
let port = server_config.port;
|
||||
let host = server_config.host.clone();
|
||||
let addr = format!("{host}:{port}");
|
||||
let socket_addr: SocketAddr = addr.parse()
|
||||
.map_err(|e| ServerInitError::ConfigurationError(format!("Invalid address '{addr}': {e}")))?;
|
||||
|
||||
log::info!("Configuring server to listen on {}", socket_addr);
|
||||
|
||||
// Bind to the address
|
||||
let listener = TcpListener::bind(&socket_addr)
|
||||
.await
|
||||
.map_err(|e| ServerInitError::NetworkBindingFailed(format!("Failed to bind to {socket_addr}: {e}")))?;
|
||||
|
||||
log::info!("Server successfully bound to {}", socket_addr);
|
||||
|
||||
// Start the server with graceful shutdown
|
||||
log::info!("Server starting on {}", socket_addr);
|
||||
|
||||
// Set up graceful shutdown
|
||||
let shutdown_handle = setup_graceful_shutdown();
|
||||
|
||||
// Run the server
|
||||
let server_handle = tokio::spawn(async move {
|
||||
if let Err(err) = serve(listener, router).await {
|
||||
log::error!("Server encountered an error: {}", err);
|
||||
Err(ServerInitError::ServerStartupFailed(err.to_string()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for shutdown signal or server error
|
||||
tokio::select! {
|
||||
result = server_handle => {
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
log::info!("Server stopped gracefully");
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::error!("Server error: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Server task panicked: {}", e);
|
||||
Err(ServerInitError::ServerStartupFailed("Server task panicked".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_handle => {
|
||||
log::info!("Received shutdown signal, stopping server gracefully");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple server initialization (backward compatibility)
|
||||
pub async fn axum_init<F, Fut>(router_fn: F) -> Result<(), ServerInitError>
|
||||
where
|
||||
F: FnOnce(PostgresClients) -> Fut,
|
||||
Fut: Future<Output = Router>,
|
||||
{
|
||||
let postgres_config = PostgresConfig::from_env()
|
||||
.map_err(|e| ServerInitError::ConfigurationError(format!("Failed to load PostgreSQL config: {e}")))?;
|
||||
|
||||
let server_config = ServerConfig {
|
||||
port: ENV.port,
|
||||
..ServerConfig::default()
|
||||
};
|
||||
|
||||
axum_init_advanced(router_fn, Some(server_config), postgres_config).await
|
||||
}
|
||||
|
||||
/// Test PostgreSQL connection with comprehensive checks
|
||||
async fn test_postgres_connection(connection: &Arc<PostgresConnection>) -> Result<(), PostgresError> {
|
||||
// Test basic connectivity
|
||||
let test_query = sea_orm::Statement::from_string(
|
||||
connection.get_database_backend(),
|
||||
"SELECT 1 as test_value".to_string()
|
||||
);
|
||||
|
||||
let result = connection.query_one(test_query).await?;
|
||||
|
||||
match result {
|
||||
Some(query_result) => {
|
||||
let test_value: Option<i32> = query_result.try_get("", "test_value").ok();
|
||||
if test_value == Some(1) {
|
||||
log::debug!("PostgreSQL connection test successful");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PostgresError::ConnectionError(DbErr::Custom(
|
||||
"Connection test query returned unexpected result".to_string()
|
||||
)))
|
||||
}
|
||||
}
|
||||
None => Err(PostgresError::ConnectionError(DbErr::Custom(
|
||||
"Connection test query returned no results".to_string()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up graceful shutdown handling
|
||||
async fn setup_graceful_shutdown() {
|
||||
use tokio::signal;
|
||||
|
||||
match signal::ctrl_c().await {
|
||||
Ok(()) => {
|
||||
log::info!("Received Ctrl+C, initiating graceful shutdown");
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Unable to listen for shutdown signal: {}", err);
|
||||
// Wait forever if we can't listen for signal
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_server_config_default() {
|
||||
let config = ServerConfig::default();
|
||||
assert_eq!(config.port, 3000);
|
||||
assert_eq!(config.host, "0.0.0.0");
|
||||
assert_eq!(config.max_request_size, 10 * 1024 * 1024);
|
||||
assert_eq!(config.request_timeout, 30);
|
||||
assert!(config.enable_logging);
|
||||
assert!(config.enable_tracing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_postgres_clients_creation() {
|
||||
// This is a basic test - in real scenarios you'd mock the connection
|
||||
let mock_config = PostgresConfig::default();
|
||||
// Note: We can't test actual connection without a real database
|
||||
// This test just verifies the struct creation logic
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_init_error_types() {
|
||||
let error = ServerInitError::ConfigurationError("Test error".to_string());
|
||||
assert_eq!(error.to_string(), "Configuration error: Test error");
|
||||
|
||||
let error = ServerInitError::NetworkBindingFailed("Bind failed".to_string());
|
||||
assert_eq!(error.to_string(), "Network binding failed: Bind failed");
|
||||
}
|
||||
}
|
||||
pub mod app_state;
|
||||
pub mod validated_json;
|
||||
pub mod zod_validate;
|
||||
|
||||
pub use app_state::{AppState, PostgresClients};
|
||||
pub use validated_json::ValidatedJson;
|
||||
pub use zod_validate::ZodValidate;
|
||||
|
||||
use crate::environment::ENV;
|
||||
use crate::postgres::{PostgresConfig, PostgresConnection, PostgresError};
|
||||
use axum::{Router, serve};
|
||||
use std::sync::Arc;
|
||||
use std::{future::Future, net::SocketAddr};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
pub struct ServerConfig {
|
||||
pub port: u16,
|
||||
pub host: String,
|
||||
pub max_request_size: usize,
|
||||
pub request_timeout: u64,
|
||||
pub worker_threads: usize,
|
||||
pub enable_logging: bool,
|
||||
pub enable_tracing: bool,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
port: 3000,
|
||||
host: "0.0.0.0".to_string(),
|
||||
max_request_size: 10 * 1024 * 1024,
|
||||
request_timeout: 30,
|
||||
worker_threads: std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(4),
|
||||
enable_logging: true,
|
||||
enable_tracing: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ServerInitError {
|
||||
#[error("Database connection failed: {0}")]
|
||||
DatabaseConnectionFailed(#[from] PostgresError),
|
||||
|
||||
#[error("Network binding failed: {0}")]
|
||||
NetworkBindingFailed(String),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
ConfigurationError(String),
|
||||
|
||||
#[error("Server startup failed: {0}")]
|
||||
ServerStartupFailed(String),
|
||||
}
|
||||
|
||||
pub async fn axum_init_advanced<F, Fut>(
|
||||
router_fn: F,
|
||||
config: Option<ServerConfig>,
|
||||
postgres_config: PostgresConfig,
|
||||
) -> Result<(), ServerInitError>
|
||||
where
|
||||
F: FnOnce(PostgresClients) -> Fut,
|
||||
Fut: Future<Output = Router>,
|
||||
{
|
||||
let server_config = config.unwrap_or_default();
|
||||
let _env = &ENV;
|
||||
|
||||
log::info!("Starting server initialization with PostgreSQL support");
|
||||
|
||||
let main_connection = match PostgresConnection::new(postgres_config.clone()).await
|
||||
{
|
||||
Ok(conn) => {
|
||||
log::info!("Main PostgreSQL connection established successfully");
|
||||
Arc::new(conn)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to establish main PostgreSQL connection: {}", e);
|
||||
return Err(ServerInitError::DatabaseConnectionFailed(e));
|
||||
}
|
||||
};
|
||||
|
||||
match test_postgres_connection(&main_connection).await {
|
||||
Ok(()) => log::info!("PostgreSQL connection test passed"),
|
||||
Err(e) => {
|
||||
log::error!("PostgreSQL connection test failed: {}", e);
|
||||
return Err(ServerInitError::DatabaseConnectionFailed(e));
|
||||
}
|
||||
}
|
||||
|
||||
let postgres_clients = PostgresClients::new(main_connection);
|
||||
|
||||
log::info!("PostgreSQL clients initialized successfully");
|
||||
|
||||
let router = router_fn(postgres_clients).await;
|
||||
|
||||
let port = server_config.port;
|
||||
let host = server_config.host.clone();
|
||||
let addr = format!("{host}:{port}");
|
||||
let socket_addr: SocketAddr = addr.parse().map_err(|e| {
|
||||
ServerInitError::ConfigurationError(format!("Invalid address '{addr}': {e}"))
|
||||
})?;
|
||||
|
||||
log::info!("Configuring server to listen on {}", socket_addr);
|
||||
|
||||
let listener = TcpListener::bind(&socket_addr).await.map_err(|e| {
|
||||
ServerInitError::NetworkBindingFailed(format!(
|
||||
"Failed to bind to {socket_addr}: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
log::info!("Server starting on {}", socket_addr);
|
||||
|
||||
let shutdown_handle = setup_graceful_shutdown();
|
||||
|
||||
let server_handle = tokio::spawn(async move {
|
||||
if let Err(err) = serve(listener, router).await {
|
||||
log::error!("Server encountered an error: {}", err);
|
||||
Err(ServerInitError::ServerStartupFailed(err.to_string()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
result = server_handle => {
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
log::info!("Server stopped gracefully");
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::error!("Server error: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Server task panicked: {}", e);
|
||||
Err(ServerInitError::ServerStartupFailed("Server task panicked".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_handle => {
|
||||
log::info!("Received shutdown signal, stopping server gracefully");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn axum_init<F, Fut>(router_fn: F) -> Result<(), ServerInitError>
|
||||
where
|
||||
F: FnOnce(PostgresClients) -> Fut,
|
||||
Fut: Future<Output = Router>,
|
||||
{
|
||||
let postgres_config = PostgresConfig::from_env().map_err(|e| {
|
||||
ServerInitError::ConfigurationError(format!(
|
||||
"Failed to load PostgreSQL config: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let server_config = ServerConfig {
|
||||
port: ENV.port,
|
||||
..ServerConfig::default()
|
||||
};
|
||||
|
||||
axum_init_advanced(router_fn, Some(server_config), postgres_config).await
|
||||
}
|
||||
|
||||
async fn test_postgres_connection(
|
||||
connection: &Arc<PostgresConnection>,
|
||||
) -> Result<(), PostgresError> {
|
||||
use sea_orm::DbErr;
|
||||
let test_query = sea_orm::Statement::from_string(
|
||||
connection.get_database_backend(),
|
||||
"SELECT 1 as test_value".to_string(),
|
||||
);
|
||||
|
||||
let result = connection.query_one(test_query).await?;
|
||||
|
||||
match result {
|
||||
Some(query_result) => {
|
||||
let test_value: Option<i32> = query_result.try_get("", "test_value").ok();
|
||||
if test_value == Some(1) {
|
||||
log::debug!("PostgreSQL connection test successful");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PostgresError::ConnectionError(DbErr::Custom(
|
||||
"Connection test query returned unexpected result".to_string(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
None => Err(PostgresError::ConnectionError(sea_orm::DbErr::Custom(
|
||||
"Connection test query returned no results".to_string(),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_graceful_shutdown() {
|
||||
use tokio::signal;
|
||||
|
||||
match signal::ctrl_c().await {
|
||||
Ok(()) => {
|
||||
log::info!("Received Ctrl+C, initiating graceful shutdown");
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Unable to listen for shutdown signal: {}", err);
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{FromRequest, Request},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
Json,
|
||||
body::Bytes,
|
||||
extract::{FromRequest, Request},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -13,46 +13,46 @@ pub struct ValidatedJson<T>(pub T);
|
||||
|
||||
impl<T, S> FromRequest<S> for ValidatedJson<T>
|
||||
where
|
||||
T: ZodValidate + 'static,
|
||||
S: Send + Sync,
|
||||
T: ZodValidate + 'static,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Response;
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let bytes = Bytes::from_request(req, state).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"message": format!("Failed to read body: {e}"),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let bytes = Bytes::from_request(req, state).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"message": format!("Failed to read body: {e}"),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
|
||||
let json_value: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"message": format!("Invalid JSON: {e}"),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
let json_value: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"message": format!("Invalid JSON: {e}"),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
|
||||
let value = T::zod_validate(&json_value).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"message": format!("Validation error: {e}"),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
let value = T::zod_validate(&json_value).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"message": format!("Validation error: {e}"),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
|
||||
Ok(ValidatedJson(value))
|
||||
}
|
||||
Ok(ValidatedJson(value))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde_json::Value;
|
||||
|
||||
pub trait ZodValidate: Sized {
|
||||
fn zod_validate(value: &Value) -> Result<Self, String>;
|
||||
fn zod_validate(value: &Value) -> Result<Self, String>;
|
||||
}
|
||||
|
||||
+228
-226
@@ -1,226 +1,228 @@
|
||||
//! Environment configuration module using once_cell::sync::Lazy for one-time loading.
|
||||
//!
|
||||
//! This module provides centralized configuration management for the application.
|
||||
//! All environment variables are loaded once at startup and cached for performance.
|
||||
//! The application now uses PostgreSQL exclusively (migration from SurrealDB complete).
|
||||
|
||||
use std::env;
|
||||
use once_cell::sync::Lazy;
|
||||
use log::{warn, info};
|
||||
|
||||
/// Struct holding all environment configuration.
|
||||
///
|
||||
/// This struct contains all configuration values loaded from environment variables.
|
||||
/// Sensitive values are masked in debug output for security.
|
||||
/// PostgreSQL is the exclusive database backend (migration from SurrealDB complete).
|
||||
#[derive(Clone)]
|
||||
pub struct Env {
|
||||
pub port: u16,
|
||||
pub access_token_secret: String,
|
||||
pub refresh_token_secret: String,
|
||||
// PostgreSQL configuration
|
||||
pub database_url: String,
|
||||
pub pool_size: u32,
|
||||
pub connect_timeout: u64,
|
||||
pub idle_timeout: u64,
|
||||
pub max_lifetime: Option<u64>,
|
||||
pub statement_timeout: Option<u64>,
|
||||
pub idle_in_transaction_session_timeout: Option<u64>,
|
||||
pub sslmode: String,
|
||||
pub retry_attempts: u32,
|
||||
pub retry_delay: u64,
|
||||
// SMTP configuration
|
||||
pub smtp_email: String,
|
||||
pub smtp_password: String,
|
||||
pub smtp_name: String,
|
||||
pub smtp_host: String,
|
||||
pub redisdb_url: String,
|
||||
pub fe_url: String,
|
||||
pub rust_env: String,
|
||||
pub minio_endpoint: String,
|
||||
pub minio_bucket_name: String,
|
||||
pub minio_access_key: String,
|
||||
pub minio_secret_key: String,
|
||||
pub minio_region: String,
|
||||
pub minio_secure: bool,
|
||||
|
||||
pub google_client_id: String,
|
||||
|
||||
pub google_client_secret: String,
|
||||
pub google_redirect_url: String,
|
||||
}
|
||||
|
||||
// Custom Debug implementation to mask secrets in logs
|
||||
impl std::fmt::Debug for Env {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Env")
|
||||
.field("port", &self.port)
|
||||
.field("access_token_secret", &"***")
|
||||
.field("refresh_token_secret", &"***")
|
||||
.field("database_url", &"***")
|
||||
.field("pool_size", &self.pool_size)
|
||||
.field("connect_timeout", &self.connect_timeout)
|
||||
.field("idle_timeout", &self.idle_timeout)
|
||||
.field("max_lifetime", &self.max_lifetime)
|
||||
.field("statement_timeout", &self.statement_timeout)
|
||||
.field("idle_in_transaction_session_timeout", &self.idle_in_transaction_session_timeout)
|
||||
.field("sslmode", &self.sslmode)
|
||||
.field("retry_attempts", &self.retry_attempts)
|
||||
.field("retry_delay", &self.retry_delay)
|
||||
.field("smtp_email", &self.smtp_email)
|
||||
.field("smtp_password", &"***")
|
||||
.field("smtp_name", &self.smtp_name)
|
||||
.field("smtp_host", &self.smtp_host)
|
||||
.field("redisdb_url", &self.redisdb_url)
|
||||
.field("fe_url", &self.fe_url)
|
||||
.field("rust_env", &self.rust_env)
|
||||
.field("minio_endpoint", &self.minio_endpoint)
|
||||
.field("minio_bucket_name", &self.minio_bucket_name)
|
||||
.field("minio_access_key", &"***")
|
||||
.field("minio_secret_key", &"***")
|
||||
.field("minio_region", &self.minio_region)
|
||||
.field("minio_secure", &self.minio_secure)
|
||||
.field("google_client_id", &self.google_client_id)
|
||||
.field("google_client_secret", &"***")
|
||||
.field("google_redirect_url", &self.google_redirect_url)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get environment variable with warning if not set.
|
||||
///
|
||||
/// This helper function attempts to read an environment variable and logs a warning
|
||||
/// if it's not set, falling back to the provided default value.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - The environment variable name
|
||||
/// * `default` - The default value to use if the variable is not set
|
||||
///
|
||||
/// # Returns
|
||||
/// The environment variable value or the default
|
||||
fn get_env_with_warning(key: &str, default: &str) -> String {
|
||||
match env::var(key) {
|
||||
Ok(val) => val,
|
||||
Err(_) => {
|
||||
warn!("Environment variable '{}' is not set. Using default: '{}'", key, default);
|
||||
default.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse environment variable as u16 with fallback.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - The environment variable name
|
||||
/// * `default` - The default numeric value
|
||||
///
|
||||
/// # Returns
|
||||
/// The parsed u16 value or the default if parsing fails
|
||||
fn get_env_u16_with_warning(key: &str, default: u16) -> u16 {
|
||||
match env::var(key) {
|
||||
Ok(val) => val.parse().unwrap_or_else(|_| {
|
||||
warn!("Environment variable '{}' has invalid value '{}'. Using default: {}", key, val, default);
|
||||
default
|
||||
}),
|
||||
Err(_) => {
|
||||
warn!("Environment variable '{}' is not set. Using default: {}", key, default);
|
||||
default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse environment variable as bool with fallback.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - The environment variable name
|
||||
/// * `default` - The default boolean value
|
||||
///
|
||||
/// # Returns
|
||||
/// The parsed boolean value or the default if parsing fails
|
||||
fn get_env_bool_with_warning(key: &str, default: bool) -> bool {
|
||||
match env::var(key) {
|
||||
Ok(val) => val.parse().unwrap_or_else(|_| {
|
||||
warn!("Environment variable '{}' has invalid value '{}'. Using default: {}", key, val, default);
|
||||
default
|
||||
}),
|
||||
Err(_) => {
|
||||
warn!("Environment variable '{}' is not set. Using default: {}", key, default);
|
||||
default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global environment configuration loaded once at startup.
|
||||
///
|
||||
/// This static variable loads all environment configuration exactly once
|
||||
/// and caches it for the lifetime of the application.
|
||||
pub static ENV: Lazy<Env> = Lazy::new(|| {
|
||||
// Load .env file if present
|
||||
load_dotenv_file();
|
||||
|
||||
let env = Env {
|
||||
// Server configuration
|
||||
port: get_env_u16_with_warning("PORT", 3000),
|
||||
|
||||
// JWT secrets
|
||||
access_token_secret: get_env_with_warning("ACCESS_TOKEN_SECRET", "default_access_secret"),
|
||||
refresh_token_secret: get_env_with_warning("REFRESH_TOKEN_SECRET", "default_refresh_secret"),
|
||||
|
||||
// PostgreSQL configuration (exclusive database backend)
|
||||
database_url: get_env_with_warning("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/imphnen"),
|
||||
pool_size: get_env_with_warning("POOL_SIZE", "10").parse().unwrap_or(10),
|
||||
connect_timeout: get_env_with_warning("CONNECT_TIMEOUT", "30").parse().unwrap_or(30),
|
||||
idle_timeout: get_env_with_warning("IDLE_TIMEOUT", "60").parse().unwrap_or(60),
|
||||
max_lifetime: get_env_with_warning("MAX_LIFETIME", "1800").parse().ok(),
|
||||
statement_timeout: get_env_with_warning("STATEMENT_TIMEOUT", "30000").parse().ok(),
|
||||
idle_in_transaction_session_timeout: get_env_with_warning("IDLE_IN_TRANSACTION_SESSION_TIMEOUT", "60000").parse().ok(),
|
||||
sslmode: get_env_with_warning("SSLMODE", "require"),
|
||||
retry_attempts: get_env_with_warning("RETRY_ATTEMPTS", "3").parse().unwrap_or(3),
|
||||
retry_delay: get_env_with_warning("RETRY_DELAY", "1").parse().unwrap_or(1),
|
||||
|
||||
// SMTP configuration
|
||||
smtp_email: get_env_with_warning("SMTP_EMAIL", "no-reply@example.com"),
|
||||
smtp_password: get_env_with_warning("SMTP_PASSWORD", "default_smtp_password"),
|
||||
smtp_name: get_env_with_warning("SMTP_NAME", "MyApp SMTP"),
|
||||
smtp_host: get_env_with_warning("SMTP_HOST", "smtp.gmail.com"),
|
||||
|
||||
// Redis configuration
|
||||
redisdb_url: get_env_with_warning("REDISDB_URL", "localhost"),
|
||||
|
||||
// Frontend URL
|
||||
fe_url: get_env_with_warning("FE_URL", "http://localhost"),
|
||||
|
||||
// Environment
|
||||
rust_env: get_env_with_warning("RUST_ENV", "development"),
|
||||
|
||||
// MinIO configuration
|
||||
minio_endpoint: get_env_with_warning("MINIO_ENDPOINT", "http://localhost:9000"),
|
||||
minio_bucket_name: get_env_with_warning("MINIO_BUCKET_NAME", "imphnen-uploads"),
|
||||
minio_access_key: get_env_with_warning("MINIO_ACCESS_KEY", "minio_access"),
|
||||
minio_secret_key: get_env_with_warning("MINIO_SECRET_KEY", "minio_secret"),
|
||||
minio_region: get_env_with_warning("MINIO_REGION", "us-east-1"),
|
||||
minio_secure: get_env_bool_with_warning("MINIO_SECURE", false),
|
||||
|
||||
// Google OAuth 2.1
|
||||
google_client_id: get_env_with_warning("GOOGLE_CLIENT_ID", "default_google_client_id"),
|
||||
google_client_secret: get_env_with_warning("GOOGLE_CLIENT_SECRET", "default_google_client_secret"),
|
||||
google_redirect_url: get_env_with_warning("GOOGLE_REDIRECT_URL", "http://localhost:8000/api/v1/auth/google/callback"),
|
||||
};
|
||||
|
||||
info!("Environment configuration loaded successfully");
|
||||
env
|
||||
});
|
||||
|
||||
/// Load .env file if present, with appropriate logging.
|
||||
fn load_dotenv_file() {
|
||||
match dotenvy::dotenv() {
|
||||
Ok(path) => info!("Loaded environment file: {:?}", path),
|
||||
Err(dotenvy::Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
warn!(".env file not found, falling back to system environment variables");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to load .env file: {}. Falling back to system environment variables", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use log::{info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use std::env;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Env {
|
||||
pub port: u16,
|
||||
pub access_token_secret: String,
|
||||
pub refresh_token_secret: String,
|
||||
pub database_url: String,
|
||||
pub pool_size: u32,
|
||||
pub connect_timeout: u64,
|
||||
pub idle_timeout: u64,
|
||||
pub max_lifetime: Option<u64>,
|
||||
pub statement_timeout: Option<u64>,
|
||||
pub idle_in_transaction_session_timeout: Option<u64>,
|
||||
pub sslmode: String,
|
||||
pub retry_attempts: u32,
|
||||
pub retry_delay: u64,
|
||||
pub smtp_email: String,
|
||||
pub smtp_password: String,
|
||||
pub smtp_name: String,
|
||||
pub smtp_host: String,
|
||||
pub redisdb_url: String,
|
||||
pub fe_url: String,
|
||||
pub rust_env: String,
|
||||
pub minio_endpoint: String,
|
||||
pub minio_bucket_name: String,
|
||||
pub minio_access_key: String,
|
||||
pub minio_secret_key: String,
|
||||
pub minio_region: String,
|
||||
pub minio_secure: bool,
|
||||
pub google_client_id: String,
|
||||
pub google_client_secret: String,
|
||||
pub google_redirect_url: String,
|
||||
pub cdn_url: String,
|
||||
pub cors_allowed_origins: Vec<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Env {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Env")
|
||||
.field("port", &self.port)
|
||||
.field("access_token_secret", &"***")
|
||||
.field("refresh_token_secret", &"***")
|
||||
.field("database_url", &"***")
|
||||
.field("pool_size", &self.pool_size)
|
||||
.field("connect_timeout", &self.connect_timeout)
|
||||
.field("idle_timeout", &self.idle_timeout)
|
||||
.field("max_lifetime", &self.max_lifetime)
|
||||
.field("statement_timeout", &self.statement_timeout)
|
||||
.field(
|
||||
"idle_in_transaction_session_timeout",
|
||||
&self.idle_in_transaction_session_timeout,
|
||||
)
|
||||
.field("sslmode", &self.sslmode)
|
||||
.field("retry_attempts", &self.retry_attempts)
|
||||
.field("retry_delay", &self.retry_delay)
|
||||
.field("smtp_email", &self.smtp_email)
|
||||
.field("smtp_password", &"***")
|
||||
.field("smtp_name", &self.smtp_name)
|
||||
.field("smtp_host", &self.smtp_host)
|
||||
.field("redisdb_url", &self.redisdb_url)
|
||||
.field("fe_url", &self.fe_url)
|
||||
.field("rust_env", &self.rust_env)
|
||||
.field("minio_endpoint", &self.minio_endpoint)
|
||||
.field("minio_bucket_name", &self.minio_bucket_name)
|
||||
.field("minio_access_key", &"***")
|
||||
.field("minio_secret_key", &"***")
|
||||
.field("minio_region", &self.minio_region)
|
||||
.field("minio_secure", &self.minio_secure)
|
||||
.field("google_client_id", &self.google_client_id)
|
||||
.field("google_client_secret", &"***")
|
||||
.field("google_redirect_url", &self.google_redirect_url)
|
||||
.field("cdn_url", &self.cdn_url)
|
||||
.field("cors_allowed_origins", &self.cors_allowed_origins)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_env_with_warning(key: &str, default: &str) -> String {
|
||||
match env::var(key) {
|
||||
Ok(val) => val,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Environment variable '{}' is not set. Using default: '{}'",
|
||||
key, default
|
||||
);
|
||||
default.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_env_u16_with_warning(key: &str, default: u16) -> u16 {
|
||||
match env::var(key) {
|
||||
Ok(val) => val.parse().unwrap_or_else(|_| {
|
||||
warn!(
|
||||
"Environment variable '{}' has invalid value '{}'. Using default: {}",
|
||||
key, val, default
|
||||
);
|
||||
default
|
||||
}),
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Environment variable '{}' is not set. Using default: {}",
|
||||
key, default
|
||||
);
|
||||
default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_env_bool_with_warning(key: &str, default: bool) -> bool {
|
||||
match env::var(key) {
|
||||
Ok(val) => val.parse().unwrap_or_else(|_| {
|
||||
warn!(
|
||||
"Environment variable '{}' has invalid value '{}'. Using default: {}",
|
||||
key, val, default
|
||||
);
|
||||
default
|
||||
}),
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Environment variable '{}' is not set. Using default: {}",
|
||||
key, default
|
||||
);
|
||||
default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub static ENV: Lazy<Env> = Lazy::new(|| {
|
||||
load_dotenv_file();
|
||||
|
||||
let env = Env {
|
||||
port: get_env_u16_with_warning("PORT", 3000),
|
||||
access_token_secret: get_env_with_warning(
|
||||
"ACCESS_TOKEN_SECRET",
|
||||
"default_access_secret",
|
||||
),
|
||||
refresh_token_secret: get_env_with_warning(
|
||||
"REFRESH_TOKEN_SECRET",
|
||||
"default_refresh_secret",
|
||||
),
|
||||
database_url: get_env_with_warning(
|
||||
"DATABASE_URL",
|
||||
"postgres://postgres:postgres@localhost:5432/imphnen",
|
||||
),
|
||||
pool_size: get_env_with_warning("POOL_SIZE", "10")
|
||||
.parse()
|
||||
.unwrap_or(10),
|
||||
connect_timeout: get_env_with_warning("CONNECT_TIMEOUT", "30")
|
||||
.parse()
|
||||
.unwrap_or(30),
|
||||
idle_timeout: get_env_with_warning("IDLE_TIMEOUT", "60")
|
||||
.parse()
|
||||
.unwrap_or(60),
|
||||
max_lifetime: get_env_with_warning("MAX_LIFETIME", "1800").parse().ok(),
|
||||
statement_timeout: get_env_with_warning("STATEMENT_TIMEOUT", "30000")
|
||||
.parse()
|
||||
.ok(),
|
||||
idle_in_transaction_session_timeout: get_env_with_warning(
|
||||
"IDLE_IN_TRANSACTION_SESSION_TIMEOUT",
|
||||
"60000",
|
||||
)
|
||||
.parse()
|
||||
.ok(),
|
||||
sslmode: get_env_with_warning("SSLMODE", "require"),
|
||||
retry_attempts: get_env_with_warning("RETRY_ATTEMPTS", "3")
|
||||
.parse()
|
||||
.unwrap_or(3),
|
||||
retry_delay: get_env_with_warning("RETRY_DELAY", "1")
|
||||
.parse()
|
||||
.unwrap_or(1),
|
||||
smtp_email: get_env_with_warning("SMTP_EMAIL", "no-reply@example.com"),
|
||||
smtp_password: get_env_with_warning("SMTP_PASSWORD", "default_smtp_password"),
|
||||
smtp_name: get_env_with_warning("SMTP_NAME", "MyApp SMTP"),
|
||||
smtp_host: get_env_with_warning("SMTP_HOST", "smtp.gmail.com"),
|
||||
redisdb_url: get_env_with_warning("REDISDB_URL", "localhost"),
|
||||
fe_url: get_env_with_warning("FE_URL", "http://localhost"),
|
||||
rust_env: get_env_with_warning("RUST_ENV", "development"),
|
||||
minio_endpoint: get_env_with_warning("MINIO_ENDPOINT", "http://localhost:9000"),
|
||||
minio_bucket_name: get_env_with_warning("MINIO_BUCKET_NAME", "imphnen-uploads"),
|
||||
minio_access_key: get_env_with_warning("MINIO_ACCESS_KEY", "minio_access"),
|
||||
minio_secret_key: get_env_with_warning("MINIO_SECRET_KEY", "minio_secret"),
|
||||
minio_region: get_env_with_warning("MINIO_REGION", "us-east-1"),
|
||||
minio_secure: get_env_bool_with_warning("MINIO_SECURE", false),
|
||||
google_client_id: get_env_with_warning(
|
||||
"GOOGLE_CLIENT_ID",
|
||||
"default_google_client_id",
|
||||
),
|
||||
google_client_secret: get_env_with_warning(
|
||||
"GOOGLE_CLIENT_SECRET",
|
||||
"default_google_client_secret",
|
||||
),
|
||||
google_redirect_url: get_env_with_warning(
|
||||
"GOOGLE_REDIRECT_URL",
|
||||
"http://localhost:8000/api/v1/auth/google/callback",
|
||||
),
|
||||
cdn_url: get_env_with_warning("CDN_URL", "https://cdn.asepharyana.tech"),
|
||||
cors_allowed_origins: get_env_with_warning(
|
||||
"CORS_ALLOWED_ORIGINS",
|
||||
"https://gacha.imphnen.dev,https://imphnen.dev,https://dimentorin.imphnen.dev",
|
||||
)
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
};
|
||||
|
||||
info!("Environment configuration loaded successfully");
|
||||
env
|
||||
});
|
||||
|
||||
fn load_dotenv_file() {
|
||||
match dotenvy::dotenv() {
|
||||
Ok(path) => info!("Loaded environment file: {:?}", path),
|
||||
Err(dotenvy::Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
warn!(".env file not found, falling back to system environment variables");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to load .env file: {}. Falling back to system environment variables",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,161 +1,112 @@
|
||||
//! JWT token encoding and decoding utilities.
|
||||
//!
|
||||
//! This module provides functions for creating and validating JWT tokens
|
||||
//! for authentication purposes, including access tokens, refresh tokens,
|
||||
//! and password reset tokens.
|
||||
|
||||
use crate::environment::ENV;
|
||||
use axum::http::StatusCode;
|
||||
use chrono::{Duration, TimeDelta, Utc};
|
||||
use jsonwebtoken::{
|
||||
DecodingKey, EncodingKey, Header, TokenData, Validation, decode, encode,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// JWT claims structure containing token payload information.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
/// Expiration timestamp
|
||||
pub exp: usize,
|
||||
/// Issued at timestamp
|
||||
pub iat: usize,
|
||||
/// Subject (usually user identifier)
|
||||
pub sub: String,
|
||||
/// User ID
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
// Token configuration constants
|
||||
const ACCESS_TOKEN_DURATION_MINUTES: i64 = 15;
|
||||
const REFRESH_TOKEN_DURATION_DAYS: i64 = 1;
|
||||
const RESET_TOKEN_DURATION_MINUTES: i64 = 5;
|
||||
|
||||
// Lazy-initialized headers and keys for performance
|
||||
static ACCESS_HEADER: once_cell::sync::Lazy<Header> = once_cell::sync::Lazy::new(Header::default);
|
||||
static ACCESS_KEY: once_cell::sync::Lazy<EncodingKey> = once_cell::sync::Lazy::new(|| {
|
||||
EncodingKey::from_secret(ENV.access_token_secret.as_ref())
|
||||
});
|
||||
|
||||
static REFRESH_HEADER: once_cell::sync::Lazy<Header> = once_cell::sync::Lazy::new(Header::default);
|
||||
static REFRESH_KEY: once_cell::sync::Lazy<EncodingKey> = once_cell::sync::Lazy::new(|| {
|
||||
EncodingKey::from_secret(ENV.refresh_token_secret.as_ref())
|
||||
});
|
||||
|
||||
/// Create JWT claims with specified expiration duration.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `sub` - Subject identifier
|
||||
/// * `user_id` - User ID
|
||||
/// * `duration` - Token validity duration
|
||||
///
|
||||
/// # Returns
|
||||
/// JWT claims structure
|
||||
fn create_claims(sub: String, user_id: String, duration: TimeDelta) -> Claims {
|
||||
let now = Utc::now();
|
||||
let exp: usize = (now + duration).timestamp() as usize;
|
||||
let iat: usize = now.timestamp() as usize;
|
||||
Claims { iat, exp, sub, user_id }
|
||||
}
|
||||
|
||||
/// Encode a JWT token with the specified header and key.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `claims` - JWT claims to encode
|
||||
/// * `header` - JWT header
|
||||
/// * `key` - Encoding key
|
||||
///
|
||||
/// # Returns
|
||||
/// Encoded JWT token or internal server error status
|
||||
fn encode_token(claims: &Claims, header: &Header, key: &EncodingKey) -> Result<String, StatusCode> {
|
||||
encode(header, claims, key).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
/// Decode a JWT token with the specified secret.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `token` - JWT token string
|
||||
/// * `secret` - Secret key for decoding
|
||||
///
|
||||
/// # Returns
|
||||
/// Decoded token data or internal server error status
|
||||
fn decode_token(token: &str, secret: &str) -> Result<TokenData<Claims>, StatusCode> {
|
||||
decode(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_ref()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
/// Encode an access token with 15-minute expiration.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `sub` - Subject identifier
|
||||
/// * `user_id` - User ID
|
||||
///
|
||||
/// # Returns
|
||||
/// Encoded JWT access token
|
||||
pub fn encode_access_token(sub: String, user_id: String) -> Result<String, StatusCode> {
|
||||
let claims = create_claims(sub, user_id, Duration::minutes(ACCESS_TOKEN_DURATION_MINUTES));
|
||||
encode_token(&claims, &ACCESS_HEADER, &ACCESS_KEY)
|
||||
}
|
||||
|
||||
/// Encode a refresh token with 1-day expiration.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `sub` - Subject identifier
|
||||
/// * `user_id` - User ID
|
||||
///
|
||||
/// # Returns
|
||||
/// Encoded JWT refresh token
|
||||
pub fn encode_refresh_token(sub: String, user_id: String) -> Result<String, StatusCode> {
|
||||
let claims = create_claims(sub, user_id, Duration::days(REFRESH_TOKEN_DURATION_DAYS));
|
||||
encode_token(&claims, &REFRESH_HEADER, &REFRESH_KEY)
|
||||
}
|
||||
|
||||
/// Encode a password reset token with 5-minute expiration.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `sub` - Subject identifier
|
||||
/// * `user_id` - User ID
|
||||
///
|
||||
/// # Returns
|
||||
/// Encoded JWT reset token
|
||||
pub fn encode_reset_password_token(sub: String, user_id: String) -> Result<String, StatusCode> {
|
||||
let claims = create_claims(sub, user_id, Duration::minutes(RESET_TOKEN_DURATION_MINUTES));
|
||||
let key = EncodingKey::from_secret(ENV.access_token_secret.as_ref());
|
||||
encode_token(&claims, &Header::default(), &key)
|
||||
}
|
||||
|
||||
/// Decode an access token.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `jwt_token` - JWT token string
|
||||
///
|
||||
/// # Returns
|
||||
/// Decoded token data containing claims
|
||||
pub fn decode_access_token(jwt_token: &str) -> Result<TokenData<Claims>, StatusCode> {
|
||||
decode_token(jwt_token, &ENV.access_token_secret)
|
||||
}
|
||||
|
||||
/// Decode a refresh token.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `jwt_token` - JWT token string
|
||||
///
|
||||
/// # Returns
|
||||
/// Decoded token data containing claims
|
||||
pub fn decode_refresh_token(jwt_token: &str) -> Result<TokenData<Claims>, StatusCode> {
|
||||
decode_token(jwt_token, &ENV.refresh_token_secret)
|
||||
}
|
||||
|
||||
/// Generate a simple JWT access token using user_id as both sub and user_id.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `user_id` - User identifier
|
||||
///
|
||||
/// # Returns
|
||||
/// Encoded JWT access token
|
||||
pub fn generate_jwt(user_id: &str) -> Result<String, StatusCode> {
|
||||
encode_access_token(user_id.to_string(), user_id.to_string())
|
||||
}
|
||||
use crate::environment::ENV;
|
||||
use axum::http::StatusCode;
|
||||
use chrono::{Duration, TimeDelta, Utc};
|
||||
use jsonwebtoken::{
|
||||
DecodingKey, EncodingKey, Header, TokenData, Validation, decode, encode,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub exp: usize,
|
||||
pub iat: usize,
|
||||
pub sub: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN_DURATION_MINUTES: i64 = 15;
|
||||
const REFRESH_TOKEN_DURATION_DAYS: i64 = 1;
|
||||
const RESET_TOKEN_DURATION_MINUTES: i64 = 5;
|
||||
|
||||
static ACCESS_HEADER: once_cell::sync::Lazy<Header> =
|
||||
once_cell::sync::Lazy::new(Header::default);
|
||||
static ACCESS_KEY: once_cell::sync::Lazy<EncodingKey> =
|
||||
once_cell::sync::Lazy::new(|| {
|
||||
EncodingKey::from_secret(ENV.access_token_secret.as_ref())
|
||||
});
|
||||
|
||||
static REFRESH_HEADER: once_cell::sync::Lazy<Header> =
|
||||
once_cell::sync::Lazy::new(Header::default);
|
||||
static REFRESH_KEY: once_cell::sync::Lazy<EncodingKey> =
|
||||
once_cell::sync::Lazy::new(|| {
|
||||
EncodingKey::from_secret(ENV.refresh_token_secret.as_ref())
|
||||
});
|
||||
|
||||
fn create_claims(sub: String, user_id: String, duration: TimeDelta) -> Claims {
|
||||
let now = Utc::now();
|
||||
let exp: usize = (now + duration).timestamp() as usize;
|
||||
let iat: usize = now.timestamp() as usize;
|
||||
Claims {
|
||||
iat,
|
||||
exp,
|
||||
sub,
|
||||
user_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_token(
|
||||
claims: &Claims,
|
||||
header: &Header,
|
||||
key: &EncodingKey,
|
||||
) -> Result<String, StatusCode> {
|
||||
encode(header, claims, key).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
fn decode_token(token: &str, secret: &str) -> Result<TokenData<Claims>, StatusCode> {
|
||||
decode(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_ref()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
pub fn encode_access_token(
|
||||
sub: String,
|
||||
user_id: String,
|
||||
) -> Result<String, StatusCode> {
|
||||
let claims = create_claims(
|
||||
sub,
|
||||
user_id,
|
||||
Duration::minutes(ACCESS_TOKEN_DURATION_MINUTES),
|
||||
);
|
||||
encode_token(&claims, &ACCESS_HEADER, &ACCESS_KEY)
|
||||
}
|
||||
|
||||
pub fn encode_refresh_token(
|
||||
sub: String,
|
||||
user_id: String,
|
||||
) -> Result<String, StatusCode> {
|
||||
let claims =
|
||||
create_claims(sub, user_id, Duration::days(REFRESH_TOKEN_DURATION_DAYS));
|
||||
encode_token(&claims, &REFRESH_HEADER, &REFRESH_KEY)
|
||||
}
|
||||
|
||||
pub fn encode_reset_password_token(
|
||||
sub: String,
|
||||
user_id: String,
|
||||
) -> Result<String, StatusCode> {
|
||||
let claims = create_claims(
|
||||
sub,
|
||||
user_id,
|
||||
Duration::minutes(RESET_TOKEN_DURATION_MINUTES),
|
||||
);
|
||||
let key = EncodingKey::from_secret(ENV.access_token_secret.as_ref());
|
||||
encode_token(&claims, &Header::default(), &key)
|
||||
}
|
||||
|
||||
pub fn decode_access_token(
|
||||
jwt_token: &str,
|
||||
) -> Result<TokenData<Claims>, StatusCode> {
|
||||
decode_token(jwt_token, &ENV.access_token_secret)
|
||||
}
|
||||
|
||||
pub fn decode_refresh_token(
|
||||
jwt_token: &str,
|
||||
) -> Result<TokenData<Claims>, StatusCode> {
|
||||
decode_token(jwt_token, &ENV.refresh_token_secret)
|
||||
}
|
||||
|
||||
pub fn generate_jwt(user_id: &str) -> Result<String, StatusCode> {
|
||||
encode_access_token(user_id.to_string(), user_id.to_string())
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
//! Email sending utilities using Lettre SMTP client.
|
||||
//!
|
||||
//! This module provides functionality for sending emails through SMTP
|
||||
//! with proper error handling and logging.
|
||||
|
||||
use crate::environment::ENV;
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{Message, SmtpTransport, Transport};
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
|
||||
/// Custom error type for email operations.
|
||||
#[derive(Debug)]
|
||||
pub enum EmailError {
|
||||
/// SMTP configuration error
|
||||
SmtpConfig(String),
|
||||
/// Message building error
|
||||
MessageBuild(String),
|
||||
/// SMTP transport error
|
||||
Transport(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for EmailError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
EmailError::SmtpConfig(msg) => write!(f, "SMTP configuration error: {}", msg),
|
||||
EmailError::MessageBuild(msg) => write!(f, "Message building error: {}", msg),
|
||||
EmailError::Transport(msg) => write!(f, "SMTP transport error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for EmailError {}
|
||||
|
||||
/// Send an email using the configured SMTP settings.
|
||||
///
|
||||
/// This function constructs and sends an email using the SMTP configuration
|
||||
/// from environment variables. It handles sender name normalization and
|
||||
/// proper error reporting.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `to` - Recipient email address
|
||||
/// * `subject` - Email subject line
|
||||
/// * `body` - Email body content (plain text)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - Email sent successfully
|
||||
/// * `Err(EmailError)` - Email sending failed
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use imphnen_libs::send_email;
|
||||
///
|
||||
/// send_email("user@example.com", "Welcome!", "Hello, welcome to our service!")?;
|
||||
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||
/// ```
|
||||
pub fn send_email(to: &str, subject: &str, body: &str) -> Result<(), Box<dyn Error>> {
|
||||
let env = &ENV;
|
||||
|
||||
// Build the email message
|
||||
let message = build_email_message(to, subject, body, env)?;
|
||||
|
||||
// Create SMTP transport
|
||||
let mailer = create_smtp_transport(env)?;
|
||||
|
||||
// Send the email
|
||||
mailer.send(&message).map_err(|e| {
|
||||
log::error!("Failed to send email to {}: {}", to, e);
|
||||
Box::new(EmailError::Transport(e.to_string())) as Box<dyn Error>
|
||||
})?;
|
||||
|
||||
log::info!("Email sent successfully to: {}", to);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build an email message with proper sender and recipient configuration.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `to` - Recipient email address
|
||||
/// * `subject` - Email subject
|
||||
/// * `body` - Email body
|
||||
/// * `env` - Environment configuration
|
||||
///
|
||||
/// # Returns
|
||||
/// Email message or error
|
||||
fn build_email_message(
|
||||
to: &str,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
env: &crate::environment::Env,
|
||||
) -> Result<Message, Box<dyn Error>> {
|
||||
let sender_name = env.smtp_name.replace("-", " "); // Normalize sender name
|
||||
|
||||
Message::builder()
|
||||
.from(Mailbox::new(Some(sender_name), env.smtp_email.parse()?))
|
||||
.to(to.parse()?)
|
||||
.subject(subject)
|
||||
.body(body.to_string())
|
||||
.map_err(|e| Box::new(EmailError::MessageBuild(e.to_string())) as Box<dyn Error>)
|
||||
}
|
||||
|
||||
/// Create SMTP transport with authentication.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `env` - Environment configuration
|
||||
///
|
||||
/// # Returns
|
||||
/// Configured SMTP transport or error
|
||||
fn create_smtp_transport(env: &crate::environment::Env) -> Result<SmtpTransport, Box<dyn Error>> {
|
||||
let credentials = Credentials::new(
|
||||
env.smtp_email.clone(),
|
||||
env.smtp_password.replace("-", " "), // Normalize password
|
||||
);
|
||||
|
||||
Ok(SmtpTransport::relay(&env.smtp_host)?
|
||||
.credentials(credentials)
|
||||
.build())
|
||||
|
||||
}
|
||||
+25
-68
@@ -1,68 +1,25 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
pub mod postgres;
|
||||
pub mod argon;
|
||||
pub mod axum;
|
||||
pub mod environment;
|
||||
pub mod jsonwebtoken;
|
||||
pub mod lettre;
|
||||
pub mod minio;
|
||||
pub mod services;
|
||||
|
||||
pub use argon::{hash_password, verify_password};
|
||||
pub use axum::{axum_init, ValidatedJson, ZodValidate};
|
||||
pub use environment::{ENV, Env};
|
||||
pub use imphnen_entities::{
|
||||
MessageResponseDto,
|
||||
ResponseSuccessDto,
|
||||
ResponseListSuccessDto,
|
||||
UsersDetailQueryDto,
|
||||
PermissionsEnum,
|
||||
PermissionsItemDto,
|
||||
PermissionsQueryDto,
|
||||
};
|
||||
pub use jsonwebtoken::{
|
||||
Claims, encode_access_token, encode_refresh_token, decode_access_token,
|
||||
decode_refresh_token, encode_reset_password_token, generate_jwt
|
||||
};
|
||||
pub use lettre::send_email;
|
||||
pub use minio::{
|
||||
MinioConfig, MinioService, UploadResult, FileType, UploadRequest, FileMetadata,
|
||||
create_minio_service_from_config, decode_base64_file, extract_content_type_from_data_url
|
||||
};
|
||||
pub use services::{UserLookupService, AuthRepositoryTrait};
|
||||
// Re-export concrete Postgres service implementations for convenience
|
||||
pub use services::PostgresUserLookupService;
|
||||
pub use services::PostgresAuthRepository;
|
||||
pub use postgres::{
|
||||
PostgresConnection, PostgresConfig, PostgresError, AppStatePostgresExt,
|
||||
};
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub postgres_connection: Arc<PostgresConnection>,
|
||||
pub user_lookup_service: Arc<dyn UserLookupService>,
|
||||
pub auth_repository: Arc<dyn AuthRepositoryTrait>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Create a new AppState with PostgreSQL connection
|
||||
pub async fn new(
|
||||
postgres_config: PostgresConfig,
|
||||
user_lookup_service: Arc<dyn UserLookupService>,
|
||||
auth_repository: Arc<dyn AuthRepositoryTrait>,
|
||||
) -> Result<Self, PostgresError> {
|
||||
let postgres_connection = PostgresConnection::new(postgres_config).await?;
|
||||
|
||||
Ok(Self {
|
||||
postgres_connection: Arc::new(postgres_connection),
|
||||
user_lookup_service,
|
||||
auth_repository,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AppStatePostgresExt for AppState {
|
||||
fn postgres_connection(&self) -> &PostgresConnection {
|
||||
&self.postgres_connection
|
||||
}
|
||||
}
|
||||
pub mod argon;
|
||||
pub mod axum;
|
||||
pub mod environment;
|
||||
pub mod jsonwebtoken;
|
||||
pub mod postgres;
|
||||
pub mod services;
|
||||
|
||||
pub use argon::{hash_password, verify_password};
|
||||
pub use axum::app_state::PostgresClients;
|
||||
pub use axum::{AppState, ValidatedJson, ZodValidate, axum_init};
|
||||
pub use environment::{ENV, Env};
|
||||
pub use imphnen_entities::{
|
||||
MessageResponseDto, PermissionsEnum, PermissionsItemDto, PermissionsQueryDto,
|
||||
ResponseListSuccessDto, ResponseSuccessDto, UsersDetailQueryDto,
|
||||
};
|
||||
pub use jsonwebtoken::{
|
||||
Claims, decode_access_token, decode_refresh_token, encode_access_token,
|
||||
encode_refresh_token, encode_reset_password_token, generate_jwt,
|
||||
};
|
||||
pub use postgres::{
|
||||
AppStatePostgresExt, PostgresConfig, PostgresConnection, PostgresError,
|
||||
};
|
||||
pub use services::PostgresAuthRepository;
|
||||
pub use services::PostgresUserLookupService;
|
||||
pub use services::{AuthRepositoryTrait, UserLookupService};
|
||||
|
||||
@@ -1,697 +0,0 @@
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use chrono::Utc;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
use crate::environment::ENV;
|
||||
|
||||
|
||||
|
||||
// --- Struct Konfigurasi MinIO ---
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MinioConfig {
|
||||
pub endpoint: String,
|
||||
pub access_key: String,
|
||||
pub secret_key: String,
|
||||
pub bucket_name: String,
|
||||
pub region: String,
|
||||
pub secure: bool,
|
||||
}
|
||||
|
||||
impl MinioConfig {
|
||||
/// Memuat konfigurasi MinIO dari variabel lingkungan.
|
||||
pub fn from_env() -> Result<Self> {
|
||||
Ok(Self {
|
||||
endpoint: ENV.minio_endpoint.clone(),
|
||||
access_key: ENV.minio_access_key.clone(),
|
||||
secret_key: ENV.minio_secret_key.clone(),
|
||||
bucket_name: ENV.minio_bucket_name.clone(),
|
||||
region: ENV.minio_region.clone(),
|
||||
secure: ENV.minio_secure,
|
||||
})
|
||||
}
|
||||
|
||||
/// Mendapatkan URL endpoint lengkap (http atau https).
|
||||
pub fn endpoint_url(&self) -> String {
|
||||
// If endpoint already has protocol, use it as-is
|
||||
if self.endpoint.starts_with("http://") || self.endpoint.starts_with("https://") {
|
||||
self.endpoint.clone()
|
||||
} else {
|
||||
// Only add protocol if not present
|
||||
let protocol = if self.secure { "https" } else { "http" };
|
||||
format!("{protocol}://{}", self.endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layanan MinIO ---
|
||||
pub struct MinioService {
|
||||
endpoint: String,
|
||||
access_key: String,
|
||||
secret_key: String,
|
||||
bucket_name: String,
|
||||
region: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl MinioService {
|
||||
/// Membuat instance layanan MinIO baru.
|
||||
pub async fn new(
|
||||
endpoint: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
bucket_name: &str,
|
||||
region: &str,
|
||||
) -> Result<Self> {
|
||||
let service = Self {
|
||||
endpoint: endpoint.to_string(),
|
||||
access_key: access_key.to_string(),
|
||||
secret_key: secret_key.to_string(),
|
||||
bucket_name: bucket_name.to_string(),
|
||||
region: region.to_string(),
|
||||
client: reqwest::Client::new(),
|
||||
};
|
||||
Ok(service)
|
||||
}
|
||||
|
||||
/// Mengunggah file biner ke MinIO dengan deduplication berdasarkan hash.
|
||||
pub async fn upload_file_with_deduplication(
|
||||
&self,
|
||||
file_data: &[u8],
|
||||
content_type: &str,
|
||||
folder: &str,
|
||||
original_filename: &str,
|
||||
) -> Result<String> {
|
||||
Self::validate_file_type(content_type, file_data)?;
|
||||
|
||||
// Calculate file hash
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(file_data);
|
||||
let file_hash = format!("{:x}", hasher.finalize());
|
||||
let short_hash = &file_hash[..16]; // Use first 16 characters for filename
|
||||
|
||||
// Check if file with same hash already exists
|
||||
if let Some(existing_file) = self.check_file_exists_by_hash(folder, short_hash).await? {
|
||||
log::info!("File with same content already exists: {}", existing_file);
|
||||
return Ok(existing_file);
|
||||
}
|
||||
|
||||
let file_extension = Self::get_file_extension(original_filename);
|
||||
let unique_filename = format!("{folder}/{short_hash}-{}.{file_extension}", Uuid::new_v4());
|
||||
let object_name = &unique_filename;
|
||||
|
||||
// Extract host from endpoint (remove protocol)
|
||||
let host = self.endpoint
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
|
||||
let url = format!("https://{host}/{}/{object_name}", self.bucket_name);
|
||||
|
||||
|
||||
let now = Utc::now();
|
||||
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date_stamp = now.format("%Y%m%d").to_string();
|
||||
|
||||
// Use UNSIGNED-PAYLOAD for simpler signature
|
||||
let payload_hash = "UNSIGNED-PAYLOAD".to_string();
|
||||
|
||||
let canonical_headers = format!(
|
||||
"host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n",
|
||||
host, payload_hash, amz_date
|
||||
);
|
||||
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
|
||||
|
||||
// For path-style, canonical URI should be /bucket/object
|
||||
let canonical_uri = format!("/{}/{object_name}", self.bucket_name);
|
||||
let canonical_request = format!(
|
||||
"PUT\n{}\n\n{}\n{}\n{}",
|
||||
canonical_uri, canonical_headers, signed_headers, payload_hash
|
||||
);
|
||||
|
||||
|
||||
let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region);
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||
amz_date,
|
||||
scope,
|
||||
hex::encode(Sha256::digest(canonical_request.as_bytes()))
|
||||
);
|
||||
|
||||
log::debug!("Scope: {}", scope);
|
||||
log::debug!("String to sign:\n{}", string_to_sign);
|
||||
|
||||
let signing_key = self.get_signature_key(&date_stamp)?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&signing_key)?;
|
||||
mac.update(string_to_sign.as_bytes());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
|
||||
let auth_header = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||
self.access_key, scope, signed_headers, signature
|
||||
);
|
||||
|
||||
// 5) Send request: Content-Type included but NOT signed
|
||||
let response = self
|
||||
.client
|
||||
.put(&url)
|
||||
.header("x-amz-date", &amz_date)
|
||||
.header("x-amz-content-sha256", &payload_hash)
|
||||
.header("Authorization", &auth_header)
|
||||
.header("Content-Type", content_type)
|
||||
// Don't set Host header manually - let reqwest handle it
|
||||
// Add headers for reverse proxy support (not signed)
|
||||
.header("X-Forwarded-Proto", "https")
|
||||
.header("X-Forwarded-Host", host)
|
||||
.body(file_data.to_vec())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_body = response.text().await?;
|
||||
bail!(
|
||||
"Gagal mengunggah file ke MinIO. Status: {}. Pesan: {}",
|
||||
status,
|
||||
error_body
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("Unggahan berhasil: {} byte ke {}", file_data.len(), unique_filename);
|
||||
Ok(unique_filename)
|
||||
}
|
||||
|
||||
/// Mengunggah file biner ke MinIO.
|
||||
pub async fn upload_file(
|
||||
&self,
|
||||
file_data: &[u8],
|
||||
content_type: &str,
|
||||
folder: &str,
|
||||
original_filename: &str,
|
||||
) -> Result<String> {
|
||||
Self::validate_file_type(content_type, file_data)?;
|
||||
|
||||
let file_extension = Self::get_file_extension(original_filename);
|
||||
let unique_filename = format!("{folder}/{}.{file_extension}", Uuid::new_v4());
|
||||
let object_name = &unique_filename;
|
||||
|
||||
// Extract host from endpoint (remove protocol)
|
||||
let host = self.endpoint
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
|
||||
let url = format!("https://{host}/{}/{object_name}", self.bucket_name);
|
||||
|
||||
|
||||
let now = Utc::now();
|
||||
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date_stamp = now.format("%Y%m%d").to_string();
|
||||
|
||||
// 1) Use UNSIGNED-PAYLOAD for HTTPS uploads (safer for proxies)
|
||||
let payload_hash = "UNSIGNED-PAYLOAD".to_string();
|
||||
|
||||
// 2) Path-style canonical URI: /{bucket}/{object}
|
||||
let canonical_uri = format!("/{}/{object_name}", self.bucket_name);
|
||||
|
||||
// 3) ONLY sign essential headers (no content-type to avoid proxy issues)
|
||||
let canonical_headers = format!(
|
||||
"host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n",
|
||||
host, payload_hash, amz_date
|
||||
);
|
||||
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
|
||||
|
||||
// 4) Canonical request
|
||||
let canonical_request = format!(
|
||||
"PUT\n{}\n\n{}\n{}\n{}",
|
||||
canonical_uri, canonical_headers, signed_headers, payload_hash
|
||||
);
|
||||
|
||||
|
||||
let scope = format!("{date_stamp}/{}/s3/aws4_request", self.region);
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||
amz_date,
|
||||
scope,
|
||||
hex::encode(Sha256::digest(canonical_request.as_bytes()))
|
||||
);
|
||||
|
||||
|
||||
let signing_key = self.get_signature_key(&date_stamp)?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&signing_key)?;
|
||||
mac.update(string_to_sign.as_bytes());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
|
||||
let auth_header = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||
self.access_key, scope, signed_headers, signature
|
||||
);
|
||||
|
||||
// 5) Send request: Content-Type included but NOT signed
|
||||
let response = self
|
||||
.client
|
||||
.put(&url)
|
||||
.header("x-amz-date", &amz_date)
|
||||
.header("x-amz-content-sha256", &payload_hash)
|
||||
.header("Authorization", &auth_header)
|
||||
.header("Content-Type", content_type)
|
||||
// Don't set Host header manually - let reqwest handle it
|
||||
// Add headers for reverse proxy support (not signed)
|
||||
.header("X-Forwarded-Proto", "https")
|
||||
.header("X-Forwarded-Host", host)
|
||||
.body(file_data.to_vec())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_body = response.text().await?;
|
||||
bail!(
|
||||
"Gagal mengunggah file ke MinIO. Status: {}. Pesan: {}",
|
||||
status,
|
||||
error_body
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("Unggahan berhasil: {} byte ke {}", file_data.len(), unique_filename);
|
||||
Ok(unique_filename)
|
||||
}
|
||||
|
||||
/// Mengunggah file yang dikodekan base64 ke MinIO.
|
||||
pub async fn upload_base64_file(
|
||||
&self,
|
||||
base64_data: &str,
|
||||
content_type: &str,
|
||||
folder: &str,
|
||||
original_filename: &str,
|
||||
) -> Result<String> {
|
||||
let file_data = decode_base64_file(base64_data)?;
|
||||
self.upload_file(&file_data, content_type, folder, original_filename)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Menghasilkan URL yang telah ditandatangani sebelumnya untuk mengunduh objek.
|
||||
pub async fn get_presigned_url(&self, object_name: &str, expiry_seconds: u32) -> Result<String> {
|
||||
// Extract host from endpoint (remove protocol)
|
||||
let host = self.endpoint
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
|
||||
let now = Utc::now();
|
||||
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date_stamp = now.format("%Y%m%d").to_string();
|
||||
let scope = format!("{date_stamp}/{}/s3/aws4_request", self.region);
|
||||
let credential = format!("{}/{}", self.access_key, scope);
|
||||
|
||||
let expires_str = expiry_seconds.to_string();
|
||||
let mut query_params = std::collections::BTreeMap::new();
|
||||
query_params.insert("X-Amz-Algorithm", "AWS4-HMAC-SHA256");
|
||||
query_params.insert("X-Amz-Credential", &credential);
|
||||
query_params.insert("X-Amz-Date", &amz_date);
|
||||
query_params.insert("X-Amz-Expires", &expires_str);
|
||||
query_params.insert("X-Amz-SignedHeaders", "host");
|
||||
|
||||
let canonical_query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
let canonical_request = format!(
|
||||
"GET\n/{}/{}\n{}\nhost:{}\n\nhost\nUNSIGNED-PAYLOAD",
|
||||
self.bucket_name, object_name, canonical_query_string, host
|
||||
);
|
||||
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||
amz_date,
|
||||
scope,
|
||||
hex::encode(Sha256::digest(canonical_request.as_bytes()))
|
||||
);
|
||||
|
||||
let signing_key = self.get_signature_key(&date_stamp)?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&signing_key)?;
|
||||
mac.update(string_to_sign.as_bytes());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
let url = format!(
|
||||
"https://{}/{}/{}?{}&X-Amz-Signature={}",
|
||||
host, self.bucket_name, object_name, canonical_query_string, signature
|
||||
);
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// Mengecek apakah file dengan hash tertentu sudah ada di bucket
|
||||
pub async fn check_file_exists_by_hash(&self, folder: &str, file_hash: &str) -> Result<Option<String>> {
|
||||
// Extract host from endpoint (remove protocol)
|
||||
let host = self.endpoint
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
|
||||
let url = format!("https://{host}/{bucket}?list-type=2&prefix={folder}", bucket = self.bucket_name);
|
||||
|
||||
let now = Utc::now();
|
||||
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date_stamp = now.format("%Y%m%d").to_string();
|
||||
let payload_hash = hex::encode(Sha256::digest(b""));
|
||||
|
||||
let canonical_query_string = format!("list-type=2&prefix={}", urlencoding::encode(folder));
|
||||
let canonical_headers = format!("host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n");
|
||||
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
|
||||
let canonical_request = format!(
|
||||
"GET\n/{}\n{}\n{}\n{}\n{}",
|
||||
self.bucket_name, canonical_query_string, canonical_headers, signed_headers, payload_hash
|
||||
);
|
||||
|
||||
let scope = format!("{date_stamp}/{}/s3/aws4_request", self.region);
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||
amz_date,
|
||||
scope,
|
||||
hex::encode(Sha256::digest(canonical_request.as_bytes()))
|
||||
);
|
||||
|
||||
let signing_key = self.get_signature_key(&date_stamp)?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&signing_key)?;
|
||||
mac.update(string_to_sign.as_bytes());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
let auth_header = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||
self.access_key, scope, signed_headers, signature
|
||||
);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("x-amz-date", &amz_date)
|
||||
.header("x-amz-content-sha256", &payload_hash)
|
||||
.header("Authorization", &auth_header)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let body = response.text().await?;
|
||||
|
||||
// Simple XML parsing to find files with matching hash
|
||||
// Look for any file that contains the hash in its name
|
||||
if body.contains(file_hash) {
|
||||
// Extract the full file path from XML response
|
||||
// This is a simplified approach - in production you might want proper XML parsing
|
||||
for line in body.lines() {
|
||||
if line.contains("<Key>") && line.contains(file_hash)
|
||||
&& let Some(start) = line.find("<Key>")
|
||||
&& let Some(end) = line.find("</Key>") {
|
||||
let file_path = &line[start + 5..end];
|
||||
return Ok(Some(file_path.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Menghapus file dari MinIO.
|
||||
pub async fn delete_file(&self, object_name: &str) -> Result<()> {
|
||||
// Extract host from endpoint (remove protocol)
|
||||
let host = self.endpoint
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
|
||||
let url = format!("https://{host}/{}/{object_name}", self.bucket_name);
|
||||
|
||||
let now = Utc::now();
|
||||
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date_stamp = now.format("%Y%m%d").to_string();
|
||||
let payload_hash = hex::encode(Sha256::digest(b""));
|
||||
|
||||
let canonical_headers = format!("host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n");
|
||||
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
|
||||
let canonical_request = format!(
|
||||
"DELETE\n/{}/{}\n\n{}\n{}\n{}",
|
||||
self.bucket_name, object_name, canonical_headers, signed_headers, payload_hash
|
||||
);
|
||||
|
||||
let scope = format!("{date_stamp}/{}/s3/aws4_request", self.region);
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||
amz_date,
|
||||
scope,
|
||||
hex::encode(Sha256::digest(canonical_request.as_bytes()))
|
||||
);
|
||||
|
||||
// Debug logging for signature calculation
|
||||
log::debug!("Region: {}", self.region);
|
||||
|
||||
let signing_key = self.get_signature_key(&date_stamp)?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&signing_key)?;
|
||||
mac.update(string_to_sign.as_bytes());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
|
||||
let auth_header = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||
self.access_key, scope, signed_headers, signature
|
||||
);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.delete(&url)
|
||||
.header("Host", host)
|
||||
.header("x-amz-date", &amz_date)
|
||||
.header("x-amz-content-sha256", &payload_hash)
|
||||
.header("Authorization", &auth_header)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_body = response.text().await?;
|
||||
bail!(
|
||||
"Gagal menghapus file dari MinIO. Status: {}. Pesan: {}",
|
||||
status,
|
||||
error_body
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("File berhasil dihapus: {}", object_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fungsi pembantu untuk menghasilkan kunci tanda tangan AWS v4.
|
||||
fn get_signature_key(&self, date_stamp: &str) -> Result<Vec<u8>> {
|
||||
let secret = format!("AWS4{}", self.secret_key);
|
||||
let mut mac1 = Hmac::<Sha256>::new_from_slice(secret.as_bytes())?;
|
||||
mac1.update(date_stamp.as_bytes());
|
||||
let date_key = mac1.finalize().into_bytes();
|
||||
|
||||
let mut mac2 = Hmac::<Sha256>::new_from_slice(&date_key)?;
|
||||
mac2.update(self.region.as_bytes());
|
||||
let date_region_key = mac2.finalize().into_bytes();
|
||||
|
||||
let mut mac3 = Hmac::<Sha256>::new_from_slice(&date_region_key)?;
|
||||
mac3.update(b"s3");
|
||||
let date_region_service_key = mac3.finalize().into_bytes();
|
||||
|
||||
let mut mac4 = Hmac::<Sha256>::new_from_slice(&date_region_service_key)?;
|
||||
mac4.update(b"aws4_request");
|
||||
Ok(mac4.finalize().into_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// Memvalidasi jenis file dan ukuran.
|
||||
fn validate_file_type(content_type: &str, file_data: &[u8]) -> Result<()> {
|
||||
const MAX_SIZE: usize = 10 * 1024 * 1024; // 10MB
|
||||
if file_data.len() > MAX_SIZE {
|
||||
bail!("Ukuran file melebihi batas 10MB");
|
||||
}
|
||||
|
||||
match content_type {
|
||||
"image/jpeg" | "image/jpg" => {
|
||||
if !file_data.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
bail!("File JPEG tidak valid");
|
||||
}
|
||||
}
|
||||
"image/png" => {
|
||||
if !file_data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||
bail!("File PNG tidak valid");
|
||||
}
|
||||
}
|
||||
"application/pdf" => {
|
||||
if !file_data.starts_with(b"%PDF") {
|
||||
bail!("File PDF tidak valid");
|
||||
}
|
||||
}
|
||||
"image/webp" => {
|
||||
if !file_data.starts_with(b"RIFF")
|
||||
|| file_data.get(8..12).is_none_or(|s| s != b"WEBP")
|
||||
{
|
||||
bail!("File WEBP tidak valid");
|
||||
}
|
||||
}
|
||||
"application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => {
|
||||
if file_data.len() < 512 {
|
||||
bail!("File dokumen tidak valid");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
bail!("Jenis file tidak didukung: {}", content_type);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mendapatkan ekstensi file dari nama file.
|
||||
fn get_file_extension(filename: &str) -> String {
|
||||
std::path::Path::new(filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("bin")
|
||||
.to_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Struct dan Enum Pembantu ---
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploadResult {
|
||||
pub object_name: String,
|
||||
pub url: String,
|
||||
pub size: usize,
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FileType {
|
||||
Jpeg,
|
||||
Png,
|
||||
Webp,
|
||||
Gif,
|
||||
Pdf,
|
||||
Doc,
|
||||
Docx,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl FileType {
|
||||
pub fn as_folder(&self) -> &str {
|
||||
match self {
|
||||
FileType::Jpeg | FileType::Png | FileType::Webp | FileType::Gif => "profiles",
|
||||
FileType::Pdf | FileType::Doc | FileType::Docx => "documents",
|
||||
FileType::Unknown => "misc",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_size(&self) -> usize {
|
||||
match self {
|
||||
FileType::Jpeg | FileType::Png | FileType::Webp | FileType::Gif => 5 * 1024 * 1024, // 5MB for images
|
||||
FileType::Pdf | FileType::Doc | FileType::Docx => 10 * 1024 * 1024, // 10MB for documents
|
||||
FileType::Unknown => 5 * 1024 * 1024, // 5MB default
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allowed_types(&self) -> Vec<&str> {
|
||||
match self {
|
||||
FileType::Jpeg => vec!["image/jpeg", "image/jpg"],
|
||||
FileType::Png => vec!["image/png"],
|
||||
FileType::Webp => vec!["image/webp"],
|
||||
FileType::Gif => vec!["image/gif"],
|
||||
FileType::Pdf => vec!["application/pdf"],
|
||||
FileType::Doc => vec!["application/msword"],
|
||||
FileType::Docx => vec!["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
|
||||
FileType::Unknown => vec![], // No allowed types for unknown
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_content_type(content_type: &str) -> Self {
|
||||
match content_type {
|
||||
"image/jpeg" | "image/jpg" => FileType::Jpeg,
|
||||
"image/png" => FileType::Png,
|
||||
"image/webp" => FileType::Webp,
|
||||
"image/gif" => FileType::Gif,
|
||||
"application/pdf" => FileType::Pdf,
|
||||
"application/msword" => FileType::Doc,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => FileType::Docx,
|
||||
_ => FileType::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_filename(filename: &str) -> Self {
|
||||
let filename_lower = filename.to_lowercase();
|
||||
if filename_lower.ends_with(".jpg") || filename_lower.ends_with(".jpeg") {
|
||||
FileType::Jpeg
|
||||
} else if filename_lower.ends_with(".png") {
|
||||
FileType::Png
|
||||
} else if filename_lower.ends_with(".webp") {
|
||||
FileType::Webp
|
||||
} else if filename_lower.ends_with(".gif") {
|
||||
FileType::Gif
|
||||
} else if filename_lower.ends_with(".pdf") {
|
||||
FileType::Pdf
|
||||
} else if filename_lower.ends_with(".doc") {
|
||||
FileType::Doc
|
||||
} else if filename_lower.ends_with(".docx") {
|
||||
FileType::Docx
|
||||
} else {
|
||||
FileType::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploadRequest {
|
||||
pub user_id: String,
|
||||
pub file_type: FileType,
|
||||
pub filename: String,
|
||||
pub content_type: String,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileMetadata {
|
||||
pub filename: String,
|
||||
pub content_type: String,
|
||||
pub size: usize,
|
||||
pub path: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
// --- Fungsi Pembantu ---
|
||||
|
||||
/// Membuat instance MinioService dari struct MinioConfig.
|
||||
pub async fn create_minio_service_from_config(config: MinioConfig) -> Result<MinioService> {
|
||||
MinioService::new(
|
||||
&config.endpoint, // Use raw endpoint, not endpoint_url()
|
||||
&config.access_key,
|
||||
&config.secret_key,
|
||||
&config.bucket_name,
|
||||
&config.region,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Mendekode data file base64.
|
||||
pub fn decode_base64_file(base64_data: &str) -> Result<Vec<u8>> {
|
||||
let clean_data = if base64_data.contains(',') {
|
||||
base64_data.split(',').nth(1).unwrap_or(base64_data)
|
||||
} else {
|
||||
base64_data
|
||||
};
|
||||
|
||||
general_purpose::STANDARD
|
||||
.decode(clean_data)
|
||||
.map_err(|e| anyhow!("Gagal mendekode data base64: {}", e))
|
||||
}
|
||||
|
||||
/// Mengekstrak tipe konten dari URL data.
|
||||
pub fn extract_content_type_from_data_url(data_url: &str) -> Option<String> {
|
||||
if data_url.starts_with("data:") && let Some(type_part) = data_url.split(';').next() {
|
||||
return Some(type_part.replace("data:", ""));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
use std::env;
|
||||
use dotenvy::dotenv;
|
||||
use sea_orm::{
|
||||
ConnectOptions, Database, DatabaseConnection, DbErr, Statement,
|
||||
ConnectionTrait, QueryResult, ExecResult, DatabaseTransaction,
|
||||
TransactionTrait,
|
||||
};
|
||||
use tokio::time::{Duration, Instant};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Configuration for PostgreSQL connection
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PostgresConfig {
|
||||
/// Database URL (e.g., postgres://user:pass@host:port/dbname)
|
||||
pub database_url: String,
|
||||
/// Maximum number of connections in the pool
|
||||
pub pool_size: u32,
|
||||
/// Connection timeout in seconds
|
||||
pub connect_timeout: u64,
|
||||
/// Idle timeout in seconds
|
||||
pub idle_timeout: u64,
|
||||
/// Max lifetime of connections in seconds
|
||||
pub max_lifetime: Option<u64>,
|
||||
/// Retry attempts for connection
|
||||
pub retry_attempts: u32,
|
||||
/// Retry delay between attempts in seconds
|
||||
pub retry_delay: u64,
|
||||
}
|
||||
|
||||
impl Default for PostgresConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
database_url: "postgres://postgres:postgres@localhost:5432/imphnen".into(),
|
||||
pool_size: 10,
|
||||
connect_timeout: 30,
|
||||
idle_timeout: 60,
|
||||
max_lifetime: Some(1800),
|
||||
retry_attempts: 3,
|
||||
retry_delay: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PostgresConfig {
|
||||
/// Load configuration from environment variables
|
||||
pub fn from_env() -> Result<Self, PostgresError> {
|
||||
dotenv().ok();
|
||||
|
||||
let database_url = env::var("DATABASE_URL")
|
||||
.map_err(|_| PostgresError::EnvVarMissing("DATABASE_URL".into()))?;
|
||||
|
||||
Ok(Self {
|
||||
database_url,
|
||||
pool_size: env::var("POOL_SIZE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(10),
|
||||
connect_timeout: env::var("CONNECT_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(30),
|
||||
idle_timeout: env::var("IDLE_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(60),
|
||||
max_lifetime: env::var("MAX_LIFETIME")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.map(Some)
|
||||
.unwrap_or(Some(1800)),
|
||||
retry_attempts: env::var("RETRY_ATTEMPTS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(3),
|
||||
retry_delay: env::var("RETRY_DELAY")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur during PostgreSQL connection
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PostgresError {
|
||||
/// Environment variable is missing
|
||||
#[error("Environment variable {0} is missing")]
|
||||
EnvVarMissing(String),
|
||||
|
||||
/// Database connection error
|
||||
#[error("Database connection error: {0}")]
|
||||
ConnectionError(#[from] DbErr),
|
||||
|
||||
/// Configuration error
|
||||
#[error("Configuration error: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
/// Retry limit exceeded
|
||||
#[error("Retry limit exceeded for database connection")]
|
||||
RetryLimitExceeded,
|
||||
|
||||
/// Timeout error
|
||||
#[error("Connection timeout: {0}")]
|
||||
TimeoutError(String),
|
||||
|
||||
#[error("Operation failed: {0}")]
|
||||
OperationFailed(String),
|
||||
}
|
||||
|
||||
/// PostgreSQL connection manager with pooling
|
||||
#[derive(Clone)]
|
||||
pub struct PostgresConnection {
|
||||
/// Database connection pool
|
||||
pub conn: DatabaseConnection,
|
||||
/// Configuration
|
||||
pub config: PostgresConfig,
|
||||
}
|
||||
|
||||
impl PostgresConnection {
|
||||
/// Create a new PostgreSQL connection with connection pooling
|
||||
pub async fn new(config: PostgresConfig) -> Result<Self, PostgresError> {
|
||||
let connect_options = Self::build_connect_options(&config)?;
|
||||
|
||||
// Implement retry logic for connection
|
||||
let mut last_error = None;
|
||||
for attempt in 1..=config.retry_attempts {
|
||||
match Self::connect_with_timeout(connect_options.clone(), config.connect_timeout).await {
|
||||
Ok(conn) => return Ok(Self { conn, config }),
|
||||
Err(err) => {
|
||||
last_error = Some(err);
|
||||
if attempt < config.retry_attempts {
|
||||
tokio::time::sleep(Duration::from_secs(config.retry_delay)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
PostgresError::ConfigError("Failed to connect to database".into())
|
||||
}))
|
||||
}
|
||||
|
||||
/// Build connection options with pooling and timeouts
|
||||
fn build_connect_options(config: &PostgresConfig) -> Result<ConnectOptions, PostgresError> {
|
||||
let mut options = ConnectOptions::new(config.database_url.clone());
|
||||
|
||||
options.max_connections(config.pool_size)
|
||||
.min_connections(5)
|
||||
.connect_timeout(Duration::from_secs(config.connect_timeout))
|
||||
.idle_timeout(Duration::from_secs(config.idle_timeout));
|
||||
|
||||
if let Some(max_lifetime) = config.max_lifetime {
|
||||
options.max_lifetime(Duration::from_secs(max_lifetime));
|
||||
}
|
||||
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
/// Connect with timeout
|
||||
async fn connect_with_timeout(
|
||||
options: ConnectOptions,
|
||||
timeout: u64,
|
||||
) -> Result<DatabaseConnection, PostgresError> {
|
||||
let deadline = Instant::now() + Duration::from_secs(timeout);
|
||||
|
||||
tokio::select! {
|
||||
result = Database::connect(options) => result.map_err(PostgresError::ConnectionError),
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
Err(PostgresError::TimeoutError(format!(
|
||||
"Connection timed out after {} seconds",
|
||||
timeout
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a raw SQL statement
|
||||
pub async fn execute(&self, statement: Statement) -> Result<ExecResult, PostgresError> {
|
||||
self.conn.execute(statement).await.map_err(PostgresError::ConnectionError)
|
||||
}
|
||||
|
||||
/// Query one result
|
||||
pub async fn query_one(&self, statement: Statement) -> Result<Option<QueryResult>, PostgresError> {
|
||||
self.conn.query_one(statement).await.map_err(PostgresError::ConnectionError)
|
||||
}
|
||||
|
||||
/// Query all results
|
||||
pub async fn query_all(&self, statement: Statement) -> Result<Vec<QueryResult>, PostgresError> {
|
||||
self.conn.query_all(statement).await.map_err(PostgresError::ConnectionError)
|
||||
}
|
||||
|
||||
/// Execute a raw SQL query and return results
|
||||
pub async fn execute_raw(&self, sql: &str) -> Result<Vec<QueryResult>, PostgresError> {
|
||||
let statement = Statement::from_string(
|
||||
self.conn.get_database_backend(),
|
||||
sql.to_string()
|
||||
);
|
||||
self.query_all(statement).await
|
||||
}
|
||||
|
||||
/// Get database backend type
|
||||
pub fn get_database_backend(&self) -> sea_orm::DatabaseBackend {
|
||||
self.conn.get_database_backend()
|
||||
}
|
||||
|
||||
/// Begin a transaction
|
||||
pub async fn begin_transaction(&self) -> Result<DatabaseTransaction, PostgresError> {
|
||||
self.conn.begin().await.map_err(PostgresError::ConnectionError)
|
||||
}
|
||||
|
||||
/// Execute a transaction with automatic commit/rollback
|
||||
pub async fn transaction<'a, F, R>(&'a self, f: F) -> Result<R, PostgresError>
|
||||
where
|
||||
F: FnOnce(&DatabaseTransaction) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<R, PostgresError>> + Send>> + Send + 'a + 'static,
|
||||
R: Send + 'a + 'static,
|
||||
{
|
||||
self.conn.transaction(|txn| {
|
||||
Box::pin(async move {
|
||||
f(txn).await
|
||||
})
|
||||
}).await.map_err(|e| {
|
||||
PostgresError::ConnectionError(DbErr::Custom(e.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a simple database query
|
||||
pub async fn query_simple(&self, sql: &str) -> Result<Vec<QueryResult>, PostgresError> {
|
||||
let statement = Statement::from_string(
|
||||
self.conn.get_database_backend(),
|
||||
sql.to_string()
|
||||
);
|
||||
self.conn.query_all(statement).await.map_err(PostgresError::ConnectionError)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension trait for AppState to add PostgreSQL functionality
|
||||
pub trait AppStatePostgresExt {
|
||||
/// Get the PostgreSQL connection
|
||||
fn postgres_connection(&self) -> &PostgresConnection;
|
||||
|
||||
/// Get the raw database connection (implements ConnectionTrait)
|
||||
fn postgres_db(&self) -> &DatabaseConnection {
|
||||
&self.postgres_connection().conn
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sea_orm::Statement;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_postgres_config_default() {
|
||||
let config = PostgresConfig::default();
|
||||
assert_eq!(config.pool_size, 10);
|
||||
assert_eq!(config.connect_timeout, 30);
|
||||
assert_eq!(config.idle_timeout, 60);
|
||||
assert_eq!(config.retry_attempts, 3);
|
||||
assert_eq!(config.retry_delay, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_postgres_connection_from_env() {
|
||||
// Skip actual connection in test
|
||||
let config = PostgresConfig::from_env();
|
||||
assert!(config.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_postgres_statement_execution() {
|
||||
// This is a mock test since we don't want to connect to a real database in tests
|
||||
let config = PostgresConfig::default();
|
||||
let connection_result = PostgresConnection::new(config).await;
|
||||
|
||||
match connection_result {
|
||||
Ok(_) => {
|
||||
// If we somehow got a connection, test statement execution
|
||||
let statement = Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT 1".to_string(),
|
||||
);
|
||||
|
||||
// We expect this to fail in a test environment without a real database
|
||||
assert!(connection_result.unwrap().execute(statement).await.is_err());
|
||||
}
|
||||
Err(_) => {
|
||||
// Expected behavior in test environment
|
||||
assert!(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
use dotenvy::dotenv;
|
||||
use sea_orm::{
|
||||
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbErr,
|
||||
};
|
||||
use std::env;
|
||||
use thiserror::Error;
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PostgresConfig {
|
||||
pub database_url: String,
|
||||
pub pool_size: u32,
|
||||
pub connect_timeout: u64,
|
||||
pub idle_timeout: u64,
|
||||
pub max_lifetime: Option<u64>,
|
||||
pub retry_attempts: u32,
|
||||
pub retry_delay: u64,
|
||||
}
|
||||
|
||||
impl Default for PostgresConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
database_url: "postgres://postgres:postgres@localhost:5432/imphnen".into(),
|
||||
pool_size: 10,
|
||||
connect_timeout: 30,
|
||||
idle_timeout: 60,
|
||||
max_lifetime: Some(1800),
|
||||
retry_attempts: 3,
|
||||
retry_delay: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PostgresConfig {
|
||||
pub fn from_env() -> Result<Self, PostgresError> {
|
||||
dotenv().ok();
|
||||
|
||||
let database_url = env::var("DATABASE_URL")
|
||||
.map_err(|_| PostgresError::EnvVarMissing("DATABASE_URL".into()))?;
|
||||
|
||||
Ok(Self {
|
||||
database_url,
|
||||
pool_size: env::var("POOL_SIZE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(10),
|
||||
connect_timeout: env::var("CONNECT_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(30),
|
||||
idle_timeout: env::var("IDLE_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(60),
|
||||
max_lifetime: env::var("MAX_LIFETIME")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.map(Some)
|
||||
.unwrap_or(Some(1800)),
|
||||
retry_attempts: env::var("RETRY_ATTEMPTS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(3),
|
||||
retry_delay: env::var("RETRY_DELAY")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PostgresError {
|
||||
#[error("Environment variable {0} is missing")]
|
||||
EnvVarMissing(String),
|
||||
|
||||
#[error("Database connection error: {0}")]
|
||||
ConnectionError(#[from] DbErr),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
#[error("Retry limit exceeded for database connection")]
|
||||
RetryLimitExceeded,
|
||||
|
||||
#[error("Connection timeout: {0}")]
|
||||
TimeoutError(String),
|
||||
|
||||
#[error("Operation failed: {0}")]
|
||||
OperationFailed(String),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PostgresConnection {
|
||||
pub conn: DatabaseConnection,
|
||||
pub config: PostgresConfig,
|
||||
}
|
||||
|
||||
impl PostgresConnection {
|
||||
pub async fn new(config: PostgresConfig) -> Result<Self, PostgresError> {
|
||||
let connect_options = Self::build_connect_options(&config)?;
|
||||
|
||||
let mut last_error = None;
|
||||
for attempt in 1..=config.retry_attempts {
|
||||
match Self::connect_with_timeout(
|
||||
connect_options.clone(),
|
||||
config.connect_timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(conn) => return Ok(Self { conn, config }),
|
||||
Err(err) => {
|
||||
last_error = Some(err);
|
||||
if attempt < config.retry_attempts {
|
||||
tokio::time::sleep(Duration::from_secs(config.retry_delay)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
PostgresError::ConfigError("Failed to connect to database".into())
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_connect_options(
|
||||
config: &PostgresConfig,
|
||||
) -> Result<ConnectOptions, PostgresError> {
|
||||
let mut options = ConnectOptions::new(config.database_url.clone());
|
||||
|
||||
options
|
||||
.max_connections(config.pool_size)
|
||||
.min_connections(5)
|
||||
.connect_timeout(Duration::from_secs(config.connect_timeout))
|
||||
.idle_timeout(Duration::from_secs(config.idle_timeout));
|
||||
|
||||
if let Some(max_lifetime) = config.max_lifetime {
|
||||
options.max_lifetime(Duration::from_secs(max_lifetime));
|
||||
}
|
||||
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
async fn connect_with_timeout(
|
||||
options: ConnectOptions,
|
||||
timeout: u64,
|
||||
) -> Result<DatabaseConnection, PostgresError> {
|
||||
let deadline = Instant::now() + Duration::from_secs(timeout);
|
||||
|
||||
tokio::select! {
|
||||
result = Database::connect(options) => result.map_err(PostgresError::ConnectionError),
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
Err(PostgresError::TimeoutError(format!(
|
||||
"Connection timed out after {} seconds",
|
||||
timeout
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_database_backend(&self) -> sea_orm::DatabaseBackend {
|
||||
self.conn.get_database_backend()
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
//! Examples and usage patterns for PostgreSQL integration with SeaORM
|
||||
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use sea_orm::{EntityTrait, ColumnTrait, QueryFilter, DatabaseConnection};
|
||||
|
||||
use crate::{
|
||||
postgres::{PostgresConnection, PostgresConfig, PostgresError},
|
||||
AppState, AppStatePostgresExt,
|
||||
imphnen_entities::seaorm::auth::users::Entity as UserEntity,
|
||||
imphnen_entities::seaorm::auth::users::Model as UserModel,
|
||||
imphnen_entities::seaorm::auth::users::ActiveModel as UserActiveModel,
|
||||
imphnen_entities::seaorm::common::enums::ResourceEnum,
|
||||
};
|
||||
|
||||
/// Example: Basic PostgreSQL connection usage
|
||||
pub async fn basic_postgres_usage_example() -> Result<(), PostgresError> {
|
||||
// Load configuration from environment variables
|
||||
let config = PostgresConfig::from_env()?;
|
||||
|
||||
// Create PostgreSQL connection
|
||||
let postgres_conn = PostgresConnection::new(config).await?;
|
||||
|
||||
// Example: Execute a raw SQL query
|
||||
let statement = sea_orm::Statement::from_string(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
"SELECT version()".into(),
|
||||
);
|
||||
|
||||
let result = postgres_conn.execute(statement).await?;
|
||||
println!("PostgreSQL version query result: {:?}", result);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Example: PostgreSQL integration with AppState
|
||||
pub async fn app_state_integration_example(
|
||||
postgres_config: PostgresConfig,
|
||||
) -> Result<AppState, PostgresError> {
|
||||
// Create AppState with PostgreSQL connection
|
||||
let app_state = AppState::new(
|
||||
postgres_config,
|
||||
Arc::new(dummy_user_lookup_service()),
|
||||
Arc::new(dummy_auth_repository()),
|
||||
).await?;
|
||||
|
||||
// Access PostgreSQL connection from AppState
|
||||
let postgres_conn = app_state.postgres_connection();
|
||||
println!("Successfully accessed PostgreSQL connection from AppState");
|
||||
|
||||
Ok(app_state)
|
||||
}
|
||||
|
||||
/// Example: Repository pattern with PostgreSQL (simplified)
|
||||
pub struct UserRepository {
|
||||
postgres_conn: Arc<PostgresConnection>,
|
||||
}
|
||||
|
||||
impl UserRepository {
|
||||
/// Create a new UserRepository
|
||||
pub fn new(postgres_conn: Arc<PostgresConnection>) -> Self {
|
||||
Self { postgres_conn }
|
||||
}
|
||||
|
||||
/// Get user by email
|
||||
pub async fn get_user_by_email(&self, email: &str) -> Result<Option<UserModel>, PostgresError> {
|
||||
let users = UserEntity::find()
|
||||
.filter(UserEntity::email.eq(email))
|
||||
.all(&self.postgres_conn.conn)
|
||||
.await
|
||||
.map_err(|e| PostgresError::ConnectionError(e.into()))?;
|
||||
|
||||
Ok(users.into_iter().next())
|
||||
}
|
||||
|
||||
/// Create a new user
|
||||
pub async fn create_user(&self, user: UserActiveModel) -> Result<UserModel, PostgresError> {
|
||||
let result = user.save(&self.postgres_conn.conn)
|
||||
.await
|
||||
.map_err(|e| PostgresError::ConnectionError(e.into()))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Example: Service layer using PostgreSQL repository
|
||||
pub struct UserService {
|
||||
user_repository: UserRepository,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
/// Create a new UserService
|
||||
pub fn new(user_repository: UserRepository) -> Self {
|
||||
Self { user_repository }
|
||||
}
|
||||
|
||||
/// Get user by email with additional business logic
|
||||
pub async fn get_user_by_email_with_logging(&self, email: &str) -> Result<Option<UserModel>, PostgresError> {
|
||||
println!("Attempting to find user with email: {}", email);
|
||||
|
||||
let user = self.user_repository.get_user_by_email(email).await?;
|
||||
|
||||
if let Some(user) = &user {
|
||||
println!("Found user: {}", user.username);
|
||||
} else {
|
||||
println!("User not found with email: {}", email);
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
/// Dummy implementations for dependencies
|
||||
fn dummy_user_lookup_service() -> impl crate::services::UserLookupService {
|
||||
struct DummyUserLookupService;
|
||||
impl crate::services::UserLookupService for DummyUserLookupService {
|
||||
async fn lookup_user(&self, _: &str) -> Result<Option<crate::imphnen_entities::User>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
DummyUserLookupService
|
||||
}
|
||||
|
||||
fn dummy_auth_repository() -> impl crate::services::AuthRepositoryTrait {
|
||||
struct DummyAuthRepository;
|
||||
impl crate::services::AuthRepositoryTrait for DummyAuthRepository {
|
||||
async fn verify_credentials(&self, _: &str, _: &str) -> Result<bool, String> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
DummyAuthRepository
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sea_orm::MockDatabaseConnection;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_postgres_config_from_env() {
|
||||
// This test doesn't actually check environment variables
|
||||
// It just ensures the method doesn't panic
|
||||
let result = PostgresConfig::from_env();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_repository_create() {
|
||||
let mock_conn = MockDatabaseConnection::new();
|
||||
let postgres_conn = Arc::new(PostgresConnection {
|
||||
conn: mock_conn,
|
||||
config: PostgresConfig::default(),
|
||||
});
|
||||
|
||||
let user_repo = UserRepository::new(postgres_conn);
|
||||
|
||||
// We can't actually test the create_user method without a real database
|
||||
// but we can test that it compiles and doesn't panic
|
||||
let user_active_model = UserActiveModel {
|
||||
id: sea_orm::Set(Uuid::new_v4()),
|
||||
email: sea_orm::Set("test@example.com".into()),
|
||||
username: sea_orm::Set("testuser".into()),
|
||||
// Add other required fields as needed
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = user_repo.create_user(user_active_model).await;
|
||||
assert!(result.is_err()); // Expected to fail with mock connection
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use super::connection::{PostgresConnection, PostgresError};
|
||||
use sea_orm::{
|
||||
ConnectionTrait, DatabaseTransaction, DbErr, ExecResult, QueryResult, Statement,
|
||||
TransactionTrait,
|
||||
};
|
||||
|
||||
impl PostgresConnection {
|
||||
pub async fn execute(
|
||||
&self,
|
||||
statement: Statement,
|
||||
) -> Result<ExecResult, PostgresError> {
|
||||
self
|
||||
.conn
|
||||
.execute(statement)
|
||||
.await
|
||||
.map_err(PostgresError::ConnectionError)
|
||||
}
|
||||
|
||||
pub async fn query_one(
|
||||
&self,
|
||||
statement: Statement,
|
||||
) -> Result<Option<QueryResult>, PostgresError> {
|
||||
self
|
||||
.conn
|
||||
.query_one(statement)
|
||||
.await
|
||||
.map_err(PostgresError::ConnectionError)
|
||||
}
|
||||
|
||||
pub async fn query_all(
|
||||
&self,
|
||||
statement: Statement,
|
||||
) -> Result<Vec<QueryResult>, PostgresError> {
|
||||
self
|
||||
.conn
|
||||
.query_all(statement)
|
||||
.await
|
||||
.map_err(PostgresError::ConnectionError)
|
||||
}
|
||||
|
||||
pub async fn execute_raw(
|
||||
&self,
|
||||
sql: &str,
|
||||
) -> Result<Vec<QueryResult>, PostgresError> {
|
||||
let statement =
|
||||
Statement::from_string(self.conn.get_database_backend(), sql.to_string());
|
||||
self.query_all(statement).await
|
||||
}
|
||||
|
||||
pub async fn begin_transaction(
|
||||
&self,
|
||||
) -> Result<DatabaseTransaction, PostgresError> {
|
||||
self
|
||||
.conn
|
||||
.begin()
|
||||
.await
|
||||
.map_err(PostgresError::ConnectionError)
|
||||
}
|
||||
|
||||
pub async fn transaction<'a, F, R>(&'a self, f: F) -> Result<R, PostgresError>
|
||||
where
|
||||
F: FnOnce(
|
||||
&DatabaseTransaction,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<R, PostgresError>> + Send>,
|
||||
> + Send
|
||||
+ 'a + 'static,
|
||||
R: Send + 'a + 'static,
|
||||
{
|
||||
self
|
||||
.conn
|
||||
.transaction(|txn| Box::pin(async move { f(txn).await }))
|
||||
.await
|
||||
.map_err(|e| PostgresError::ConnectionError(DbErr::Custom(e.to_string())))
|
||||
}
|
||||
|
||||
pub async fn query_simple(
|
||||
&self,
|
||||
sql: &str,
|
||||
) -> Result<Vec<QueryResult>, PostgresError> {
|
||||
let statement =
|
||||
Statement::from_string(self.conn.get_database_backend(), sql.to_string());
|
||||
self
|
||||
.conn
|
||||
.query_all(statement)
|
||||
.await
|
||||
.map_err(PostgresError::ConnectionError)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AppStatePostgresExt {
|
||||
fn postgres_connection(&self) -> &PostgresConnection;
|
||||
|
||||
fn postgres_db(&self) -> &sea_orm::DatabaseConnection {
|
||||
&self.postgres_connection().conn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod connection;
|
||||
pub mod helpers;
|
||||
|
||||
pub use connection::{PostgresConfig, PostgresConnection, PostgresError};
|
||||
pub use helpers::AppStatePostgresExt;
|
||||
@@ -1,819 +0,0 @@
|
||||
//! Service abstractions for the application
|
||||
#![allow(clippy::field_reassign_with_default)]
|
||||
|
||||
use crate::{postgres::PostgresError, AppState};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
use imphnen_entities::seaorm::auth::roles::Entity as RolesEntity;
|
||||
use imphnen_entities::seaorm::auth::users::Model as UserModel;
|
||||
use imphnen_entities::UsersDetailQueryDto;
|
||||
use imphnen_entities::PermissionsQueryDto;
|
||||
use sea_orm::prelude::Json;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait,
|
||||
ActiveValue,
|
||||
ColumnTrait,
|
||||
EntityTrait,
|
||||
PaginatorTrait,
|
||||
QueryFilter,
|
||||
QuerySelect,
|
||||
};
|
||||
use std::result::Result;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Service-related errors
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ServiceError {
|
||||
#[error("User not found: {0}")]
|
||||
UserNotFound(String),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
DatabaseError(#[from] sea_orm::DbErr),
|
||||
|
||||
#[error("Connection error: {0}")]
|
||||
ConnectionError(#[from] PostgresError),
|
||||
|
||||
#[error("Authentication failed: {0}")]
|
||||
AuthenticationFailed(String),
|
||||
|
||||
#[error("Authorization failed: {0}")]
|
||||
AuthorizationFailed(String),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
#[error("Internal service error: {0}")]
|
||||
InternalError(String),
|
||||
}
|
||||
|
||||
/// User reference types for different identification methods
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum UserReference {
|
||||
/// User ID (UUID)
|
||||
Id(Uuid),
|
||||
/// User email address
|
||||
Email(String),
|
||||
/// User username
|
||||
Username(String),
|
||||
/// PostgreSQL-specific user model
|
||||
Model(UserModel),
|
||||
}
|
||||
|
||||
/// Extended user information with additional computed fields
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ExtendedUserInfo {
|
||||
pub basic_info: UsersDetailQueryDto,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
pub login_count: u64,
|
||||
pub account_age_days: i64,
|
||||
pub is_recently_active: bool,
|
||||
}
|
||||
|
||||
/// User registration data structure
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserRegistrationData {
|
||||
pub id: Option<Uuid>,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub username: String,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub metadata: Option<Json>,
|
||||
pub role_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Convert UserModel to UsersDetailQueryDto
|
||||
fn model_to_dto(model: &UserModel, role_model: Option<&imphnen_entities::seaorm::auth::roles::Model>) -> UsersDetailQueryDto {
|
||||
let mut dto = UsersDetailQueryDto::default();
|
||||
dto.id = model.id.to_string();
|
||||
dto.fullname = format!("{} {}", model.first_name.as_deref().unwrap_or(""), model.last_name.as_deref().unwrap_or("")).trim().to_string();
|
||||
dto.legal_name = None;
|
||||
dto.email = model.email.clone();
|
||||
dto.avatar = model.avatar_url.clone();
|
||||
dto.is_active = model.is_active;
|
||||
dto.is_deleted = model.deleted_at.is_some();
|
||||
dto.profile_extension = model.metadata.clone().and_then(|m| serde_json::from_value(m).ok());
|
||||
dto.password = String::new();
|
||||
|
||||
if let Some(role) = role_model {
|
||||
let mut role_dto = imphnen_entities::RolesDetailQueryDto::default();
|
||||
role_dto.id = role.id.to_string();
|
||||
role_dto.name = role.name.clone();
|
||||
role_dto.is_deleted = false;
|
||||
|
||||
// Populate permissions
|
||||
if let Some(perms_json) = &role.permissions {
|
||||
println!("DEBUG: perms_json: {:?}", perms_json);
|
||||
if let Ok(perms_list) = serde_json::from_value::<Vec<String>>(perms_json.clone()) {
|
||||
println!("DEBUG: perms_list: {:?}", perms_list);
|
||||
let dtos = perms_list.into_iter().map(|p| {
|
||||
// Create PermissionsQueryDto wrapped in Option
|
||||
Some(PermissionsQueryDto {
|
||||
id: Some(p.clone()),
|
||||
name: Some(p),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
})
|
||||
}).collect();
|
||||
role_dto.permissions = Some(dtos);
|
||||
}
|
||||
}
|
||||
|
||||
dto.role = role_dto;
|
||||
} else {
|
||||
dto.role = imphnen_entities::RolesDetailQueryDto::default();
|
||||
}
|
||||
|
||||
dto.created_at = model.created_at.to_rfc3339();
|
||||
dto.updated_at = model.updated_at.to_rfc3339();
|
||||
dto.mentor_id = None;
|
||||
dto.from_profile_extension()
|
||||
}
|
||||
|
||||
/// User lookup service trait with comprehensive user retrieval methods
|
||||
#[async_trait]
|
||||
pub trait UserLookupService: Send + Sync {
|
||||
async fn get_user_by_id(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError>;
|
||||
|
||||
async fn get_user_by_email(
|
||||
&self,
|
||||
email: &str,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError>;
|
||||
|
||||
async fn get_user_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError>;
|
||||
|
||||
async fn get_user_by_reference(
|
||||
&self,
|
||||
reference: UserReference,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError>;
|
||||
|
||||
async fn user_exists(
|
||||
&self,
|
||||
reference: UserReference,
|
||||
state: &AppState,
|
||||
) -> Result<bool, ServiceError>;
|
||||
|
||||
async fn get_users_by_ids(
|
||||
&self,
|
||||
user_ids: Vec<Uuid>,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<ExtendedUserInfo>, ServiceError>;
|
||||
|
||||
async fn search_users(
|
||||
&self,
|
||||
query: &str,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<ExtendedUserInfo>, ServiceError>;
|
||||
|
||||
async fn count_users(&self, state: &AppState) -> Result<u64, ServiceError>;
|
||||
}
|
||||
|
||||
/// Authentication repository trait with comprehensive auth operations
|
||||
#[async_trait]
|
||||
pub trait AuthRepositoryTrait: Send + Sync {
|
||||
async fn get_user_for_auth(
|
||||
&self,
|
||||
email: &str,
|
||||
state: &AppState,
|
||||
) -> Result<UserModel, ServiceError>;
|
||||
|
||||
async fn validate_credentials(
|
||||
&self,
|
||||
email: &str,
|
||||
password: &str,
|
||||
state: &AppState,
|
||||
) -> Result<UserModel, ServiceError>;
|
||||
|
||||
async fn update_last_login(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError>;
|
||||
|
||||
async fn create_user(
|
||||
&self,
|
||||
user_data: UserRegistrationData,
|
||||
state: &AppState,
|
||||
) -> Result<UserModel, ServiceError>;
|
||||
|
||||
async fn update_password(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
new_password_hash: &str,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError>;
|
||||
|
||||
async fn deactivate_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError>;
|
||||
|
||||
async fn reactivate_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError>;
|
||||
|
||||
async fn get_user_permissions(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<String>, ServiceError>;
|
||||
|
||||
async fn has_permission(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
permission: &str,
|
||||
state: &AppState,
|
||||
) -> Result<bool, ServiceError>;
|
||||
}
|
||||
|
||||
/// Default implementation of UserLookupService using PostgreSQL
|
||||
pub struct PostgresUserLookupService;
|
||||
|
||||
impl Default for PostgresUserLookupService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PostgresUserLookupService {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Convert UserModel to ExtendedUserInfo
|
||||
fn model_to_extended_info(&self, model: UserModel, role_model: Option<imphnen_entities::seaorm::auth::roles::Model>) -> ExtendedUserInfo {
|
||||
let basic_info = model_to_dto(&model, role_model.as_ref());
|
||||
|
||||
let account_age_days = (Utc::now() - model.created_at).num_days();
|
||||
let is_recently_active =
|
||||
model.updated_at > Utc::now() - chrono::Duration::days(30);
|
||||
|
||||
ExtendedUserInfo {
|
||||
basic_info,
|
||||
last_login_at: None,
|
||||
login_count: 0,
|
||||
account_age_days,
|
||||
is_recently_active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserLookupService for PostgresUserLookupService {
|
||||
async fn get_user_by_id(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
|
||||
let (user, role) = UsersEntity::find_by_id(user_id)
|
||||
.find_also_related(RolesEntity)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with ID {user_id} not found"))
|
||||
})?;
|
||||
|
||||
Ok(self.model_to_extended_info(user, role))
|
||||
}
|
||||
|
||||
async fn get_user_by_email(
|
||||
&self,
|
||||
email: &str,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
|
||||
let (user, role) = UsersEntity::find()
|
||||
.filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(email))
|
||||
.find_also_related(RolesEntity)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with email {email} not found"))
|
||||
})?;
|
||||
|
||||
Ok(self.model_to_extended_info(user, role))
|
||||
}
|
||||
|
||||
async fn get_user_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
|
||||
let (user, role) = UsersEntity::find()
|
||||
.filter(imphnen_entities::seaorm::auth::users::Column::Username.eq(username))
|
||||
.find_also_related(RolesEntity)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!(
|
||||
"User with username {} not found",
|
||||
username
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(self.model_to_extended_info(user, role))
|
||||
}
|
||||
|
||||
async fn get_user_by_reference(
|
||||
&self,
|
||||
reference: UserReference,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError> {
|
||||
match reference {
|
||||
UserReference::Id(id) => self.get_user_by_id(id, state).await,
|
||||
UserReference::Email(email) => self.get_user_by_email(&email, state).await,
|
||||
UserReference::Username(username) => {
|
||||
self.get_user_by_username(&username, state).await
|
||||
}
|
||||
UserReference::Model(model) => {
|
||||
let role = if let Some(role_id) = model.role_id {
|
||||
RolesEntity::find_by_id(role_id).one(&state.postgres_connection.conn).await.unwrap_or(None)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(self.model_to_extended_info(model, role))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn user_exists(
|
||||
&self,
|
||||
reference: UserReference,
|
||||
state: &AppState,
|
||||
) -> Result<bool, ServiceError> {
|
||||
let exists = match reference {
|
||||
UserReference::Id(id) => {
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
|
||||
UsersEntity::find_by_id(id)
|
||||
.count(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
> 0
|
||||
}
|
||||
UserReference::Email(email) => {
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
|
||||
UsersEntity::find()
|
||||
.filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(&email))
|
||||
.count(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
> 0
|
||||
}
|
||||
UserReference::Username(username) => {
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
|
||||
UsersEntity::find()
|
||||
.filter(
|
||||
imphnen_entities::seaorm::auth::users::Column::Username.eq(&username),
|
||||
)
|
||||
.count(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
> 0
|
||||
}
|
||||
UserReference::Model(_) => true,
|
||||
};
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
async fn get_users_by_ids(
|
||||
&self,
|
||||
user_ids: Vec<Uuid>,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<ExtendedUserInfo>, ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
|
||||
let users_with_roles = UsersEntity::find()
|
||||
.filter(imphnen_entities::seaorm::auth::users::Column::Id.is_in(user_ids))
|
||||
.find_also_related(RolesEntity)
|
||||
.all(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?;
|
||||
|
||||
Ok(
|
||||
users_with_roles
|
||||
.into_iter()
|
||||
.map(|(user, role)| self.model_to_extended_info(user, role))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn search_users(
|
||||
&self,
|
||||
query: &str,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<ExtendedUserInfo>, ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
|
||||
let search_pattern = format!("%{query}%");
|
||||
|
||||
let users_with_roles = UsersEntity::find()
|
||||
.filter(
|
||||
imphnen_entities::seaorm::auth::users::Column::Email
|
||||
.contains(&search_pattern)
|
||||
.or(
|
||||
imphnen_entities::seaorm::auth::users::Column::Username
|
||||
.contains(&search_pattern),
|
||||
)
|
||||
.or(
|
||||
imphnen_entities::seaorm::auth::users::Column::FirstName
|
||||
.contains(&search_pattern),
|
||||
)
|
||||
.or(
|
||||
imphnen_entities::seaorm::auth::users::Column::LastName
|
||||
.contains(&search_pattern),
|
||||
),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.find_also_related(RolesEntity)
|
||||
.all(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?;
|
||||
|
||||
Ok(
|
||||
users_with_roles
|
||||
.into_iter()
|
||||
.map(|(user, role)| self.model_to_extended_info(user, role))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn count_users(&self, state: &AppState) -> Result<u64, ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
|
||||
let count = UsersEntity::find()
|
||||
.count(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?;
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
/// Default implementation of AuthRepositoryTrait using PostgreSQL
|
||||
pub struct PostgresAuthRepository;
|
||||
|
||||
impl Default for PostgresAuthRepository {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PostgresAuthRepository {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthRepositoryTrait for PostgresAuthRepository {
|
||||
async fn get_user_for_auth(
|
||||
&self,
|
||||
email: &str,
|
||||
state: &AppState,
|
||||
) -> Result<UserModel, ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
|
||||
UsersEntity::find()
|
||||
.filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(email))
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with email {email} not found"))
|
||||
})
|
||||
}
|
||||
|
||||
async fn validate_credentials(
|
||||
&self,
|
||||
email: &str,
|
||||
password: &str,
|
||||
state: &AppState,
|
||||
) -> Result<UserModel, ServiceError> {
|
||||
use crate::argon::verify_password;
|
||||
|
||||
let user = self.get_user_for_auth(email, state).await?;
|
||||
|
||||
if !user.is_active {
|
||||
return Err(ServiceError::AuthenticationFailed(
|
||||
"Account is deactivated".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !user.is_verified {
|
||||
return Err(ServiceError::AuthenticationFailed(
|
||||
"Account not verified".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let is_valid = verify_password(password, &user.password_hash).map_err(|e| {
|
||||
ServiceError::InternalError(format!("Password verification failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !is_valid {
|
||||
return Err(ServiceError::AuthenticationFailed(
|
||||
"Invalid password".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn update_last_login(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::{
|
||||
ActiveModel, Entity as UsersEntity,
|
||||
};
|
||||
|
||||
let user = UsersEntity::find_by_id(user_id)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with ID {user_id} not found"))
|
||||
})?;
|
||||
|
||||
let mut active_model: ActiveModel = user.into();
|
||||
active_model.updated_at = ActiveValue::Set(Utc::now());
|
||||
|
||||
active_model
|
||||
.update(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_user(
|
||||
&self,
|
||||
user_registration_data: UserRegistrationData,
|
||||
state: &AppState,
|
||||
) -> Result<UserModel, ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::ActiveModel;
|
||||
let user_id = user_registration_data.id.unwrap_or_else(Uuid::new_v4); // Use provided ID or generate new
|
||||
let active_model = ActiveModel {
|
||||
id: ActiveValue::Set(user_id),
|
||||
email: ActiveValue::Set(user_registration_data.email),
|
||||
password_hash: ActiveValue::Set(user_registration_data.password_hash),
|
||||
username: ActiveValue::Set(user_registration_data.username),
|
||||
first_name: ActiveValue::Set(user_registration_data.first_name),
|
||||
last_name: ActiveValue::Set(user_registration_data.last_name),
|
||||
avatar_url: ActiveValue::Set(user_registration_data.avatar_url),
|
||||
is_verified: ActiveValue::Set(false),
|
||||
is_active: ActiveValue::Set(true),
|
||||
// Role-based permissions will determine admin access.
|
||||
metadata: ActiveValue::Set(user_registration_data.metadata),
|
||||
created_at: ActiveValue::Set(Utc::now()),
|
||||
updated_at: ActiveValue::Set(Utc::now()),
|
||||
deleted_at: ActiveValue::Set(None),
|
||||
role_id: ActiveValue::Set(user_registration_data.role_id),
|
||||
};
|
||||
|
||||
let created_user: UserModel = active_model
|
||||
.insert(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?;
|
||||
|
||||
Ok(created_user)
|
||||
}
|
||||
async fn update_password(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
new_password_hash: &str,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::{
|
||||
ActiveModel, Entity as UsersEntity,
|
||||
};
|
||||
|
||||
let user = UsersEntity::find_by_id(user_id)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with ID {user_id} not found"))
|
||||
})?;
|
||||
|
||||
let mut active_model: ActiveModel = user.into();
|
||||
active_model.password_hash = ActiveValue::Set(new_password_hash.to_string());
|
||||
active_model.updated_at = ActiveValue::Set(Utc::now());
|
||||
|
||||
active_model
|
||||
.update(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn deactivate_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::{
|
||||
ActiveModel, Entity as UsersEntity,
|
||||
};
|
||||
|
||||
let user = UsersEntity::find_by_id(user_id)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with ID {user_id} not found"))
|
||||
})?;
|
||||
|
||||
let mut active_model: ActiveModel = user.into();
|
||||
active_model.is_active = ActiveValue::Set(false);
|
||||
active_model.updated_at = ActiveValue::Set(Utc::now());
|
||||
|
||||
active_model
|
||||
.update(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reactivate_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::{
|
||||
ActiveModel, Entity as UsersEntity,
|
||||
};
|
||||
|
||||
let user = UsersEntity::find_by_id(user_id)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with ID {user_id} not found"))
|
||||
})?;
|
||||
|
||||
let mut active_model: ActiveModel = user.into();
|
||||
active_model.is_active = ActiveValue::Set(true);
|
||||
active_model.updated_at = ActiveValue::Set(Utc::now());
|
||||
|
||||
active_model
|
||||
.update(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_user_permissions(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<String>, ServiceError> {
|
||||
let user = UsersEntity::find_by_id(user_id)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with ID {user_id} not found"))
|
||||
})?;
|
||||
|
||||
// Determine permissions from role if available. Fall back to verification-based permissions.
|
||||
let permissions = if let Some(role_id) = user.role_id {
|
||||
// Try to fetch the role from DB and return its configured permissions
|
||||
match RolesEntity::find_by_id(role_id).one(&state.postgres_connection.conn).await.map_err(ServiceError::DatabaseError)? {
|
||||
Some(role) => {
|
||||
let perms = if let Some(perms_json) = role.permissions.clone() {
|
||||
serde_json::from_value::<Vec<String>>(perms_json).unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
if role.is_system_role {
|
||||
if perms.is_empty() {
|
||||
vec!["admin.*".to_string(), "user.*".to_string(), "content.*".to_string()]
|
||||
} else {
|
||||
perms
|
||||
}
|
||||
} else if perms.is_empty() {
|
||||
if user.is_verified {
|
||||
vec!["user.read".to_string(), "user.update".to_string(), "content.read".to_string()]
|
||||
} else {
|
||||
vec!["user.read".to_string(), "content.read".to_string()]
|
||||
}
|
||||
} else {
|
||||
perms
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if user.is_verified {
|
||||
vec!["user.read".to_string(), "user.update".to_string(), "content.read".to_string()]
|
||||
} else {
|
||||
vec!["user.read".to_string(), "content.read".to_string()]
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if user.is_verified {
|
||||
vec![
|
||||
"user.read".to_string(),
|
||||
"user.update".to_string(),
|
||||
"content.read".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec!["user.read".to_string(), "content.read".to_string()]
|
||||
};
|
||||
|
||||
Ok(permissions)
|
||||
}
|
||||
|
||||
async fn has_permission(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
permission: &str,
|
||||
state: &AppState,
|
||||
) -> Result<bool, ServiceError> {
|
||||
let permissions = self.get_user_permissions(user_id, state).await?;
|
||||
Ok(
|
||||
permissions.contains(&permission.to_string())
|
||||
|| permissions.iter().any(|p| p.ends_with(".*")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_user_reference_creation() {
|
||||
let id_ref = UserReference::Id(Uuid::new_v4());
|
||||
let email_ref = UserReference::Email("test@example.com".to_string());
|
||||
let username_ref = UserReference::Username("testuser".to_string());
|
||||
|
||||
assert!(matches!(id_ref, UserReference::Id(_)));
|
||||
assert!(matches!(email_ref, UserReference::Email(_)));
|
||||
assert!(matches!(username_ref, UserReference::Username(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_service_error_types() {
|
||||
let error = ServiceError::UserNotFound("Test user".to_string());
|
||||
assert_eq!(error.to_string(), "User not found: Test user");
|
||||
|
||||
let error = ServiceError::AuthenticationFailed("Invalid password".to_string());
|
||||
assert_eq!(error.to_string(), "Authentication failed: Invalid password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_registration_data() {
|
||||
let registration_data = UserRegistrationData {
|
||||
id: None,
|
||||
email: "test@example.com".to_string(),
|
||||
password_hash: "hashed_password".to_string(),
|
||||
username: "testuser".to_string(),
|
||||
first_name: Some("Test".to_string()),
|
||||
last_name: Some("User".to_string()),
|
||||
avatar_url: None,
|
||||
metadata: None,
|
||||
role_id: None,
|
||||
};
|
||||
|
||||
assert_eq!(registration_data.email, "test@example.com");
|
||||
assert_eq!(registration_data.username, "testuser");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use imphnen_entities::seaorm::auth::roles::Entity as RolesEntity;
|
||||
use imphnen_entities::seaorm::auth::users::{
|
||||
Entity as UsersEntity, Model as UserModel,
|
||||
};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter,
|
||||
};
|
||||
use std::result::Result;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::dto::UserRegistrationData;
|
||||
use super::error::ServiceError;
|
||||
use crate::AppState;
|
||||
|
||||
#[async_trait]
|
||||
pub trait AuthRepositoryTrait: Send + Sync {
|
||||
async fn get_user_for_auth(
|
||||
&self,
|
||||
email: &str,
|
||||
state: &AppState,
|
||||
) -> Result<UserModel, ServiceError>;
|
||||
async fn validate_credentials(
|
||||
&self,
|
||||
email: &str,
|
||||
password: &str,
|
||||
state: &AppState,
|
||||
) -> Result<UserModel, ServiceError>;
|
||||
async fn update_last_login(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError>;
|
||||
async fn create_user(
|
||||
&self,
|
||||
user_data: UserRegistrationData,
|
||||
state: &AppState,
|
||||
) -> Result<UserModel, ServiceError>;
|
||||
async fn update_password(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
new_password_hash: &str,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError>;
|
||||
async fn deactivate_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError>;
|
||||
async fn reactivate_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError>;
|
||||
async fn get_user_permissions(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<String>, ServiceError>;
|
||||
async fn has_permission(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
permission: &str,
|
||||
state: &AppState,
|
||||
) -> Result<bool, ServiceError>;
|
||||
}
|
||||
|
||||
pub struct PostgresAuthRepository;
|
||||
|
||||
impl Default for PostgresAuthRepository {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PostgresAuthRepository {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthRepositoryTrait for PostgresAuthRepository {
|
||||
async fn get_user_for_auth(
|
||||
&self,
|
||||
email: &str,
|
||||
state: &AppState,
|
||||
) -> Result<UserModel, ServiceError> {
|
||||
UsersEntity::find()
|
||||
.filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(email))
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with email {email} not found"))
|
||||
})
|
||||
}
|
||||
|
||||
async fn validate_credentials(
|
||||
&self,
|
||||
email: &str,
|
||||
password: &str,
|
||||
state: &AppState,
|
||||
) -> Result<UserModel, ServiceError> {
|
||||
use crate::argon::verify_password;
|
||||
let user = self.get_user_for_auth(email, state).await?;
|
||||
if !user.is_active {
|
||||
return Err(ServiceError::AuthenticationFailed(
|
||||
"Account is deactivated".to_string(),
|
||||
));
|
||||
}
|
||||
if !user.is_verified {
|
||||
return Err(ServiceError::AuthenticationFailed(
|
||||
"Account not verified".to_string(),
|
||||
));
|
||||
}
|
||||
let is_valid = verify_password(password, &user.password_hash).map_err(|e| {
|
||||
ServiceError::InternalError(format!("Password verification failed: {e}"))
|
||||
})?;
|
||||
if !is_valid {
|
||||
return Err(ServiceError::AuthenticationFailed(
|
||||
"Invalid password".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn update_last_login(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::ActiveModel;
|
||||
let user = UsersEntity::find_by_id(user_id)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with ID {user_id} not found"))
|
||||
})?;
|
||||
let mut active_model: ActiveModel = user.into();
|
||||
active_model.updated_at = ActiveValue::Set(Utc::now());
|
||||
active_model
|
||||
.update(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_user(
|
||||
&self,
|
||||
data: UserRegistrationData,
|
||||
state: &AppState,
|
||||
) -> Result<UserModel, ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::ActiveModel;
|
||||
let user_id = data.id.unwrap_or_else(Uuid::new_v4);
|
||||
let active_model = ActiveModel {
|
||||
id: ActiveValue::Set(user_id),
|
||||
email: ActiveValue::Set(data.email),
|
||||
password_hash: ActiveValue::Set(data.password_hash),
|
||||
username: ActiveValue::Set(data.username),
|
||||
first_name: ActiveValue::Set(data.first_name),
|
||||
last_name: ActiveValue::Set(data.last_name),
|
||||
avatar_url: ActiveValue::Set(data.avatar_url),
|
||||
is_verified: ActiveValue::Set(false),
|
||||
is_active: ActiveValue::Set(true),
|
||||
metadata: ActiveValue::Set(data.metadata),
|
||||
created_at: ActiveValue::Set(Utc::now()),
|
||||
updated_at: ActiveValue::Set(Utc::now()),
|
||||
deleted_at: ActiveValue::Set(None),
|
||||
role_id: ActiveValue::Set(data.role_id),
|
||||
};
|
||||
active_model
|
||||
.insert(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)
|
||||
}
|
||||
|
||||
async fn update_password(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
new_password_hash: &str,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::ActiveModel;
|
||||
let user = UsersEntity::find_by_id(user_id)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with ID {user_id} not found"))
|
||||
})?;
|
||||
let mut active_model: ActiveModel = user.into();
|
||||
active_model.password_hash = ActiveValue::Set(new_password_hash.to_string());
|
||||
active_model.updated_at = ActiveValue::Set(Utc::now());
|
||||
active_model
|
||||
.update(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn deactivate_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::ActiveModel;
|
||||
let user = UsersEntity::find_by_id(user_id)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with ID {user_id} not found"))
|
||||
})?;
|
||||
let mut active_model: ActiveModel = user.into();
|
||||
active_model.is_active = ActiveValue::Set(false);
|
||||
active_model.updated_at = ActiveValue::Set(Utc::now());
|
||||
active_model
|
||||
.update(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reactivate_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<(), ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::ActiveModel;
|
||||
let user = UsersEntity::find_by_id(user_id)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with ID {user_id} not found"))
|
||||
})?;
|
||||
let mut active_model: ActiveModel = user.into();
|
||||
active_model.is_active = ActiveValue::Set(true);
|
||||
active_model.updated_at = ActiveValue::Set(Utc::now());
|
||||
active_model
|
||||
.update(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_user_permissions(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<String>, ServiceError> {
|
||||
let user = UsersEntity::find_by_id(user_id)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with ID {user_id} not found"))
|
||||
})?;
|
||||
|
||||
let permissions = if let Some(role_id) = user.role_id {
|
||||
match RolesEntity::find_by_id(role_id)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
{
|
||||
Some(role) => {
|
||||
let perms = role
|
||||
.permissions
|
||||
.clone()
|
||||
.and_then(|j| serde_json::from_value::<Vec<String>>(j).ok())
|
||||
.unwrap_or_default();
|
||||
if role.is_system_role {
|
||||
if perms.is_empty() {
|
||||
vec![
|
||||
"admin.*".to_string(),
|
||||
"user.*".to_string(),
|
||||
"content.*".to_string(),
|
||||
]
|
||||
} else {
|
||||
perms
|
||||
}
|
||||
} else if perms.is_empty() {
|
||||
if user.is_verified {
|
||||
vec![
|
||||
"user.read".to_string(),
|
||||
"user.update".to_string(),
|
||||
"content.read".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec!["user.read".to_string(), "content.read".to_string()]
|
||||
}
|
||||
} else {
|
||||
perms
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if user.is_verified {
|
||||
vec![
|
||||
"user.read".to_string(),
|
||||
"user.update".to_string(),
|
||||
"content.read".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec!["user.read".to_string(), "content.read".to_string()]
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if user.is_verified {
|
||||
vec![
|
||||
"user.read".to_string(),
|
||||
"user.update".to_string(),
|
||||
"content.read".to_string(),
|
||||
]
|
||||
} else {
|
||||
vec!["user.read".to_string(), "content.read".to_string()]
|
||||
};
|
||||
|
||||
Ok(permissions)
|
||||
}
|
||||
|
||||
async fn has_permission(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
permission: &str,
|
||||
state: &AppState,
|
||||
) -> Result<bool, ServiceError> {
|
||||
let permissions = self.get_user_permissions(user_id, state).await?;
|
||||
Ok(
|
||||
permissions.contains(&permission.to_string())
|
||||
|| permissions.iter().any(|p| p.ends_with(".*")),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use imphnen_entities::seaorm::auth::users::Model as UserModel;
|
||||
use imphnen_entities::{PermissionsQueryDto, UsersDetailQueryDto};
|
||||
use sea_orm::prelude::Json;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum UserReference {
|
||||
Id(Uuid),
|
||||
Email(String),
|
||||
Username(String),
|
||||
Model(UserModel),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ExtendedUserInfo {
|
||||
pub basic_info: UsersDetailQueryDto,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
pub login_count: u64,
|
||||
pub account_age_days: i64,
|
||||
pub is_recently_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserRegistrationData {
|
||||
pub id: Option<Uuid>,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub username: String,
|
||||
pub first_name: Option<String>,
|
||||
pub last_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub metadata: Option<Json>,
|
||||
pub role_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub fn model_to_dto(
|
||||
model: &UserModel,
|
||||
role_model: Option<&imphnen_entities::seaorm::auth::roles::Model>,
|
||||
) -> UsersDetailQueryDto {
|
||||
let mut dto = UsersDetailQueryDto::default();
|
||||
dto.id = model.id.to_string();
|
||||
dto.fullname = format!(
|
||||
"{} {}",
|
||||
model.first_name.as_deref().unwrap_or(""),
|
||||
model.last_name.as_deref().unwrap_or("")
|
||||
)
|
||||
.trim()
|
||||
.to_string();
|
||||
dto.legal_name = None;
|
||||
dto.email = model.email.clone();
|
||||
dto.avatar = model.avatar_url.clone();
|
||||
dto.is_active = model.is_active;
|
||||
dto.is_deleted = model.deleted_at.is_some();
|
||||
dto.profile_extension = model
|
||||
.metadata
|
||||
.clone()
|
||||
.and_then(|m| serde_json::from_value(m).ok());
|
||||
dto.password = String::new();
|
||||
|
||||
if let Some(role) = role_model {
|
||||
let mut role_dto = imphnen_entities::RolesDetailQueryDto::default();
|
||||
role_dto.id = role.id.to_string();
|
||||
role_dto.name = role.name.clone();
|
||||
role_dto.is_deleted = false;
|
||||
|
||||
if let Some(perms_json) = &role.permissions
|
||||
&& let Ok(perms_list) =
|
||||
serde_json::from_value::<Vec<String>>(perms_json.clone())
|
||||
{
|
||||
let dtos = perms_list
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
Some(PermissionsQueryDto {
|
||||
id: Some(p.clone()),
|
||||
name: Some(p),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
role_dto.permissions = Some(dtos);
|
||||
}
|
||||
|
||||
dto.role = role_dto;
|
||||
} else {
|
||||
dto.role = imphnen_entities::RolesDetailQueryDto::default();
|
||||
}
|
||||
|
||||
dto.created_at = model.created_at.to_rfc3339();
|
||||
dto.updated_at = model.updated_at.to_rfc3339();
|
||||
dto.mentor_id = None;
|
||||
dto.from_profile_extension()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use crate::postgres::PostgresError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ServiceError {
|
||||
#[error("User not found: {0}")]
|
||||
UserNotFound(String),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
DatabaseError(#[from] sea_orm::DbErr),
|
||||
|
||||
#[error("Connection error: {0}")]
|
||||
ConnectionError(#[from] PostgresError),
|
||||
|
||||
#[error("Authentication failed: {0}")]
|
||||
AuthenticationFailed(String),
|
||||
|
||||
#[error("Authorization failed: {0}")]
|
||||
AuthorizationFailed(String),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
#[error("Internal service error: {0}")]
|
||||
InternalError(String),
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#![allow(clippy::field_reassign_with_default)]
|
||||
|
||||
pub mod auth_repository;
|
||||
pub mod dto;
|
||||
pub mod error;
|
||||
pub mod user_lookup;
|
||||
|
||||
pub use auth_repository::{AuthRepositoryTrait, PostgresAuthRepository};
|
||||
pub use dto::{ExtendedUserInfo, UserReference, UserRegistrationData};
|
||||
pub use error::ServiceError;
|
||||
pub use user_lookup::{PostgresUserLookupService, UserLookupService};
|
||||
@@ -0,0 +1,257 @@
|
||||
use async_trait::async_trait;
|
||||
use imphnen_entities::seaorm::auth::roles::Entity as RolesEntity;
|
||||
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect};
|
||||
use std::result::Result;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::dto::{ExtendedUserInfo, UserReference, model_to_dto};
|
||||
use super::error::ServiceError;
|
||||
use crate::AppState;
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserLookupService: Send + Sync {
|
||||
async fn get_user_by_id(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError>;
|
||||
async fn get_user_by_email(
|
||||
&self,
|
||||
email: &str,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError>;
|
||||
async fn get_user_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError>;
|
||||
async fn get_user_by_reference(
|
||||
&self,
|
||||
reference: UserReference,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError>;
|
||||
async fn user_exists(
|
||||
&self,
|
||||
reference: UserReference,
|
||||
state: &AppState,
|
||||
) -> Result<bool, ServiceError>;
|
||||
async fn get_users_by_ids(
|
||||
&self,
|
||||
user_ids: Vec<Uuid>,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<ExtendedUserInfo>, ServiceError>;
|
||||
async fn search_users(
|
||||
&self,
|
||||
query: &str,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<ExtendedUserInfo>, ServiceError>;
|
||||
async fn count_users(&self, state: &AppState) -> Result<u64, ServiceError>;
|
||||
}
|
||||
|
||||
pub struct PostgresUserLookupService;
|
||||
|
||||
impl Default for PostgresUserLookupService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PostgresUserLookupService {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn model_to_extended_info(
|
||||
&self,
|
||||
model: imphnen_entities::seaorm::auth::users::Model,
|
||||
role_model: Option<imphnen_entities::seaorm::auth::roles::Model>,
|
||||
) -> ExtendedUserInfo {
|
||||
let basic_info = model_to_dto(&model, role_model.as_ref());
|
||||
let account_age_days = (chrono::Utc::now() - model.created_at).num_days();
|
||||
let is_recently_active =
|
||||
model.updated_at > chrono::Utc::now() - chrono::Duration::days(30);
|
||||
ExtendedUserInfo {
|
||||
basic_info,
|
||||
last_login_at: None,
|
||||
login_count: 0,
|
||||
account_age_days,
|
||||
is_recently_active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserLookupService for PostgresUserLookupService {
|
||||
async fn get_user_by_id(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError> {
|
||||
let (user, role) = UsersEntity::find_by_id(user_id)
|
||||
.find_also_related(RolesEntity)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with ID {user_id} not found"))
|
||||
})?;
|
||||
Ok(self.model_to_extended_info(user, role))
|
||||
}
|
||||
|
||||
async fn get_user_by_email(
|
||||
&self,
|
||||
email: &str,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError> {
|
||||
let (user, role) = UsersEntity::find()
|
||||
.filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(email))
|
||||
.find_also_related(RolesEntity)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!("User with email {email} not found"))
|
||||
})?;
|
||||
Ok(self.model_to_extended_info(user, role))
|
||||
}
|
||||
|
||||
async fn get_user_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError> {
|
||||
let (user, role) = UsersEntity::find()
|
||||
.filter(imphnen_entities::seaorm::auth::users::Column::Username.eq(username))
|
||||
.find_also_related(RolesEntity)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
.ok_or_else(|| {
|
||||
ServiceError::UserNotFound(format!(
|
||||
"User with username {username} not found"
|
||||
))
|
||||
})?;
|
||||
Ok(self.model_to_extended_info(user, role))
|
||||
}
|
||||
|
||||
async fn get_user_by_reference(
|
||||
&self,
|
||||
reference: UserReference,
|
||||
state: &AppState,
|
||||
) -> Result<ExtendedUserInfo, ServiceError> {
|
||||
match reference {
|
||||
UserReference::Id(id) => self.get_user_by_id(id, state).await,
|
||||
UserReference::Email(email) => self.get_user_by_email(&email, state).await,
|
||||
UserReference::Username(username) => {
|
||||
self.get_user_by_username(&username, state).await
|
||||
}
|
||||
UserReference::Model(model) => {
|
||||
let role = if let Some(role_id) = model.role_id {
|
||||
RolesEntity::find_by_id(role_id)
|
||||
.one(&state.postgres_connection.conn)
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(self.model_to_extended_info(model, role))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn user_exists(
|
||||
&self,
|
||||
reference: UserReference,
|
||||
state: &AppState,
|
||||
) -> Result<bool, ServiceError> {
|
||||
let exists = match reference {
|
||||
UserReference::Id(id) => {
|
||||
UsersEntity::find_by_id(id)
|
||||
.count(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
> 0
|
||||
}
|
||||
UserReference::Email(email) => {
|
||||
UsersEntity::find()
|
||||
.filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(&email))
|
||||
.count(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
> 0
|
||||
}
|
||||
UserReference::Username(username) => {
|
||||
UsersEntity::find()
|
||||
.filter(
|
||||
imphnen_entities::seaorm::auth::users::Column::Username.eq(&username),
|
||||
)
|
||||
.count(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?
|
||||
> 0
|
||||
}
|
||||
UserReference::Model(_) => true,
|
||||
};
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
async fn get_users_by_ids(
|
||||
&self,
|
||||
user_ids: Vec<Uuid>,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<ExtendedUserInfo>, ServiceError> {
|
||||
let users_with_roles = UsersEntity::find()
|
||||
.filter(imphnen_entities::seaorm::auth::users::Column::Id.is_in(user_ids))
|
||||
.find_also_related(RolesEntity)
|
||||
.all(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?;
|
||||
Ok(
|
||||
users_with_roles
|
||||
.into_iter()
|
||||
.map(|(u, r)| self.model_to_extended_info(u, r))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn search_users(
|
||||
&self,
|
||||
query: &str,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<ExtendedUserInfo>, ServiceError> {
|
||||
use imphnen_entities::seaorm::auth::users::Column;
|
||||
let pattern = format!("%{query}%");
|
||||
let users_with_roles = UsersEntity::find()
|
||||
.filter(
|
||||
Column::Email
|
||||
.contains(&pattern)
|
||||
.or(Column::Username.contains(&pattern))
|
||||
.or(Column::FirstName.contains(&pattern))
|
||||
.or(Column::LastName.contains(&pattern)),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.find_also_related(RolesEntity)
|
||||
.all(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)?;
|
||||
Ok(
|
||||
users_with_roles
|
||||
.into_iter()
|
||||
.map(|(u, r)| self.model_to_extended_info(u, r))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn count_users(&self, state: &AppState) -> Result<u64, ServiceError> {
|
||||
UsersEntity::find()
|
||||
.count(&state.postgres_connection.conn)
|
||||
.await
|
||||
.map_err(ServiceError::DatabaseError)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user