From e9482aba13ba8f5069ac1ec4d27ba282f239d032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Slez=C3=A1k?= Date: Wed, 3 Apr 2024 16:58:18 +0200 Subject: [PATCH 1/2] Provide the frontend translations via the /po.js path --- rust/Cargo.lock | 12 ++++++ rust/agama-server/Cargo.toml | 2 +- rust/agama-server/src/web/service.rs | 59 ++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index aa3ca3b46b..33d52732a8 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -508,6 +508,7 @@ dependencies = [ "axum", "axum-core", "bytes", + "cookie", "futures-util", "headers", "http 1.1.0", @@ -867,6 +868,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd91cf61412820176e137621345ee43b3f4423e589e7ae4e50d601d93e35ef8" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 739926c0a6..b714ad84b8 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -39,7 +39,7 @@ utoipa = { version = "4.2.0", features = ["axum_extras"] } config = "0.14.0" rand = "0.8.5" jsonwebtoken = "9.2.0" -axum-extra = { version = "0.9.2", features = ["typed-header"] } +axum-extra = { version = "0.9.2", features = ["cookie", "typed-header"] } chrono = { version = "0.4.34", default-features = false, features = [ "now", "std", diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index d28ebd3c7d..a4cc5a664f 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -1,12 +1,15 @@ use super::http::{login, logout, session}; use super::{auth::TokenClaims, config::ServiceConfig, state::ServiceState, EventsSender}; use axum::{ + body::Body, extract::Request, + http::{header, HeaderMap, HeaderValue, StatusCode}, middleware, response::IntoResponse, routing::{get, post}, Router, }; +use axum_extra::extract::cookie::CookieJar; use std::{ convert::Infallible, path::{Path, PathBuf}, @@ -85,8 +88,64 @@ impl MainServiceBuilder { .route("/auth", post(login).get(session).delete(logout)); let serve = ServeDir::new(self.public_dir); + + // handle the /po.js request + // the requested language (locale) is sent in the "agamaLanguage" HTTP cookie + // this reimplements the Cockpit translation support + async fn po(jar: CookieJar) -> impl IntoResponse { + let mut response_headers = HeaderMap::new(); + + if let Some(cookie) = jar.get("agamaLanguage") { + let mut target_file = String::new(); + let mut found = false; + // FIXME: this does not work, the public_dir setting is not accessible :-/ + // when using something like PathBuf::from("/usr/share/cockpit/agama") here + // it works just fine.... + let prefix = self.public_dir; + + // try parsing the cookie + if let Some((lang, region)) = cookie.value().split_once('-') { + // first try the full locale + target_file = format!("po.{}_{}.js", lang, region.to_uppercase()); + found = prefix.join(&target_file).exists(); + + if !found { + // then try the language only + target_file = format!("po.{}.js", lang); + found = prefix.join(&target_file).exists(); + } + } + + if !found { + // use the full cookie without parsing + target_file = format!("po.{}.js", cookie.value()); + found = prefix.join(&target_file).exists(); + } + + if found { + // translation found, redirect to the real file + response_headers.insert( + header::LOCATION, + // if the file exists then the name is a valid header value and unwrapping is safe + HeaderValue::from_str(&target_file).unwrap() + ); + + return (StatusCode::TEMPORARY_REDIRECT, response_headers, Body::empty()) + } + } + + // fallback, return empty javascript translations if the language is not supported + response_headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/javascript"), + ); + + (StatusCode::OK, response_headers, Body::empty()) + } + Router::new() .nest_service("/", serve) + .route("/po.js", get(po)) .nest("/api", api_router) .layer(TraceLayer::new_for_http()) .layer(CompressionLayer::new().br(true)) From f9881450a1bc75e53a2c21951264f872f28204c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Slez=C3=A1k?= Date: Thu, 4 Apr 2024 13:13:48 +0200 Subject: [PATCH 2/2] Refactoring --- rust/agama-server/src/web/http.rs | 66 ++++++++++++++++++++++++++-- rust/agama-server/src/web/service.rs | 61 ++----------------------- rust/agama-server/src/web/state.rs | 2 + 3 files changed, 68 insertions(+), 61 deletions(-) diff --git a/rust/agama-server/src/web/http.rs b/rust/agama-server/src/web/http.rs index dce25e517e..f95e884090 100644 --- a/rust/agama-server/src/web/http.rs +++ b/rust/agama-server/src/web/http.rs @@ -5,11 +5,13 @@ use super::{ state::ServiceState, }; use axum::{ + body::Body, extract::State, - http::{header::SET_COOKIE, HeaderMap}, + http::{header, HeaderMap, HeaderValue, StatusCode}, response::IntoResponse, Json, }; +use axum_extra::extract::cookie::CookieJar; use pam::Client; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -62,7 +64,7 @@ pub async fn login( let mut headers = HeaderMap::new(); let cookie = format!("agamaToken={}; HttpOnly", &token); headers.insert( - SET_COOKIE, + header::SET_COOKIE, cookie.parse().expect("could not build a valid cookie"), ); @@ -76,7 +78,7 @@ pub async fn logout(_claims: TokenClaims) -> Result Result Result<(), AuthError> { Ok(()) } + +// builds a response tuple for translation redirection +fn redirect_to_file(file: &str) -> (StatusCode, HeaderMap, Body) { + tracing::info!("Redirecting to translation file {}", file); + + let mut response_headers = HeaderMap::new(); + // translation found, redirect to the real file + response_headers.insert( + header::LOCATION, + // if the file exists then the name is a valid value and unwrapping is safe + HeaderValue::from_str(file).unwrap(), + ); + + ( + StatusCode::TEMPORARY_REDIRECT, + response_headers, + Body::empty(), + ) +} + +// handle the /po.js request +// the requested language (locale) is sent in the "agamaLang" HTTP cookie +// this reimplements the Cockpit translation support +pub async fn po(State(state): State, jar: CookieJar) -> impl IntoResponse { + if let Some(cookie) = jar.get("agamaLang") { + tracing::info!("Language cookie: {}", cookie.value()); + // try parsing the cookie + if let Some((lang, region)) = cookie.value().split_once('-') { + // first try language + country + let target_file = format!("po.{}_{}.js", lang, region.to_uppercase()); + if state.public_dir.join(&target_file).exists() { + return redirect_to_file(&target_file); + } else { + // then try the language only + let target_file = format!("po.{}.js", lang); + if state.public_dir.join(&target_file).exists() { + return redirect_to_file(&target_file); + }; + } + } else { + // use the cookie as is + let target_file = format!("po.{}.js", cookie.value()); + if state.public_dir.join(&target_file).exists() { + return redirect_to_file(&target_file); + } + } + } + + tracing::info!("Translation not found"); + // fallback, return empty javascript translations if the language is not supported + let mut response_headers = HeaderMap::new(); + response_headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/javascript"), + ); + + (StatusCode::OK, response_headers, Body::empty()) +} diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index a4cc5a664f..bb00f00200 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -1,15 +1,12 @@ use super::http::{login, logout, session}; use super::{auth::TokenClaims, config::ServiceConfig, state::ServiceState, EventsSender}; use axum::{ - body::Body, extract::Request, - http::{header, HeaderMap, HeaderValue, StatusCode}, middleware, response::IntoResponse, routing::{get, post}, Router, }; -use axum_extra::extract::cookie::CookieJar; use std::{ convert::Infallible, path::{Path, PathBuf}, @@ -77,6 +74,7 @@ impl MainServiceBuilder { let state = ServiceState { config: self.config, events: self.events, + public_dir: self.public_dir.clone(), }; let api_router = self @@ -87,65 +85,12 @@ impl MainServiceBuilder { .route("/ping", get(super::http::ping)) .route("/auth", post(login).get(session).delete(logout)); + tracing::info!("Serving static files from {}", self.public_dir.display()); let serve = ServeDir::new(self.public_dir); - // handle the /po.js request - // the requested language (locale) is sent in the "agamaLanguage" HTTP cookie - // this reimplements the Cockpit translation support - async fn po(jar: CookieJar) -> impl IntoResponse { - let mut response_headers = HeaderMap::new(); - - if let Some(cookie) = jar.get("agamaLanguage") { - let mut target_file = String::new(); - let mut found = false; - // FIXME: this does not work, the public_dir setting is not accessible :-/ - // when using something like PathBuf::from("/usr/share/cockpit/agama") here - // it works just fine.... - let prefix = self.public_dir; - - // try parsing the cookie - if let Some((lang, region)) = cookie.value().split_once('-') { - // first try the full locale - target_file = format!("po.{}_{}.js", lang, region.to_uppercase()); - found = prefix.join(&target_file).exists(); - - if !found { - // then try the language only - target_file = format!("po.{}.js", lang); - found = prefix.join(&target_file).exists(); - } - } - - if !found { - // use the full cookie without parsing - target_file = format!("po.{}.js", cookie.value()); - found = prefix.join(&target_file).exists(); - } - - if found { - // translation found, redirect to the real file - response_headers.insert( - header::LOCATION, - // if the file exists then the name is a valid header value and unwrapping is safe - HeaderValue::from_str(&target_file).unwrap() - ); - - return (StatusCode::TEMPORARY_REDIRECT, response_headers, Body::empty()) - } - } - - // fallback, return empty javascript translations if the language is not supported - response_headers.insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/javascript"), - ); - - (StatusCode::OK, response_headers, Body::empty()) - } - Router::new() .nest_service("/", serve) - .route("/po.js", get(po)) + .route("/po.js", get(super::http::po)) .nest("/api", api_router) .layer(TraceLayer::new_for_http()) .layer(CompressionLayer::new().br(true)) diff --git a/rust/agama-server/src/web/state.rs b/rust/agama-server/src/web/state.rs index c35592b8c5..01cdf6f625 100644 --- a/rust/agama-server/src/web/state.rs +++ b/rust/agama-server/src/web/state.rs @@ -1,6 +1,7 @@ //! Implements the web service state. use super::{config::ServiceConfig, EventsSender}; +use std::path::PathBuf; /// Web service state. /// @@ -9,4 +10,5 @@ use super::{config::ServiceConfig, EventsSender}; pub struct ServiceState { pub config: ServiceConfig, pub events: EventsSender, + pub public_dir: PathBuf, }