//! E-4 invite landing page (AC-2.5), server-rendered HTML with the copy of SPEC §13.3 "Invite landing page". //! //! Swedish when `Accept-Language` prefers `sv`, English otherwise. The page shows the app name, "Download Kvitt" //! linking to the latest APK, "Open in Kvitt" linking to `kvitt://invite?server=…&code=…`, the code in groups of four //! and one instruction sentence. It never checks the code and holds no personal data of its own: the optional //! `from` query parameter (the inviting admin's display name, set by E-12's link) is only passed on into the //! `kvitt://invite` link, never shown. Any code of 1 to 32 characters gets 200; anything else a 404 page. use axum::extract::{Path, RawQuery, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; use crate::AppState; use crate::api::invites::FROM_PARAM; use crate::api::{apk, validate}; /// Longest code the page accepts (E-4). pub const MAX_CODE_CHARS: usize = 32; /// The page language. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Lang { En, Sv, } /// The copy of SPEC §13.3 "Invite landing page". struct Copy { html_lang: &'static str, app_name: &'static str, download: &'static str, open: &'static str, instruction: &'static str, } const EN: Copy = Copy { html_lang: "en", app_name: "Kvitt", download: "Download Kvitt", open: "Open in Kvitt", instruction: "Install Kvitt, then open this link again on your phone.", }; const SV: Copy = Copy { html_lang: "sv", app_name: "Kvitt", download: "Ladda ner Kvitt", open: "Öppna i Kvitt", instruction: "Installera Kvitt och öppna sedan den här länken igen på din telefon.", }; /// Swedish if the highest-weighted language range of `Accept-Language` is `sv` (or `sv-*`); English otherwise. /// Among equal weights the first listed wins; `*` and ranges with `q=0` never select Swedish. pub fn preferred_lang(accept_language: Option<&str>) -> Lang { let Some(header) = accept_language else { return Lang::En; }; let mut best: Option<(f32, &str)> = None; for part in header.split(',') { let mut pieces = part.split(';'); let tag = pieces.next().unwrap_or("").trim(); if tag.is_empty() { continue; } let q = pieces .find_map(|p| p.trim().strip_prefix("q=")) .and_then(|q| q.trim().parse::().ok()) .unwrap_or(1.0); if q <= 0.0 { continue; } if best.is_none_or(|(best_q, _)| q > best_q) { best = Some((q, tag)); } } match best { Some((_, tag)) => { let primary = tag.split('-').next().unwrap_or(""); if primary.eq_ignore_ascii_case("sv") { Lang::Sv } else { Lang::En } } None => Lang::En, } } fn escape(text: &str) -> String { let mut out = String::with_capacity(text.len()); for c in text.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), other => out.push(other), } } out } /// The code as the page shows and links it: uppercase, without `-` and spaces. fn clean_code(code: &str) -> String { code.chars() .filter(|c| *c != '-' && *c != ' ') .flat_map(char::to_uppercase) .collect() } fn grouped(code: &str) -> String { let chars: Vec = code.chars().collect(); chars .chunks(4) .map(|c| c.iter().collect::()) .collect::>() .join("-") } const STYLE: &str = "body{font-family:system-ui,sans-serif;max-width:28rem;margin:3rem auto;padding:0 1rem;\ text-align:center;color:#1b1b1b;background:#fff}h1{font-size:2rem}.code{font-family:monospace;font-size:1.5rem;\ letter-spacing:.1em;margin:1.5rem 0}a.button{display:block;margin:.75rem 0;padding:.9rem;border-radius:.75rem;\ text-decoration:none;font-weight:600;background:#2e7d32;color:#fff}a.secondary{background:#e8f5e9;color:#1b5e20}\ @media (prefers-color-scheme:dark){body{background:#121212;color:#eee}a.secondary{background:#1e3320;color:#c8e6c9}}"; /// The `from` value of a raw query string: the first `from=` pair, `+` as space, percent-decoded as UTF-8, trimmed. /// `None` unless it is a display name of §9.6 (1 to 40 code points, no control characters); a bad value is dropped, /// never an error, so the page still opens. pub fn from_param(raw_query: Option<&str>) -> Option { let value = raw_query? .split('&') .find_map(|pair| pair.strip_prefix(FROM_PARAM)?.strip_prefix('='))?; let decoded = percent_decode_str(&value.replace('+', " ")) .decode_utf8() .ok()? .trim() .to_owned(); let length = decoded.chars().count(); ((1..=validate::DISPLAY_NAME_MAX).contains(&length) && !decoded.chars().any(char::is_control)) .then_some(decoded) } /// The landing page HTML (AC-2.5). `download_url` is the latest APK, when one is published; `from` the inviting /// admin's name, passed on into the `kvitt://invite` link only. pub fn render( lang: Lang, code: &str, public_url: &str, download_url: Option<&str>, from: Option<&str>, ) -> String { let copy = match lang { Lang::En => EN, Lang::Sv => SV, }; let code = clean_code(code); let mut open_url = format!( "kvitt://invite?server={}&code={}", utf8_percent_encode(public_url, NON_ALPHANUMERIC), utf8_percent_encode(&code, NON_ALPHANUMERIC) ); if let Some(name) = from { open_url.push_str(&format!( "&{FROM_PARAM}={}", utf8_percent_encode(name, NON_ALPHANUMERIC) )); } let download = download_url .map(|url| { format!( "{}\n", escape(url), copy.download ) }) .unwrap_or_default(); format!( "\n\n\n\n\ \n\n\ {app}\n\n\n\n

