//! What a link to a player unfurls as. //! //! `/profile/` 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, did: String, /// The account's own picture, as it uploaded it. None where it has none. avatar: Option>, /// 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, /// 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, } /// 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 { 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 { 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, Path(actor): Path) -> 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, Path(actor): Path) -> 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!( "\n\n\n\ \n\ {name} - lance.blue\n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\ \n\n\

{name}

\n\ \n\n", name = name, blurb = blurb, here = escape(here), image = escape(image), ) } /// Nobody by that name, for a fetcher. fn missing(origin: &str) -> String { format!( "\n\n\n\ \nNo such player - lance.blue\n\ \n\n

lance.blue

\n\n\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("")); assert!(html.contains( "" )); assert!(html.contains( "" )); assert!(html.contains("")); // The canonical address a crawler routed here should index is the // page's, never this document's own. assert!( html.contains("") ); } /// 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!( "" ))); // 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: "@\">".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("