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:
Generated
+28
@@ -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"
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+25
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -14,18 +14,43 @@ pub struct LocaleDto {
|
||||
|
||||
impl From<Locale> 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,9 @@ impl I18nApplicationService {
|
||||
|
||||
/// Get a translation for a key and locale
|
||||
pub async fn translate(&self, key: &str, locale: Option<Locale>) -> I18nResult<String> {
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>>,
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
pub mod config;
|
||||
pub mod di;
|
||||
pub mod errors;
|
||||
pub mod locale;
|
||||
pub mod mime_detect;
|
||||
pub mod stubs;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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<T> = Result<T, I18nError>;
|
||||
|
||||
/// 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<Self> {
|
||||
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<String>;
|
||||
|
||||
/// 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<String>;
|
||||
|
||||
/// 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<Locale>;
|
||||
|
||||
/// Check if a locale is supported
|
||||
/// True iff the given locale is in the registry.
|
||||
async fn is_supported(&self, locale: Locale) -> bool;
|
||||
}
|
||||
|
||||
@@ -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<Arc<LocaleRegistry>>,
|
||||
/// Cached translations (locale code → JSON tree).
|
||||
cache: RwLock<HashMap<Locale, Value>>,
|
||||
}
|
||||
|
||||
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<LocaleRegistry>) -> 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<String> {
|
||||
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<String> {
|
||||
// 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<String> {
|
||||
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<Locale> {
|
||||
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}}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl FromRequestParts<Arc<AppState>> for RequestLocale {
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &Arc<AppState>,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
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::<LangOverride>::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<String> =
|
||||
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<LocaleRegistry> {
|
||||
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<String> = 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<String> = 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`");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "نظام تخزين سحابي بسيط"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "سیستم ذخیرهسازی ابری سادهگرا"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "न्यूनतम क्लाउड स्टोरेज सिस्टम"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "ミニマリストクラウドストレージシステム"
|
||||
|
||||
@@ -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": "미니멀리스트 클라우드 스토리지 시스템"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "Минималистичная система облачного хранения"
|
||||
|
||||
@@ -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": "極簡雲端儲存系統"
|
||||
|
||||
@@ -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": "极简云存储系统"
|
||||
|
||||
Reference in New Issue
Block a user