M3: server core API (E-3 to E-30, E-33), landing page, pictures, rate limiting, recurring generation, bootstrap and pair-device

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 22:00:58 +02:00
parent 6dce4c73a9
commit 7869ec48d8
38 changed files with 7623 additions and 141 deletions
+226 -7
View File
@@ -1,10 +1,181 @@
//! E-4 invite landing page (AC-2.5).
//! E-4 invite landing page (AC-2.5), server-rendered HTML with the copy of SPEC §13.3 "Invite landing page".
//!
//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501
//! `not_implemented`).
//! 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. Any code of 1 to 32
//! characters gets 200; anything else a 404 page.
use crate::api::not_implemented;
use crate::error::AppError;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use crate::AppState;
use crate::api::apk;
/// 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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
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 landing page HTML (AC-2.5). `download_url` is the latest APK, when one is published.
pub fn render(lang: Lang, code: &str, public_url: &str, download_url: Option<&str>) -> String {
let copy = match lang {
Lang::En => EN,
Lang::Sv => SV,
};
let code = clean_code(code);
let open_url = format!(
"kvitt://invite?server={}&code={}",
utf8_percent_encode(public_url, NON_ALPHANUMERIC),
utf8_percent_encode(&code, 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,
@@ -21,6 +192,54 @@ use crate::error::AppError;
(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() -> AppError {
not_implemented()
pub async fn get_invite_landing(
State(state): State<AppState>,
headers: HeaderMap,
Path(code): Path<String>,
) -> 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()),
)
}
#[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);
assert!(page.contains("&lt;B&gt;"));
assert!(!page.contains("<b>"));
}
}