Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
20 kB · 505 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506//! What a link to a player unfurls as.//!//! `/profile/<handle-or-did>` is a page the site builds once and the browser//! fills in, which is right for a reader and useless to a card service: an//! unfurler runs no JavaScript, so one shell would preview as the same//! generic card for every player on the site.//!//! So the distribution splits them by who is asking. A person's request for//! that address is served from the bucket; a known card fetcher's is sent//! here, to a document that says who the address is about and points at a//! picture drawn for them. Nothing here is what a person reads - the page//! they land on is still the site's - which is why this file has none of the//! profile in it, only the tags and the card.//!//! The other reason to keep the split: this is the only place a profile costs//! a network round trip of ours. A reader's page resolves the identity from//! their own browser, against the account's own PDS.
use axum::extract::{Path, State};use axum::http::{HeaderMap, HeaderValue, StatusCode, header};use axum::response::{Html, IntoResponse, Response};
use crate::card;use crate::routes::AppState;use crate::share::{TAGLINE, escape};
/// How long a preview is allowed to stand.////// A minute, where a match report gets a day. A report is about something/// that finished and cannot change; this is about an account as it is right/// now - a match count that moves with every game, a pattern count that moves/// whenever somebody paints one, a handle that can change hands. A card/// cached for an hour is an hour of previews that disagree with the page they/// point at.////// It bounds our own edge and nothing else: a card service snapshots what it/// fetched when the post was made, and no header of ours reaches back into/// somebody else's timeline to update it.const CACHING: &str = "public, max-age=60";
/// What the card and the tags both need.struct Player { /// "@handle", or the DID where no handle resolves back to it. What the /// card prints and what the tags say. name: String, /// The same handle without its @, or None. What the address is written /// with: `/profile/@alice.example` carries one @ and not two. handle: Option<String>, did: String, /// The account's own picture, as it uploaded it. None where it has none. avatar: Option<Vec<u8>>, /// Camo published, or None where the repository could not be read. A /// figure that could not be read is drawn as an em dash rather than as a /// zero: "none" and "we could not tell" are different things to say /// about somebody. camo: Option<usize>, /// Matches by our own record of them. None where the row could not be /// counted, for the same reason camo's is optional. matches: Option<i64>,}
/// An em dash: what a figure nothing records yet is drawn as, here and on the/// page. Two of the four are this today.const NOTHING: &str = "\u{2014}";
impl Player { /// How the player stands, read against itself. /// /// Neither has a source yet and both are declared anyway, the way the /// pilot card declares its own placeholders: the shape of the card is /// what is being decided, and one that showed only what is easy to count /// would be the wrong shape. See plan/leaderboard.md for the rating and /// plan/match-records.md for the attribution a kill ratio needs. fn scores(&self) -> Vec<(String, String)> { vec![ ("Rating".to_owned(), NOTHING.to_owned()), ("K/D".to_owned(), NOTHING.to_owned()), ] }
/// What the player has done, counted. Read one at a time. fn activity(&self) -> Vec<(String, String)> { vec![ ( "Matches".to_owned(), self.matches .map_or_else(|| NOTHING.to_owned(), |n| n.to_string()), ), ("Forces".to_owned(), NOTHING.to_owned()), // Camo, the way the rest of the site says it: the masthead's // link, the account menu's "Your camo", the editor. A card that // called it something else would be the only place on the site // that did. ( "Camo".to_owned(), self.camo .map_or_else(|| NOTHING.to_owned(), |n| n.to_string()), ), ] }
/// What the card is handed, however it is going to be drawn. fn drawn(&self) -> card::Player<'_> { card::Player { name: &self.name, avatar: self.avatar.clone(), scores: self.scores(), activity: self.activity(), // Nothing writes a bio, nothing awards a medal and nothing says // what faction anybody flies for. The card leaves room and draws // none of them. bio: None, medals: 0, faction: false, } }}
impl Player { /// How the site writes this account's address: the handle with its @, or /// the DID where no handle resolves back to it. /// /// The same rule `web/src/destinations.ts` follows, and it has to be the /// same rule: this is the address a card advertises as its own, so a /// reader who follows the post and a reader already on the page are /// looking at one URL rather than two spellings of one. /// /// Not percent-encoded. An @ and a colon are both legal in a path /// segment, every client sends them as typed, and `%3A` in an address a /// card carries is a URL nobody can read out loud. fn address(&self) -> &str { self.handle.as_deref().unwrap_or(&self.did) }
/// The address of the page this is a preview of. fn here(&self, origin: &str) -> String { format!("{origin}/profile/{}", self.address()) }
/// The picture's own address. /// /// Under this prefix and not under the page's: `/profile/...` is the /// site's, and the distribution answers all of it from the bucket - a /// card asked for there would come back as the shell's HTML. /// /// The DID rather than the handle, alone among the addresses here. This /// one is nobody's to read, and keying the picture on the account rather /// than on the name it currently goes by means a rename does not strand /// the card a post is already carrying. fn card(&self, origin: &str) -> String { format!("{origin}/unfurl/profile/{}/card.png", self.did) }}
/// Resolve the address, or answer that nobody is there.async fn look_up(state: &AppState, actor: &str) -> Option<Player> { let found = state.atproto.resolve_actor(actor).await?; Some(Player { name: match &found.handle { Some(handle) => format!("@{handle}"), None => found.did.clone(), }, handle: found.handle.clone(), camo: state.atproto.camo_count(&found.did).await, matches: state.db.match_count(&found.did).await.ok(), avatar: None, did: found.did, })}
/// The same, with the picture. Only the card needs the bytes, and they are/// the largest thing either route fetches.async fn look_up_with_face(state: &AppState, actor: &str) -> Option<Player> { let mut player = look_up(state, actor).await?; player.avatar = state.atproto.avatar(&player.did).await; Some(player)}
/// Where the site is published, which is not this service's own address.fn site(state: &AppState) -> String { state .share_origin .clone() .unwrap_or_else(|| state.web_origin.clone())}
/// The preview document for a player.pub async fn player(State(state): State<AppState>, Path(actor): Path<String>) -> Response { let origin = site(&state); let Some(player) = look_up(&state, &actor).await else { // Nobody by that name. A 404 rather than a card, so a post carrying a // dead link previews as a bare link instead of as somebody's page. return ( StatusCode::NOT_FOUND, headers("text/html; charset=utf-8", "public, max-age=60"), Html(missing(&origin)), ) .into_response(); };
let here = player.here(&origin); let image = player.card(&origin); ( headers("text/html; charset=utf-8", CACHING), Html(document(&player, &here, &image)), ) .into_response()}
/// The picture that preview points at.pub async fn player_card(State(state): State<AppState>, Path(actor): Path<String>) -> Response { let Some(player) = look_up_with_face(&state, &actor).await else { return ( StatusCode::NOT_FOUND, headers("text/plain", "public, max-age=60"), ) .into_response(); };
let Some(bytes) = card::draw_player(&player.drawn()) else { tracing::error!(did = player.did, "player card: nothing drawn"); return ( StatusCode::INTERNAL_SERVER_ERROR, headers("text/plain", "no-store"), ) .into_response(); };
(headers("image/png", CACHING), bytes).into_response()}
fn headers(content_type: &'static str, caching: &'static str) -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); headers.insert(header::CACHE_CONTROL, HeaderValue::from_static(caching)); headers}
/// The document itself: the tags, and one line for whoever is not a fetcher.fn document(player: &Player, here: &str, image: &str) -> String { let name = escape(&player.name); // The description is the site's own line, the same one a match report's // card carries. A reader seeing a player's card in a feed has usually // never heard of lance.blue, and what the site is is more use to them // than a second reading of the handle printed above it. // // It says nothing about the player on purpose: the two things that would // - a rank and a bio - have no source yet, and a card that invented // either would be telling a stranger something untrue. Ranks are coming // and will be earned rather than rated (plan/achievements.md), and they // go in the title and on the card itself when they do. let blurb = escape(TAGLINE); format!( "<!doctype html>\n<html lang=\"en\">\n<head>\n\ <meta charset=\"utf-8\">\n\ <title>{name} - lance.blue</title>\n\ <meta name=\"description\" content=\"{blurb}\">\n\ <meta property=\"og:type\" content=\"profile\">\n\ <meta property=\"og:site_name\" content=\"lance.blue\">\n\ <meta property=\"og:url\" content=\"{here}\">\n\ <meta property=\"og:title\" content=\"{name}\">\n\ <meta property=\"og:description\" content=\"{blurb}\">\n\ <meta property=\"og:image\" content=\"{image}\">\n\ <meta property=\"og:image:alt\" content=\"{name} on lance.blue.\">\n\ <meta name=\"twitter:card\" content=\"summary_large_image\">\n\ <meta name=\"twitter:image\" content=\"{image}\">\n\ <link rel=\"canonical\" href=\"{here}\">\n\ </head>\n<body>\n\ <p><a href=\"{here}\">{name}</a></p>\n\ </body>\n</html>\n", name = name, blurb = blurb, here = escape(here), image = escape(image), )}
/// Nobody by that name, for a fetcher.fn missing(origin: &str) -> String { format!( "<!doctype html>\n<html lang=\"en\">\n<head>\n\ <meta charset=\"utf-8\">\n<title>No such player - lance.blue</title>\n\ </head>\n<body>\n<p><a href=\"{origin}\">lance.blue</a></p>\n</body>\n</html>\n", origin = escape(origin), )}
#[cfg(test)]mod tests { use super::*;
fn player() -> Player { Player { name: "@a.example".to_owned(), handle: Some("a.example".to_owned()), did: "did:plc:abc123".to_owned(), avatar: None, camo: Some(8), matches: Some(12), } }
/// The two addresses in the document are not the same address, and the /// difference is the whole design: the tags point a reader at the site's /// page, and the picture at this service, because the site's prefix is /// answered from a bucket that has no card in it. #[test] fn the_page_names_the_site_and_the_card_names_this_service() { let player = player(); let origin = "https://lance.blue"; assert_eq!(player.here(origin), "https://lance.blue/profile/a.example"); assert_eq!( player.card(origin), "https://lance.blue/unfurl/profile/did:plc:abc123/card.png" ); }
/// What an unfurler actually reads. #[test] fn the_tags_are_about_the_player() { let player = player(); let origin = "https://lance.blue"; let html = document(&player, &player.here(origin), &player.card(origin)); assert!(html.contains("<meta property=\"og:title\" content=\"@a.example\">")); assert!(html.contains( "<meta property=\"og:url\" content=\"https://lance.blue/profile/a.example\">" )); assert!(html.contains( "<meta property=\"og:image\" content=\"https://lance.blue/unfurl/profile/did:plc:abc123/card.png\">" )); assert!(html.contains("<meta name=\"twitter:card\" content=\"summary_large_image\">")); // The canonical address a crawler routed here should index is the // page's, never this document's own. assert!( html.contains("<link rel=\"canonical\" href=\"https://lance.blue/profile/a.example\">") ); }
/// The description is the site's line, and it is the one thing on the /// card that must not become filler again: it is under every player's /// handle in somebody else's timeline. #[test] fn the_description_says_what_the_site_is() { let player = player(); let html = document( &player, "https://lance.blue/profile/a.example", "https://x/c.png", ); assert!(html.contains(&format!( "<meta property=\"og:description\" content=\"{TAGLINE}\">" ))); // Nothing about the player: a rank and a bio are what would go here // and neither has a source yet. assert!(!TAGLINE.contains("a.example")); }
/// A handle is a domain somebody else chose, and it reaches this document /// from a DID document written by whoever holds it. #[test] fn a_handle_cannot_write_markup() { let hostile = Player { name: "@\"><script>alert(1)</script>".to_owned(), handle: None, did: "did:plc:abc123".to_owned(), avatar: None, camo: None, matches: None, }; let html = document( &hostile, "https://lance.blue/profile/x", "https://lance.blue/c.png", ); assert!(!html.contains("<script>")); assert!(html.contains("<script>")); }
/// An account with no handle anybody can confirm is the DID, everywhere: /// on the card, in the tags, and in the address they both name. #[test] fn an_unverified_handle_is_never_shown() { let bare = Player { name: "did:plc:abc123".to_owned(), handle: None, did: "did:plc:abc123".to_owned(), avatar: None, camo: None, matches: None, }; let html = document( &bare, &bare.here("https://lance.blue"), "https://lance.blue/c.png", ); assert!(html.contains("<meta property=\"og:title\" content=\"did:plc:abc123\">")); }
/// Written out when this test runs with LANCE_CARD_OUT set, so a change /// to the drawing can be looked at rather than only asserted about. fn keep(bytes: &[u8]) { keep_as("LANCE_CARD_OUT", bytes); }
fn keep_as(variable: &str, bytes: &[u8]) { if let Ok(path) = std::env::var(variable) { std::fs::write(path, bytes).expect("card written"); } }
/// The card is drawn from type alone where it has to be, so it is drawn /// for anybody - an account with no picture, no handle and no figures /// included. #[test] fn a_card_is_drawn_for_an_account_with_nothing_on_it() { let bare = Player { name: "did:plc:abc123".to_owned(), handle: None, did: "did:plc:abc123".to_owned(), avatar: None, camo: None, matches: None, }; let bytes = card::draw_player(&bare.drawn()).expect("a player card is drawn"); assert_eq!(&bytes[1..4], b"PNG"); keep(&bytes); }
/// The card, written out so a change to the drawing can be looked at /// rather than only asserted about. Writes nothing unless LANCE_CARD_DIR /// names somewhere to write. /// /// LANCE_CARD_DIR=/tmp/cards LANCE_AVATAR=avatar.jpg \ /// cargo test -p headquarters-api unfurl::tests::the_card_is_drawn /// /// Three of them: as served, with the bio and medals nothing fills yet, /// and with no picture. The middle one is what the room being held back /// is for, and the reason the shape leaves it. #[test] fn the_card_is_drawn() { let Ok(dir) = std::env::var("LANCE_CARD_DIR") else { return; }; let avatar = std::env::var("LANCE_AVATAR") .ok() .and_then(|path| std::fs::read(path).ok()); let subject = Player { name: "@permadeath.com".to_owned(), handle: Some("permadeath.com".to_owned()), did: "did:plc:nlzmjyfv6loqtxyzvdcznwgf".to_owned(), avatar, camo: Some(8), matches: Some(12), }; const BIO: &str = "Snollygoster brabble nudiustertian, absquatulate vellichor \ gongoozler mumpsimus cattywampus taradiddle skedaddle widdershins.";
for (name, card) in [ ("served", subject.drawn()), ( "filled", card::Player { bio: Some(BIO.to_owned()), medals: 5, faction: true, ..subject.drawn() }, ), ( "no-face", card::Player { avatar: None, ..subject.drawn() }, ), ] { let bytes = card::draw_player(&card).expect("a player card is drawn"); std::fs::write(format!("{dir}/card-{name}.png"), &bytes).expect("written"); } }
/// A figure that could not be read and a figure nothing records are both /// an em dash, and a zero is neither of them. #[test] fn nothing_recorded_and_nothing_read_are_both_a_dash() { let unread = Player { name: "@a.example".to_owned(), handle: Some("a.example".to_owned()), did: "did:plc:abc123".to_owned(), avatar: None, camo: None, matches: Some(0), }; let activity = unread.activity(); assert_eq!(activity[0], ("Matches".to_owned(), "0".to_owned())); assert_eq!(activity[1].1, NOTHING, "Forces has no source yet"); assert_eq!( activity[2].1, NOTHING, "an unread repository is not zero camo" ); for score in unread.scores() { assert_eq!(score.1, NOTHING, "{} has no source yet", score.0); } }}