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
+259 -174
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,101 +241,255 @@ 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();
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
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 {
@@ -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;")
}