feat(templating): add and use templates for /magic and emails
This commit is contained in:
Generated
+78
@@ -154,6 +154,59 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc"
|
||||||
|
dependencies = [
|
||||||
|
"askama_macros",
|
||||||
|
"itoa",
|
||||||
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_derive"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738"
|
||||||
|
dependencies = [
|
||||||
|
"askama_parser",
|
||||||
|
"basic-toml",
|
||||||
|
"glob",
|
||||||
|
"memchr",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc-hash",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_macros"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a"
|
||||||
|
dependencies = [
|
||||||
|
"askama_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_parser"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da"
|
||||||
|
dependencies = [
|
||||||
|
"rustc-hash",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"unicode-ident",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-channel"
|
name = "async-channel"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@@ -912,6 +965,15 @@ version = "1.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "basic-toml"
|
||||||
|
version = "0.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
@@ -2189,6 +2251,12 @@ dependencies = [
|
|||||||
"weezl",
|
"weezl",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glob"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "group"
|
name = "group"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -3720,6 +3788,7 @@ dependencies = [
|
|||||||
"accept-language",
|
"accept-language",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
"askama",
|
||||||
"async-compression",
|
"async-compression",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -6497,6 +6566,15 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ lettre = { version = "0.11.18", default-features = false, features = ["smtp-tran
|
|||||||
idna = "1.1"
|
idna = "1.1"
|
||||||
smol_str = { version = "0.3.2", features = ["serde"] }
|
smol_str = { version = "0.3.2", features = ["serde"] }
|
||||||
accept-language = "3.1.0"
|
accept-language = "3.1.0"
|
||||||
|
askama = "0.16.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ impl I18nApplicationService {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get a translation with `{{name}}` substitution applied. Mirrors
|
||||||
|
/// the frontend convention so JSON values stay interchangeable.
|
||||||
|
/// `None` locale resolves to the server default (English).
|
||||||
|
pub async fn translate_args(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
locale: Option<Locale>,
|
||||||
|
args: &[(&str, &str)],
|
||||||
|
) -> I18nResult<String> {
|
||||||
|
self.i18n_service
|
||||||
|
.translate_args(key, locale.unwrap_or_default(), args)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Load translations for a locale
|
/// Load translations for a locale
|
||||||
pub async fn load_translations(&self, locale: Locale) -> I18nResult<()> {
|
pub async fn load_translations(&self, locale: Locale) -> I18nResult<()> {
|
||||||
self.i18n_service.load_translations(locale).await
|
self.i18n_service.load_translations(locale).await
|
||||||
|
|||||||
@@ -29,12 +29,14 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use chrono::Utc;
|
use askama::Template;
|
||||||
|
|
||||||
use crate::application::ports::email_sender::{EmailMessage, EmailSender};
|
use crate::application::ports::email_sender::{EmailMessage, EmailSender};
|
||||||
|
use crate::application::services::i18n_application_service::I18nApplicationService;
|
||||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||||
use crate::common::config::MagicLinkConfig;
|
use crate::common::config::MagicLinkConfig;
|
||||||
use crate::common::errors::{DomainError, ErrorKind};
|
use crate::common::errors::{DomainError, ErrorKind};
|
||||||
|
use crate::common::locale::Locale;
|
||||||
use crate::domain::entities::magic_link_token::{
|
use crate::domain::entities::magic_link_token::{
|
||||||
MagicLinkResourceKind, MagicLinkStatus, MagicLinkToken,
|
MagicLinkResourceKind, MagicLinkStatus, MagicLinkToken,
|
||||||
};
|
};
|
||||||
@@ -93,6 +95,7 @@ pub struct MagicLinkInviteService {
|
|||||||
magic_link_repo: Arc<dyn MagicLinkTokenRepository>,
|
magic_link_repo: Arc<dyn MagicLinkTokenRepository>,
|
||||||
email_sender: Arc<dyn EmailSender>,
|
email_sender: Arc<dyn EmailSender>,
|
||||||
user_lifecycle: Arc<UserLifecycleService>,
|
user_lifecycle: Arc<UserLifecycleService>,
|
||||||
|
i18n: Arc<I18nApplicationService>,
|
||||||
magic_link_cfg: MagicLinkConfig,
|
magic_link_cfg: MagicLinkConfig,
|
||||||
/// Public base URL of this OxiCloud instance — used to build the
|
/// Public base URL of this OxiCloud instance — used to build the
|
||||||
/// `/magic/v1/{token}` invitation link. Sourced from
|
/// `/magic/v1/{token}` invitation link. Sourced from
|
||||||
@@ -101,11 +104,13 @@ pub struct MagicLinkInviteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MagicLinkInviteService {
|
impl MagicLinkInviteService {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
user_storage: Arc<UserPgRepository>,
|
user_storage: Arc<UserPgRepository>,
|
||||||
magic_link_repo: Arc<dyn MagicLinkTokenRepository>,
|
magic_link_repo: Arc<dyn MagicLinkTokenRepository>,
|
||||||
email_sender: Arc<dyn EmailSender>,
|
email_sender: Arc<dyn EmailSender>,
|
||||||
user_lifecycle: Arc<UserLifecycleService>,
|
user_lifecycle: Arc<UserLifecycleService>,
|
||||||
|
i18n: Arc<I18nApplicationService>,
|
||||||
magic_link_cfg: MagicLinkConfig,
|
magic_link_cfg: MagicLinkConfig,
|
||||||
public_base_url: String,
|
public_base_url: String,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -114,6 +119,7 @@ impl MagicLinkInviteService {
|
|||||||
magic_link_repo,
|
magic_link_repo,
|
||||||
email_sender,
|
email_sender,
|
||||||
user_lifecycle,
|
user_lifecycle,
|
||||||
|
i18n,
|
||||||
magic_link_cfg,
|
magic_link_cfg,
|
||||||
public_base_url,
|
public_base_url,
|
||||||
}
|
}
|
||||||
@@ -259,30 +265,38 @@ impl MagicLinkInviteService {
|
|||||||
token.token(),
|
token.token(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let kind_label = match resource {
|
let kind_key = match resource {
|
||||||
Resource::Folder(_) => "folder",
|
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||||
Resource::File(_) => "file",
|
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||||
};
|
};
|
||||||
let subject = format!(
|
// PR C will resolve the recipient's preferred_locale. For now
|
||||||
"{} shared a {} with you on OxiCloud",
|
// (PR B) every magic-link email defaults to the server default
|
||||||
inviter_username, kind_label
|
// locale; the bilingual partial below means non-English
|
||||||
);
|
// recipients still see English as a safety net.
|
||||||
let text_body = format!(
|
let locale = Locale::default();
|
||||||
"{inviter} shared a {kind} with you on OxiCloud.\n\
|
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
|
||||||
\n\
|
let ttl_hours = self.magic_link_cfg.invite_ttl_hours.to_string();
|
||||||
Open it by clicking the link below:\n\
|
let invite_args: Vec<(&str, &str)> = vec![
|
||||||
{link}\n\
|
("inviter", inviter_username),
|
||||||
\n\
|
("kind", &kind_label),
|
||||||
The link works once and expires in {ttl} hours.\n\
|
("link", &link),
|
||||||
If you didn't expect this invitation, you can safely ignore this message.\n\
|
("ttl_hours", &ttl_hours),
|
||||||
\n\
|
];
|
||||||
— OxiCloud, {now}\n",
|
|
||||||
inviter = inviter_username,
|
let subject = self
|
||||||
kind = kind_label,
|
.i18n_or(
|
||||||
link = link,
|
"server.magic_link.email.invitation.subject",
|
||||||
ttl = self.magic_link_cfg.invite_ttl_hours,
|
&locale,
|
||||||
now = Utc::now().to_rfc3339(),
|
&invite_args,
|
||||||
);
|
)
|
||||||
|
.await;
|
||||||
|
let text_body = self
|
||||||
|
.render_bilingual(
|
||||||
|
"server.magic_link.email.invitation.body",
|
||||||
|
&locale,
|
||||||
|
&invite_args,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let message = EmailMessage {
|
let message = EmailMessage {
|
||||||
to: recipient.email().to_string(),
|
to: recipient.email().to_string(),
|
||||||
@@ -441,24 +455,22 @@ impl MagicLinkInviteService {
|
|||||||
self.public_base_url.trim_end_matches('/'),
|
self.public_base_url.trim_end_matches('/'),
|
||||||
token.token(),
|
token.token(),
|
||||||
);
|
);
|
||||||
let subject = "Sign in to OxiCloud".to_string();
|
// PR C will switch to `user.preferred_locale` once the column
|
||||||
let text_body = format!(
|
// lands. Today the login-via-email path uses the server default.
|
||||||
"Hello,\n\
|
let locale = Locale::default();
|
||||||
\n\
|
let ttl_minutes = self.magic_link_cfg.login_ttl_minutes.to_string();
|
||||||
Use the link below to sign in to OxiCloud. The link works \
|
let login_args: Vec<(&str, &str)> = vec![("link", &link), ("ttl_minutes", &ttl_minutes)];
|
||||||
once and expires in {ttl} minutes. Open it on the same \
|
|
||||||
device where you requested it.\n\
|
let subject = self
|
||||||
\n\
|
.i18n_or(
|
||||||
{link}\n\
|
"server.magic_link.email.login.subject",
|
||||||
\n\
|
&locale,
|
||||||
If you didn't request this sign-in link, you can safely \
|
&login_args,
|
||||||
ignore this message — no further action is needed.\n\
|
)
|
||||||
\n\
|
.await;
|
||||||
— OxiCloud, {now}\n",
|
let text_body = self
|
||||||
ttl = self.magic_link_cfg.login_ttl_minutes,
|
.render_bilingual("server.magic_link.email.login.body", &locale, &login_args)
|
||||||
link = link,
|
.await;
|
||||||
now = Utc::now().to_rfc3339(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let message = EmailMessage {
|
let message = EmailMessage {
|
||||||
to: user.email().to_string(),
|
to: user.email().to_string(),
|
||||||
@@ -498,6 +510,60 @@ impl MagicLinkInviteService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a translation, falling back to the literal key on any
|
||||||
|
/// lookup error. Identical to the handler-side helper — kept inline
|
||||||
|
/// here because the service layer can't pull in a UI util module
|
||||||
|
/// without a circular dependency.
|
||||||
|
async fn i18n_or(&self, key: &str, locale: &Locale, args: &[(&str, &str)]) -> String {
|
||||||
|
self.i18n
|
||||||
|
.translate_args(key, Some(locale.clone()), args)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| key.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an email body and, when the resolved locale isn't
|
||||||
|
/// English, append the English translation below a divider. This
|
||||||
|
/// is the "always readable" safety net: when locale inheritance
|
||||||
|
/// guesses wrong (PR 9 invitation flow) or `preferred_locale` is
|
||||||
|
/// stale, the recipient still has the English text to fall back
|
||||||
|
/// on. English-locale recipients get a single block — the partial
|
||||||
|
/// emits no divider in that case.
|
||||||
|
async fn render_bilingual(
|
||||||
|
&self,
|
||||||
|
body_key: &str,
|
||||||
|
locale: &Locale,
|
||||||
|
args: &[(&str, &str)],
|
||||||
|
) -> String {
|
||||||
|
let body = self.i18n_or(body_key, locale, args).await;
|
||||||
|
let english_fallback = if locale.is_english() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
// Resolve the English copy through the same interpolation
|
||||||
|
// path so placeholder values are substituted identically.
|
||||||
|
// PR-A's English-fallback inside the I18nService means the
|
||||||
|
// resolution is reliable even if a translator hasn't
|
||||||
|
// populated the English copy yet — defensive default in
|
||||||
|
// both layers.
|
||||||
|
Some(self.i18n_or(body_key, &Locale::english(), args).await)
|
||||||
|
};
|
||||||
|
let divider = self
|
||||||
|
.i18n_or(
|
||||||
|
"server.magic_link.email.english_fallback_divider",
|
||||||
|
locale,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let template = BilingualEmailBody {
|
||||||
|
body: body.clone(),
|
||||||
|
divider,
|
||||||
|
english_fallback,
|
||||||
|
};
|
||||||
|
// `.render()` only fails on programmer error (template field
|
||||||
|
// out of sync). Fall back to the raw body so we still send
|
||||||
|
// *something* if the divider partial breaks.
|
||||||
|
template.render().unwrap_or(body)
|
||||||
|
}
|
||||||
|
|
||||||
/// Look up the resend-recipient hint for a token whose redemption
|
/// Look up the resend-recipient hint for a token whose redemption
|
||||||
/// just failed. Returns `Some` exactly when:
|
/// just failed. Returns `Some` exactly when:
|
||||||
///
|
///
|
||||||
@@ -545,6 +611,20 @@ impl MagicLinkInviteService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plain-text email body wrapper: emits the localized text, then —
|
||||||
|
/// only when the resolved locale isn't English — a divider plus the
|
||||||
|
/// English copy. Lives next to the service rather than under
|
||||||
|
/// `templates/` because the partial is just a few lines and being
|
||||||
|
/// co-located keeps the relationship between rendering code and
|
||||||
|
/// template obvious.
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "magic_link/email_body.txt")]
|
||||||
|
struct BilingualEmailBody {
|
||||||
|
body: String,
|
||||||
|
divider: String,
|
||||||
|
english_fallback: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Hint surfaced by the 410-Gone page to offer a one-click "send me a
|
/// Hint surfaced by the 410-Gone page to offer a one-click "send me a
|
||||||
/// fresh link" affordance to a recipient whose magic-link is no longer
|
/// fresh link" affordance to a recipient whose magic-link is no longer
|
||||||
/// usable. Carries the recipient's email twice: the raw form (used by
|
/// usable. Carries the recipient's email twice: the raw form (used by
|
||||||
|
|||||||
@@ -1017,6 +1017,7 @@ impl AppServiceFactory {
|
|||||||
invite_magic_link_repo,
|
invite_magic_link_repo,
|
||||||
email_sender,
|
email_sender,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
app_state.applications.i18n_service.clone(),
|
||||||
self.config.magic_link.clone(),
|
self.config.magic_link.clone(),
|
||||||
self.config.base_url(),
|
self.config.base_url(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -20,9 +20,14 @@
|
|||||||
//! - Token not found / expired / already used → 410 Gone.
|
//! - Token not found / expired / already used → 410 Gone.
|
||||||
//! - Magic-link feature disabled (no SMTP / repo) → 503.
|
//! - Magic-link feature disabled (no SMTP / repo) → 503.
|
||||||
//! - Owning user deactivated → 410 Gone.
|
//! - Owning user deactivated → 410 Gone.
|
||||||
|
//!
|
||||||
|
//! Page bodies are rendered via askama templates under
|
||||||
|
//! `templates/magic_link/`; all user-visible strings come from the
|
||||||
|
//! `server.magic_link.page.*` keys in `static/locales/`.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
@@ -35,11 +40,12 @@ use serde::Deserialize;
|
|||||||
use crate::application::services::auth_application_service::{
|
use crate::application::services::auth_application_service::{
|
||||||
MagicLinkRedeemResult, MagicLinkRedemption,
|
MagicLinkRedeemResult, MagicLinkRedemption,
|
||||||
};
|
};
|
||||||
use crate::application::services::magic_link_invite_service::ResendRecipientHint;
|
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::common::errors::ErrorKind;
|
use crate::common::errors::ErrorKind;
|
||||||
|
use crate::common::locale::Locale;
|
||||||
use crate::domain::entities::magic_link_token::MagicLinkResourceKind;
|
use crate::domain::entities::magic_link_token::MagicLinkResourceKind;
|
||||||
use crate::interfaces::api::cookie_auth;
|
use crate::interfaces::api::cookie_auth;
|
||||||
|
use crate::interfaces::middleware::locale::RequestLocale;
|
||||||
use crate::interfaces::middleware::rate_limit::extract_client_ip;
|
use crate::interfaces::middleware::rate_limit::extract_client_ip;
|
||||||
|
|
||||||
/// Build the `/magic/v1/{token}` router. Mounted at the top of the
|
/// Build the `/magic/v1/{token}` router. Mounted at the top of the
|
||||||
@@ -82,13 +88,11 @@ async fn redeem_magic_link(
|
|||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(token): Path<String>,
|
Path(token): Path<String>,
|
||||||
Query(query): Query<RedeemQuery>,
|
Query(query): Query<RedeemQuery>,
|
||||||
|
RequestLocale(locale): RequestLocale,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let Some(auth_svc) = state.auth_service.as_ref() else {
|
let Some(auth_svc) = state.auth_service.as_ref() else {
|
||||||
return error_page(
|
return service_unavailable_page(&state, &locale).await;
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Authentication subsystem is not configured.",
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// PR 22 browser binding: read the per-request challenge from the
|
// PR 22 browser binding: read the per-request challenge from the
|
||||||
@@ -118,7 +122,7 @@ async fn redeem_magic_link(
|
|||||||
build_success_response(&state, *redemption)
|
build_success_response(&state, *redemption)
|
||||||
}
|
}
|
||||||
Ok(MagicLinkRedeemResult::NeedsCrossBrowserConfirm) => {
|
Ok(MagicLinkRedeemResult::NeedsCrossBrowserConfirm) => {
|
||||||
cross_browser_confirmation_page(&token)
|
cross_browser_confirmation_page(&state, &locale, &token).await
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Log the cause for ops; the user gets a generic page so the
|
// Log the cause for ops; the user gets a generic page so the
|
||||||
@@ -130,17 +134,11 @@ async fn redeem_magic_link(
|
|||||||
error = %e.message,
|
error = %e.message,
|
||||||
);
|
);
|
||||||
match e.kind {
|
match e.kind {
|
||||||
ErrorKind::NotImplemented => error_page(
|
ErrorKind::NotImplemented => service_unavailable_page(&state, &locale).await,
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Magic-link sign-in is not enabled on this server.",
|
|
||||||
),
|
|
||||||
ErrorKind::NotFound | ErrorKind::AccessDenied => {
|
ErrorKind::NotFound | ErrorKind::AccessDenied => {
|
||||||
expired_or_used_page(&state, &token).await
|
expired_or_used_page(&state, &locale, &token).await
|
||||||
}
|
}
|
||||||
_ => error_page(
|
_ => internal_error_page(&state, &locale).await,
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"Something went wrong while signing you in. Please try again.",
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,20 +166,19 @@ async fn redeem_magic_link(
|
|||||||
async fn resend_magic_link(
|
async fn resend_magic_link(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(token): Path<String>,
|
Path(token): Path<String>,
|
||||||
|
RequestLocale(locale): RequestLocale,
|
||||||
req: axum::http::Request<axum::body::Body>,
|
req: axum::http::Request<axum::body::Body>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let confirmation = || {
|
let confirmation_state = state.clone();
|
||||||
resend_confirmation_page(
|
let confirmation_locale = locale.clone();
|
||||||
"If the sign-in link belonged to an active account, a fresh \
|
let confirmation = move || {
|
||||||
link has just been sent. Please check your inbox.",
|
let s = confirmation_state.clone();
|
||||||
)
|
let l = confirmation_locale.clone();
|
||||||
|
async move { resend_confirmation_page(&s, &l).await }
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(invite_svc) = state.magic_link_invite_service.as_ref() else {
|
let Some(invite_svc) = state.magic_link_invite_service.as_ref() else {
|
||||||
return error_page(
|
return service_unavailable_page(&state, &locale).await;
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Magic-link sign-in is not enabled on this server.",
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let client_ip = extract_client_ip(&req);
|
let client_ip = extract_client_ip(&req);
|
||||||
@@ -201,7 +198,7 @@ async fn resend_magic_link(
|
|||||||
ip = %client_ip,
|
ip = %client_ip,
|
||||||
"Per-IP rate limit exceeded on /magic/v1/{{token}}/resend"
|
"Per-IP rate limit exceeded on /magic/v1/{{token}}/resend"
|
||||||
);
|
);
|
||||||
return confirmation();
|
return confirmation().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let hint = match invite_svc.lookup_resend_recipient(&token).await {
|
let hint = match invite_svc.lookup_resend_recipient(&token).await {
|
||||||
@@ -209,7 +206,7 @@ async fn resend_magic_link(
|
|||||||
_ => {
|
_ => {
|
||||||
// Unknown / pending / deactivated — uniform response so the
|
// Unknown / pending / deactivated — uniform response so the
|
||||||
// outcome is not an oracle for "is this a known token".
|
// outcome is not an oracle for "is this a known token".
|
||||||
return confirmation();
|
return confirmation().await;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -228,7 +225,7 @@ async fn resend_magic_link(
|
|||||||
ip = %client_ip,
|
ip = %client_ip,
|
||||||
"Per-target-email rate limit exceeded on /magic/v1/{{token}}/resend"
|
"Per-target-email rate limit exceeded on /magic/v1/{{token}}/resend"
|
||||||
);
|
);
|
||||||
return confirmation();
|
return confirmation().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let challenge = cookie_auth::generate_magic_request_challenge();
|
let challenge = cookie_auth::generate_magic_request_challenge();
|
||||||
@@ -244,102 +241,256 @@ async fn resend_magic_link(
|
|||||||
error = %e.message,
|
error = %e.message,
|
||||||
"Resend dispatch failed for an unexpected reason"
|
"Resend dispatch failed for an unexpected reason"
|
||||||
);
|
);
|
||||||
return error_page(
|
return resend_failure_page(&state, &locale).await;
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"Something went wrong while sending the link. Please try again.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut response = confirmation();
|
let mut response = confirmation().await;
|
||||||
cookie_auth::append_magic_request_cookie(response.headers_mut(), &challenge, login_ttl_secs);
|
cookie_auth::append_magic_request_cookie(response.headers_mut(), &challenge, login_ttl_secs);
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plain HTML confirmation rendered after the resend button is clicked.
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
/// Same shape on every outcome — see [`resend_magic_link`] for why.
|
// Template structs
|
||||||
fn resend_confirmation_page(message: &str) -> Response {
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
let body = format!(
|
//
|
||||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>OxiCloud</title>\
|
// Each user-visible page maps to one askama-derived struct. The strings
|
||||||
<style>body{{font-family:system-ui,sans-serif;max-width:560px;margin:6em auto;\
|
// they hold are already-resolved translations — the templates themselves
|
||||||
padding:0 1em;color:#333;line-height:1.5}}h1{{font-size:1.4em}}\
|
// are pure layout (HTML structure + escaping), no conditional locale
|
||||||
.muted{{color:#666;font-size:.9em;margin-top:2em}}</style>\
|
// logic. That keeps the template language minimal and pushes all i18n
|
||||||
</head><body>\
|
// concerns to the call site, where we already have async + an
|
||||||
<h1>Check your inbox</h1>\
|
// I18nApplicationService handle.
|
||||||
<p>{}</p>\
|
|
||||||
<p class=\"muted\"><a href=\"/\">Return to OxiCloud</a></p>\
|
#[derive(Template)]
|
||||||
</body></html>",
|
#[template(path = "magic_link/page_expired_or_used.html")]
|
||||||
html_escape(message)
|
struct ExpiredOrUsedTemplate {
|
||||||
);
|
locale_code: String,
|
||||||
let mut response = (StatusCode::OK, body).into_response();
|
title: String,
|
||||||
response.headers_mut().insert(
|
body: String,
|
||||||
CONTENT_TYPE,
|
return_link: String,
|
||||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
/// `Some` when the row was recoverable (status = expired or used,
|
||||||
);
|
/// owning user still active) and we want to render the resend
|
||||||
response
|
/// button. `None` for unknown/pending/deactivated tokens — the page
|
||||||
|
/// then matches the generic shape, no oracle.
|
||||||
|
resend: Option<ResendOffer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the 410-Gone landing for a token that's either expired,
|
struct ResendOffer {
|
||||||
/// already used, or unknown. When the token belongs to an active user
|
action_url: String,
|
||||||
/// (i.e. the redemption failed because the row exists but is past its
|
button_label: String,
|
||||||
/// useful state), the page carries a one-click "send a fresh link"
|
}
|
||||||
/// form pre-targeted at the recipient's masked email. When the token
|
|
||||||
/// is unknown, the user is deactivated, or any plumbing is missing,
|
#[derive(Template)]
|
||||||
/// the page falls back to the existing generic message — by design
|
#[template(path = "magic_link/page_cross_browser_confirm.html")]
|
||||||
/// the two responses are indistinguishable to the caller.
|
struct CrossBrowserConfirmTemplate {
|
||||||
async fn expired_or_used_page(state: &Arc<AppState>, token: &str) -> Response {
|
locale_code: String,
|
||||||
let generic = || {
|
title: String,
|
||||||
error_page(
|
body: String,
|
||||||
StatusCode::GONE,
|
warning: String,
|
||||||
"This sign-in link is no longer valid. It may have already been \
|
confirm_url: String,
|
||||||
used or expired. Request a fresh link from the login page.",
|
continue_label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "magic_link/page_resend_confirmation.html")]
|
||||||
|
struct ResendConfirmationTemplate {
|
||||||
|
locale_code: String,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
return_link: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "magic_link/page_generic_error.html")]
|
||||||
|
struct GenericErrorTemplate {
|
||||||
|
locale_code: String,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
return_link: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Page builders
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// Helpers that resolve the user-visible strings via i18n, instantiate
|
||||||
|
// the template, and wrap the rendered HTML in an axum Response with
|
||||||
|
// the right status + Content-Type. The status is the caller's choice,
|
||||||
|
// the locale comes from the `RequestLocale` extractor.
|
||||||
|
|
||||||
|
/// Pre-resolve the strings shared across nearly every page (the title
|
||||||
|
/// fallback and "Return to OxiCloud" footer link). Keeps every builder
|
||||||
|
/// terse.
|
||||||
|
async fn translate(state: &Arc<AppState>, locale: &Locale, key: &str) -> String {
|
||||||
|
state
|
||||||
|
.applications
|
||||||
|
.i18n_service
|
||||||
|
.translate(key, Some(locale.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| key.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn translate_args(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
locale: &Locale,
|
||||||
|
key: &str,
|
||||||
|
args: &[(&str, &str)],
|
||||||
|
) -> String {
|
||||||
|
state
|
||||||
|
.applications
|
||||||
|
.i18n_service
|
||||||
|
.translate_args(key, Some(locale.clone()), args)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| key.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn expired_or_used_page(state: &Arc<AppState>, locale: &Locale, token: &str) -> Response {
|
||||||
|
let hint = match state.magic_link_invite_service.as_ref() {
|
||||||
|
Some(svc) => svc.lookup_resend_recipient(token).await.ok().flatten(),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (title, body, resend) = if let Some(hint) = hint {
|
||||||
|
(
|
||||||
|
translate(state, locale, "server.magic_link.page.expired_title").await,
|
||||||
|
translate(state, locale, "server.magic_link.page.expired_body").await,
|
||||||
|
Some(ResendOffer {
|
||||||
|
action_url: format!("/magic/v1/{}/resend", token),
|
||||||
|
button_label: translate_args(
|
||||||
|
state,
|
||||||
|
locale,
|
||||||
|
"server.magic_link.page.resend_to",
|
||||||
|
&[("email", &hint.masked_email)],
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Generic "no longer valid" page — the body conveys both
|
||||||
|
// outcomes (expired or used) in one sentence to defeat the
|
||||||
|
// oracle.
|
||||||
|
(
|
||||||
|
translate(state, locale, "server.magic_link.page.expired_title").await,
|
||||||
|
translate(state, locale, "server.magic_link.page.generic_unavailable").await,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(invite_svc) = state.magic_link_invite_service.as_ref() else {
|
let template = ExpiredOrUsedTemplate {
|
||||||
return generic();
|
locale_code: locale.as_str().to_string(),
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
return_link: translate(state, locale, "server.magic_link.page.return_link").await,
|
||||||
|
resend,
|
||||||
};
|
};
|
||||||
let hint = match invite_svc.lookup_resend_recipient(token).await {
|
render(StatusCode::GONE, template)
|
||||||
Ok(Some(h)) => h,
|
|
||||||
_ => return generic(),
|
|
||||||
};
|
|
||||||
resend_offer_page(token, &hint)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HTML body for the enriched 410 page: explains the outcome and
|
async fn cross_browser_confirmation_page(
|
||||||
/// offers a single-button form posting to `POST /magic/v1/{token}/resend`.
|
state: &Arc<AppState>,
|
||||||
/// Plain HTML — no JavaScript, no external assets — so it works the
|
locale: &Locale,
|
||||||
/// same in any mail-client embedded browser. The form action is the
|
token: &str,
|
||||||
/// only place the token round-trips; the masked email is the only
|
) -> Response {
|
||||||
/// thing rendered, so screenshots / shoulder-surfing don't expose the
|
let template = CrossBrowserConfirmTemplate {
|
||||||
/// full address.
|
locale_code: locale.as_str().to_string(),
|
||||||
fn resend_offer_page(token: &str, hint: &ResendRecipientHint) -> Response {
|
title: translate(state, locale, "server.magic_link.page.cross_browser_title").await,
|
||||||
let action = format!("/magic/v1/{}/resend", html_escape(token));
|
body: translate(state, locale, "server.magic_link.page.cross_browser_body").await,
|
||||||
let masked = html_escape(&hint.masked_email);
|
warning: translate(
|
||||||
let body = format!(
|
state,
|
||||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>OxiCloud</title>\
|
locale,
|
||||||
<style>body{{font-family:system-ui,sans-serif;max-width:560px;margin:6em auto;\
|
"server.magic_link.page.cross_browser_warning",
|
||||||
padding:0 1em;color:#333;line-height:1.5}}h1{{font-size:1.4em}}\
|
)
|
||||||
.btn{{display:inline-block;padding:.7em 1.4em;background:#2563eb;color:#fff;\
|
.await,
|
||||||
border-radius:6px;text-decoration:none;font-weight:600;margin-top:.4em;\
|
confirm_url: format!("/magic/v1/{}?confirm=1", token),
|
||||||
border:0;cursor:pointer;font-size:1em}}.btn:hover{{background:#1d4ed8}}\
|
continue_label: translate(
|
||||||
.muted{{color:#666;font-size:.9em;margin-top:2em}}</style>\
|
state,
|
||||||
</head><body>\
|
locale,
|
||||||
<h1>This sign-in link is no longer valid</h1>\
|
"server.magic_link.page.cross_browser_continue",
|
||||||
<p>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.</p>\
|
.await,
|
||||||
<form method=\"post\" action=\"{action}\">\
|
};
|
||||||
<button type=\"submit\" class=\"btn\">Send a fresh link to {masked}</button>\
|
render(StatusCode::OK, template)
|
||||||
</form>\
|
}
|
||||||
<p class=\"muted\"><a href=\"/\">Return to OxiCloud</a></p>\
|
|
||||||
</body></html>"
|
async fn resend_confirmation_page(state: &Arc<AppState>, locale: &Locale) -> Response {
|
||||||
);
|
let template = ResendConfirmationTemplate {
|
||||||
let mut response = (StatusCode::GONE, body).into_response();
|
locale_code: locale.as_str().to_string(),
|
||||||
|
title: translate(
|
||||||
|
state,
|
||||||
|
locale,
|
||||||
|
"server.magic_link.page.resend_confirmation_title",
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
body: translate(
|
||||||
|
state,
|
||||||
|
locale,
|
||||||
|
"server.magic_link.page.resend_confirmation_body",
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
return_link: translate(state, locale, "server.magic_link.page.return_link").await,
|
||||||
|
};
|
||||||
|
render(StatusCode::OK, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn service_unavailable_page(state: &Arc<AppState>, locale: &Locale) -> Response {
|
||||||
|
let template = GenericErrorTemplate {
|
||||||
|
locale_code: locale.as_str().to_string(),
|
||||||
|
title: translate(state, locale, "server.magic_link.page.expired_title").await,
|
||||||
|
body: translate(state, locale, "server.magic_link.page.service_unavailable").await,
|
||||||
|
return_link: translate(state, locale, "server.magic_link.page.return_link").await,
|
||||||
|
};
|
||||||
|
render(StatusCode::SERVICE_UNAVAILABLE, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn internal_error_page(state: &Arc<AppState>, locale: &Locale) -> Response {
|
||||||
|
let template = GenericErrorTemplate {
|
||||||
|
locale_code: locale.as_str().to_string(),
|
||||||
|
title: translate(state, locale, "server.magic_link.page.expired_title").await,
|
||||||
|
body: translate(state, locale, "server.magic_link.page.internal_error").await,
|
||||||
|
return_link: translate(state, locale, "server.magic_link.page.return_link").await,
|
||||||
|
};
|
||||||
|
render(StatusCode::INTERNAL_SERVER_ERROR, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resend_failure_page(state: &Arc<AppState>, locale: &Locale) -> Response {
|
||||||
|
let template = GenericErrorTemplate {
|
||||||
|
locale_code: locale.as_str().to_string(),
|
||||||
|
title: translate(state, locale, "server.magic_link.page.expired_title").await,
|
||||||
|
body: translate(state, locale, "server.magic_link.page.resend_failure").await,
|
||||||
|
return_link: translate(state, locale, "server.magic_link.page.return_link").await,
|
||||||
|
};
|
||||||
|
render(StatusCode::INTERNAL_SERVER_ERROR, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an askama template into a UTF-8 HTML response with the given
|
||||||
|
/// status. Template render failures only happen when a hand-edited
|
||||||
|
/// template references a field that doesn't exist on the struct, which
|
||||||
|
/// would have failed at compile time — but we still log + return a
|
||||||
|
/// minimal fallback rather than panic in production.
|
||||||
|
fn render<T: Template>(status: StatusCode, template: T) -> Response {
|
||||||
|
match template.render() {
|
||||||
|
Ok(body) => {
|
||||||
|
let mut response = (status, body).into_response();
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
CONTENT_TYPE,
|
CONTENT_TYPE,
|
||||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||||
);
|
);
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "audit",
|
||||||
|
event = "magic_link.template_render_failed",
|
||||||
|
error = %e,
|
||||||
|
"askama render failed — template definition out of sync with caller"
|
||||||
|
);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Internal error rendering page.",
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption) -> Response {
|
fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption) -> Response {
|
||||||
let target = redirect_target(&redemption);
|
let target = redirect_target(&redemption);
|
||||||
@@ -361,46 +512,6 @@ fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption
|
|||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the cross-browser confirmation page (PR 22). Shown when the
|
|
||||||
/// magic-link token carries a `request_challenge` (login-via-email)
|
|
||||||
/// but the inbound cookie didn't match — typically because the user
|
|
||||||
/// requested the link from one browser and clicked it from another
|
|
||||||
/// (phone vs desktop, work vs personal). The Continue button submits
|
|
||||||
/// back to the same endpoint with `?confirm=1` so the service skips
|
|
||||||
/// the challenge check and proceeds with redemption. Audit-logged at
|
|
||||||
/// `magic_link.redeemed reason="cross_browser_confirmed"`.
|
|
||||||
fn cross_browser_confirmation_page(token: &str) -> Response {
|
|
||||||
let confirm_url = format!("/magic/v1/{}?confirm=1", html_escape(token));
|
|
||||||
let body = format!(
|
|
||||||
"<!doctype html><html><head><meta charset=\"utf-8\">\
|
|
||||||
<title>Sign in — OxiCloud</title>\
|
|
||||||
<style>body{{font-family:system-ui,sans-serif;max-width:520px;margin:6em auto;\
|
|
||||||
padding:0 1em;color:#333;line-height:1.5}}\
|
|
||||||
h1{{font-size:1.4em}}.btn{{display:inline-block;padding:.7em 1.4em;\
|
|
||||||
background:#2563eb;color:#fff;border-radius:6px;text-decoration:none;\
|
|
||||||
font-weight:600;margin-top:1em}}.btn:hover{{background:#1d4ed8}}\
|
|
||||||
.note{{background:#fef3c7;border-left:3px solid #f59e0b;\
|
|
||||||
padding:.75em 1em;margin:1.5em 0;border-radius:4px;font-size:.95em}}</style>\
|
|
||||||
</head><body>\
|
|
||||||
<h1>Continue signing in on this device?</h1>\
|
|
||||||
<p>You opened this sign-in link in a different browser or device than \
|
|
||||||
the one where you requested it.</p>\
|
|
||||||
<p class=\"note\">If <strong>you</strong> requested this link, it's safe to continue. \
|
|
||||||
If you didn't request it, close this page — clicking Continue would sign \
|
|
||||||
someone else into your account.</p>\
|
|
||||||
<p><a class=\"btn\" href=\"{confirm_url}\">Continue and sign in</a></p>\
|
|
||||||
</body></html>",
|
|
||||||
confirm_url = confirm_url,
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut response = (StatusCode::OK, body).into_response();
|
|
||||||
response.headers_mut().insert(
|
|
||||||
CONTENT_TYPE,
|
|
||||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
|
||||||
);
|
|
||||||
response
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the SPA hash-route the redemption should land on. Mirrors the
|
/// Build the SPA hash-route the redemption should land on. Mirrors the
|
||||||
/// front-end's `deserializeHash()` parser at `static/js/app/main.js`.
|
/// front-end's `deserializeHash()` parser at `static/js/app/main.js`.
|
||||||
///
|
///
|
||||||
@@ -422,29 +533,3 @@ fn redirect_target(redemption: &MagicLinkRedemption) -> String {
|
|||||||
_ => "/#/files".to_string(),
|
_ => "/#/files".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn error_page(status: StatusCode, message: &str) -> Response {
|
|
||||||
let body = format!(
|
|
||||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>OxiCloud</title>\
|
|
||||||
<style>body{{font-family:system-ui,sans-serif;max-width:640px;margin:6em auto;\
|
|
||||||
padding:0 1em;color:#333}}h1{{font-size:1.4em}}p{{line-height:1.5}}</style>\
|
|
||||||
</head><body><h1>Sign-in link</h1><p>{}</p>\
|
|
||||||
<p><a href=\"/\">Return to OxiCloud</a></p></body></html>",
|
|
||||||
html_escape(message)
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut response = (status, body).into_response();
|
|
||||||
response.headers_mut().insert(
|
|
||||||
CONTENT_TYPE,
|
|
||||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
|
||||||
);
|
|
||||||
response
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tiny HTML escape — only used in the error fallback page. Anything more
|
|
||||||
/// elaborate belongs in a templating layer (not in scope here).
|
|
||||||
fn html_escape(s: &str) -> String {
|
|
||||||
s.replace('&', "&")
|
|
||||||
.replace('<', "<")
|
|
||||||
.replace('>', ">")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{ body }}
|
||||||
|
{%- if let Some(en) = english_fallback %}
|
||||||
|
|
||||||
|
{{ divider }}
|
||||||
|
|
||||||
|
{{ en }}
|
||||||
|
{%- endif %}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="{{ locale_code }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>OxiCloud</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 520px; margin: 6em auto;
|
||||||
|
padding: 0 1em; color: #333; line-height: 1.5; }
|
||||||
|
h1 { font-size: 1.4em; }
|
||||||
|
.btn { display: inline-block; padding: .7em 1.4em; background: #2563eb; color: #fff;
|
||||||
|
border-radius: 6px; text-decoration: none; font-weight: 600; margin-top: 1em; }
|
||||||
|
.btn:hover { background: #1d4ed8; }
|
||||||
|
.note { background: #fef3c7; border-left: 3px solid #f59e0b;
|
||||||
|
padding: .75em 1em; margin: 1.5em 0; border-radius: 4px; font-size: .95em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<p>{{ body }}</p>
|
||||||
|
<p class="note">{{ warning }}</p>
|
||||||
|
<p><a class="btn" href="{{ confirm_url }}">{{ continue_label }}</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="{{ locale_code }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>OxiCloud</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 560px; margin: 6em auto;
|
||||||
|
padding: 0 1em; color: #333; line-height: 1.5; }
|
||||||
|
h1 { font-size: 1.4em; }
|
||||||
|
.btn { display: inline-block; padding: .7em 1.4em; background: #2563eb; color: #fff;
|
||||||
|
border-radius: 6px; text-decoration: none; font-weight: 600; margin-top: .4em;
|
||||||
|
border: 0; cursor: pointer; font-size: 1em; }
|
||||||
|
.btn:hover { background: #1d4ed8; }
|
||||||
|
.muted { color: #666; font-size: .9em; margin-top: 2em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
{%- if let Some(offer) = resend %}
|
||||||
|
<p>{{ body }}</p>
|
||||||
|
<form method="post" action="{{ offer.action_url }}">
|
||||||
|
<button type="submit" class="btn">{{ offer.button_label }}</button>
|
||||||
|
</form>
|
||||||
|
{%- else %}
|
||||||
|
<p>{{ body }}</p>
|
||||||
|
{%- endif %}
|
||||||
|
<p class="muted"><a href="/">{{ return_link }}</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="{{ locale_code }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>OxiCloud</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 6em auto;
|
||||||
|
padding: 0 1em; color: #333; }
|
||||||
|
h1 { font-size: 1.4em; }
|
||||||
|
p { line-height: 1.5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<p>{{ body }}</p>
|
||||||
|
<p><a href="/">{{ return_link }}</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="{{ locale_code }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>OxiCloud</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 560px; margin: 6em auto;
|
||||||
|
padding: 0 1em; color: #333; line-height: 1.5; }
|
||||||
|
h1 { font-size: 1.4em; }
|
||||||
|
.muted { color: #666; font-size: .9em; margin-top: 2em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<p>{{ body }}</p>
|
||||||
|
<p class="muted"><a href="/">{{ return_link }}</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user