M12e: contract pass (pair invite kind, admin name in invite links, sync failed flag, rejection actor, device version name, template id, update check result, untouched accounts, D-027 re-stamp, PushEvent in :core:model, snackbar hosts)
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
This commit is contained in:
@@ -2,16 +2,18 @@
|
||||
//!
|
||||
//! 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.
|
||||
//! 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, State};
|
||||
use axum::extract::{Path, RawQuery, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::api::apk;
|
||||
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;
|
||||
@@ -123,18 +125,48 @@ letter-spacing:.1em;margin:1.5rem 0}a.button{display:block;margin:.75rem 0;paddi
|
||||
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 {
|
||||
/// 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 open_url = format!(
|
||||
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!(
|
||||
@@ -186,6 +218,7 @@ fn html(status: StatusCode, body: String) -> Response {
|
||||
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"),
|
||||
@@ -196,6 +229,7 @@ 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) {
|
||||
@@ -217,7 +251,13 @@ pub async fn get_invite_landing(
|
||||
};
|
||||
html(
|
||||
StatusCode::OK,
|
||||
render(lang, &code, &state.config.public_url, download.as_deref()),
|
||||
render(
|
||||
lang,
|
||||
&code,
|
||||
&state.config.public_url,
|
||||
download.as_deref(),
|
||||
from_param(query.as_deref()).as_deref(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -238,8 +278,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn html_is_escaped() {
|
||||
let page = render(Lang::En, "<b>", "https://x", None);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user