{app}

\n

{instruction}

\n\

{code}

\n{download}{open_label}\n\n\n", lang = copy.html_lang, app = copy.app_name, instruction = copy.instruction, code = escape(&grouped(&code)), open = escape(&open_url), open_label = copy.open, ) } const NOT_FOUND_HTML: &str = "\n\n\n\nKvitt\n\n\ \n

Kvitt

\n

404

\n\n\n"; fn html(status: StatusCode, body: String) -> Response { let mut response = (status, body).into_response(); let headers = response.headers_mut(); headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"), ); // The URL carries an invite code: keep it out of caches and referrers. headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); headers.insert( header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"), ); response } #[utoipa::path( get, path = "/i/{code}", operation_id = "getInviteLanding", summary = "E-4 Invite landing page (HTML)", description = "HTML per AC-2.5 for any `code` of 1 to 32 characters; validity is not checked. Swedish when `Accept-Language` prefers `sv`, English otherwise.", security(()), params( ("code" = String, Path), ("from" = Option, Query, description = "The inviting admin's display name (E-12 link), passed on into the `kvitt://invite` link; an invalid value is ignored."), ), responses( (status = 200, description = "Landing page.", body = String, content_type = "text/html; charset=utf-8"), (status = 404, description = "`code` not 1 to 32 characters: an HTML 404 page.", body = String, content_type = "text/html; charset=utf-8"), ) )] pub async fn get_invite_landing( State(state): State, headers: HeaderMap, Path(code): Path, RawQuery(query): RawQuery, ) -> Response { let len = code.chars().count(); if !(1..=MAX_CODE_CHARS).contains(&len) { return html(StatusCode::NOT_FOUND, NOT_FOUND_HTML.to_owned()); } let lang = preferred_lang( headers .get(header::ACCEPT_LANGUAGE) .and_then(|v| v.to_str().ok()), ); let download = match apk::read_latest(&state).await { Ok(latest) => { latest.map(|l| format!("{}/apk/files/{}", state.config.public_url, l.file_name)) } Err(e) => { tracing::warn!(error_code = e.code.as_str(), "landing page: no APK link"); None } }; html( StatusCode::OK, render( lang, &code, &state.config.public_url, download.as_deref(), from_param(query.as_deref()).as_deref(), ), ) } #[cfg(test)] mod tests { use super::*; #[test] fn language_preference() { assert_eq!(preferred_lang(None), Lang::En); assert_eq!(preferred_lang(Some("sv-SE,sv;q=0.9,en;q=0.8")), Lang::Sv); assert_eq!(preferred_lang(Some("en-GB,sv;q=0.9")), Lang::En); assert_eq!(preferred_lang(Some("de;q=0.5, sv;q=0.7")), Lang::Sv); assert_eq!(preferred_lang(Some("sv;q=0, en")), Lang::En); assert_eq!(preferred_lang(Some("*")), Lang::En); assert_eq!(preferred_lang(Some("SV")), Lang::Sv); } #[test] fn html_is_escaped() { let page = render(Lang::En, "", "https://x", None, Some("Å")); assert!(page.contains("<B>")); assert!(!page.contains("")); assert!(page.contains("&from=%3Ci%3E%C3%85\""), "{page}"); assert!(!page.contains("")); } #[test] fn from_parameter() { assert_eq!(from_param(None), None); assert_eq!(from_param(Some("from=Jacob")).as_deref(), Some("Jacob")); assert_eq!( from_param(Some("x=1&from=%C3%85sa+Berg")).as_deref(), Some("Åsa Berg") ); assert_eq!(from_param(Some("from=")), None); assert_eq!(from_param(Some("from=%20%20")), None); assert_eq!(from_param(Some("from=%FF")), None, "not UTF-8"); assert_eq!(from_param(Some("from=a%0Ab")), None, "control character"); assert_eq!(from_param(Some(&format!("from={}", "a".repeat(41)))), None); assert_eq!( from_param(Some(&format!("from={}", "å".repeat(40)))).map(|n| n.chars().count()), Some(40) ); assert_eq!(from_param(Some("fromage=1")), None); } }