//! The few HTML pages the server shows during sign-in. //! //! Hand-written and script-free: three small pages don't justify a template //! engine, and with no JavaScript the content security policy can forbid it //! outright. use axum::http::header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, X_FRAME_OPTIONS}; use axum::http::{HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use crate::db::PendingLogin; /// The sign-in page: a username/password form, plus a button for the /// identity provider when one is configured. `username` refills the form after /// a failed attempt, so only the password needs retyping. pub fn login( pending: &PendingLogin, oidc_name: Option<&str>, error: Option<&str>, username: &str, ) -> Response { let error = error .map(|e| format!(r#""#, esc(e))) .unwrap_or_default(); let oidc = oidc_name .map(|name| { format!( r#"Sign in with {name}
or
"#, id = esc(&pending.id), name = esc(name), ) }) .unwrap_or_default(); let body = format!( r#"

Sign in to mtgc

Signing in {device} to sync your library.

{error} {oidc}
"#, device = esc(&pending.device_name), id = esc(&pending.id), username = esc(username), focus_user = if username.is_empty() { " autofocus" } else { "" }, focus_password = if username.is_empty() { "" } else { " autofocus" }, ); page(StatusCode::OK, "Sign in", &body, "") } /// Sign-in finished: send the browser back to the app. /// /// A page rather than a bare redirect, because a phone browser may decline to /// follow a redirect into an app on its own; the button then gives the user a /// tap that it will honour. pub fn return_to_app(url: &str) -> Response { let url = esc(url); let body = format!( r#"

Returning to mtgc…

If the app doesn't open by itself, use the button below. You can close this tab afterwards.

Return to mtgc"# ); // Meta refresh rather than a 30x, so the page above is what's left showing // if the hand-off doesn't happen. let refresh = format!(r#""#); page(StatusCode::OK, "Signed in", &body, &refresh) } pub fn error(status: StatusCode, message: &str) -> Response { let body = format!( r#"

Sign-in didn't work

{}

"#, esc(message) ); page(status, "Sign-in problem", &body, "") } fn page(status: StatusCode, title: &str, body: &str, head_extra: &str) -> Response { let mut resp = (status, Html(render(title, body, head_extra))).into_response(); let h = resp.headers_mut(); h.insert( CONTENT_SECURITY_POLICY, HeaderValue::from_static("default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"), ); // The password page must never be framed by another site. h.insert(X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); // Each page embeds a single-use id or code. h.insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); resp } fn render(title: &str, body: &str, head_extra: &str) -> String { format!( r#" {head_extra} {title} · mtgc
{body}
"#, title = esc(title), ) } /// Matches the app's terracotta brand colour, light and dark. const STYLE: &str = r#" :root { --bg:#faf7f5; --card:#fff; --fg:#2b2320; --muted:#6f625c; --accent:#c8553d; --accent-fg:#fff; --line:#e6ddd8; --err:#a12d1c; color-scheme: light dark; } @media (prefers-color-scheme: dark) { :root { --bg:#1c1816; --card:#262120; --fg:#f1e9e5; --muted:#b3a59e; --line:#3d3532; --err:#ff8f7a; } } * { box-sizing: border-box; } body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, sans-serif; padding:16px; } main { width:100%; max-width:380px; background:var(--card); border:1px solid var(--line); border-radius:12px; padding:28px 24px; } h1 { font-size:1.35rem; margin:0 0 8px; } .muted { color:var(--muted); margin:0 0 20px; } .error { color:var(--err); margin:0 0 16px; } form { display:flex; flex-direction:column; gap:14px; } label { display:flex; flex-direction:column; gap:6px; font-size:.9rem; color:var(--muted); } input { font:inherit; color:var(--fg); background:var(--bg); padding:10px 12px; border:1px solid var(--line); border-radius:8px; } input:focus { outline:2px solid var(--accent); outline-offset:1px; } button, .button { display:block; width:100%; text-align:center; font:inherit; font-weight:600; padding:11px; border-radius:8px; border:1px solid var(--accent); background:var(--accent); color:var(--accent-fg); cursor:pointer; text-decoration:none; } .button.secondary { background:transparent; color:var(--accent); } .or { display:flex; align-items:center; gap:10px; color:var(--muted); font-size:.85rem; margin:18px 0; } .or::before, .or::after { content:""; flex:1; border-top:1px solid var(--line); } "#; /// Escape text for HTML content and double-quoted attributes. fn esc(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), c => out.push(c), } } out } #[cfg(test)] mod tests { use super::*; #[test] fn escaping_neutralises_markup_and_attribute_breakouts() { assert_eq!(esc(r#""x" & 'y'"#), "<b>"x" & 'y'</b>"); } }