feat: locales
This commit is contained in:
parent
0d7356ec1f
commit
7ee817de46
11 changed files with 618 additions and 200 deletions
|
|
@ -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
103
src/locales.rs
Normal 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
|
||||
}
|
||||
35
src/main.rs
35
src/main.rs
|
|
@ -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);
|
||||
|
|
|
|||
169
src/server.rs
169
src/server.rs
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue