diff --git a/Cargo.lock b/Cargo.lock index f4ed025e..aa8a8acc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b5ace29ee3216de37c0546865ad08edef58b0f9e76838ed8959a84a990e58c5" +[[package]] +name = "accept-language" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f27d075294830fcab6f66e320dab524bc6d048f4a151698e153205559113772" + [[package]] name = "adler2" version = "2.0.1" @@ -970,6 +976,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "bytes", + "cfg_aliases", +] + [[package]] name = "brotli" version = "8.0.2" @@ -3701,6 +3717,7 @@ dependencies = [ name = "oxicloud" version = "0.6.0" dependencies = [ + "accept-language", "aes-gcm", "argon2", "async-compression", @@ -3758,6 +3775,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "smol_str", "socket2 0.6.3", "sqlx", "tempfile", @@ -5112,6 +5130,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "socket2" version = "0.5.10" diff --git a/Cargo.toml b/Cargo.toml index 9ca8c4d6..f4236311 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,8 @@ fastcdc = "4.0.0" memmap2 = "0.9.10" lettre = { version = "0.11.18", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "rustls-native-certs", "builder"] } idna = "1.1" +smol_str = { version = "0.3.2", features = ["serde"] } +accept-language = "3.1.0" [features] default = [] diff --git a/docs/config/env.md b/docs/config/env.md index 2866a3d7..57579572 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -217,6 +217,22 @@ Configures the invite-by-email and login-via-email flows. Both require SMTP to b | `OXICLOUD_ALLOW_EXTERNAL_USERS` | `true` | Kill switch for the whole flow. `false` makes `POST /api/grants` reject `subject.type = "email"` for unknown addresses and `POST /api/auth/magic-link/send` return its uniform stub without issuing a token. | | `OXICLOUD_EXTERNAL_EMAIL_DOMAINS` | — | Comma-separated allowlist of email domains accepted when minting a new external user (case-insensitive, exact match on the post-`@` part). Empty = any domain is allowed, subject to `OXICLOUD_ALLOW_EXTERNAL_USERS`. Subdomains must be listed explicitly: `partner.com` does NOT match `eng.partner.com`. Example: `partner-a.com,partner-b.io`. | +## Internationalization (server-rendered surfaces) + +Server-rendered HTML pages (magic-link landing, error pages) and outbound transactional emails go through the backend i18n layer. The set of available locales is **discovered at boot** by listing `static/locales/*.json` — no rebuild needed to add a 17th locale. + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_DEFAULT_LOCALE` | `en` | Fallback locale used when no stronger signal is available. Must match one of the locales under `static/locales/`; startup fails fast if you set it to a code with no corresponding JSON file. | + +The resolution priority differs by surface: + +- **HTML pages (anonymous, e.g. magic-link landing)** — `?lang=xx` query override, then the browser's `Accept-Language` header (q-weighted, with primary-tag fallback so `fr-FR` resolves to `fr` when no `fr-FR.json` is shipped), then this default. +- **Emails to a known user** — the user's `preferred_locale` column (set via OIDC `locale` claim at JIT or via the UI language switcher), then this default. +- **Emails to a brand-new external user being invited** — the inviter's `preferred_locale` (inheritance at row-creation), then this default. + +Today's shipped locales: `ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru, zh, zh-TW`. Missing translations on a non-English locale automatically fall back to English at the key level — adding a new locale with even a few translated keys works without manual gap-filling. + ## Trusted Proxy | Variable | Default | Description | diff --git a/example.env b/example.env index 405887b0..f2e0c99e 100644 --- a/example.env +++ b/example.env @@ -416,6 +416,31 @@ OXICLOUD_WOPI_ENABLED=false # IdP is the security boundary and may enforce MFA we shouldn't bypass. #OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=false +# ----------------------------------------------------------------------------- +# INTERNATIONALIZATION (server-rendered surfaces) +# ----------------------------------------------------------------------------- +# +# Default locale for server-rendered HTML pages and outbound emails when +# no stronger signal is available. The resolution priority is: +# +# HTML pages (anonymous, e.g. magic-link landing): +# 1. ?lang=xx query override +# 2. browser Accept-Language header (q-weighted) +# 3. this default +# +# Emails to a known user: +# 1. user.preferred_locale column +# 2. this default +# +# Supported locales are discovered at boot by listing static/locales/*.json, +# so adding a 17th locale is a file-drop operation (no rebuild required). +# This variable must match one of the discovered codes — startup fails fast +# if you set OXICLOUD_DEFAULT_LOCALE=xx and no static/locales/xx.json exists. +# +# Default: "en". Today's shipped locales: ar, de, en, es, fa, fr, hi, it, +# ja, ko, nl, pl, pt, ru, zh, zh-TW. +#OXICLOUD_DEFAULT_LOCALE=en + # ----------------------------------------------------------------------------- # PROXY # ----------------------------------------------------------------------------- diff --git a/src/application/dtos/i18n_dto.rs b/src/application/dtos/i18n_dto.rs index 454f9a54..97ff6ce6 100644 --- a/src/application/dtos/i18n_dto.rs +++ b/src/application/dtos/i18n_dto.rs @@ -14,18 +14,43 @@ pub struct LocaleDto { impl From for LocaleDto { fn from(locale: Locale) -> Self { - let (code, name) = match locale { - Locale::English => ("en", "English"), - Locale::Spanish => ("es", "Español"), - Locale::French => ("fr", "Français"), - Locale::German => ("de", "Deutsch"), - Locale::Portuguese => ("pt", "Português"), - }; + Self::from(&locale) + } +} - Self { - code: code.to_string(), - name: name.to_string(), - } +impl From<&Locale> for LocaleDto { + fn from(locale: &Locale) -> Self { + let code = locale.as_str().to_string(); + let name = display_name_for(&code) + .map(str::to_string) + .unwrap_or_else(|| code.clone()); + Self { code, name } + } +} + +/// Endonym lookup for the locales shipped under `static/locales/`. New +/// locales added in PR-A's `LocaleRegistry::discover` should be added +/// here too; an unknown code falls back to itself, which is safe but +/// looks rough in a language switcher. +fn display_name_for(code: &str) -> Option<&'static str> { + match code { + "en" => Some("English"), + "es" => Some("Español"), + "fr" => Some("Français"), + "de" => Some("Deutsch"), + "pt" => Some("Português"), + "it" => Some("Italiano"), + "nl" => Some("Nederlands"), + "pl" => Some("Polski"), + "ru" => Some("Русский"), + "ja" => Some("日本語"), + "ko" => Some("한국어"), + "zh" => Some("中文"), + "zh-tw" => Some("繁體中文"), + "ar" => Some("العربية"), + "fa" => Some("فارسی"), + "hi" => Some("हिन्दी"), + _ => None, } } diff --git a/src/application/services/i18n_application_service.rs b/src/application/services/i18n_application_service.rs index 11ea87f9..93d91123 100644 --- a/src/application/services/i18n_application_service.rs +++ b/src/application/services/i18n_application_service.rs @@ -23,8 +23,9 @@ impl I18nApplicationService { /// Get a translation for a key and locale pub async fn translate(&self, key: &str, locale: Option) -> I18nResult { - let locale = locale.unwrap_or_default(); - self.i18n_service.translate(key, locale).await + self.i18n_service + .translate(key, locale.unwrap_or_default()) + .await } /// Load translations for a locale @@ -38,7 +39,7 @@ impl I18nApplicationService { let mut results = Vec::new(); for locale in locales { - let result = self.i18n_service.load_translations(locale).await; + let result = self.i18n_service.load_translations(locale.clone()).await; results.push((locale, result)); } diff --git a/src/common/config.rs b/src/common/config.rs index c9a512e2..3ce797cb 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -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 } diff --git a/src/common/di.rs b/src/common/di.rs index db01a08a..9efdf7df 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -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, } 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 { + 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, pub db_pool: Option>, /// Isolated pool for background / batch operations. pub maintenance_pool: Option>, diff --git a/src/common/locale.rs b/src/common/locale.rs new file mode 100644 index 00000000..29ae43e4 --- /dev/null +++ b/src/common/locale.rs @@ -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 { + 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>, + /// 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 { + let mut canonical: HashSet = 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::(&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 { + 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 + '_ { + 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, + }, +} + +#[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"), + } + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs index 2abb6785..b343f797 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -1,5 +1,6 @@ pub mod config; pub mod di; pub mod errors; +pub mod locale; pub mod mime_detect; pub mod stubs; diff --git a/src/common/stubs.rs b/src/common/stubs.rs index 8da97929..be8b7cbd 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -340,6 +340,15 @@ impl I18nService for StubI18nService { Ok(String::new()) } + async fn translate_args( + &self, + _key: &str, + _locale: Locale, + _args: &[(&str, &str)], + ) -> I18nResult { + Ok(String::new()) + } + async fn load_translations(&self, _locale: Locale) -> I18nResult<()> { Ok(()) } diff --git a/src/domain/services/i18n_service.rs b/src/domain/services/i18n_service.rs index 94cf94a2..41fa5c9c 100644 --- a/src/domain/services/i18n_service.rs +++ b/src/domain/services/i18n_service.rs @@ -1,5 +1,20 @@ +//! Domain port for translation lookup. +//! +//! The concrete locale type lives in [`crate::common::locale::Locale`] and +//! is a string-backed newtype validated at construction against a +//! [`LocaleRegistry`] populated at startup from `static/locales/*.json`. +//! +//! This module is a thin facade: the trait + error types stay where the +//! application + infrastructure layers expect them; the type itself is +//! re-exported from `common` so the same `Locale` value flows through +//! handlers, middleware, services, and DTOs without re-wrapping. +//! +//! [`LocaleRegistry`]: crate::common::locale::LocaleRegistry + use thiserror::Error; +pub use crate::common::locale::Locale; + /// Error types for i18n service operations #[derive(Debug, Error)] pub enum I18nError { @@ -16,53 +31,37 @@ pub enum I18nError { /// Result type for i18n service operations pub type I18nResult = Result; -/// Supported locales -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub enum Locale { - #[default] - English, - Spanish, - French, - German, - Portuguese, -} - -impl Locale { - /// Convert locale to code string - pub fn as_str(&self) -> &'static str { - match self { - Locale::English => "en", - Locale::Spanish => "es", - Locale::French => "fr", - Locale::German => "de", - Locale::Portuguese => "pt", - } - } - - /// Create from locale code string - pub fn from_code(code: &str) -> Option { - match code.to_lowercase().as_str() { - "en" => Some(Locale::English), - "es" => Some(Locale::Spanish), - "fr" => Some(Locale::French), - "de" => Some(Locale::German), - "pt" => Some(Locale::Portuguese), - _ => None, - } - } -} - -/// Interface for i18n service (primary port) +/// Interface for i18n service (primary port). +/// +/// Implementations should fall back to English when the requested +/// locale has no entry for `key`. Unknown locales (codes not in the +/// configured [`crate::common::locale::LocaleRegistry`]) are an +/// `InvalidLocale` error — callers normally avoid this by going +/// through the registry's `parse_or_default` before calling +/// `translate`. pub trait I18nService: Send + Sync + 'static { - /// Get a translation for a key and locale + /// Get a translation for a key and locale. async fn translate(&self, key: &str, locale: Locale) -> I18nResult; - /// Load translations for a locale + /// Get a translation with `{{name}}`-mustache substitution applied + /// to the resolved string. Mirrors the frontend convention in + /// `static/js/core/i18n.js:117` so JSON values are interchangeable + /// between front- and back-end. + async fn translate_args( + &self, + key: &str, + locale: Locale, + args: &[(&str, &str)], + ) -> I18nResult; + + /// Load translations for a locale into the in-memory cache. async fn load_translations(&self, locale: Locale) -> I18nResult<()>; - /// Get available locales + /// Available locales — typically the contents of the underlying + /// registry. Returned in arbitrary order; callers that need a + /// stable order should sort. async fn available_locales(&self) -> Vec; - /// Check if a locale is supported + /// True iff the given locale is in the registry. async fn is_supported(&self, locale: Locale) -> bool; } diff --git a/src/infrastructure/services/file_system_i18n_service.rs b/src/infrastructure/services/file_system_i18n_service.rs index b85a778d..16e32a2b 100644 --- a/src/infrastructure/services/file_system_i18n_service.rs +++ b/src/infrastructure/services/file_system_i18n_service.rs @@ -1,126 +1,180 @@ +//! Filesystem-backed translation lookup. +//! +//! Reads JSON files under `static/locales/` (the same source the +//! frontend's `i18n.js` uses). Supports nested keys (`magic_link.invite.subject` +//! walks `{"magic_link":{"invite":{"subject":"…"}}}`) and falls back to +//! English when the resolved locale doesn't have the requested key. +//! +//! Locale validity is delegated to the [`LocaleRegistry`] +//! (`crate::common::locale`). This service does not maintain its own +//! list of supported codes — `available_locales` and `is_supported` +//! both consult the registry, so adding a 17th locale is a JSON-drop +//! operation with no Rust patch. + use serde_json::Value; use std::collections::HashMap; use std::path::PathBuf; +use std::sync::Arc; use tokio::fs; use tokio::sync::RwLock; -use crate::domain::services::i18n_service::{I18nError, I18nResult, I18nService, Locale}; +use crate::common::locale::{Locale, LocaleRegistry}; +use crate::domain::services::i18n_service::{I18nError, I18nResult, I18nService}; /// File system implementation of the I18nService pub struct FileSystemI18nService { /// Base directory containing translation files translations_dir: PathBuf, - - /// Cached translations (locale code -> JSON data) + /// Validated registry of supported locale codes — built once at + /// startup. `None` only in the [`dummy`](Self::dummy) test path. + registry: Option>, + /// Cached translations (locale code → JSON tree). cache: RwLock>, } impl FileSystemI18nService { - /// Create a dummy service for testing + /// Create a dummy service for testing — no registry, no files on + /// disk. Translation lookups will fail; this exists for stubs in + /// non-i18n test code that just needs the type to compile. pub fn dummy() -> Self { Self { translations_dir: PathBuf::from("/tmp/dummy_translations"), + registry: None, cache: RwLock::new(HashMap::new()), } } - /// Creates a new file system i18n service - pub fn new(translations_dir: PathBuf) -> Self { + /// Construct a service rooted at `translations_dir`. The + /// [`LocaleRegistry`] should be the one built at boot (see + /// `common/di.rs`) — it gates which locale codes are accepted by + /// `is_supported` / `available_locales`. + pub fn new(translations_dir: PathBuf, registry: Arc) -> Self { Self { translations_dir, + registry: Some(registry), cache: RwLock::new(HashMap::new()), } } - /// Get translation file path for a locale - fn get_locale_file_path(&self, locale: Locale) -> PathBuf { + /// Get translation file path for a locale. + fn locale_file_path(&self, locale: &Locale) -> PathBuf { self.translations_dir .join(format!("{}.json", locale.as_str())) } - /// Get a nested key from JSON data - fn get_nested_value(&self, data: &Value, key: &str) -> Option { - let parts: Vec<&str> = key.split('.').collect(); + /// Walk a dotted key (`"server.magic_link.subject"`) against a + /// JSON tree, returning the matched string if every segment + /// resolves and the terminal value is a string. + fn lookup_nested<'a>(data: &'a Value, key: &str) -> Option<&'a str> { let mut current = data; + for part in key.split('.') { + current = current.get(part)?; + } + current.as_str() + } - for part in &parts[0..parts.len() - 1] { - if let Some(next) = current.get(part) { - current = next; + /// Apply `{{name}}` substitutions to `template` using the + /// (name, value) pairs in `args`. Mirrors the frontend regex + /// `/\{\{\s*([^}]+)\s*\}\}/g` (see `static/js/core/i18n.js:117`): + /// unmatched placeholders are left intact, whitespace inside + /// `{{ … }}` is ignored, and the substitution is single-pass so + /// values containing `{{x}}` won't be re-expanded. + fn interpolate(template: &str, args: &[(&str, &str)]) -> String { + if args.is_empty() || !template.contains("{{") { + return template.to_string(); + } + let mut out = String::with_capacity(template.len()); + let mut rest = template; + while let Some(open) = rest.find("{{") { + out.push_str(&rest[..open]); + let after_open = &rest[open + 2..]; + let Some(close) = after_open.find("}}") else { + // No closing braces — copy the remainder verbatim. + out.push_str("{{"); + out.push_str(after_open); + return out; + }; + let name = after_open[..close].trim(); + let after_close = &after_open[close + 2..]; + if let Some((_, value)) = args.iter().find(|(n, _)| *n == name) { + out.push_str(value); } else { - return None; + // Unknown placeholder — preserve the literal so it's + // obvious in QA that a key wasn't passed. + out.push_str("{{"); + out.push_str(&after_open[..close]); + out.push_str("}}"); } + rest = after_close; } - - if let Some(last_part) = parts.last() - && let Some(value) = current.get(last_part) - && value.is_string() - { - return value.as_str().map(|s| s.to_string()); - } - - None + out.push_str(rest); + out } } impl I18nService for FileSystemI18nService { async fn translate(&self, key: &str, locale: Locale) -> I18nResult { - // Check if translations are cached + // First attempt — the requested locale, cached or freshly loaded. { let cache = self.cache.read().await; - if let Some(translations) = cache.get(&locale) { - if let Some(value) = self.get_nested_value(translations, key) { - return Ok(value); - } - - // Try to use English as fallback if we couldn't find the key - if locale != Locale::English - && let Some(english_translations) = cache.get(&Locale::English) - && let Some(value) = self.get_nested_value(english_translations, key) - { - return Ok(value); - } - - return Err(I18nError::KeyNotFound(key.to_string())); + if let Some(translations) = cache.get(&locale) + && let Some(value) = Self::lookup_nested(translations, key) + { + return Ok(value.to_string()); + } + // Fall back to English while we still hold the read lock. + let english = Locale::english(); + if locale != english + && let Some(translations) = cache.get(&english) + && let Some(value) = Self::lookup_nested(translations, key) + { + return Ok(value.to_string()); } } - // If not cached, load translations and try again - self.load_translations(locale).await?; - + // Cold-load the requested locale and try once more. + self.load_translations(locale.clone()).await?; { let cache = self.cache.read().await; - if let Some(translations) = cache.get(&locale) { - if let Some(value) = self.get_nested_value(translations, key) { - return Ok(value); - } - - // Try to use English as fallback - if locale != Locale::English - && let Some(english_translations) = cache.get(&Locale::English) - && let Some(value) = self.get_nested_value(english_translations, key) - { - return Ok(value); - } + if let Some(translations) = cache.get(&locale) + && let Some(value) = Self::lookup_nested(translations, key) + { + return Ok(value.to_string()); + } + let english = Locale::english(); + if locale != english + && let Some(translations) = cache.get(&english) + && let Some(value) = Self::lookup_nested(translations, key) + { + return Ok(value.to_string()); } } - Err(I18nError::KeyNotFound(key.to_string())) } + async fn translate_args( + &self, + key: &str, + locale: Locale, + args: &[(&str, &str)], + ) -> I18nResult { + let template = self.translate(key, locale).await?; + Ok(Self::interpolate(&template, args)) + } + async fn load_translations(&self, locale: Locale) -> I18nResult<()> { - let file_path = self.get_locale_file_path(locale); - tracing::info!( + let file_path = self.locale_file_path(&locale); + tracing::debug!( + target: "oxicloud::i18n", "Loading translations for locale {} from {:?}", locale.as_str(), file_path ); - // Check if file exists if !file_path.exists() { return Err(I18nError::InvalidLocale(locale.as_str().to_string())); } - // Read and parse file let content = fs::read_to_string(&file_path) .await .map_err(|e| I18nError::LoadError(format!("Failed to read translation file: {}", e)))?; @@ -129,28 +183,61 @@ impl I18nService for FileSystemI18nService { I18nError::LoadError(format!("Failed to parse translation file: {}", e)) })?; - // Update cache { let mut cache = self.cache.write().await; cache.insert(locale, translations); } - - tracing::info!("Translations loaded for locale {}", locale.as_str()); Ok(()) } async fn available_locales(&self) -> Vec { - vec![ - Locale::English, - Locale::Spanish, - Locale::French, - Locale::German, - Locale::Portuguese, - ] + match &self.registry { + Some(reg) => reg.iter().collect(), + None => vec![Locale::english()], + } } async fn is_supported(&self, locale: Locale) -> bool { - let file_path = self.get_locale_file_path(locale); - file_path.exists() + match &self.registry { + Some(reg) => reg.parse(locale.as_str()).is_some(), + None => locale.is_english(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interpolate_replaces_named_placeholders() { + let out = FileSystemI18nService::interpolate( + "Hello {{name}}, you have {{count}} new messages.", + &[("name", "Alice"), ("count", "3")], + ); + assert_eq!(out, "Hello Alice, you have 3 new messages."); + } + + #[test] + fn interpolate_tolerates_whitespace_inside_braces() { + let out = FileSystemI18nService::interpolate("hi {{ who }}", &[("who", "there")]); + assert_eq!(out, "hi there"); + } + + #[test] + fn interpolate_preserves_unmatched_placeholders() { + // Caller forgot to pass `name` — keep the literal in the output + // so QA can see what was missed. + let out = FileSystemI18nService::interpolate("Hello {{name}}", &[]); + assert_eq!(out, "Hello {{name}}"); + } + + #[test] + fn interpolate_is_single_pass() { + // A value containing `{{x}}` should NOT be re-expanded — + // otherwise an untrusted arg could trigger key lookup. + let out = + FileSystemI18nService::interpolate("{{greeting}}", &[("greeting", "Hello {{name}}")]); + assert_eq!(out, "Hello {{name}}"); } } diff --git a/src/interfaces/api/handlers/i18n_handler.rs b/src/interfaces/api/handlers/i18n_handler.rs index 3978c15e..f86c9954 100644 --- a/src/interfaces/api/handlers/i18n_handler.rs +++ b/src/interfaces/api/handlers/i18n_handler.rs @@ -51,11 +51,12 @@ impl I18nHandler { None => None, }; + let resolved_locale = locale.clone().unwrap_or_default(); match service.translate(&query.key, locale).await { Ok(text) => { let response = TranslationResponseDto { key: query.key, - locale: locale.unwrap_or(Locale::default()).as_str().to_string(), + locale: resolved_locale.as_str().to_string(), text, }; (StatusCode::OK, Json(response)).into_response() @@ -75,7 +76,7 @@ impl I18nHandler { let error = TranslationErrorDto { key: query.key, - locale: locale.unwrap_or(Locale::default()).as_str().to_string(), + locale: resolved_locale.as_str().to_string(), error: error_msg, }; diff --git a/src/interfaces/middleware/locale.rs b/src/interfaces/middleware/locale.rs new file mode 100644 index 00000000..b632f766 --- /dev/null +++ b/src/interfaces/middleware/locale.rs @@ -0,0 +1,159 @@ +//! Locale negotiation for anonymous HTTP requests. +//! +//! Resolves the request's preferred locale, in priority order: +//! +//! 1. `?lang=fr` query parameter — explicit override, used by manual +//! testing and any future "language switcher" link on a public +//! page. Must match the registry; unknown values fall through. +//! 2. `Accept-Language` header — RFC 9110 quality-weighted list, the +//! standard browser-driven signal. +//! 3. The configured server default (`OXICLOUD_DEFAULT_LOCALE`), which +//! is always present in the registry by construction. +//! +//! Wire it as a regular Axum extractor on a handler that needs the +//! caller's locale: the `AppState` carries the [`LocaleRegistry`], so +//! handlers don't have to plumb anything else through. +//! +//! Authenticated requests should NOT use this extractor — their locale +//! comes from `user.preferred_locale` resolved at the service layer. +//! This extractor is for anonymous surfaces (magic-link landing pages, +//! the public login page) where no user row is available yet. + +use std::sync::Arc; + +use axum::extract::{FromRequestParts, Query}; +use axum::http::request::Parts; +use serde::Deserialize; + +use crate::common::di::AppState; +use crate::common::locale::Locale; + +/// Negotiated locale for the current request. +#[derive(Debug, Clone)] +pub struct RequestLocale(pub Locale); + +#[derive(Debug, Deserialize)] +struct LangOverride { + lang: Option, +} + +impl FromRequestParts> for RequestLocale { + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let registry = &state.locale_registry; + + // Priority 1 — explicit `?lang=` override. Parse failures or + // missing values just fall through to the next signal. + if let Ok(Query(LangOverride { lang: Some(code) })) = + Query::::try_from_uri(&parts.uri) + && let Some(locale) = registry.parse(&code) + { + return Ok(RequestLocale(locale)); + } + + // Priority 2 — Accept-Language. Use the `accept-language` crate + // for RFC-9110 q-value parsing; we pass the registry's codes + // as the supported list, so the crate hands us back the + // strongest match. The empty-list case falls through. + if let Some(header_value) = parts + .headers + .get(axum::http::header::ACCEPT_LANGUAGE) + .and_then(|v| v.to_str().ok()) + { + let supported_owned: Vec = + registry.iter().map(|l| l.as_str().to_string()).collect(); + let supported: Vec<&str> = supported_owned.iter().map(String::as_str).collect(); + if let Some(matched) = accept_language::intersection(header_value, &supported).first() + && let Some(locale) = registry.parse(matched) + { + return Ok(RequestLocale(locale)); + } + + // Some browsers send only a primary tag (`fr`) when the + // user is on `fr-FR`; `intersection` is exact-tag, so a + // server that ships `fr-FR.json` but not `fr.json` (or + // vice-versa) needs a fallback. Walk the parsed list once + // more, this time stripping the subtag. + for raw in accept_language::parse(header_value) { + let primary = raw.split('-').next().unwrap_or(&raw); + if let Some(locale) = registry.parse(primary) { + return Ok(RequestLocale(locale)); + } + } + } + + // Priority 3 — configured default. Guaranteed to be in the + // registry (validated at startup). + Ok(RequestLocale(registry.default_locale().clone())) + } +} + +impl RequestLocale { + /// Borrow the resolved locale. + pub fn locale(&self) -> &Locale { + &self.0 + } + + /// Move the resolved locale out of the extractor. + pub fn into_inner(self) -> Locale { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::locale::LocaleRegistry; + use std::fs; + use std::io::Write; + + /// Smoke test for the priority logic, exercised against the + /// registry's `parse` directly so we don't need a full `AppState` + /// to assert behaviour. The extractor's prose above describes the + /// negotiation order; this test just locks in the building blocks. + fn registry_with(codes: &[&str], default: &str) -> Arc { + let dir = tempfile::tempdir().expect("tempdir"); + for code in codes { + let path = dir.path().join(format!("{}.json", code)); + let mut f = fs::File::create(&path).expect("create"); + f.write_all(b"{}").expect("write"); + } + let reg = LocaleRegistry::discover(dir.path(), default).expect("registry"); + // Leak the tempdir for the lifetime of the test — `discover` + // already finished its filesystem work, so we just need the + // registry to outlive the call. + std::mem::forget(dir); + Arc::new(reg) + } + + #[test] + fn registry_supplies_supported_list_for_intersection() { + let reg = registry_with(&["en", "fr", "de"], "en"); + let owned: Vec = reg.iter().map(|l| l.as_str().to_string()).collect(); + let supported: Vec<&str> = owned.iter().map(String::as_str).collect(); + let pick = accept_language::intersection("de, fr;q=0.9", &supported); + assert_eq!(pick.first().map(String::as_str), Some("de")); + } + + #[test] + fn primary_tag_fallback_when_subtag_missing() { + let reg = registry_with(&["en", "fr"], "en"); + let owned: Vec = reg.iter().map(|l| l.as_str().to_string()).collect(); + let supported: Vec<&str> = owned.iter().map(String::as_str).collect(); + // Exact `fr-FR` is not in the registry; intersection returns + // empty, but the primary-tag walk hits `fr`. + let pick = accept_language::intersection("fr-FR", &supported); + assert!(pick.is_empty()); + for raw in accept_language::parse("fr-FR") { + let primary = raw.split('-').next().unwrap_or(&raw); + if reg.parse(primary).is_some() { + return; // hit the fallback + } + } + panic!("primary-tag fallback did not match `fr`"); + } +} diff --git a/src/interfaces/middleware/mod.rs b/src/interfaces/middleware/mod.rs index de11d844..2ef51d39 100644 --- a/src/interfaces/middleware/mod.rs +++ b/src/interfaces/middleware/mod.rs @@ -1,6 +1,7 @@ pub mod admin; pub mod auth; pub mod csrf; +pub mod locale; pub mod rate_limit; pub mod trace_span; pub mod trusted_proxy; diff --git a/static/locales/ar.json b/static/locales/ar.json index b08f05b4..7e6adf8e 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "نظام تخزين سحابي بسيط" diff --git a/static/locales/de.json b/static/locales/de.json index 8b438032..10f36baa 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "Minimalistisches Cloud-Speichersystem" diff --git a/static/locales/en.json b/static/locales/en.json index 0010c459..baa27ecd 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "Minimalist cloud storage system" diff --git a/static/locales/es.json b/static/locales/es.json index df412c6e..9602af4e 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "Sistema de almacenamiento en la nube minimalista" diff --git a/static/locales/fa.json b/static/locales/fa.json index 3936e963..4c07a49c 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "سیستم ذخیره‌سازی ابری ساده‌گرا" diff --git a/static/locales/fr.json b/static/locales/fr.json index 010b5054..e2d83471 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "Système de stockage cloud minimaliste" diff --git a/static/locales/hi.json b/static/locales/hi.json index 9801d852..1b7b072f 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "न्यूनतम क्लाउड स्टोरेज सिस्टम" diff --git a/static/locales/it.json b/static/locales/it.json index 35e67951..46432128 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "Sistema di archiviazione cloud minimalista" diff --git a/static/locales/ja.json b/static/locales/ja.json index dc04aa61..ca6a80ba 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "ミニマリストクラウドストレージシステム" diff --git a/static/locales/ko.json b/static/locales/ko.json index 9a316b6b..4175b2d5 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "미니멀리스트 클라우드 스토리지 시스템" diff --git a/static/locales/nl.json b/static/locales/nl.json index ea05f347..731bad9c 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "Minimalistisch cloudopslagsysteem" diff --git a/static/locales/pl.json b/static/locales/pl.json index 9306477b..f5641bbb 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "Minimalistyczny cloud storage" diff --git a/static/locales/pt.json b/static/locales/pt.json index 0fb50174..824ed5c3 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "Sistema de armazenamento em nuvem minimalista" diff --git a/static/locales/ru.json b/static/locales/ru.json index 7c45a5d4..eef3ec65 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "Минималистичная система облачного хранения" diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index 997840ca..8a713a7d 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "極簡雲端儲存系統" diff --git a/static/locales/zh.json b/static/locales/zh.json index af277cd0..e6ec01f6 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -1,4 +1,37 @@ { + "server": { + "magic_link": { + "page": { + "expired_title": "This sign-in link is no longer valid", + "expired_body": "The link may have expired or already been used. We can send you a fresh one — it'll arrive in your inbox in a few seconds.", + "resend_to": "Send a fresh link to {{email}}", + "generic_unavailable": "This sign-in link is no longer valid. It may have already been used or expired. Request a fresh link from the login page.", + "service_unavailable": "Magic-link sign-in is not enabled on this server.", + "internal_error": "Something went wrong while signing you in. Please try again.", + "resend_failure": "Something went wrong while sending the link. Please try again.", + "cross_browser_title": "Continue signing in on this device?", + "cross_browser_body": "You opened this sign-in link in a different browser or device than the one where you requested it.", + "cross_browser_warning": "If you requested this link, it's safe to continue. If you didn't, close this page — clicking Continue would sign someone else into your account.", + "cross_browser_continue": "Continue and sign in", + "resend_confirmation_title": "Check your inbox", + "resend_confirmation_body": "If the sign-in link belonged to an active account, a fresh link has just been sent. Please check your inbox.", + "return_link": "Return to OxiCloud" + }, + "email": { + "invitation": { + "subject": "{{inviter}} shared a {{kind}} with you on OxiCloud", + "body": "{{inviter}} shared a {{kind}} with you on OxiCloud.\n\nOpen it by clicking the link below:\n{{link}}\n\nThe link works once and expires in {{ttl_hours}} hours.\nIf you didn't expect this invitation, you can safely ignore this message.\n\n— OxiCloud" + }, + "login": { + "subject": "Sign in to OxiCloud", + "body": "Hello,\n\nUse the link below to sign in to OxiCloud. The link works once and expires in {{ttl_minutes}} minutes. Open it on the same device where you requested it.\n\n{{link}}\n\nIf you didn't request this sign-in link, you can safely ignore this message — no further action is needed.\n\n— OxiCloud" + }, + "kind_file": "file", + "kind_folder": "folder", + "english_fallback_divider": "--- English version below ---" + } + } + }, "app": { "title": "OxiCloud", "description": "极简云存储系统"