Something went wrong. Try again.
Paper Mario mod hosting site - AT Protocol appview starhaven.dev
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202//! The mods the homepage features, which whoever runs the server picks with//! `server feature`.
use anyhow::{anyhow, Context as _, Result};use sqlx::sqlite::SqliteConnectOptions;use sqlx::SqlitePool;
use crate::atproto::id::{Did, Handle, Rkey};use crate::atproto::lexicon::{ModListing, Record as _};use crate::config::Config;
/// How many mods the homepage features at most.pub const LIMIT: i64 = 5;
/// `server feature [ADDRESS...]`: features the mods at these addresses, in/// order, in place of whatever was before.pub async fn run(config: &Config, addresses: &[String]) -> Result<()> { let options = SqliteConnectOptions::new() .filename(config.database_path()) .create_if_missing(true); let db = SqlitePool::connect_with(options).await?; sqlx::migrate!().run(&db).await?;
set(&db, addresses).await?; if addresses.is_empty() { println!("The homepage features no mods."); } for (did, rkey) in list(&db).await? { let listing = ModListing::load_local(&db, &did, &rkey).await?; let title = listing.map(|listing| listing.title).unwrap_or_default(); println!("Featured {title} (at://{did}/{}/{rkey})", ModListing::NSID); } Ok(())}
/// Replaces the featured mods with the ones at `addresses`, which are their/// pages' addresses, such as `https://starhaven.dev/alice.test/master-quest`.pub async fn set(db: &SqlitePool, addresses: &[String]) -> Result<()> { let mut mods = Vec::with_capacity(addresses.len()); for address in addresses { mods.push(find(db, address).await?); }
let mut tx = db.begin().await?; sqlx::query!("DELETE FROM featured_mod") .execute(&mut *tx) .await?; for (position, (did, rkey)) in (0_i64..).zip(&mods) { sqlx::query!( "INSERT INTO featured_mod (position, mod_did, mod_rkey) VALUES (?, ?, ?)", position, did, rkey ) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(())}
/// The featured mods that are still listed, in order.pub async fn list(db: &SqlitePool) -> Result<Vec<(Did, Rkey)>> { let rows = sqlx::query!( "SELECT f.mod_did, f.mod_rkey FROM featured_mod f JOIN mod_listing l ON l.author_did = f.mod_did AND l.rkey = f.mod_rkey ORDER BY f.position LIMIT ?", LIMIT ) .fetch_all(db) .await?; Ok(rows .into_iter() .map(|row| (Did::new(row.mod_did), Rkey::from_verified(row.mod_rkey))) .collect())}
/// The mod whose page is at `address`: its last two path segments are its/// author's handle and its slug.async fn find(db: &SqlitePool, address: &str) -> Result<(Did, Rkey)> { let path = address.split_once("://").map_or(address, |(_, rest)| { rest.split_once('/').map_or("", |(_, path)| path) }); let mut segments = path.trim_matches('/').rsplit('/'); let (Some(slug), Some(handle)) = (segments.next(), segments.next()) else { return Err(anyhow!( "{address} isn't a mod's address, such as https://starhaven.dev/alice.test/master-quest" )); }; let handle = Handle::new(handle).with_context(|| format!("{address} has no handle in it"))?; let did = handle .cached_did(db) .await? .ok_or_else(|| anyhow!("No account here has the handle {handle}"))?; let (rkey, _) = ModListing::find_by_slug(db, &did, slug) .await? .ok_or_else(|| anyhow!("{handle} has no mod at {address}"))?; Ok((did, rkey))}
#[cfg(test)]mod tests { use super::*; use crate::atproto::actor; use crate::atproto::lexicon::mod_listing::{Game, Tags};
async fn db_with_mods(slugs: &[&str]) -> SqlitePool { let db = SqlitePool::connect("sqlite::memory:").await.unwrap(); sqlx::migrate!().run(&db).await.unwrap(); let did = Did::new("did:plc:lily"); actor::upsert(&db, &did, &Handle::new("lily.test").unwrap()) .await .unwrap(); for slug in slugs { let listing = ModListing { title: slug.to_string(), slug: slug.to_string(), description: String::new(), details: String::new(), details_images: vec![], game: Game::Pm64, tags: Tags::default(), license: String::new(), media: vec![], credits: vec![], created_at: chrono::Utc::now(), }; listing .save_local(&db, &did, &Rkey::from_verified(format!("r-{slug}"))) .await .unwrap(); } db }
fn rkeys(featured: &[(Did, Rkey)]) -> Vec<&str> { featured.iter().map(|(_, rkey)| rkey.as_str()).collect() }
#[tokio::test] async fn features_mods_by_address_in_the_order_given() { let db = db_with_mods(&["quest", "pit"]).await;
set( &db, &[ "https://starhaven.dev/lily.test/pit".to_string(), "lily.test/quest/".to_string(), ], ) .await .unwrap();
assert_eq!(rkeys(&list(&db).await.unwrap()), ["r-pit", "r-quest"]); }
#[tokio::test] async fn featuring_again_replaces_the_mods_featured_before() { let db = db_with_mods(&["quest", "pit"]).await;
set(&db, &["lily.test/quest".to_string()]).await.unwrap(); set(&db, &["lily.test/pit".to_string()]).await.unwrap(); assert_eq!(rkeys(&list(&db).await.unwrap()), ["r-pit"]);
set(&db, &[]).await.unwrap(); assert!(list(&db).await.unwrap().is_empty()); }
#[tokio::test] async fn an_unknown_mod_features_nothing_and_says_why() { let db = db_with_mods(&["quest"]).await; set(&db, &["lily.test/quest".to_string()]).await.unwrap();
let err = set( &db, &["lily.test/quest".to_string(), "lily.test/nope".to_string()], ) .await .unwrap_err();
assert_eq!(err.to_string(), "lily.test has no mod at lily.test/nope"); assert_eq!(rkeys(&list(&db).await.unwrap()), ["r-quest"]); }
#[tokio::test] async fn a_deleted_mod_stops_being_featured() { let db = db_with_mods(&["quest"]).await; set(&db, &["lily.test/quest".to_string()]).await.unwrap();
ModListing::delete_local( &db, &Did::new("did:plc:lily"), &Rkey::from_verified("r-quest"), ) .await .unwrap();
assert!(list(&db).await.unwrap().is_empty()); }}