feat: locales

This commit is contained in:
Pascal Engélibert 2022-12-04 10:12:34 +01:00
commit 7ee817de46
Signed by: tuxmain
GPG key ID: 3504BC6D362F7DCA
11 changed files with 618 additions and 200 deletions

View file

@ -36,8 +36,9 @@ pub struct Config {
pub cookies_https_only: bool,
#[serde(default = "Config::default_cookies_domain")]
pub cookies_domain: Option<String>,
#[serde(default = "Config::default_lang")]
pub lang: String,
/// Format: "language_REGION"
#[serde(default = "Config::default_default_lang")]
pub default_lang: String,
#[serde(default = "Config::default_listen")]
pub listen: SocketAddr,
/// Send a matrix message on new comment
@ -102,8 +103,8 @@ impl Config {
fn default_cookies_domain() -> Option<String> {
None
}
fn default_lang() -> String {
"en_GB".into()
fn default_default_lang() -> String {
"en_US".into()
}
fn default_listen() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 31720)
@ -152,7 +153,7 @@ impl Default for Config {
comment_text_max_len: Self::default_comment_text_max_len(),
cookies_https_only: Self::default_cookies_https_only(),
cookies_domain: Self::default_cookies_domain(),
lang: Self::default_lang(),
default_lang: Self::default_default_lang(),
listen: Self::default_listen(),
matrix_notify: Self::default_matrix_notify(),
matrix_password: Self::default_matrix_password(),

103
src/locales.rs Normal file
View file

@ -0,0 +1,103 @@
use crate::config::Config;
use fluent_bundle::{bundle::FluentBundle, FluentArgs, FluentResource};
use fluent_langneg::{
accepted_languages, negotiate::filter_matches, negotiate_languages, NegotiationStrategy,
};
use intl_memoizer::concurrent::IntlLangMemoizer;
use log::error;
use std::{borrow::Cow, collections::HashMap, ops::Deref, str::FromStr};
use unic_langid::{langid, LanguageIdentifier};
static LOCALE_FILES: &[(LanguageIdentifier, &str)] = &[
(langid!("en"), include_str!("../locales/en.ftl")),
(langid!("fr"), include_str!("../locales/fr.ftl")),
];
pub struct Locales {
bundles: HashMap<LanguageIdentifier, FluentBundle<FluentResource, IntlLangMemoizer>>,
pub default_lang: LanguageIdentifier,
langs: Vec<LanguageIdentifier>,
}
impl Locales {
pub fn new(config: &Config) -> Self {
let mut langs = Vec::new();
Self {
bundles: LOCALE_FILES
.iter()
.map(|(lang, raw)| {
let mut bundle = FluentBundle::new_concurrent(vec![lang.clone()]);
bundle
.add_resource(
FluentResource::try_new(raw.to_string()).unwrap_or_else(|e| {
panic!("Failed parsing `{lang}` locale: {e:?}")
}),
)
.unwrap();
langs.push(lang.clone());
(lang.clone(), bundle)
})
.collect::<HashMap<LanguageIdentifier, FluentBundle<FluentResource, IntlLangMemoizer>>>(
),
default_lang: filter_matches(
&[LanguageIdentifier::from_str(&config.default_lang)
.expect("Invalid default language")],
&langs,
NegotiationStrategy::Filtering,
)
.get(0)
.expect("Unavailable default language")
.deref()
.clone(),
langs,
}
}
// TODO fix fluent-langneg's weird API
pub fn tr<'a>(
&'a self,
langs: &[LanguageIdentifier],
key: &str,
args: Option<&'a FluentArgs>,
) -> Option<Cow<str>> {
for prefered_lang in negotiate_languages(
langs,
&self.langs,
Some(&self.default_lang),
NegotiationStrategy::Filtering,
) {
if let Some(bundle) = self.bundles.get(prefered_lang.as_ref()) {
println!("got bundle");
if let Some(message) = bundle.get_message(key) {
let mut errors = Vec::new();
let ret = bundle.format_pattern(message.value().unwrap(), args, &mut errors);
for error in errors {
error!("Formatting message `{key}` in lang `{prefered_lang}`: {error}");
}
return Some(ret);
}
}
}
None
}
}
pub fn get_client_langs<State>(req: &tide::Request<State>) -> Vec<LanguageIdentifier> {
if let Some(header) = req.header("Accept-Language") {
accepted_languages::parse(header.as_str())
} else {
println!("NO HEADER");
Vec::new()
}
}
/// Get the first language that is likely to be usable with chrono
pub fn get_time_lang(langs: &[LanguageIdentifier]) -> Option<String> {
for lang in langs {
if let Some(region) = &lang.region {
return Some(format!("{}_{}", lang.language.as_str(), region.as_str()));
}
}
None
}

View file

@ -3,6 +3,7 @@ mod cli;
mod config;
mod db;
mod helpers;
mod locales;
mod notify;
mod queries;
mod server;
@ -13,6 +14,9 @@ use argon2::{
Argon2,
};
use clap::Parser;
use log::warn;
use std::{collections::HashMap, str::FromStr};
use unic_langid::LanguageIdentifier;
#[tokio::main]
async fn main() {
@ -28,9 +32,38 @@ async fn main() {
// These will never be dropped nor mutated
let templates = Box::leak(Box::new(templates));
let config = Box::leak(Box::new(config));
let locales = Box::leak(Box::new(locales::Locales::new(config)));
// TODO args
templates.tera.register_function(
"tr",
Box::new(
|args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
let langs = if let Some(tera::Value::Array(langs)) = args.get("l") {
langs
.iter()
.filter_map(|lang| lang.as_str())
.filter_map(|lang| LanguageIdentifier::from_str(lang).ok())
.collect()
} else {
vec![locales.default_lang.clone()]
};
let key = args
.get("k")
.ok_or_else(|| tera::Error::from("Missing argument `k`"))?
.as_str()
.ok_or_else(|| tera::Error::from("Argument `k` must be string"))?;
let res = locales.tr(&langs, key, None);
if res.is_none() {
warn!("(calling `tr` in template) translation key `{key}` not found");
}
Ok(res.into())
},
),
);
tokio::spawn(cleaner::run_cleaner(config, dbs.clone()));
server::run_server(config, dbs, templates).await;
server::run_server(config, dbs, templates, locales).await;
}
cli::MainSubcommand::Psw => {
let mut config = config::read_config(&opt.opt.dir.0);

View file

@ -1,11 +1,20 @@
use crate::{config::*, db::*, helpers, queries::*, templates::*};
#![allow(clippy::too_many_arguments)]
use crate::{config::*, db::*, helpers, locales::*, queries::*, templates::*};
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use crossbeam_channel::Sender;
use fluent_bundle::FluentArgs;
use log::{error, warn};
use tera::Context;
use unic_langid::LanguageIdentifier;
pub async fn run_server(config: &'static Config, dbs: Dbs, templates: &'static Templates) {
pub async fn run_server(
config: &'static Config,
dbs: Dbs,
templates: &'static Templates,
locales: &'static Locales,
) {
tide::log::start();
let (notify_send, notify_recv) = crossbeam_channel::bounded(10);
@ -15,17 +24,36 @@ pub async fn run_server(config: &'static Config, dbs: Dbs, templates: &'static T
app.at(&format!("{}t/:topic", config.root_url)).get({
let dbs = dbs.clone();
move |req: tide::Request<()>| {
serve_comments(req, config, templates, dbs.clone(), Context::new(), 200)
let client_langs = get_client_langs(&req);
serve_comments(
req,
config,
templates,
dbs.clone(),
client_langs,
Context::new(),
200,
)
}
});
app.at(&format!("{}t/:topic", config.root_url)).post({
let dbs = dbs.clone();
move |req: tide::Request<()>| {
handle_post_comments(req, config, templates, dbs.clone(), notify_send.clone())
handle_post_comments(
req,
config,
templates,
dbs.clone(),
locales,
notify_send.clone(),
)
}
});
app.at(&format!("{}admin", config.root_url))
.get(move |req: tide::Request<()>| serve_admin_login(req, config, templates));
.get(move |req: tide::Request<()>| {
let client_langs = get_client_langs(&req);
serve_admin_login(req, config, templates, client_langs)
});
app.at(&format!("{}admin", config.root_url)).post({
let dbs = dbs.clone();
move |req: tide::Request<()>| handle_post_admin(req, config, templates, dbs.clone())
@ -38,6 +66,7 @@ async fn serve_comments<'a>(
config: &Config,
templates: &Templates,
dbs: Dbs,
client_langs: Vec<LanguageIdentifier>,
mut context: Context,
status_code: u16,
) -> tide::Result<tide::Response> {
@ -53,6 +82,18 @@ async fn serve_comments<'a>(
context.insert("config", &config);
context.insert("admin", &admin);
let time_lang = get_time_lang(&client_langs);
context.insert(
"time_lang",
time_lang.as_ref().unwrap_or(&config.default_lang),
);
context.insert(
"l",
&client_langs
.iter()
.map(|lang| lang.language.as_str())
.collect::<Vec<&str>>(),
);
if admin {
if let Ok(query) = req.query::<ApproveQuery>() {
@ -120,10 +161,23 @@ async fn serve_admin<'a>(
config: &Config,
templates: &Templates,
dbs: Dbs,
client_langs: &[LanguageIdentifier],
) -> tide::Result<tide::Response> {
let mut context = Context::new();
context.insert("config", &config);
context.insert("admin", &true);
let time_lang = get_time_lang(client_langs);
context.insert(
"time_lang",
time_lang.as_ref().unwrap_or(&config.default_lang),
);
context.insert(
"l",
&client_langs
.iter()
.map(|lang| lang.language.as_str())
.collect::<Vec<&str>>(),
);
context.insert(
"comments",
@ -164,9 +218,22 @@ async fn serve_admin_login(
_req: tide::Request<()>,
config: &Config,
templates: &Templates,
client_langs: Vec<LanguageIdentifier>,
) -> tide::Result<tide::Response> {
let mut context = Context::new();
context.insert("config", &config);
let time_lang = get_time_lang(&client_langs);
context.insert(
"time_lang",
time_lang.as_ref().unwrap_or(&config.default_lang),
);
context.insert(
"l",
&client_langs
.iter()
.map(|lang| lang.language.as_str())
.collect::<Vec<&str>>(),
);
Ok(tide::Response::builder(200)
.content_type(tide::http::mime::HTML)
@ -179,12 +246,15 @@ async fn handle_post_comments(
config: &Config,
templates: &Templates,
dbs: Dbs,
locales: &Locales,
notify_send: Sender<()>,
) -> tide::Result<tide::Response> {
let admin = req.cookie("admin").map_or(false, |psw| {
check_admin_password_hash(config, &String::from(psw.value()))
});
let client_langs = get_client_langs(&req);
let client_addr = if !admin && config.antispam_enable {
match helpers::get_client_addr(config, &req) {
Some(Ok(addr)) => {
@ -222,10 +292,19 @@ async fn handle_post_comments(
if let Some(antispam_timeout) =
helpers::antispam_check_client_mutation(client_addr, &dbs, config).unwrap()
{
errors.push(format!(
"The edition quota from your IP is reached. You will be unblocked in {}s.",
antispam_timeout
));
errors.push(
locales
.tr(
&client_langs,
"error-antispam",
Some(&FluentArgs::from_iter([(
"antispam_timeout",
antispam_timeout,
)])),
)
.unwrap()
.into_owned(),
);
}
}
@ -283,21 +362,33 @@ async fn handle_post_comments(
return Err(tide::Error::from_str(404, "Not found"));
};
if let Some(client_addr) = &client_addr {
// We're admin
/*if let Some(client_addr) = &client_addr {
if let Some(antispam_timeout) =
helpers::antispam_check_client_mutation(client_addr, &dbs, config).unwrap()
{
errors.push(format!(
"The edition quota from your IP is reached. You will be unblocked in {}s.",
antispam_timeout
));
let client_langs = get_client_langs(&req);
errors.push(
locales
.tr(
&client_langs,
"error-antispam",
Some(&FluentArgs::from_iter([(
"antispam_timeout",
antispam_timeout,
)])),
)
.unwrap()
.into_owned(),
);
}
}
}*/
if errors.is_empty() {
if let Some(client_addr) = &client_addr {
// We're admin
/*if let Some(client_addr) = &client_addr {
helpers::antispam_update_client_mutation(client_addr, &dbs).unwrap();
}
}*/
let time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -328,6 +419,7 @@ async fn handle_post_comments(
config,
templates,
dbs,
client_langs,
context,
if errors.is_empty() { 200 } else { 400 },
)
@ -344,31 +436,40 @@ async fn handle_post_admin(
if check_admin_password(config, &String::from(psw.value())).is_some() {
#[allow(clippy::match_single_binding)]
match req.body_form::<AdminQuery>().await? {
_ => serve_admin(req, config, templates, dbs).await,
_ => {
let client_langs = get_client_langs(&req);
serve_admin(req, config, templates, dbs, &client_langs).await
}
}
} else {
serve_admin_login(req, config, templates).await
let client_langs = get_client_langs(&req);
serve_admin_login(req, config, templates, client_langs).await
}
} else if let AdminQuery::Login(query) = req.body_form::<AdminQuery>().await? {
if let Some(password_hash) = check_admin_password(config, &query.psw) {
serve_admin(req, config, templates, dbs).await.map(|mut r| {
let mut cookie = tide::http::Cookie::new("admin", password_hash);
cookie.set_http_only(Some(true));
cookie.set_path(config.root_url.clone());
if let Some(domain) = &config.cookies_domain {
cookie.set_domain(domain.clone());
}
if config.cookies_https_only {
cookie.set_secure(Some(true));
}
r.insert_cookie(cookie);
r
})
let client_langs = get_client_langs(&req);
serve_admin(req, config, templates, dbs, &client_langs)
.await
.map(|mut r| {
let mut cookie = tide::http::Cookie::new("admin", password_hash);
cookie.set_http_only(Some(true));
cookie.set_path(config.root_url.clone());
if let Some(domain) = &config.cookies_domain {
cookie.set_domain(domain.clone());
}
if config.cookies_https_only {
cookie.set_secure(Some(true));
}
r.insert_cookie(cookie);
r
})
} else {
serve_admin_login(req, config, templates).await
let client_langs = get_client_langs(&req);
serve_admin_login(req, config, templates, client_langs).await
}
} else {
serve_admin_login(req, config, templates).await
let client_langs = get_client_langs(&req);
serve_admin_login(req, config, templates, client_langs).await
}
}