From 854f1d3a071bad5c5021b767533eb621baab22d8 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 3 Jun 2026 13:35:51 +0200 Subject: [PATCH] feat(templating): add and use templates for /magic and emails --- Cargo.lock | 78 ++++ Cargo.toml | 1 + .../services/i18n_application_service.rs | 14 + .../services/magic_link_invite_service.rs | 164 +++++-- src/common/di.rs | 1 + .../api/handlers/magic_link_handler.rs | 433 +++++++++++------- templates/magic_link/email_body.txt | 7 + .../page_cross_browser_confirm.html | 23 + .../magic_link/page_expired_or_used.html | 29 ++ templates/magic_link/page_generic_error.html | 18 + .../magic_link/page_resend_confirmation.html | 18 + 11 files changed, 570 insertions(+), 216 deletions(-) create mode 100644 templates/magic_link/email_body.txt create mode 100644 templates/magic_link/page_cross_browser_confirm.html create mode 100644 templates/magic_link/page_expired_or_used.html create mode 100644 templates/magic_link/page_generic_error.html create mode 100644 templates/magic_link/page_resend_confirmation.html diff --git a/Cargo.lock b/Cargo.lock index aa8a8acc..19b57a59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index f4236311..6843402e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = [] diff --git a/src/application/services/i18n_application_service.rs b/src/application/services/i18n_application_service.rs index 93d91123..6d3a15b2 100644 --- a/src/application/services/i18n_application_service.rs +++ b/src/application/services/i18n_application_service.rs @@ -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, + args: &[(&str, &str)], + ) -> I18nResult { + 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 diff --git a/src/application/services/magic_link_invite_service.rs b/src/application/services/magic_link_invite_service.rs index 10175a3f..4d5a851c 100644 --- a/src/application/services/magic_link_invite_service.rs +++ b/src/application/services/magic_link_invite_service.rs @@ -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, email_sender: Arc, user_lifecycle: Arc, + i18n: Arc, 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, magic_link_repo: Arc, email_sender: Arc, user_lifecycle: Arc, + i18n: Arc, 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, +} + /// 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 diff --git a/src/common/di.rs b/src/common/di.rs index 9efdf7df..d5f71326 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -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(), ), diff --git a/src/interfaces/api/handlers/magic_link_handler.rs b/src/interfaces/api/handlers/magic_link_handler.rs index 6d5608e8..25889219 100644 --- a/src/interfaces/api/handlers/magic_link_handler.rs +++ b/src/interfaces/api/handlers/magic_link_handler.rs @@ -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>, Path(token): Path, Query(query): Query, + 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>, Path(token): Path, + RequestLocale(locale): RequestLocale, req: axum::http::Request, ) -> 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!( - "OxiCloud\ - \ - \ -

Check your inbox

\ -

{}

\ -

Return to OxiCloud

\ - ", - 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, } -/// 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, 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, 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, + 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, 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!( - "OxiCloud\ - \ - \ -

This sign-in link is no longer valid

\ -

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.

\ -
\ - \ -
\ -

Return to OxiCloud

\ - " - ); - 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, + 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, 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, 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, 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, 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(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, redemption: MagicLinkRedemption) -> Response { @@ -361,46 +512,6 @@ fn build_success_response(state: &Arc, 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!( - "\ - Sign in — OxiCloud\ - \ - \ -

Continue signing in on this device?

\ -

You opened this sign-in link in a different browser or device than \ - the one where you requested it.

\ -

If you 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.

\ -

Continue and sign in

\ - ", - 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!( - "OxiCloud\ - \ -

Sign-in link

{}

\ -

Return to OxiCloud

", - 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('>', ">") -} diff --git a/templates/magic_link/email_body.txt b/templates/magic_link/email_body.txt new file mode 100644 index 00000000..bd16502b --- /dev/null +++ b/templates/magic_link/email_body.txt @@ -0,0 +1,7 @@ +{{ body }} +{%- if let Some(en) = english_fallback %} + +{{ divider }} + +{{ en }} +{%- endif %} diff --git a/templates/magic_link/page_cross_browser_confirm.html b/templates/magic_link/page_cross_browser_confirm.html new file mode 100644 index 00000000..a5f22bf3 --- /dev/null +++ b/templates/magic_link/page_cross_browser_confirm.html @@ -0,0 +1,23 @@ + + + + + OxiCloud + + + +

{{ title }}

+

{{ body }}

+

{{ warning }}

+

{{ continue_label }}

+ + diff --git a/templates/magic_link/page_expired_or_used.html b/templates/magic_link/page_expired_or_used.html new file mode 100644 index 00000000..8dd60553 --- /dev/null +++ b/templates/magic_link/page_expired_or_used.html @@ -0,0 +1,29 @@ + + + + + OxiCloud + + + +

{{ title }}

+ {%- if let Some(offer) = resend %} +

{{ body }}

+
+ +
+ {%- else %} +

{{ body }}

+ {%- endif %} +

{{ return_link }}

+ + diff --git a/templates/magic_link/page_generic_error.html b/templates/magic_link/page_generic_error.html new file mode 100644 index 00000000..48d21986 --- /dev/null +++ b/templates/magic_link/page_generic_error.html @@ -0,0 +1,18 @@ + + + + + OxiCloud + + + +

{{ title }}

+

{{ body }}

+

{{ return_link }}

+ + diff --git a/templates/magic_link/page_resend_confirmation.html b/templates/magic_link/page_resend_confirmation.html new file mode 100644 index 00000000..b3ceeb29 --- /dev/null +++ b/templates/magic_link/page_resend_confirmation.html @@ -0,0 +1,18 @@ + + + + + OxiCloud + + + +

{{ title }}

+

{{ body }}

+

{{ return_link }}

+ +