8c607153c0
Server: E-3 invite_kind; E-12 link ?from=<admin name>, E-4 passes it into kvitt://invite; X-Kvitt-App-Version-Name stored in devices.app_version_name (migration 0003, schema 3); OpenAPI and examples follow. Phone: :core:model, :core:fakes, :data, features and :app updated together; goldens re-recorded for S-20 (version name) and new S-1/S-22 states. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
308 lines
11 KiB
Rust
308 lines
11 KiB
Rust
//! 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::<f32>().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<char> = code.chars().collect();
|
|
chars
|
|
.chunks(4)
|
|
.map(|c| c.iter().collect::<String>())
|
|
.collect::<Vec<_>>()
|
|
.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<String> {
|
|
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!(
|
|
"<a class=\"button secondary\" href=\"{}\">{}</a>\n",
|
|
escape(url),
|
|
copy.download
|
|
)
|
|
})
|
|
.unwrap_or_default();
|
|
format!(
|
|
"<!doctype html>\n<html lang=\"{lang}\">\n<head>\n<meta charset=\"utf-8\">\n\
|
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n\
|
|
<title>{app}</title>\n<style>{STYLE}</style>\n</head>\n<body>\n<h1>{app}</h1>\n<p>{instruction}</p>\n\
|
|
<p class=\"code\">{code}</p>\n{download}<a class=\"button\" href=\"{open}\">{open_label}</a>\n</body>\n</html>\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 = "<!doctype html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>Kvitt</title>\n</head>\n\
|
|
<body>\n<h1>Kvitt</h1>\n<p>404</p>\n</body>\n</html>\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<String>, 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<AppState>,
|
|
headers: HeaderMap,
|
|
Path(code): Path<String>,
|
|
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, "<b>", "https://x", None, Some("<i>Å"));
|
|
assert!(page.contains("<B>"));
|
|
assert!(!page.contains("<b>"));
|
|
assert!(page.contains("&from=%3Ci%3E%C3%85\""), "{page}");
|
|
assert!(!page.contains("<i>"));
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|