feat(templating): add and use templates for /magic and emails

This commit is contained in:
Edouard Vanbelle
2026-06-03 13:35:51 +02:00
parent 044bd76738
commit 854f1d3a07
11 changed files with 570 additions and 216 deletions
Generated
+78
View File
@@ -154,6 +154,59 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "async-channel"
version = "1.9.0"
@@ -912,6 +965,15 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "bitflags"
version = "2.11.1"
@@ -2189,6 +2251,12 @@ dependencies = [
"weezl",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "group"
version = "0.12.1"
@@ -3720,6 +3788,7 @@ dependencies = [
"accept-language",
"aes-gcm",
"argon2",
"askama",
"async-compression",
"async-stream",
"async-trait",
@@ -6497,6 +6566,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
+1
View File
@@ -73,6 +73,7 @@ lettre = { version = "0.11.18", default-features = false, features = ["smtp-tran
idna = "1.1"
smol_str = { version = "0.3.2", features = ["serde"] }
accept-language = "3.1.0"
askama = "0.16.0"
[features]
default = []
@@ -28,6 +28,20 @@ impl I18nApplicationService {
.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
pub async fn load_translations(&self, locale: Locale) -> I18nResult<()> {
self.i18n_service.load_translations(locale).await
@@ -29,12 +29,14 @@
use std::sync::Arc;
use chrono::Utc;
use askama::Template;
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::common::config::MagicLinkConfig;
use crate::common::errors::{DomainError, ErrorKind};
use crate::common::locale::Locale;
use crate::domain::entities::magic_link_token::{
MagicLinkResourceKind, MagicLinkStatus, MagicLinkToken,
};
@@ -93,6 +95,7 @@ pub struct MagicLinkInviteService {
magic_link_repo: Arc<dyn MagicLinkTokenRepository>,
email_sender: Arc<dyn EmailSender>,
user_lifecycle: Arc<UserLifecycleService>,
i18n: Arc<I18nApplicationService>,
magic_link_cfg: MagicLinkConfig,
/// Public base URL of this OxiCloud instance — used to build the
/// `/magic/v1/{token}` invitation link. Sourced from
@@ -101,11 +104,13 @@ pub struct MagicLinkInviteService {
}
impl MagicLinkInviteService {
#[allow(clippy::too_many_arguments)]
pub fn new(
user_storage: Arc<UserPgRepository>,
magic_link_repo: Arc<dyn MagicLinkTokenRepository>,
email_sender: Arc<dyn EmailSender>,
user_lifecycle: Arc<UserLifecycleService>,
i18n: Arc<I18nApplicationService>,
magic_link_cfg: MagicLinkConfig,
public_base_url: String,
) -> Self {
@@ -114,6 +119,7 @@ impl MagicLinkInviteService {
magic_link_repo,
email_sender,
user_lifecycle,
i18n,
magic_link_cfg,
public_base_url,
}
@@ -259,30 +265,38 @@ impl MagicLinkInviteService {
token.token(),
);
let kind_label = match resource {
Resource::Folder(_) => "folder",
Resource::File(_) => "file",
let kind_key = match resource {
Resource::Folder(_) => "server.magic_link.email.kind_folder",
Resource::File(_) => "server.magic_link.email.kind_file",
};
let subject = format!(
"{} shared a {} with you on OxiCloud",
inviter_username, kind_label
);
let text_body = format!(
"{inviter} shared a {kind} with you on OxiCloud.\n\
\n\
Open it by clicking the link below:\n\
{link}\n\
\n\
The link works once and expires in {ttl} hours.\n\
If you didn't expect this invitation, you can safely ignore this message.\n\
\n\
— OxiCloud, {now}\n",
inviter = inviter_username,
kind = kind_label,
link = link,
ttl = self.magic_link_cfg.invite_ttl_hours,
now = Utc::now().to_rfc3339(),
);
// PR C will resolve the recipient's preferred_locale. For now
// (PR B) every magic-link email defaults to the server default
// locale; the bilingual partial below means non-English
// recipients still see English as a safety net.
let locale = Locale::default();
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
let ttl_hours = self.magic_link_cfg.invite_ttl_hours.to_string();
let invite_args: Vec<(&str, &str)> = vec![
("inviter", inviter_username),
("kind", &kind_label),
("link", &link),
("ttl_hours", &ttl_hours),
];
let subject = self
.i18n_or(
"server.magic_link.email.invitation.subject",
&locale,
&invite_args,
)
.await;
let text_body = self
.render_bilingual(
"server.magic_link.email.invitation.body",
&locale,
&invite_args,
)
.await;
let message = EmailMessage {
to: recipient.email().to_string(),
@@ -441,24 +455,22 @@ impl MagicLinkInviteService {
self.public_base_url.trim_end_matches('/'),
token.token(),
);
let subject = "Sign in to OxiCloud".to_string();
let text_body = format!(
"Hello,\n\
\n\
Use the link below to sign in to OxiCloud. The link works \
once and expires in {ttl} minutes. Open it on the same \
device where you requested it.\n\
\n\
{link}\n\
\n\
If you didn't request this sign-in link, you can safely \
ignore this message — no further action is needed.\n\
\n\
— OxiCloud, {now}\n",
ttl = self.magic_link_cfg.login_ttl_minutes,
link = link,
now = Utc::now().to_rfc3339(),
);
// PR C will switch to `user.preferred_locale` once the column
// lands. Today the login-via-email path uses the server default.
let locale = Locale::default();
let ttl_minutes = self.magic_link_cfg.login_ttl_minutes.to_string();
let login_args: Vec<(&str, &str)> = vec![("link", &link), ("ttl_minutes", &ttl_minutes)];
let subject = self
.i18n_or(
"server.magic_link.email.login.subject",
&locale,
&login_args,
)
.await;
let text_body = self
.render_bilingual("server.magic_link.email.login.body", &locale, &login_args)
.await;
let message = EmailMessage {
to: user.email().to_string(),
@@ -498,6 +510,60 @@ impl MagicLinkInviteService {
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
/// 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
/// fresh link" affordance to a recipient whose magic-link is no longer
/// usable. Carries the recipient's email twice: the raw form (used by
+1
View File
@@ -1017,6 +1017,7 @@ impl AppServiceFactory {
invite_magic_link_repo,
email_sender,
lifecycle,
app_state.applications.i18n_service.clone(),
self.config.magic_link.clone(),
self.config.base_url(),
),
+254 -169
View File
@@ -20,9 +20,14 @@
//! - Token not found / expired / already used → 410 Gone.
//! - Magic-link feature disabled (no SMTP / repo) → 503.
//! - 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 askama::Template;
use axum::{
Router,
extract::{Path, Query, State},
@@ -35,11 +40,12 @@ use serde::Deserialize;
use crate::application::services::auth_application_service::{
MagicLinkRedeemResult, MagicLinkRedemption,
};
use crate::application::services::magic_link_invite_service::ResendRecipientHint;
use crate::common::di::AppState;
use crate::common::errors::ErrorKind;
use crate::common::locale::Locale;
use crate::domain::entities::magic_link_token::MagicLinkResourceKind;
use crate::interfaces::api::cookie_auth;
use crate::interfaces::middleware::locale::RequestLocale;
use crate::interfaces::middleware::rate_limit::extract_client_ip;
/// 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>>,
Path(token): Path<String>,
Query(query): Query<RedeemQuery>,
RequestLocale(locale): RequestLocale,
headers: HeaderMap,
) -> Response {
let Some(auth_svc) = state.auth_service.as_ref() else {
return error_page(
StatusCode::SERVICE_UNAVAILABLE,
"Authentication subsystem is not configured.",
);
return service_unavailable_page(&state, &locale).await;
};
// 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)
}
Ok(MagicLinkRedeemResult::NeedsCrossBrowserConfirm) => {
cross_browser_confirmation_page(&token)
cross_browser_confirmation_page(&state, &locale, &token).await
}
Err(e) => {
// 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,
);
match e.kind {
ErrorKind::NotImplemented => error_page(
StatusCode::SERVICE_UNAVAILABLE,
"Magic-link sign-in is not enabled on this server.",
),
ErrorKind::NotImplemented => service_unavailable_page(&state, &locale).await,
ErrorKind::NotFound | ErrorKind::AccessDenied => {
expired_or_used_page(&state, &token).await
expired_or_used_page(&state, &locale, &token).await
}
_ => error_page(
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong while signing you in. Please try again.",
),
_ => internal_error_page(&state, &locale).await,
}
}
}
@@ -168,20 +166,19 @@ async fn redeem_magic_link(
async fn resend_magic_link(
State(state): State<Arc<AppState>>,
Path(token): Path<String>,
RequestLocale(locale): RequestLocale,
req: axum::http::Request<axum::body::Body>,
) -> Response {
let confirmation = || {
resend_confirmation_page(
"If the sign-in link belonged to an active account, a fresh \
link has just been sent. Please check your inbox.",
)
let confirmation_state = state.clone();
let confirmation_locale = locale.clone();
let confirmation = move || {
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 {
return error_page(
StatusCode::SERVICE_UNAVAILABLE,
"Magic-link sign-in is not enabled on this server.",
);
return service_unavailable_page(&state, &locale).await;
};
let client_ip = extract_client_ip(&req);
@@ -201,7 +198,7 @@ async fn resend_magic_link(
ip = %client_ip,
"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 {
@@ -209,7 +206,7 @@ async fn resend_magic_link(
_ => {
// Unknown / pending / deactivated — uniform response so the
// 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,
"Per-target-email rate limit exceeded on /magic/v1/{{token}}/resend"
);
return confirmation();
return confirmation().await;
}
let challenge = cookie_auth::generate_magic_request_challenge();
@@ -244,102 +241,256 @@ async fn resend_magic_link(
error = %e.message,
"Resend dispatch failed for an unexpected reason"
);
return error_page(
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong while sending the link. Please try again.",
);
return resend_failure_page(&state, &locale).await;
}
let mut response = confirmation();
let mut response = confirmation().await;
cookie_auth::append_magic_request_cookie(response.headers_mut(), &challenge, login_ttl_secs);
response
}
/// Plain HTML confirmation rendered after the resend button is clicked.
/// Same shape on every outcome — see [`resend_magic_link`] for why.
fn resend_confirmation_page(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: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>Check your inbox</h1>\
<p>{}</p>\
<p class=\"muted\"><a href=\"/\">Return to OxiCloud</a></p>\
</body></html>",
html_escape(message)
);
let mut response = (StatusCode::OK, body).into_response();
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
response
// ═══════════════════════════════════════════════════════════════════════════
// Template structs
// ═══════════════════════════════════════════════════════════════════════════
//
// Each user-visible page maps to one askama-derived struct. The strings
// they hold are already-resolved translations — the templates themselves
// are pure layout (HTML structure + escaping), no conditional locale
// logic. That keeps the template language minimal and pushes all i18n
// concerns to the call site, where we already have async + an
// I18nApplicationService handle.
#[derive(Template)]
#[template(path = "magic_link/page_expired_or_used.html")]
struct ExpiredOrUsedTemplate {
locale_code: String,
title: String,
body: String,
return_link: String,
/// `Some` when the row was recoverable (status = expired or used,
/// owning user still active) and we want to render the resend
/// 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,
/// already used, or unknown. When the token belongs to an active user
/// (i.e. the redemption failed because the row exists but is past its
/// 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,
/// the page falls back to the existing generic message — by design
/// the two responses are indistinguishable to the caller.
async fn expired_or_used_page(state: &Arc<AppState>, token: &str) -> Response {
let generic = || {
error_page(
StatusCode::GONE,
"This sign-in link is no longer valid. It may have already been \
used or expired. Request a fresh link from the login page.",
struct ResendOffer {
action_url: String,
button_label: String,
}
#[derive(Template)]
#[template(path = "magic_link/page_cross_browser_confirm.html")]
struct CrossBrowserConfirmTemplate {
locale_code: String,
title: String,
body: String,
warning: String,
confirm_url: String,
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 {
return generic();
let template = ExpiredOrUsedTemplate {
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 {
Ok(Some(h)) => h,
_ => return generic(),
};
resend_offer_page(token, &hint)
render(StatusCode::GONE, template)
}
/// HTML body for the enriched 410 page: explains the outcome and
/// offers a single-button form posting to `POST /magic/v1/{token}/resend`.
/// Plain HTML — no JavaScript, no external assets — so it works the
/// same in any mail-client embedded browser. The form action is the
/// only place the token round-trips; the masked email is the only
/// thing rendered, so screenshots / shoulder-surfing don't expose the
/// full address.
fn resend_offer_page(token: &str, hint: &ResendRecipientHint) -> Response {
let action = format!("/magic/v1/{}/resend", html_escape(token));
let masked = html_escape(&hint.masked_email);
let body = format!(
"<!doctype html><html><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>This sign-in link is no longer valid</h1>\
<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>\
<form method=\"post\" action=\"{action}\">\
<button type=\"submit\" class=\"btn\">Send a fresh link to {masked}</button>\
</form>\
<p class=\"muted\"><a href=\"/\">Return to OxiCloud</a></p>\
</body></html>"
);
let mut response = (StatusCode::GONE, body).into_response();
async fn cross_browser_confirmation_page(
state: &Arc<AppState>,
locale: &Locale,
token: &str,
) -> Response {
let template = CrossBrowserConfirmTemplate {
locale_code: locale.as_str().to_string(),
title: translate(state, locale, "server.magic_link.page.cross_browser_title").await,
body: translate(state, locale, "server.magic_link.page.cross_browser_body").await,
warning: translate(
state,
locale,
"server.magic_link.page.cross_browser_warning",
)
.await,
confirm_url: format!("/magic/v1/{}?confirm=1", token),
continue_label: translate(
state,
locale,
"server.magic_link.page.cross_browser_continue",
)
.await,
};
render(StatusCode::OK, template)
}
async fn resend_confirmation_page(state: &Arc<AppState>, locale: &Locale) -> Response {
let template = ResendConfirmationTemplate {
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(
CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
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 {
let target = redirect_target(&redemption);
@@ -361,46 +512,6 @@ fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption
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
/// front-end's `deserializeHash()` parser at `static/js/app/main.js`.
///
@@ -422,29 +533,3 @@ fn redirect_target(redemption: &MagicLinkRedemption) -> 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
+7
View File
@@ -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>