diff --git a/Cargo.lock b/Cargo.lock index 116e8eb..c3a9a05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -283,6 +283,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "domain" version = "0.10.3" @@ -334,6 +345,40 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ad0989667548f06ccd0e306ed56b61bd4d35458d54df5ec7587c0e8ed5e94" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "fnv" version = "1.0.7" @@ -573,6 +618,25 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "intl-memoizer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe22e020fce238ae18a6d5d8c502ee76a52a6e880d99477657e6acc30ec57bda" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "itoa" version = "1.0.14" @@ -707,12 +771,15 @@ dependencies = [ "axum", "bb8", "domain", + "fluent-bundle", "rusqlite", "serde", "serde_json", "tera", "tokio", + "tower", "tower-http", + "unic-langid", ] [[package]] @@ -1004,6 +1071,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1040,6 +1113,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.1.0", +] + +[[package]] +name = "self_cell" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fdfc24bc566f839a2da4c4295b82db7d25a24253867d5c64355abb5799bdbe" + [[package]] name = "semver" version = "1.0.23" @@ -1246,6 +1334,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", +] + [[package]] name = "tokio" version = "1.42.0" @@ -1376,6 +1473,15 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "859eb650cfee7434994602c3a68b25d77ad9e68c8a6cd491616ef86661382eb3" +[[package]] +name = "type-map" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb68604048ff8fa93347f02441e4487594adc20bb8a084f9e564d2b827a0a9f" +dependencies = [ + "rustc-hash", +] + [[package]] name = "typenum" version = "1.17.0" @@ -1409,6 +1515,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" +[[package]] +name = "unic-langid" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dd9d1e72a73b25e07123a80776aae3e7b0ec461ef94f9151eed6ec88005a44" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5422c1f65949306c99240b81de9f3f15929f5a8bfe05bb44b034cc8bf593e5" +dependencies = [ + "tinystr", +] + [[package]] name = "unic-segment" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 68e7a7b..7a4e1a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,8 +20,11 @@ tokio = {version = "1", default-features = false, features = [ "macros", "rt-mul #rand = "0.8" tera = { version = "1", default-features = false } domain = { version = "0.10.3", features = [ "tsig", "unstable-client-transport" ]} -axum = { version = "0.8.1", default-features = false, features = [ "http1", "json", "form", "query", "tokio" ]} +axum = { version = "0.8.1", default-features = false, features = [ "http1", "json", "form", "query", "tokio", "original-uri" ]} bb8 = { version = "0.9" } rusqlite = { version = "0.32"} async-trait = { version = "0.1" } tower-http = { version = "0.6", default-features = false, features = [ "fs" ]} +fluent-bundle = "0.15.3" +unic-langid = "*" +tower = "*" diff --git a/locales/en.ftl b/locales/en.ftl new file mode 100644 index 0000000..37f83cc --- /dev/null +++ b/locales/en.ftl @@ -0,0 +1,5 @@ +zone_records_title = Zone { $zone_name } records + +record_type_address = Addresses +record_type_mailserver = E-mail servers +record_type_nameserver = Name servers diff --git a/locales/fr.ftl b/locales/fr.ftl new file mode 100644 index 0000000..e03c103 --- /dev/null +++ b/locales/fr.ftl @@ -0,0 +1,5 @@ +zone_records_title = Enregistrements de la zone { $zone_name } + +record_type_address = Adresses +record_type_mailserver = Serveurs de courriel +record_type_nameserver = Serveurs de noms diff --git a/src/errors.rs b/src/errors.rs index c50b555..270636c 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -372,11 +372,19 @@ impl From for Error { fn from(value: TemplateError) -> Self { match value { TemplateError::RenderError { name, reason } => { + let mut this_reason = reason.as_ref(); + let mut cause = format!("{}", this_reason); + + while let Some(source) = this_reason.source() { + cause.push_str(&format!(": {source}")); + this_reason = source; + } + Error::new("template:render", "Failed to render the template") .with_details(json!({ "name": name })) - .with_cause(&reason.to_string()) + .with_cause(&cause) }, TemplateError::SerializationError { reason } => { Error::new("template:serialization", "Failed to serialize context") diff --git a/src/locale.rs b/src/locale.rs new file mode 100644 index 0000000..dfc15b8 --- /dev/null +++ b/src/locale.rs @@ -0,0 +1,192 @@ +use std::{collections::HashMap, str::FromStr}; + +use fluent_bundle::{concurrent::FluentBundle, FluentArgs, FluentResource, FluentValue}; +use unic_langid::LanguageIdentifier; + +const SOURCES: &[(&str, &str)] = &[ + ("en", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/en.ftl"))), + ("fr", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/fr.ftl"))), +]; + +#[derive(Clone)] +pub struct Localization { + bundles: std::sync::Arc>>, +} + +impl Localization { + pub fn init() -> Self { + let mut bundles = HashMap::new(); + for (locale, translations) in SOURCES { + let res = FluentResource::try_new(translations.to_string()).expect("Failed to parse an FTL string."); + let langid: LanguageIdentifier = locale.parse().expect("Parsing failed"); + + let mut bundle = FluentBundle::new_concurrent(vec![langid.clone()]); + + bundle + .add_resource(res) + .expect("Failed to add FTL resources to the bundle."); + + bundles.insert(langid, bundle); + } + + + Localization { + bundles: std::sync::Arc::new(bundles), + } + } + + pub fn language_middleware(&self, default_locale: LanguageIdentifier) -> ExtractLanguageLayer { + ExtractLanguageLayer { + default_locale, + supported_locales: std::sync::Arc::new(self.bundles.keys().cloned().collect()) + } + } +} + +impl tera::Function for Localization { + fn call(&self, args: &HashMap) -> tera::Result { + let locale = args.get("lang") + .and_then(|lang| lang.as_str()) + .and_then(|lang| LanguageIdentifier::from_str(lang).ok()) + .ok_or(tera::Error::msg("localize: Missing lang parameter"))?; + + let msg = args.get("msg") + .and_then(|lang| lang.as_str()) + .ok_or(tera::Error::msg("localize: Missing msg parameter"))?; + + let bundle = self.bundles.get(&locale) + .ok_or_else(|| tera::Error::msg("localize: Could not find locale {locale}"))?; + + let message = bundle.get_message(msg) + .ok_or_else(|| tera::Error::msg("localize: Could not find message {msg}"))?; + + + let pattern = message.value() + .ok_or_else(|| tera::Error::msg("localize: Message {msg} has no value"))?; + + let mut msg_args = FluentArgs::new(); + + for (key, value) in args { + if key != "msg" && key != "lang" { + msg_args.set(key, fluent_value_from_tera(value, key)?); + } + } + + let mut errors = Vec::new(); + + let localized_message = bundle.format_pattern(pattern, Some(&msg_args), &mut errors); + + for err in errors { + eprint!("[warn] localization error: {err}"); + } + + Ok(tera::Value::from(localized_message)) + + } +} + +fn fluent_value_from_tera<'s>(value: &'s tera::Value, name: &str) -> tera::Result> { + match value { + tera::Value::String(string) => Ok(FluentValue::from(string)), + tera::Value::Number(number) => Ok(FluentValue::from(number.as_f64())), + _ => Err(tera::Error::msg(format!("localize: Argument {name} can only be a string or a number"))), + } +} + +#[derive(Clone)] +pub struct ExtractLanguageLayer { + default_locale: LanguageIdentifier, + supported_locales: std::sync::Arc>, +} + +impl tower::Layer for ExtractLanguageLayer { + + type Service = ExtractLanguageService; + + fn layer(&self, inner: S) -> Self::Service { + ExtractLanguageService { + inner, + default_locale: self.default_locale.clone(), + supported_locales: self.supported_locales.clone(), + } + } +} + +#[derive(Clone)] +pub struct ExtractLanguageService { + inner: S, + default_locale: LanguageIdentifier, + supported_locales: std::sync::Arc>, +} + +impl ExtractLanguageService { + // https://httpwg.org/specs/rfc9110.html#field.accept-language + pub fn language_from_header(&self, headers: &axum::http::HeaderMap) -> LanguageIdentifier { + let lang_preferences = headers + .get("Accept-Language") + .and_then(|val| val.to_str().ok()); + + if let Some(lang_preferences) = lang_preferences { + let mut languages = Vec::new(); + + for lang_item in lang_preferences.split(",") { + let lang_item = lang_item.trim(); + let lang_config = lang_item.split_once(';'); + if let Some((lang_id, config)) = lang_config { + let lang_id = lang_id.trim(); + let preference = config.trim().strip_prefix("q=") + .and_then(|value| value.parse::().ok()); + if let Some(preference) = preference { + languages.push((lang_id.trim(), preference)); + } + } else { + languages.push((lang_item, 1f32)); + } + } + + languages.sort_by(|l1, l2| l1.1.total_cmp(&l2.1)); + + let iter = languages.into_iter() + .rev() + .filter(|l| l.1 >= 0.001 && l.1 <= 1f32) + .filter_map(|(lang, _)| LanguageIdentifier::from_str(lang).ok()); + + for lang in iter { + for supported_lang in self.supported_locales.iter() { + if lang == *supported_lang { + return lang; + } + } + } + + self.default_locale.clone() + } else { + self.default_locale.clone() + } + } +} + +impl tower::Service for ExtractLanguageService +where + S: tower::Service + Send + 'static, + S::Future: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + // `BoxFuture` is a type alias for `Pin>` + type Future = std::pin::Pin> + Send + 'static>>; + + fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> std::task::Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut req: axum::extract::Request) -> Self::Future { + let headers = req.headers(); + + let lang = self.language_from_header(headers); + + req.extensions_mut().insert(lang); + + Box::pin(self.inner.call(req)) + } +} diff --git a/src/main.rs b/src/main.rs index 612de74..1e444d4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod validation; mod macros; mod template; mod proto; +mod locale; use std::sync::Arc; @@ -32,7 +33,12 @@ pub struct AppState { #[tokio::main] async fn main() { - let template_engine = TemplateEngine::new(std::path::Path::new("./templates")); + let localization = locale::Localization::init(); + + let template_engine = TemplateEngine::new( + std::path::Path::new("./templates"), + localization.clone() + ); let dns_driver = dns::dns_driver::DnsDriver::from_config(DnsDriverConfig { address: "127.0.0.1:5353".parse().unwrap(), @@ -61,7 +67,8 @@ async fn main() { .route("/zones/{zone_name}/records", routing::get(routes::ui::zones::get_records_page)) .route("/zones/{zone_name}/records/new", routing::get(routes::ui::zones::get_new_record_page)) .nest_service("/assets", ServeDir::new("assets")) - .with_state(app_state); + .with_state(app_state) + .layer(localization.language_middleware("en".parse().unwrap())); let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap(); axum::serve(listener, app).await.unwrap(); diff --git a/src/routes/ui/zones.rs b/src/routes/ui/zones.rs index ba7be22..36bda72 100644 --- a/src/routes/ui/zones.rs +++ b/src/routes/ui/zones.rs @@ -1,7 +1,9 @@ use axum::extract::Request; -use axum::extract::{Query, Path, State}; +use axum::extract::{Query, Path, State, OriginalUri}; +use axum::Extension; use serde::Deserialize; use serde_json::{Value, json}; +use unic_langid::LanguageIdentifier; use crate::validation; use crate::AppState; @@ -12,19 +14,23 @@ use crate::resources::dns::friendly; pub async fn get_records_page( Path(zone_name): Path, State(app): State, - request: Request, + OriginalUri(url): OriginalUri, + Extension(lang): Extension, ) -> Result, Error> { let zone = app.db.get_zone_by_name(&zone_name).await?; let records = zone.get_records(app.records).await?; let records = friendly::FriendlyRecords::from(records.clone()); + println!("{}", lang); + Ok(Template::new( "pages/records.html", app.template_engine, json!({ "current_zone": zone.name, "records": records, - "url": request.uri().to_string(), + "url": url.to_string(), + "lang": lang.to_string(), }) )) } @@ -40,7 +46,8 @@ pub async fn get_new_record_page( Path(zone_name): Path, State(app): State, Query(params): Query, - request: Request, + OriginalUri(url): OriginalUri, + Extension(lang): Extension, ) -> Result, Error> { let zone = app.db.get_zone_by_name(&zone_name).await?; @@ -60,6 +67,8 @@ pub async fn get_new_record_page( (None, None) }; + println!("{}", lang); + Ok(Template::new( "pages/new_record.html", app.template_engine, @@ -69,7 +78,8 @@ pub async fn get_new_record_page( "domain_error": domain_error, "config": params.config, "rtype": params.rtype, - "url": request.uri().to_string(), + "url": url.to_string(), + "lang": lang.to_string(), }) )) } diff --git a/src/template.rs b/src/template.rs index 1d630e9..0e59f1b 100644 --- a/src/template.rs +++ b/src/template.rs @@ -6,24 +6,28 @@ use axum::response::{Html, IntoResponse}; use serde::Serialize; use tera::{Tera, Context}; -use crate::errors::Error; +use crate::{errors::Error, locale::Localization}; #[derive(Clone)] pub struct TemplateEngine { - tera: Arc, + pub tera: Arc, } +#[derive(Debug)] pub enum TemplateError { SerializationError { reason: Box }, RenderError { name: String, reason: Box }, } impl TemplateEngine { - pub fn new(template_directory: &Path) -> Self { + pub fn new(template_directory: &Path, localization: Localization) -> Self { let template_glob = template_directory.join("**").join("*"); match Tera::new(template_glob.to_str().expect("valid glob path string")) { - Ok(tera) => TemplateEngine { tera: Arc::new(tera) }, + Ok(mut tera) => { + tera.register_function("tr", localization); + TemplateEngine { tera: Arc::new(tera) } + }, Err(e) => { println!("Loading templates failed: {}", e); exit(1) diff --git a/templates/bases/base.html b/templates/bases/base.html index f653f85..c3cdc2c 100644 --- a/templates/bases/base.html +++ b/templates/bases/base.html @@ -1,5 +1,5 @@ - + diff --git a/templates/macros.html b/templates/macros.html index 5316a26..98df93b 100644 --- a/templates/macros.html +++ b/templates/macros.html @@ -1,7 +1,7 @@ -{% macro rrset(rtype, ttl, data, zone) %} +{% macro rrset(rtype, ttl, data, zone, lang) %}
  • - {{ rtype }} + {{ tr(msg="record_type_" ~ rtype, lang=lang) }}