feat(i18n): add i18n on server side

- remove the hardcoded list of locales in favor of a discovry on start time
    - server will stop on badly formatted locale .json
    - add server.* entries for serer side translation

    server side translation will be used for templating and email
    note: no json in some embded html (like in /magic), amount of work was similar
This commit is contained in:
Edouard Vanbelle
2026-06-03 13:18:05 +02:00
parent e0f59aa942
commit 044bd76738
32 changed files with 1520 additions and 149 deletions
+38
View File
@@ -869,6 +869,36 @@ pub struct AppConfig {
pub smtp: SmtpConfig,
/// Magic-link authentication configuration (TTL, external-users kill switch)
pub magic_link: MagicLinkConfig,
/// I18n configuration (default locale for server-rendered surfaces)
pub i18n: I18nConfig,
}
/// Server-side i18n knobs.
///
/// Locale discovery itself is driven by `static/locales/*.json` at boot
/// (see [`crate::common::locale::LocaleRegistry`]) — no hardcoded list,
/// no `build.rs`. This struct only carries the configurable defaults
/// around that discovery.
#[derive(Debug, Clone)]
pub struct I18nConfig {
/// Fallback locale used when:
/// - an anonymous request's `Accept-Language` matches nothing in
/// the registry,
/// - a user's `preferred_locale` is `NULL`,
/// - an OIDC `locale` claim doesn't resolve.
///
/// Must be present in `static/locales/`; the registry-build step
/// errors at startup if this is set to a locale we don't ship.
/// Defaults to `"en"`. Override via `OXICLOUD_DEFAULT_LOCALE`.
pub default_locale: String,
}
impl Default for I18nConfig {
fn default() -> Self {
Self {
default_locale: "en".to_string(),
}
}
}
impl Default for AppConfig {
@@ -891,6 +921,7 @@ impl Default for AppConfig {
nextcloud: NextcloudConfig::default(),
smtp: SmtpConfig::default(),
magic_link: MagicLinkConfig::default(),
i18n: I18nConfig::default(),
}
}
}
@@ -1445,6 +1476,13 @@ impl AppConfig {
config.magic_link.open_to_password_users = v == "true" || v == "1";
}
if let Ok(v) = env::var("OXICLOUD_DEFAULT_LOCALE") {
let trimmed = v.trim();
if !trimmed.is_empty() {
config.i18n.default_locale = trimmed.to_string();
}
}
config
}
+52 -21
View File
@@ -1,5 +1,5 @@
use sqlx::PgPool;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::application::ports::blob_storage_ports::BlobStorageBackend;
@@ -27,6 +27,7 @@ use crate::application::services::{
};
use crate::common::config::AppConfig;
use crate::common::errors::DomainError;
use crate::common::locale::LocaleRegistry;
use crate::infrastructure::repositories::pg::SharePgRepository;
use crate::infrastructure::repositories::pg::{
FileBlobReadRepository, FileBlobWriteRepository, FileMetadataRepository, FolderDbRepository,
@@ -77,27 +78,55 @@ pub struct AppServiceFactory {
storage_path: PathBuf,
locales_path: PathBuf,
config: AppConfig,
/// Validated set of locales discovered under `locales_path`. Built
/// once at factory construction time; consumed by the I18n service
/// and the `Accept-Language` extractor. See
/// [`crate::common::locale::LocaleRegistry`] for the discovery rules.
locale_registry: Arc<LocaleRegistry>,
}
impl AppServiceFactory {
/// Creates a new service factory
pub fn new(storage_path: PathBuf, locales_path: PathBuf) -> Self {
let config = AppConfig::default();
let locale_registry = Self::build_registry(&locales_path, &config);
Self {
storage_path,
locales_path,
config: AppConfig::default(),
config,
locale_registry,
}
}
/// Creates a new service factory with custom configuration
pub fn with_config(storage_path: PathBuf, locales_path: PathBuf, config: AppConfig) -> Self {
let locale_registry = Self::build_registry(&locales_path, &config);
Self {
storage_path,
locales_path,
config,
locale_registry,
}
}
/// Discover locales from disk at boot. A misconfigured default or
/// an empty locale directory is treated as a fatal config error —
/// fail fast so the operator notices at startup rather than when
/// the first magic-link mail is queued.
fn build_registry(locales_path: &Path, config: &AppConfig) -> Arc<LocaleRegistry> {
let registry = LocaleRegistry::discover(locales_path, &config.i18n.default_locale)
.unwrap_or_else(|e| {
panic!(
"Failed to build locale registry from {}: {}. \
Check OXICLOUD_DEFAULT_LOCALE and that static/locales/ \
contains valid *.json files.",
locales_path.display(),
e
)
});
Arc::new(registry)
}
/// Gets the configuration
pub fn config(&self) -> &AppConfig {
&self.config
@@ -341,8 +370,12 @@ impl AppServiceFactory {
folder_repo_concrete.clone(),
));
// I18n repository
let i18n_repository = Arc::new(FileSystemI18nService::new(self.locales_path.clone()));
// I18n repository — file-system backed, gated by the locale
// registry built at factory construction.
let i18n_repository = Arc::new(FileSystemI18nService::new(
self.locales_path.clone(),
self.locale_registry.clone(),
));
// Trash repository — reads soft-delete flags from storage.files/folders
let trash_repository = if core.config.features.enable_trash {
@@ -567,24 +600,16 @@ impl AppServiceFactory {
service
}
/// Preloads translations
/// Preloads translations for every locale in the registry. Build
/// the registry at startup via `LocaleRegistry::discover` and pass
/// the resulting list here.
pub async fn preload_translations(&self, i18n_service: &I18nApplicationService) {
use crate::domain::services::i18n_service::Locale;
if let Err(e) = i18n_service.load_translations(Locale::English).await {
tracing::warn!("Failed to load English translations: {}", e);
}
if let Err(e) = i18n_service.load_translations(Locale::Spanish).await {
tracing::warn!("Failed to load Spanish translations: {}", e);
}
if let Err(e) = i18n_service.load_translations(Locale::French).await {
tracing::warn!("Failed to load French translations: {}", e);
}
if let Err(e) = i18n_service.load_translations(Locale::German).await {
tracing::warn!("Failed to load German translations: {}", e);
}
if let Err(e) = i18n_service.load_translations(Locale::Portuguese).await {
tracing::warn!("Failed to load Portuguese translations: {}", e);
let locales = i18n_service.available_locales().await;
for locale in locales {
let code = locale.as_str().to_string();
if let Err(e) = i18n_service.load_translations(locale).await {
tracing::warn!("Failed to load translations for {}: {}", code, e);
}
}
tracing::info!("Translations preloaded");
}
@@ -883,6 +908,7 @@ impl AppServiceFactory {
core,
repositories: repos,
applications: apps,
locale_registry: self.locale_registry.clone(),
db_pool: Some(pool.clone()),
maintenance_pool: Some(maintenance_pool),
auth_service: auth_services,
@@ -1279,6 +1305,11 @@ pub struct AppState {
pub core: CoreServices,
pub repositories: RepositoryServices,
pub applications: ApplicationServices,
/// Validated set of locales the server knows about. Surfaced to
/// handlers so the `Accept-Language` extractor and any
/// locale-validation code (OIDC JIT, profile-edit) can consult one
/// canonical list.
pub locale_registry: Arc<LocaleRegistry>,
pub db_pool: Option<Arc<PgPool>>,
/// Isolated pool for background / batch operations.
pub maintenance_pool: Option<Arc<PgPool>>,
+420
View File
@@ -0,0 +1,420 @@
//! Locale newtype and registry.
//!
//! Replaces the closed `enum Locale { English, Spanish, … }` that used
//! to live in `domain::services::i18n_service`. Locales are now a
//! string-backed newtype validated at construction against a
//! [`LocaleRegistry`] that is built **once at startup** by listing the
//! files under `static/locales/*.json`.
//!
//! Adding a 17th locale is a JSON-file-drop: no Rust patch, no
//! re-compile. The trade-off is that all locale matching is done by
//! exact string compare against a hash-set; tag negotiation (matching
//! `fr-FR` to a registry containing only `fr`) is the [extractor]'s
//! responsibility, not this type's.
//!
//! Construction always goes through the registry to guarantee an
//! unknown code can never end up in a `Locale` value — fallback to the
//! server default happens at parse time, not at use time. That keeps
//! every consumer dumb: if you have a `Locale`, the underlying string
//! is known good.
//!
//! [extractor]: crate::interfaces::middleware::locale
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
/// A validated locale code (e.g. `"en"`, `"fr"`, `"zh-TW"`).
///
/// Construction goes through [`LocaleRegistry`] so the contained string
/// is always present in `static/locales/`. Two locales compare equal
/// iff their canonical codes are equal — case-insensitively normalised
/// at registry-build time (see [`LocaleRegistry::canonicalise`]).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Locale(SmolStr);
impl Locale {
/// The canonical English locale. Used as the universal fallback.
/// Safe to call without a registry: every install is required to
/// ship `static/locales/en.json`, and the canonical form is fixed
/// at `"en"`.
pub fn english() -> Self {
Self(SmolStr::new_static("en"))
}
/// Borrow the underlying canonical code, e.g. `"en"`, `"zh-TW"`.
pub fn as_str(&self) -> &str {
self.0.as_str()
}
/// True iff this is `Locale::english()`.
pub fn is_english(&self) -> bool {
self.0.as_str() == "en"
}
/// Format-only parse: accepts strings that look like RFC 5646
/// language tags (`fr`, `en-US`, `zh-TW`), returns the
/// canonicalised newtype. **Does not check the registry** — the
/// result may not be a locale this server has translations for.
/// Callers that need that guarantee should use
/// [`LocaleRegistry::parse`] instead.
///
/// Returns `None` for empty input, non-ASCII characters, or
/// shapes outside `^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$`.
pub fn from_code(code: &str) -> Option<Self> {
if code.is_empty() || code.len() > 35 {
return None;
}
let mut parts = code.split('-');
let primary = parts.next()?;
if !(2..=3).contains(&primary.len()) || !primary.chars().all(|c| c.is_ascii_alphabetic()) {
return None;
}
for sub in parts {
if !(2..=8).contains(&sub.len()) || !sub.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
}
Some(Self(SmolStr::new(code.to_ascii_lowercase())))
}
}
impl Default for Locale {
fn default() -> Self {
Self::english()
}
}
impl std::fmt::Display for Locale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0.as_str())
}
}
/// Validated set of supported locales, built once at startup by
/// listing `static/locales/*.json`.
///
/// Stored on `AppState` and consulted by:
/// - [`Locale::from_code`] when parsing user-supplied / claim-derived
/// codes.
/// - The `Accept-Language` extractor when negotiating an anonymous
/// request's preference.
/// - The OIDC JIT provisioning path when storing a `locale` claim on
/// a freshly created user row.
///
/// Locales not present here are treated as unknown — callers fall back
/// to the configured server default.
#[derive(Debug, Clone)]
pub struct LocaleRegistry {
/// Canonicalised codes (e.g. `"en"`, `"zh-tw"`). Lookups are
/// case-insensitive: input is canonicalised, then probed against
/// this set.
canonical: Arc<HashSet<SmolStr>>,
/// The configured fallback locale. Resolved from
/// `OXICLOUD_DEFAULT_LOCALE` at startup; defaults to English when
/// unset.
default: Locale,
}
impl LocaleRegistry {
/// Scan `dir` for `*.json` files; the filename stem (less the
/// `.json` extension) is treated as a locale code. Each file is
/// parsed eagerly as JSON — a syntactically broken file aborts the
/// boot with [`LocaleRegistryError::ParseFailure`] so the operator
/// sees the path + parse error immediately, not after a translator
/// notices half a UI is missing.
///
/// Per-key English fallback at translate time (see
/// [`crate::infrastructure::services::file_system_i18n_service`]) is
/// still the safety net for *partial* translations — a file
/// shipped with five out of twenty keys works fine. What we will
/// not tolerate is a file that the JSON parser rejects outright,
/// because that drops every key for that locale at once with no
/// surface signal beyond a buried warn log.
///
/// `default` is the configured fallback. It must resolve against
/// the discovered codes; if not, the registry build fails so the
/// operator notices their config typo at boot rather than mid-flow.
pub fn discover(dir: &Path, default_code: &str) -> Result<Self, LocaleRegistryError> {
let mut canonical: HashSet<SmolStr> = HashSet::new();
let entries = fs::read_dir(dir).map_err(|e| LocaleRegistryError::ReadDir {
path: dir.to_path_buf(),
source: e,
})?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
// Eager parse — full content is loaded lazily by the I18n
// service later, but a quick parse here catches
// syntactically broken files at boot. Failures propagate
// (don't silently skip) so a translator's stray comma is
// visible at the first restart, not at the first user
// request.
let content =
fs::read_to_string(&path).map_err(|e| LocaleRegistryError::ReadFailure {
path: path.clone(),
source: e,
})?;
serde_json::from_str::<serde_json::Value>(&content).map_err(|e| {
LocaleRegistryError::ParseFailure {
path: path.clone(),
source: e,
}
})?;
canonical.insert(Self::canonicalise(stem));
}
if canonical.is_empty() {
return Err(LocaleRegistryError::Empty(dir.to_path_buf()));
}
let default_canon = Self::canonicalise(default_code);
if !canonical.contains(&default_canon) {
return Err(LocaleRegistryError::DefaultNotPresent {
requested: default_code.to_string(),
available: canonical.iter().map(|s| s.to_string()).collect(),
});
}
let default = Locale(default_canon);
let mut sorted: Vec<&str> = canonical.iter().map(|s| s.as_str()).collect();
sorted.sort();
tracing::info!(
target: "oxicloud::i18n",
"Loaded {} locales: {}",
sorted.len(),
sorted.join(", ")
);
Ok(Self {
canonical: Arc::new(canonical),
default,
})
}
/// Parse a code, returning a [`Locale`] iff it's in the registry.
/// Matching is case-insensitive on both sides — `"FR"`, `"fr"`,
/// `"Fr"` all collapse to the same canonical form.
pub fn parse(&self, code: &str) -> Option<Locale> {
let canon = Self::canonicalise(code);
if self.canonical.contains(&canon) {
Some(Locale(canon))
} else {
None
}
}
/// Parse a code, falling back to the configured default when the
/// code is unknown. The common shape for callers that want a
/// `Locale` no matter what.
pub fn parse_or_default(&self, code: &str) -> Locale {
self.parse(code).unwrap_or_else(|| self.default.clone())
}
/// Borrow the configured fallback locale.
pub fn default_locale(&self) -> &Locale {
&self.default
}
/// Iterate every locale in the registry, in arbitrary order. Used
/// by the preload step at startup.
pub fn iter(&self) -> impl Iterator<Item = Locale> + '_ {
self.canonical.iter().map(|s| Locale(s.clone()))
}
/// Number of locales in the registry. Used by tests + startup logs.
pub fn len(&self) -> usize {
self.canonical.len()
}
/// True iff the registry has no entries. Convenience for tests;
/// production builds always have ≥1 (English is mandatory).
pub fn is_empty(&self) -> bool {
self.canonical.is_empty()
}
/// Canonical form for matching: ASCII-lowercase. This means `fr-FR`
/// and `fr-fr` collapse to the same key, which is the right policy
/// — RFC 5646 says language tags are case-insensitive, and storing
/// a single canonical form keeps the hash-set small and predictable.
fn canonicalise(code: &str) -> SmolStr {
SmolStr::new(code.to_ascii_lowercase())
}
}
#[derive(Debug, thiserror::Error)]
pub enum LocaleRegistryError {
#[error("Failed to read locale directory {path}: {source}")]
ReadDir {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("Failed to read locale file {path}: {source}")]
ReadFailure {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("Locale file {path} is not valid JSON: {source}")]
ParseFailure {
path: std::path::PathBuf,
#[source]
source: serde_json::Error,
},
#[error("Locale directory {0} contains no valid *.json files")]
Empty(std::path::PathBuf),
#[error(
"Configured default locale {requested:?} is not in the registry. \
Available: {available:?}"
)]
DefaultNotPresent {
requested: String,
available: Vec<String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn tmp_dir_with(files: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
for (name, body) in files {
let path = dir.path().join(name);
let mut f = fs::File::create(&path).expect("create file");
f.write_all(body.as_bytes()).expect("write");
}
dir
}
#[test]
fn from_code_accepts_well_formed_tags() {
assert_eq!(Locale::from_code("en").unwrap().as_str(), "en");
assert_eq!(Locale::from_code("FR").unwrap().as_str(), "fr");
assert_eq!(Locale::from_code("zh-TW").unwrap().as_str(), "zh-tw");
assert_eq!(Locale::from_code("en-US").unwrap().as_str(), "en-us");
}
#[test]
fn from_code_rejects_garbage() {
assert!(Locale::from_code("").is_none());
assert!(Locale::from_code("e").is_none()); // too short
assert!(Locale::from_code("toolong").is_none()); // primary > 3
assert!(Locale::from_code("en_US").is_none()); // underscore not allowed
assert!(Locale::from_code("12").is_none()); // digits in primary
assert!(Locale::from_code("en-X").is_none()); // subtag too short
}
#[test]
fn default_is_english() {
assert_eq!(Locale::default().as_str(), "en");
}
#[test]
fn english_is_always_canonical_en() {
assert_eq!(Locale::english().as_str(), "en");
assert!(Locale::english().is_english());
}
#[test]
fn discover_lists_only_json_files() {
let dir = tmp_dir_with(&[
("en.json", "{}"),
("fr.json", "{}"),
("README.md", "not a locale"),
("backup.txt", "ignored"),
]);
let reg = LocaleRegistry::discover(dir.path(), "en").expect("registry");
assert_eq!(reg.len(), 2);
assert!(reg.parse("en").is_some());
assert!(reg.parse("fr").is_some());
assert!(reg.parse("README").is_none());
}
#[test]
fn discover_fails_fast_on_broken_json() {
// A translator's stray comma must take the server down on the
// next restart rather than silently dropping their locale —
// see [`LocaleRegistry::discover`] doc for the rationale.
let dir = tmp_dir_with(&[
("en.json", "{}"),
("broken.json", "{ not valid json"),
("fr.json", r#"{"hello":"world"}"#),
]);
let err = LocaleRegistry::discover(dir.path(), "en").unwrap_err();
match err {
LocaleRegistryError::ParseFailure { path, .. } => {
assert_eq!(
path.file_name().and_then(|s| s.to_str()),
Some("broken.json")
);
}
other => panic!("expected ParseFailure, got {:?}", other),
}
}
#[test]
fn parse_is_case_insensitive() {
let dir = tmp_dir_with(&[("en.json", "{}"), ("zh-TW.json", "{}")]);
let reg = LocaleRegistry::discover(dir.path(), "en").expect("registry");
assert_eq!(
reg.parse("ZH-tw").map(|l| l.as_str().to_string()),
Some("zh-tw".to_string())
);
assert_eq!(
reg.parse("zh-tw").map(|l| l.as_str().to_string()),
Some("zh-tw".to_string())
);
assert_eq!(
reg.parse("zh-TW").map(|l| l.as_str().to_string()),
Some("zh-tw".to_string())
);
}
#[test]
fn parse_or_default_falls_back() {
let dir = tmp_dir_with(&[("en.json", "{}"), ("fr.json", "{}")]);
let reg = LocaleRegistry::discover(dir.path(), "en").expect("registry");
assert_eq!(reg.parse_or_default("klingon").as_str(), "en");
assert_eq!(reg.parse_or_default("fr").as_str(), "fr");
}
#[test]
fn empty_directory_is_error() {
let dir = tmp_dir_with(&[]);
let err = LocaleRegistry::discover(dir.path(), "en").unwrap_err();
assert!(matches!(err, LocaleRegistryError::Empty(_)));
}
#[test]
fn default_must_be_in_registry() {
let dir = tmp_dir_with(&[("en.json", "{}"), ("fr.json", "{}")]);
let err = LocaleRegistry::discover(dir.path(), "de").unwrap_err();
match err {
LocaleRegistryError::DefaultNotPresent { requested, .. } => {
assert_eq!(requested, "de");
}
_ => panic!("expected DefaultNotPresent"),
}
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod config;
pub mod di;
pub mod errors;
pub mod locale;
pub mod mime_detect;
pub mod stubs;
+9
View File
@@ -340,6 +340,15 @@ impl I18nService for StubI18nService {
Ok(String::new())
}
async fn translate_args(
&self,
_key: &str,
_locale: Locale,
_args: &[(&str, &str)],
) -> I18nResult<String> {
Ok(String::new())
}
async fn load_translations(&self, _locale: Locale) -> I18nResult<()> {
Ok(())
}