Something went wrong. Try again.
Paper Mario mod hosting site - AT Protocol appview starhaven.dev
appview
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488use std::convert::Infallible;
use axum::extract::FromRequestParts;use axum::http::request::Parts;use axum::http::{header, HeaderMap};use axum::response::{Html, IntoResponse, Response};use maud::{html, Markup, PreEscaped, DOCTYPE};
use crate::atproto::lexicon::ActorProfile;use crate::markdown;use crate::oauth::session::{get_identity_from_headers, get_session_from_headers, IdentityCookie};use crate::state::AppState;use crate::transfer;
/// The Star Haven community's Discord server.const DISCORD_INVITE: &str = "https://discord.gg/PbynXak4T6";
/// Discord's logo, from Simple Icons (CC0). Lucide, which every other icon/// here comes from, has no brand logos.fn discord_icon() -> Markup { html! { svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" { path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" {} } }}
pub fn icon(icon: icondata::Icon) -> Markup { html! { svg viewBox=(icon.view_box.unwrap_or("0 0 24 24")) fill=(icon.fill.unwrap_or("none")) stroke=(icon.stroke.unwrap_or("currentColor")) stroke-width=(icon.stroke_width.unwrap_or("2")) stroke-linecap=(icon.stroke_linecap.unwrap_or("round")) stroke-linejoin=(icon.stroke_linejoin.unwrap_or("round")) aria-hidden="true" { (PreEscaped(icon.data)) } }}
/// The value of `name` in a raw (`application/x-www-form-urlencoded`) query/// string, if present.fn query_param(query: &str, name: &str) -> Option<String> { query .split('&') .find_map(|pair| pair.strip_prefix(name)?.strip_prefix('=')) .map(|value| value.replace('+', " ")) .map(|value| urlencoding::decode(&value).unwrap_or_default().into_owned())}
pub struct Page { body: Markup, title: Option<String>, has_nav: bool, identity: Option<IdentityCookie>, /// The signed-in account's avatar, for the bar's account button. avatar_url: Option<String>, /// The request path's first segment, e.g. "forum" for "/forum/123" -- /// used to highlight the matching nav tab. tab: String, /// The `q` param, when the request is for `/search` -- prefills the nav /// search box so it reflects what's actually being searched for. search_query: Option<String>, /// The `type` param, when the request is for `/search` -- carried along /// as a hidden field so a new search from the nav box keeps the current /// result type instead of resetting it to "all". search_type: Option<String>, /// Whether htmx made this request, in which case it is swapping the body /// into a document that already has the nav, styles and scripts. is_htmx: bool, /// Mods other accounts have offered to transfer to the signed-in one. transfers: Vec<transfer::Banner>, /// Whether the request asked for Markdown, as some AI agents do, in its /// `Accept` header or with `?format=markdown`. wants_markdown: bool, /// Where this page is as Markdown, whatever the request sends. markdown_url: String,}
impl Page { /// The account signed in, if any. pub fn viewer(&self) -> Option<&crate::atproto::id::Did> { self.identity.as_ref().map(|identity| &identity.did) }
pub fn body(mut self, body: Markup) -> Self { self.body = body; self }
pub fn title(mut self, title: impl Into<String>) -> Self { self.title = Some(title.into()); self }
pub fn no_nav(mut self) -> Self { self.has_nav = false; self }
/// Whether to answer with [`Self::markdown`] rather than the body. A page /// that doesn't write its own Markdown gets its body converted. pub fn wants_markdown(&self) -> bool { self.wants_markdown }
/// Answers with `markdown` in place of the page. pub fn markdown(self, markdown: String) -> Response { ( [ (header::CONTENT_TYPE, "text/markdown; charset=utf-8"), (header::VARY, "Accept"), ], format!("{}\n", markdown.trim_end()), ) .into_response() }
/// The page as HTML, or as Markdown converted from its body for a /// request that wants it. Either way it varies with `Accept`, so a cache /// keeps the two apart, and the HTML links to the Markdown for an agent /// that didn't know to ask. pub fn render(self) -> Response { if self.wants_markdown { let markdown = markdown::from_html(&self.body.0); return self.markdown(markdown); } let alternate = format!( "<{}>; rel=\"alternate\"; type=\"text/markdown\"", self.markdown_url ); let mut response = self.html(); let headers = response.headers_mut(); headers.insert(header::VARY, header::HeaderValue::from_static("Accept")); if let Ok(alternate) = header::HeaderValue::from_str(&alternate) { headers.append(header::LINK, alternate); } response }
fn html(self) -> Response { let title = match &self.title { Some(title) => format!("{title} · Star Haven"), None => "Star Haven".to_string(), };
// The title comes along so htmx can retitle the document; everything // else in the head is already there. if self.is_htmx { return Html( html! { title { (title) } (self.body) } .into_string(), ) .into_response(); }
Html( html! { (DOCTYPE) html lang="en" { head { meta charset="utf-8"; meta name="viewport" content="width=device-width"; meta name="text-scale" content="scale";
title { (title) }
link rel="icon" type="image/svg+xml" href="/assets/star.svg"; link rel="stylesheet" href="/assets/app.css"; // What app.css imports, fetched alongside it rather // than after it. link rel="preload" as="style" href="/assets/reset.css"; link rel="preload" as="style" href="/assets/tokens.css"; link rel="preload" as="style" href="/assets/base.css"; link rel="preload" as="style" href="/assets/components.css"; script src="/assets/lib/htmx.min.js" defer {} link rel="stylesheet" href="/assets/components/media_input.css"; script type="module" src="/assets/components/media_carousel.js" {} script type="module" src="/assets/components/media_input.js" {} script type="module" src="/assets/components/credits_input.js" {} script type="module" src="/assets/components/upload_dialog.js" {} script type="module" src="/assets/components/download_fields.js" {} script type="module" src="/assets/components/share_form.js" {} script type="module" src="/assets/components/avatar_form.js" {} script type="module" src="/assets/components/featured_carousel.js" {} @if cfg!(debug_assertions) { (crate::assets::reload_script()) }
meta name="description" content="Mods for Paper Mario games"; meta property="author" content="Star Haven"; meta name="keywords" content="paper mario, modding, mods, romhack, romhacking, video game"; meta property="og:site_name" content="Star Haven"; meta property="og:title" content=(title); meta property="og:type" content="website"; meta property="og:locale" content="en_US"; } body { @if self.has_nav { (self.header()) } @else { // Only a way home, for a page that's about one // thing, such as signing in. header.site-header.site-header-bare { div.site-header-bar { (logo()) } } } (transfer::render_banners(&self.transfers)) div id="page-content" { (self.body) } footer.site-footer { a href="/privacy" { "Privacy" } a href="/terms" { "Terms" } span { "Not affiliated with Nintendo." } } } } } .into_string(), ) .into_response() }}
impl Page { /// The bar every page but a bare one starts with: the logo, search, /// Discord and account, then the tabs. Phones get the tabs, Discord and /// account in [`Self::menu`] instead. fn header(&self) -> Markup { html! { header.site-header { div.site-header-bar { (logo()) (self.search_form()) a.round-button.discord-button.wide-only href=(DISCORD_INVITE) aria-label="Star Haven on Discord" { (discord_icon()) } @match &self.identity { Some(identity) => { button.round-button.account-button.wide-only popovertarget="account-menu" aria-label={ "Account: " (identity.display_name()) } { @if let Some(url) = &self.avatar_url { img src=(url) alt=""; } } div.menu id="account-menu" popover { ul { (account_links(identity)) } } } None => { a.pill-button.login-button.wide-only href="/login" { "Sign in" } } } button.round-button.menu-button.narrow-only type="button" aria-label="Menu" aria-haspopup="dialog" aria-controls="site-menu" onclick="document.getElementById('site-menu').showModal()" { (icon(icondata::LuMenu)) } } nav.site-tabs.wide-only aria-label="Main" { (self.tabs("site-tab")) } (self.menu()) } } }
/// The tabs between the site's sections, each drawn as a `class`. fn tabs(&self, class: &str) -> Markup { let tab = self.tab.as_str(); let create = match self.identity { Some(_) => "/create", None => "/login", }; let current = |active: bool| active.then_some("page"); html! { a class={ (class) " accent-mauve" } href="/" aria-current=[current(tab.is_empty() || tab == "mods")] { "Mods" } a class={ (class) " accent-blue" } href=(create) aria-current=[current(tab == "create")] { "Share a mod" } a class={ (class) " accent-green" } href="https://docs.starhaven.dev" { "Help" span aria-hidden="true" { "\u{a0}↗" } } } }
/// The search box, which sits folded up behind its button until it's /// wanted, or while it holds a search. fn search_form(&self) -> Markup { html! { form.nav-search role="search" method="get" action="/search" hx-get="/search" hx-target="#page-content" hx-swap="innerHTML" hx-push-url="true" { @if let Some(search_type) = &self.search_type { input type="hidden" name="type" value=(search_type); } input type="search" name="q" placeholder="Search" aria-label="Search" value=[self.search_query.as_deref()]; // An empty search opens the box rather than searching for // nothing. button type="submit" aria-label="Search" onclick="if (!this.form.q.value) { event.preventDefault(); this.form.q.focus(); }" { (icon(icondata::LuSearch)) } } } }
/// What a phone gets in place of the tabs and the rest of the bar. fn menu(&self) -> Markup { html! { // Clicking the backdrop, which is the dialog itself, closes it. dialog.site-menu id="site-menu" aria-label="Menu" onclick="if (event.target === this) this.close()" { div.site-menu-sheet { form method="dialog" { button.round-button type="submit" aria-label="Close menu" { (icon(icondata::LuX)) } } nav aria-label="Main" { (self.tabs("site-menu-tab")) } div.site-menu-actions { @match &self.identity { Some(identity) => { ul.site-menu-account { (account_links(identity)) } } None => { a.pill-button.login-button href="/login" { "Sign in" } } } a.pill-button.discord-button href=(DISCORD_INVITE) { (discord_icon()) "Join us on Discord" } div.site-menu-legal { a href="/privacy" { "Privacy" } a href="/terms" { "Terms" } } } } } } }}
/// Star Haven's star and name, linking home.fn logo() -> Markup { html! { a.site-logo href="/" aria-label="Star Haven" { img.site-logo-star src="/assets/star.svg" width="212" height="202" alt=""; img.site-logo-text src="/assets/logotype.svg" width="477" height="70" alt=""; } }}
/// Where the signed-in account goes from the bar: its profile, and out.fn account_links(identity: &IdentityCookie) -> Markup { html! { li { a href={ "/" (identity.display_name()) } { (icon(icondata::LuUser)) "Profile" } } li { form method="post" action="/auth/logout" { button.accent-red type="submit" { (icon(icondata::LuLogOut)) "Sign out" } } } }}
/// Extracts a `Page` pre-populated with the current session's identity, the/// nav tab matching the request path, and the mods offered to them -- take/// `page: Page` in a handler, then `.body(...)` (and any other builder calls)/// before `.render()`.////// Also where a signed-in visit deletes the originals of mods they/// transferred away, since only their own session can.impl FromRequestParts<AppState> for Page { type Rejection = Infallible;
async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result<Self, Self::Rejection> { let identity = get_identity_from_headers(&parts.headers); let is_htmx = parts.headers.contains_key("hx-request");
// From the encrypted session, not the identity cookie, which anyone // can write. let session = get_session_from_headers(&state.secrets.cookie_secret, &parts.headers); let mut transfers = Vec::new(); if let Some(session) = session { if !is_htmx { transfers = transfer::banners(state, &session.did) .await .unwrap_or_else(|err| { eprintln!("failed to load {}'s transfer offers: {err:?}", session.did); Vec::new() }); } transfer::spawn_delete_originals(state, session).await; } // Only a whole page has the bar to show it in. let avatar_url = match &identity { Some(identity) if !is_htmx => ActorProfile::load(&state.db, &identity.did) .await .map(|profile| profile.avatar_url(&identity.did, 128)) .inspect_err(|err| eprintln!("failed to load {}'s profile: {err:?}", identity.did)) .ok(), _ => None, }; let tab = parts .uri .path() .trim_start_matches('/') .split('/') .next() .unwrap_or("") .to_string();
let query = (tab == "search").then(|| parts.uri.query()).flatten(); let search_query = query.and_then(|query| query_param(query, "q")); let search_type = query.and_then(|query| query_param(query, "type"));
Ok(Self { body: PreEscaped(String::new()), title: None, has_nav: true, identity, avatar_url, tab, search_query, search_type, is_htmx, transfers, wants_markdown: accepts_markdown(&parts.headers) || parts .uri .query() .and_then(|query| query_param(query, "format")) == Some("markdown".to_string()), markdown_url: match parts.uri.query() { Some(query) => format!("{}?{query}&format=markdown", parts.uri.path()), None => format!("{}?format=markdown", parts.uri.path()), }, }) }}
/// Whether `headers` accept Markdown at all. Browsers never list it, so a/// request that does is from something that would rather read Markdown.pub fn accepts_markdown(headers: &HeaderMap) -> bool { headers .get_all(header::ACCEPT) .iter() .filter_map(|value| value.to_str().ok()) .flat_map(|value| value.split(',')) .any(|range| { let mut params = range.split(';').map(str::trim); params.next() == Some("text/markdown") && !params.any(|param| matches!(param, "q=0" | "q=0.0" | "q=0.00" | "q=0.000")) })}
#[cfg(test)]mod tests { use super::*;
fn accept(value: &str) -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert(header::ACCEPT, value.parse().unwrap()); headers }
#[test] fn markdown_is_wanted_only_when_accepted() { assert!(accepts_markdown(&accept("text/markdown"))); assert!(accepts_markdown(&accept("text/markdown;q=0.9, text/html"))); assert!(!accepts_markdown(&accept("text/markdown;q=0, text/html"))); assert!(!accepts_markdown(&accept( "text/html,application/xhtml+xml,*/*;q=0.8" ))); assert!(!accepts_markdown(&HeaderMap::new())); }}