middleware.rs 188 lignes · 7678 octets
//! Locale middleware for Axum
//!
//! This module provides middleware to inject locale into request extensions.

use axum::{extract::Request, response::Response};
use sea_orm::DatabaseConnection;
use std::{
    future::Future,
    pin::Pin,
    sync::Arc,
    task::{Context, Poll},
};
use tower::Service;

use crate::auth::jwt::Claims;
use crate::i18n::extractor::Locale;
use crate::i18n::service::I18nService;
use crate::services::user_preferences_service::UserPreferencesService;

/// Middleware service that injects locale into request extensions
///
/// Priority order for locale detection:
/// 1. User preference from database (if authenticated)
/// 2. Cookie `locale` (if present)
/// 3. Accept-Language header (only if not "fr", to respect French as default)
/// 4. Default: "fr" (FR-001: French is the default language)
#[derive(Clone)]
pub struct LocaleMiddleware<S> {
    inner: S,
    db: Arc<DatabaseConnection>,
    i18n_service: Arc<I18nService>,
}

impl<S> LocaleMiddleware<S> {
    /// Create a new `LocaleMiddleware` wrapping the given inner service.
    pub fn new(inner: S, db: Arc<DatabaseConnection>, i18n_service: Arc<I18nService>) -> Self {
        Self {
            inner,
            db,
            i18n_service,
        }
    }
}

impl<S, B> Service<Request<B>> for LocaleMiddleware<S>
where
    S: Service<Request<B>, Response = Response> + Clone + Send + 'static,
    S::Future: Send + 'static,
    B: Send + 'static,
{
    type Response = Response;
    type Error = S::Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, req: Request<B>) -> Self::Future {
        let db = self.db.clone();
        let i18n_service = self.i18n_service.clone();
        let mut inner = self.inner.clone();

        Box::pin(async move {
            // Extract locale from request
            let (parts, body) = Request::into_parts(req);
            let cookie_jar = axum_extra::extract::CookieJar::from_headers(&parts.headers);
            let has_locale_cookie = cookie_jar.get("locale").is_some();

            // Priority 1: Check user preference from database (if authenticated)
            let (locale, should_set_cookie) = if let Some(claims) = parts.extensions.get::<Claims>()
            {
                // User is authenticated, try to get preference from database
                match UserPreferencesService::get_preferences(&db, claims.sub).await {
                    Ok(Some(prefs))
                        if matches!(
                            prefs.language.as_str(),
                            "fr" | "en" | "de" | "es" | "it" | "pt"
                        ) =>
                    {
                        (Locale(prefs.language.clone()), false) // Don't set cookie for authenticated users
                    }
                    _ => {
                        // No preference or invalid, fall through to cookie/header
                        let (loc, should_set) =
                            extract_locale_from_request(&parts, has_locale_cookie);
                        (loc, should_set)
                    }
                }
            } else {
                // Not authenticated, use cookie/header
                // Set cookie if detected from Accept-Language header and no cookie exists
                let (loc, should_set) = extract_locale_from_request(&parts, has_locale_cookie);
                (loc, should_set)
            };

            // Inject locale and I18nService into extensions
            let mut req = Request::from_parts(parts, body);
            req.extensions_mut().insert(locale.clone());
            req.extensions_mut().insert(i18n_service);

            // Call inner service and modify response to add cookie if needed
            let response = inner.call(req).await?;

            // If we should set a cookie (non-authenticated user, detected from header, no cookie exists)
            if should_set_cookie {
                let (mut parts, body) = response.into_parts();

                // Create locale cookie (1 year expiration)
                use time::Duration as TimeDuration;
                let cookie =
                    axum_extra::extract::cookie::Cookie::build(("locale", locale.as_str()))
                        .http_only(false) // Allow JavaScript access for client-side language switching
                        .same_site(axum_extra::extract::cookie::SameSite::Lax)
                        .path("/")
                        .max_age(TimeDuration::seconds(31536000)); // 1 year

                // Add cookie to response headers
                if let Ok(header_value) = cookie.to_string().parse() {
                    parts
                        .headers
                        .insert(axum::http::header::SET_COOKIE, header_value);
                } else {
                    tracing::error!("Failed to create locale cookie header");
                }

                Ok(axum::response::Response::from_parts(parts, body))
            } else {
                Ok(response)
            }
        })
    }
}

/// Extract locale from cookie or Accept-Language header
///
/// Returns (Locale, should_set_cookie):
/// - should_set_cookie is true if locale was detected from Accept-Language header
///   and no cookie exists (for non-authenticated users)
fn extract_locale_from_request(
    parts: &axum::http::request::Parts,
    has_locale_cookie: bool,
) -> (Locale, bool) {
    // Priority 2: Check cookie
    if let Some(cookie) = axum_extra::extract::CookieJar::from_headers(&parts.headers).get("locale")
    {
        let locale_str = cookie.value().to_string();
        if matches!(locale_str.as_str(), "fr" | "en" | "de" | "es" | "it" | "pt") {
            return (Locale(locale_str), false); // Cookie exists, don't set it again
        }
    }

    // Priority 3: Check Accept-Language header
    // NOTE: We only use Accept-Language if it matches a supported language
    // If the browser sends "de" but user hasn't explicitly chosen it, we should default to "fr"
    // So we only use Accept-Language if there's no cookie AND it's not the default "fr"
    if let Some(accept_lang) = parts.headers.get("accept-language") {
        if let Ok(accept_lang_str) = accept_lang.to_str() {
            // Simple parsing: take first language code
            if let Some(locale_str) = accept_lang_str
                .split(',')
                .next()
                .and_then(|s| s.split(';').next())
                .map(|s| {
                    let trimmed = s.trim();
                    if trimmed.len() >= 2 {
                        trimmed[..2].to_string()
                    } else {
                        "fr".to_string() // Default to French
                    }
                })
                .filter(|s| matches!(s.as_str(), "fr" | "en" | "de" | "es" | "it" | "pt"))
            {
                // Only use Accept-Language if it's not the default "fr"
                // This ensures that users without explicit preference get French by default
                if locale_str != "fr" {
                    // Set cookie if detected from header and no cookie exists
                    let should_set = !has_locale_cookie;
                    return (Locale(locale_str), should_set);
                }
                // If Accept-Language is "fr", fall through to default (no cookie set)
            }
        }
    }

    // Priority 4: Default to French (FR-001: French is the default language)
    (Locale("fr".to_string()), false)
}