diff --git a/Cargo.lock b/Cargo.lock index 31afabe..32c0d07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,6 +186,26 @@ dependencies = [ "ulid", ] +[[package]] +name = "atproto-record" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a83494a80a9add1c3678e821f92153a4b5d139c0b6898bbf5eb97501cf2359" +dependencies = [ + "anyhow", + "atproto-dasl", + "atproto-identity", + "base64", + "chrono", + "cid", + "multihash", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.19", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -2752,6 +2772,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -2827,6 +2848,7 @@ dependencies = [ "bitflags", "byteorder", "bytes", + "chrono", "crc", "digest 0.10.7", "dotenvy", @@ -2868,6 +2890,7 @@ dependencies = [ "base64", "bitflags", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -2902,6 +2925,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -2933,6 +2957,7 @@ dependencies = [ "anyhow", "atproto-identity", "atproto-oauth", + "atproto-record", "axum", "base64", "chrono", diff --git a/Cargo.toml b/Cargo.toml index c299806..c7fbd6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,9 +21,9 @@ debug = "full" [dependencies] axum = "0.8" maud = { version = "0.27", features = ["axum"] } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } tokio-stream = { version = "0.1", features = ["sync"] } -sqlx = { version = "0.8", features = [ "runtime-tokio", "sqlite", "migrate" ] } +sqlx = { version = "0.8", features = [ "runtime-tokio", "sqlite", "migrate", "chrono" ] } atproto-identity = "0.14.5" atproto-oauth = "0.14.5" serde = { version = "1", features = ["derive"] } @@ -43,3 +43,4 @@ clap = { version = "4.6.6", default-features = false, features = ["std", "derive tower-livereload = "0.10" rust-embed = { version = "8", features = ["mime-guess"] } notify-debouncer-mini = "0.7" +atproto-record = "0.14.5" diff --git a/migrations/20260808000000_atproto_mods.sql b/migrations/20260808000000_atproto_mods.sql new file mode 100644 index 0000000..5859c03 --- /dev/null +++ b/migrations/20260808000000_atproto_mods.sql @@ -0,0 +1,41 @@ +-- Reshape the mod tables around atproto records. +-- Tables named after a lexicon are singular. Plural ones are the appview's own. + +DROP TABLE IF EXISTS mod_authors; +DROP TABLE IF EXISTS authors; +DROP TABLE IF EXISTS mod_group_relations; +DROP TABLE IF EXISTS mods; + +-- Handle cache +CREATE TABLE IF NOT EXISTS actors +( + did TEXT PRIMARY KEY NOT NULL, + handle TEXT NOT NULL, + + -- Re-resolve past some age: a handle can change without us seeing the + -- identity event that says so. + resolved_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS actors_handle ON actors (handle); + +-- dev.starhaven.mod.listing +CREATE TABLE IF NOT EXISTS mod_listing +( + author_did TEXT NOT NULL, + rkey TEXT NOT NULL, + + title TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT NOT NULL, + details TEXT NOT NULL DEFAULT '', + game TEXT NOT NULL, + category TEXT NOT NULL DEFAULT '', + license TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '[]', -- Maybe a join table later + media TEXT NOT NULL DEFAULT '[]', + created_at DATETIME NOT NULL, + + PRIMARY KEY (author_did, rkey) +); +CREATE INDEX IF NOT EXISTS mod_listing_slug ON mod_listing (author_did, slug, rkey); +CREATE INDEX IF NOT EXISTS mod_listing_game ON mod_listing (game); diff --git a/src/assets.rs b/src/assets.rs index a548403..b174021 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -57,8 +57,8 @@ mod reload { Debouncer, }; use std::{ - collections::HashMap, collections::hash_map::DefaultHasher, + collections::HashMap, hash::{Hash, Hasher}, path::{Path, PathBuf}, sync::Mutex, diff --git a/src/atproto/actor.rs b/src/atproto/actor.rs new file mode 100644 index 0000000..220e296 --- /dev/null +++ b/src/atproto/actor.rs @@ -0,0 +1,287 @@ +//! Cache of the handle behind each DID. +//! +//! Records identify their author by DID, but URLs and bylines are handles, so +//! every page needs the mapping in one direction or the other. Handles are +//! also mutable: a row is only true as of `resolved_at`, and a URL carrying a +//! stale handle should redirect to the current one rather than 404. + +use std::time::{Duration, Instant}; + +use anyhow::Result; +use atproto_identity::model::Document; +use atproto_identity::resolve::{resolve_subject, IdentityResolver, SharedIdentityResolver}; +use sqlx::{Row, SqlitePool}; + +use crate::atproto::id::{Did, Handle}; +use crate::state::AppState; + +/// Record the handle a DID currently resolves to. +pub async fn upsert(db: &SqlitePool, did: &Did, handle: &Handle) -> Result<()> { + sqlx::query( + "INSERT INTO actors (did, handle, resolved_at) + VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ON CONFLICT (did) DO UPDATE SET + handle = excluded.handle, + resolved_at = excluded.resolved_at", + ) + .bind(did.as_str()) + .bind(handle.as_str()) + .execute(db) + .await?; + + Ok(()) +} + +impl Did { + /// The handle to show for this DID, resolving over the network on a miss. + /// + /// None means the DID has no handle we can verify, so the caller should + /// fall back to showing the DID itself. + pub async fn resolve_handle(&self, state: &AppState) -> Result> { + if let Some(handle) = self.cached_handle(&state.db).await? { + return Ok(Some(handle)); + } + if recently_missed(state, self.as_str()) { + return Ok(None); + } + + let Some(handle) = verified_handle(&state.identity_resolver, self).await? else { + remember_miss(state, self.as_str()); + return Ok(None); + }; + + upsert(&state.db, self, &handle).await?; + Ok(Some(handle)) + } + + /// The handle last seen for this DID, without resolving. + async fn cached_handle(&self, db: &SqlitePool) -> Result> { + let row = sqlx::query("SELECT handle FROM actors WHERE did = ?") + .bind(self.as_str()) + .fetch_optional(db) + .await?; + + Ok(row.map(|row| row.get("handle"))) + } +} + +impl Handle { + /// The DID this handle belongs to, resolving over the network on a miss. + /// + /// Returns None for a handle that does not resolve or fails verification, + /// both of which a route should treat as a 404. + pub async fn resolve_did(&self, state: &AppState) -> Result> { + if let Some(did) = self.cached_did(&state.db).await? { + return Ok(Some(did)); + } + if recently_missed(state, self.as_str()) { + return Ok(None); + } + + let document = match state.identity_resolver.resolve(self.as_str()).await { + Ok(document) => document, + // A handle nobody owns is an ordinary 404, not a server error. + Err(_) => { + remember_miss(state, self.as_str()); + return Ok(None); + } + }; + + // Anyone can point a DNS record at someone else's DID, so a handle only + // counts if the DID claims it back. + if !claims_handle(&document, self) { + remember_miss(state, self.as_str()); + return Ok(None); + } + + let did = Did::new(document.id); + upsert(&state.db, &did, self).await?; + Ok(Some(did)) + } + + /// The DID this handle was last seen to belong to. + async fn cached_did(&self, db: &SqlitePool) -> Result> { + // rowid as a tie-break: resolved_at is millisecond precision, and two + // upserts landing in the same millisecond is common enough (two + // requests racing to resolve the same handle) that ties are not rare. + // rowid only moves on insert, not update, but a tie here means two + // distinct DIDs (two distinct rows), so it reflects insertion order. + let row = sqlx::query( + "SELECT did FROM actors WHERE handle = ? + ORDER BY resolved_at DESC, rowid DESC LIMIT 1", + ) + .bind(self.as_str()) + .fetch_optional(db) + .await?; + + Ok(row.map(|row| row.get("did"))) + } +} + +/// How long a handle or DID that failed to resolve is remembered as a miss. +const NEGATIVE_TTL: Duration = Duration::from_secs(5 * 60); + +/// Cap on remembered misses, so enumerating subjects cannot grow the map. +const MAX_MISSES: usize = 10_000; + +/// The handle a DID currently owns, verified in both directions. +/// +/// `alsoKnownAs` is self-asserted - anyone can claim any handle in their own +/// DID document - so the handle has to resolve back to the same DID. +pub async fn verified_handle( + resolver: &SharedIdentityResolver, + did: &Did, +) -> Result> { + let Ok(document) = resolver.resolve(did.as_str()).await else { + return Ok(None); + }; + let Some(handle) = document.handles().and_then(Handle::from_aka) else { + return Ok(None); + }; + + match resolve_subject( + &resolver.http_client, + &*resolver.dns_resolver, + handle.as_str(), + ) + .await + { + Ok(resolved) if resolved == did.as_str() => Ok(Some(handle)), + _ => Ok(None), + } +} + +/// Whether a DID document lists `handle` among its aliases. +fn claims_handle(document: &Document, handle: &Handle) -> bool { + document + .also_known_as + .iter() + .filter_map(|alias| Handle::from_aka(alias)) + .any(|alias| &alias == handle) +} + +fn recently_missed(state: &AppState, subject: &str) -> bool { + let misses = state + .resolve_misses + .lock() + .expect("resolve_misses poisoned"); + misses + .get(subject) + .is_some_and(|at| at.elapsed() < NEGATIVE_TTL) +} + +fn remember_miss(state: &AppState, subject: &str) { + let mut misses = state + .resolve_misses + .lock() + .expect("resolve_misses poisoned"); + + if misses.len() >= MAX_MISSES { + misses.retain(|_, at| at.elapsed() < NEGATIVE_TTL); + // Still full means the expiry sweep freed nothing, so drop the lot + // rather than let an enumeration attack grow the map without bound. + if misses.len() >= MAX_MISSES { + misses.clear(); + } + } + + misses.insert(subject.to_string(), Instant::now()); +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn test_db() -> SqlitePool { + let db = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::migrate!().run(&db).await.unwrap(); + db + } + + fn did_of(value: &str) -> Did { + Did::new(value) + } + + fn handle_of(value: &str) -> Handle { + Handle::new(value).unwrap() + } + + #[tokio::test] + async fn maps_a_did_both_ways() { + let db = test_db().await; + upsert( + &db, + &did_of("did:plc:lily"), + &handle_of("lily.starhaven.dev"), + ) + .await + .unwrap(); + + assert_eq!( + did_of("did:plc:lily").cached_handle(&db).await.unwrap(), + Some(handle_of("lily.starhaven.dev")) + ); + assert_eq!( + handle_of("lily.starhaven.dev") + .cached_did(&db) + .await + .unwrap(), + Some(did_of("did:plc:lily")) + ); + assert_eq!( + did_of("did:plc:nobody").cached_handle(&db).await.unwrap(), + None + ); + } + + /// A rename updates the row rather than adding one: the DID is the key. + #[tokio::test] + async fn a_rename_replaces_the_old_handle() { + let db = test_db().await; + let lily = did_of("did:plc:lily"); + upsert(&db, &lily, &handle_of("lily.bsky.social")) + .await + .unwrap(); + upsert(&db, &lily, &handle_of("lily.starhaven.dev")) + .await + .unwrap(); + + assert_eq!( + lily.cached_handle(&db).await.unwrap(), + Some(handle_of("lily.starhaven.dev")) + ); + assert_eq!( + handle_of("lily.bsky.social").cached_did(&db).await.unwrap(), + None + ); + } + + /// Someone else can take a handle its previous owner gave up. + #[tokio::test] + async fn the_newest_claim_on_a_handle_wins() { + let db = test_db().await; + let mario = handle_of("mario.starhaven.dev"); + upsert(&db, &did_of("did:plc:first"), &mario).await.unwrap(); + upsert(&db, &did_of("did:plc:second"), &mario) + .await + .unwrap(); + + assert_eq!( + mario.cached_did(&db).await.unwrap(), + Some(did_of("did:plc:second")) + ); + } + + #[test] + fn only_accepts_a_handle_the_did_claims_back() { + let document = Document::builder() + .id("did:plc:lily") + .add_also_known_as("at://lily.starhaven.dev") + .build() + .unwrap(); + + assert!(claims_handle(&document, &handle_of("lily.starhaven.dev"))); + assert!(claims_handle(&document, &handle_of("Lily.Starhaven.Dev"))); + assert!(!claims_handle(&document, &handle_of("mario.starhaven.dev"))); + } +} diff --git a/src/atproto/id.rs b/src/atproto/id.rs new file mode 100644 index 0000000..178dd9d --- /dev/null +++ b/src/atproto/id.rs @@ -0,0 +1,180 @@ +//! Identifier newtypes. +//! +//! DIDs and handles are both strings, and the appview passes them around +//! together, so nothing but a type stops one being bound to the wrong column. +//! Wrapping them also gives their normalization rules a single home: a +//! `Handle` is lowercase because it cannot be constructed otherwise. +//! +//! There is deliberately no conversion between `Did` and `Handle`. Going from +//! one to the other is a database lookup or a network resolution that can fail +//! or return nothing, which is what `actor::handle` and `actor::resolve` are. + +use std::fmt; + +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; + +/// The stable identity of a repo, and the author of every record in it. +/// +/// Handles change; this does not, which is why it keys every table. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Did(String); + +impl Did { + // Not validated: every caller constructs this from an already-resolved + // DID document or an OAuth token subject, never raw untrusted input. + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// A human-readable name pointing at a [`Did`]. +/// +/// Case-insensitive and stored lowercase, so comparisons are plain equality. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Handle(String); + +impl Handle { + pub fn new(value: &str) -> Result { + let value = value.trim_start_matches('@').to_lowercase(); + + if value.is_empty() || value.len() > 253 { + bail!("handle has an implausible length: {value}"); + } + // A handle is a domain name, so it needs at least two labels. + let labels: Vec<&str> = value.split('.').collect(); + if labels.len() < 2 { + bail!("handle is not a domain name: {value}"); + } + for label in labels { + if label.is_empty() || label.starts_with('-') || label.ends_with('-') { + bail!("handle has an invalid label: {value}"); + } + if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + bail!("handle has an invalid label: {value}"); + } + } + + Ok(Self(value)) + } + + /// Read a handle out of a DID document's `alsoKnownAs` entry. + /// + /// These are at-URIs, and self-asserted: that this parses says nothing + /// about whether the DID is entitled to the handle. + pub fn from_aka(alias: &str) -> Option { + Self::new(alias.trim_start_matches("at://")).ok() + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// A record's key within its collection. +/// +/// Usually a TID, but not always - `actor.profile` uses the literal `self`. +/// The real identity of a record: unlike a slug or display name, it is never +/// reused, so it is what an edit targets and what a rename does not disturb. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Rkey(String); + +impl Rkey { + /// Mint a fresh record key for a record we are about to create. + pub fn new() -> Self { + Self(atproto_record::tid::Tid::new().encode()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for Rkey { + fn default() -> Self { + Self::new() + } +} + +impl AsRef for Rkey { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for Rkey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl AsRef for Did { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for Did { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl AsRef for Handle { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for Handle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handles_are_lowercased_on_construction() { + assert_eq!( + Handle::new("Lily.Starhaven.Dev").unwrap().as_str(), + "lily.starhaven.dev" + ); + assert_eq!( + Handle::new("@lily.starhaven.dev").unwrap().as_str(), + "lily.starhaven.dev" + ); + } + + #[test] + fn rejects_things_that_are_not_domain_names() { + assert!(Handle::new("lily").is_err()); + assert!(Handle::new("lily..dev").is_err()); + assert!(Handle::new("-lily.dev").is_err()); + assert!(Handle::new("lily.starhaven.dev/mods").is_err()); + assert!(Handle::new("").is_err()); + } + + #[test] + fn reads_handles_out_of_also_known_as() { + assert_eq!( + Handle::from_aka("at://lily.starhaven.dev") + .unwrap() + .as_str(), + "lily.starhaven.dev" + ); + assert_eq!(Handle::from_aka("mailto:lily@example.com"), None); + } +} diff --git a/src/atproto/lexicon/mod.rs b/src/atproto/lexicon/mod.rs new file mode 100644 index 0000000..29eca64 --- /dev/null +++ b/src/atproto/lexicon/mod.rs @@ -0,0 +1,32 @@ +//! Serde definitions for the `dev.starhaven.*` lexicons. +//! +//! One module per lexicon, named after it. These structs are the schema +//! boundary: records reach us as untrusted JSON written by whoever owns the +//! repo, so anything the appview relies on is checked in its `validate` +//! rather than assumed. +//! +//! Every field added after launch must be optional. Records already written to +//! users' PDSs are never migrated, so old records simply lack it forever. + +pub mod mod_listing; + +pub use mod_listing::ModListing; + +use anyhow::{bail, Result}; + +/// Lexicon `maxLength` counts UTF-8 bytes. +fn max_len(field: &str, value: &str, max: usize) -> Result<()> { + if value.len() > max { + bail!("{field} is longer than {max} bytes"); + } + Ok(()) +} + +// Approximates graphemes with chars, so this rejects slightly less than the +// lexicon allows. Swap in a segmenter if that difference ever bites. +fn max_graphemes(field: &str, value: &str, max: usize) -> Result<()> { + if value.chars().count() > max { + bail!("{field} is longer than {max} graphemes"); + } + Ok(()) +} diff --git a/src/atproto/lexicon/mod_listing.rs b/src/atproto/lexicon/mod_listing.rs new file mode 100644 index 0000000..c4a8749 --- /dev/null +++ b/src/atproto/lexicon/mod_listing.rs @@ -0,0 +1,449 @@ +//! `dev.starhaven.mod.listing` - a mod's page in the browser. +//! +//! Versioned downloads are `mod.release` records pointing at this one, so +//! publishing an update does not mean rewriting the listing. + +use std::fmt; +use std::str::FromStr; + +use anyhow::{anyhow, bail, Result}; +use atproto_record::lexicon::TypedBlob; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::{Row, SqlitePool}; + +use super::{max_graphemes, max_len}; +use crate::atproto::id::{Did, Rkey}; + +/// A game a mod can target, each implying exactly one platform (see +/// [`Game::platform`]) - a game with more than one release, like TTYD, gets a +/// distinct variant per platform (`Ttyd` for GCN, `TtydSwitch` for the +/// remake) rather than a separately-chosen platform field. +/// +/// An enum, not a validated string: an unrecognized game is then rejected by +/// serde itself when a record is parsed, rather than needing its own check. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Game { + #[serde(rename = "64")] + Pm64, + #[serde(rename = "ttyd")] + Ttyd, + #[serde(rename = "ttydswitch")] + TtydSwitch, + #[serde(rename = "spm")] + Spm, + #[serde(rename = "ss")] + Ss, + #[serde(rename = "cs")] + Cs, + #[serde(rename = "tok")] + Tok, +} + +impl Game { + pub const ALL: &[Game] = &[ + Self::Pm64, + Self::Ttyd, + Self::TtydSwitch, + Self::Spm, + Self::Ss, + Self::Cs, + Self::Tok, + ]; + + pub fn as_str(&self) -> &'static str { + match self { + Self::Pm64 => "64", + Self::Ttyd => "ttyd", + Self::TtydSwitch => "ttydswitch", + Self::Spm => "spm", + Self::Ss => "ss", + Self::Cs => "cs", + Self::Tok => "tok", + } + } + + /// The console this game runs on. + pub fn platform(&self) -> Platform { + match self { + Self::Pm64 => Platform::N64, + Self::Ttyd => Platform::Gcn, + Self::TtydSwitch => Platform::Switch, + Self::Spm => Platform::Wii, + Self::Ss => Platform::ThreeDs, + Self::Cs => Platform::WiiU, + Self::Tok => Platform::Switch, + } + } +} + +impl fmt::Display for Game { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for Game { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + Self::ALL + .iter() + .copied() + .find(|game| game.as_str() == s) + .ok_or_else(|| anyhow!("not a known game: {s}")) + } +} + +/// A console a mod's game runs on. Not a stored field - always derived from +/// [`Game::platform`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + N64, + Gcn, + Wii, + ThreeDs, + WiiU, + Switch, +} + +impl Platform { + pub fn as_str(&self) -> &'static str { + match self { + Self::N64 => "n64", + Self::Gcn => "gcn", + Self::Wii => "wii", + Self::ThreeDs => "3ds", + Self::WiiU => "wiiu", + Self::Switch => "switch", + } + } +} + +impl fmt::Display for Platform { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModListing { + pub title: String, + pub slug: String, + pub description: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub details: String, + pub game: Game, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub category: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub license: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub media: Vec, + #[serde(with = "atproto_record::datetime::format")] + pub created_at: DateTime, +} + +/// A screenshot or video, stored as a PDS blob. +/// +/// Mod deliverables are far too large for blob storage and live in the object +/// store instead, referenced by URL from a `mod.release`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaItem { + pub file: TypedBlob, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alt: Option, +} + +impl ModListing { + /// The lexicon NSID, which is also the collection name in a repo. + pub const NSID: &'static str = "dev.starhaven.mod.listing"; + + /// The console this mod runs on, implied by `game`. + pub fn platform(&self) -> Platform { + self.game.platform() + } + + /// Check the constraints the lexicon declares but JSON cannot express. + pub fn validate(&self) -> Result<()> { + max_graphemes("title", &self.title, 300)?; + max_graphemes("description", &self.description, 1000)?; + max_graphemes("details", &self.details, 30000)?; + + max_len("slug", &self.slug, 64)?; + if !self + .slug + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + bail!("slug must be lowercase ascii, digits and dashes"); + } + + max_len("category", &self.category, 64)?; + max_len("license", &self.license, 128)?; + if self.tags.len() > 8 { + bail!("tags has more than 8 entries"); + } + for tag in &self.tags { + max_len("tag", tag, 64)?; + } + if self.media.len() > 16 { + bail!("media has more than 16 entries"); + } + for item in &self.media { + if let Some(alt) = &item.alt { + max_graphemes("alt", alt, 1000)?; + } + } + + Ok(()) + } + + /// Parse and validate a record as it appears on the wire. + pub fn from_value(value: &Value) -> Result { + let record: Self = serde_json::from_value(value.clone())?; + record.validate()?; + Ok(record) + } + + // TODO(pds): once handlers write to the author's PDS, call this to build + // the `com.atproto.repo.createRecord` body before saving locally. + /// Serialize for `com.atproto.repo.createRecord`, which wants `$type`. + pub fn to_value(&self) -> Result { + let mut value = serde_json::to_value(self)?; + match value.as_object_mut() { + Some(object) => { + object.insert("$type".to_string(), Self::NSID.into()); + Ok(value) + } + None => bail!("record did not serialize to an object"), + } + } + + /// Persist this listing, keyed on `(did, rkey)`. + /// + /// `rkey` is not derived from the record - it is the caller's to mint + /// once (on create) and remember (on edit), so an edit updates the same + /// row instead of colliding with, or losing to, another listing that + /// happens to share a slug. + pub async fn save(&self, db: &SqlitePool, did: &Did, rkey: &Rkey) -> Result<()> { + sqlx::query( + "INSERT INTO mod_listing + (author_did, rkey, title, slug, description, details, game, + category, license, tags, media, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (author_did, rkey) DO UPDATE SET + title = excluded.title, + slug = excluded.slug, + description = excluded.description, + details = excluded.details, + game = excluded.game, + category = excluded.category, + license = excluded.license, + tags = excluded.tags, + media = excluded.media, + created_at = excluded.created_at", + ) + .bind(did.as_str()) + .bind(rkey.as_str()) + .bind(&self.title) + .bind(&self.slug) + .bind(&self.description) + .bind(&self.details) + .bind(self.game.as_str()) + .bind(&self.category) + .bind(&self.license) + .bind(serde_json::to_string(&self.tags)?) + .bind(serde_json::to_string(&self.media)?) + .bind(self.created_at) + .execute(db) + .await?; + + Ok(()) + } + + /// Load a listing back by its record key, e.g. before editing it. + pub async fn load(db: &SqlitePool, did: &Did, rkey: &Rkey) -> Result> { + let row = sqlx::query( + "SELECT title, slug, description, details, game, category, + license, tags, media, created_at + FROM mod_listing WHERE author_did = ? AND rkey = ?", + ) + .bind(did.as_str()) + .bind(rkey.as_str()) + .fetch_optional(db) + .await?; + + row.map(row_to_listing).transpose() + } + + /// Resolve a pretty URL (`/@handle/mods/:slug`) to the listing it names. + /// + /// Slugs are not unique - two listings can legitimately share one - so + /// this resolves to the lowest rkey among matches, the same rule every + /// appview instance can apply to the same records and agree on. + pub async fn find_by_slug( + db: &SqlitePool, + did: &Did, + slug: &str, + ) -> Result> { + let row = sqlx::query( + "SELECT rkey, title, slug, description, details, game, + category, license, tags, media, created_at + FROM mod_listing WHERE author_did = ? AND slug = ? ORDER BY rkey LIMIT 1", + ) + .bind(did.as_str()) + .bind(slug) + .fetch_optional(db) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + let rkey: Rkey = row.get("rkey"); + let listing = row_to_listing(row)?; + + Ok(Some((rkey, listing))) + } + + /// Remove a listing. + pub async fn delete(db: &SqlitePool, did: &Did, rkey: &Rkey) -> Result<()> { + sqlx::query("DELETE FROM mod_listing WHERE author_did = ? AND rkey = ?") + .bind(did.as_str()) + .bind(rkey.as_str()) + .execute(db) + .await?; + + Ok(()) + } +} + +fn row_to_listing(row: sqlx::sqlite::SqliteRow) -> Result { + let tags: String = row.get("tags"); + let media: String = row.get("media"); + let game: String = row.get("game"); + + Ok(ModListing { + title: row.get("title"), + slug: row.get("slug"), + description: row.get("description"), + details: row.get("details"), + game: game.parse()?, + category: row.get("category"), + license: row.get("license"), + tags: serde_json::from_str(&tags)?, + media: serde_json::from_str(&media)?, + created_at: row.get("created_at"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn listing() -> ModListing { + ModListing { + title: "Master Quest".to_string(), + slug: "master-quest".to_string(), + description: "a mod".to_string(), + details: String::new(), + game: Game::Pm64, + category: String::new(), + tags: vec!["hard".to_string()], + license: String::new(), + media: vec![], + created_at: Utc::now(), + } + } + + async fn test_db() -> SqlitePool { + let db = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::migrate!().run(&db).await.unwrap(); + db + } + + #[tokio::test] + async fn save_load_and_delete_round_trip() { + let db = test_db().await; + let did = Did::new("did:plc:example"); + let rkey = Rkey::new(); + + let original = listing(); + original.save(&db, &did, &rkey).await.unwrap(); + + let loaded = ModListing::load(&db, &did, &rkey).await.unwrap().unwrap(); + assert_eq!(loaded.title, original.title); + // Not carried by any bespoke column mapping - tags round-trips + // because `load` reads the same JSON column `save` wrote. + assert_eq!(loaded.tags, original.tags); + + ModListing::delete(&db, &did, &rkey).await.unwrap(); + assert!(ModListing::load(&db, &did, &rkey).await.unwrap().is_none()); + } + + #[tokio::test] + async fn saving_again_with_the_same_rkey_updates_in_place() { + let db = test_db().await; + let did = Did::new("did:plc:example"); + let rkey = Rkey::new(); + + listing().save(&db, &did, &rkey).await.unwrap(); + let mut edited = listing(); + edited.title = "Master Quest 2".to_string(); + edited.save(&db, &did, &rkey).await.unwrap(); + + let loaded = ModListing::load(&db, &did, &rkey).await.unwrap().unwrap(); + assert_eq!(loaded.title, "Master Quest 2"); + } + + /// The reason rkey exists: two listings can share a slug without one + /// overwriting the other, and the collision resolves deterministically. + #[tokio::test] + async fn a_slug_collision_keeps_both_and_resolves_to_the_lower_rkey() { + let db = test_db().await; + let did = Did::new("did:plc:example"); + + let first = Rkey::new(); + listing().save(&db, &did, &first).await.unwrap(); + + let second = Rkey::new(); + let mut other = listing(); + other.title = "A Different Master Quest".to_string(); + other.save(&db, &did, &second).await.unwrap(); + + let (winner, _) = ModListing::find_by_slug(&db, &did, "master-quest") + .await + .unwrap() + .unwrap(); + let expected = if first.as_str() < second.as_str() { + &first + } else { + &second + }; + assert_eq!(&winner, expected); + + // The loser is still there, reachable by its own rkey. + assert!(ModListing::load(&db, &did, &first).await.unwrap().is_some()); + assert!(ModListing::load(&db, &did, &second) + .await + .unwrap() + .is_some()); + } + + #[test] + fn rejects_an_unrecognized_game_in_a_record() { + let mut value = serde_json::to_value(listing()).unwrap(); + value["game"] = "melee".into(); + assert!(ModListing::from_value(&value).is_err()); + } + + #[test] + fn platform_is_implied_by_game() { + assert_eq!(Game::TtydSwitch.platform(), Platform::Switch); + } +} diff --git a/src/atproto/mod.rs b/src/atproto/mod.rs new file mode 100644 index 0000000..4e9c10b --- /dev/null +++ b/src/atproto/mod.rs @@ -0,0 +1,10 @@ +//! Everything that speaks atproto: identifiers, our lexicons, and the handle +//! cache. +//! +//! The site's own concerns - routes, templates, moderation - live outside this +//! module and deal in [`id::Did`] and each lexicon's own storage methods +//! rather than raw records. + +pub mod actor; +pub mod id; +pub mod lexicon; diff --git a/src/error.rs b/src/error.rs index 9440131..457b3e7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -18,6 +18,14 @@ pub enum AppError { /// The request was unauthenticated or the session was invalid. #[error("unauthorized")] Unauthorized, + + /// The caller is authenticated but not allowed to do this. + #[error("forbidden")] + Forbidden, + + /// Nothing exists at this URL. + #[error("not found")] + NotFound, } impl IntoResponse for AppError { @@ -26,6 +34,8 @@ impl IntoResponse for AppError { AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, AppError::BadRequest(_) => StatusCode::BAD_REQUEST, AppError::Unauthorized => StatusCode::UNAUTHORIZED, + AppError::Forbidden => StatusCode::FORBIDDEN, + AppError::NotFound => StatusCode::NOT_FOUND, }; (status, self.to_string()).into_response() } diff --git a/src/main.rs b/src/main.rs index 93c78ce..f526021 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ mod assets; +mod atproto; mod config; mod error; +mod mods; mod oauth; mod secrets; mod state; @@ -27,6 +29,7 @@ async fn main() { let app = Router::new() .merge(oauth::router()) .merge(assets::router()) + .merge(mods::router()) .route("/", get(index)) .route("/hello", post(hello)) .with_state(state); @@ -80,7 +83,7 @@ async fn index(headers: HeaderMap) -> Html { h1 { "starhaven" } @match &identity { Some(identity) => { - p { "logged in as " (identity.handle.as_deref().unwrap_or(&identity.did)) } + p { "logged in as " (identity.display_name()) } form method="post" action="/auth/logout" { button type="submit" { "log out" } } diff --git a/src/mods.rs b/src/mods.rs new file mode 100644 index 0000000..76d2b6d --- /dev/null +++ b/src/mods.rs @@ -0,0 +1,172 @@ +//! `/handle/mods/:slug` - view, create and edit a mod listing. + +use axum::extract::{Form, Path, State}; +use axum::http::HeaderMap; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::get; +use axum::Router; +use chrono::Utc; +use maud::html; +use serde::Deserialize; + +use crate::atproto::id::{Handle, Rkey}; +use crate::atproto::lexicon::mod_listing::Game; +use crate::atproto::lexicon::ModListing; +use crate::error::AppError; +use crate::layout; +use crate::oauth::session::get_session_from_headers; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new().route("/{handle}/mods/{slug}", get(show).post(save)) +} + +async fn show( + State(state): State, + Path((handle, slug)): Path<(String, String)>, + headers: HeaderMap, +) -> Result { + let handle = Handle::new(&handle).map_err(|e| AppError::BadRequest(e.to_string()))?; + let did = handle + .resolve_did(&state) + .await? + .ok_or(AppError::NotFound)?; + + let found = ModListing::find_by_slug(&state.db, &did, &slug) + .await + .map_err(AppError::Internal)?; + + let is_owner = get_session_from_headers(&state.secrets.cookie_secret, &headers) + .is_some_and(|session| session.did == did); + + let Some((_rkey, listing)) = found else { + if is_owner { + return Ok(layout(edit_form(&handle, &slug, None)).into_response()); + } + return Err(AppError::NotFound); + }; + + Ok(layout(html! { + h1 { (listing.title) } + p { "by " a href={ "/" (handle) } { (handle) } } + p { (listing.description) } + @if !listing.details.is_empty() { p { (listing.details) } } + dl { + dt { "game" } dd { (listing.game) } + dt { "platform" } dd { (listing.platform()) } + @if !listing.category.is_empty() { dt { "category" } dd { (listing.category) } } + @if !listing.license.is_empty() { dt { "license" } dd { (listing.license) } } + dt { "created" } dd { (listing.created_at.to_rfc3339()) } + } + @if is_owner { + (edit_form(&handle, &slug, Some(&listing))) + } + }) + .into_response()) +} + +fn edit_form(handle: &Handle, slug: &str, listing: Option<&ModListing>) -> maud::Markup { + let title = listing.map_or("", |l| &l.title); + let description = listing.map_or("", |l| &l.description); + let details = listing.map_or("", |l| &l.details); + let category = listing.map_or("", |l| &l.category); + let license = listing.map_or("", |l| &l.license); + let game = listing.map(|l| l.game); + + html! { + form method="post" action={ "/" (handle) "/mods/" (slug) } { + label { "title" input type="text" name="title" value=(title) required; } + label { "description" textarea name="description" required { (description) } } + label { "details" textarea name="details" { (details) } } + label { + "game" + select name="game" required { + @for g in Game::ALL { + option value=(g.as_str()) selected[game == Some(*g)] { (g) } + } + } + } + label { "category" input type="text" name="category" value=(category); } + label { "license" input type="text" name="license" value=(license); } + button type="submit" { "save" } + } + } +} + +#[derive(Deserialize)] +struct ListingForm { + title: String, + description: String, + #[serde(default)] + details: String, + game: String, + #[serde(default)] + category: String, + #[serde(default)] + license: String, +} + +async fn save( + State(state): State, + Path((handle, slug)): Path<(String, String)>, + headers: HeaderMap, + Form(form): Form, +) -> Result { + let handle = Handle::new(&handle).map_err(|e| AppError::BadRequest(e.to_string()))?; + // Same resolution `show` does, so the two agree on what this URL names. + let did = handle + .resolve_did(&state) + .await? + .ok_or(AppError::NotFound)?; + + let session = get_session_from_headers(&state.secrets.cookie_secret, &headers) + .ok_or(AppError::Unauthorized)?; + if session.did != did { + return Err(AppError::Forbidden); + } + + let game: Game = form + .game + .parse() + .map_err(|e: anyhow::Error| AppError::BadRequest(e.to_string()))?; + + let existing = ModListing::find_by_slug(&state.db, &did, &slug) + .await + .map_err(AppError::Internal)?; + + // Create new listing if not found + let (rkey, mut listing) = existing.unwrap_or_else(|| { + ( + Rkey::new(), + ModListing { + title: String::new(), + slug: slug.clone(), + description: String::new(), + details: String::new(), + game, + category: String::new(), + tags: vec![], + license: String::new(), + media: vec![], + created_at: Utc::now(), + }, + ) + }); + + listing.title = form.title; + listing.description = form.description; + listing.details = form.details; + listing.game = game; + listing.category = form.category; + listing.license = form.license; + + listing + .validate() + .map_err(|e| AppError::BadRequest(e.to_string()))?; + listing + .save(&state.db, &did, &rkey) + .await + .map_err(AppError::Internal)?; + + Ok(Redirect::to(&format!("/{handle}/mods/{slug}")).into_response()) +} diff --git a/src/oauth/callback.rs b/src/oauth/callback.rs index daf21f2..911af4b 100644 --- a/src/oauth/callback.rs +++ b/src/oauth/callback.rs @@ -12,6 +12,7 @@ use axum::Router; use chrono::{Duration, Utc}; use serde::Deserialize; +use crate::atproto::id::{Did, Handle}; use crate::error::AppError; use crate::oauth::session::{ build_identity_cookie_header, build_session_cookie_header, encode_identity_cookie, @@ -189,6 +190,7 @@ pub async fn callback( )); } }; + let did = Did::new(did); let expires_at = Utc::now() + Duration::seconds(i64::from(token_response.expires_in)); @@ -200,11 +202,20 @@ pub async fn callback( dpop_private_key: persisted.dpop_private_key.clone(), }; + let hint_verified = document.as_ref().is_some_and(|d| d.id == did.as_str()); + let handle = match Handle::new(login_hint) { + Ok(handle) if hint_verified => Some(handle), + _ => crate::atproto::actor::verified_handle(&state.identity_resolver, &did).await?, + }; + + // Associate this handle with the DID so we can look it up later. + if let Some(handle) = &handle { + crate::atproto::actor::upsert(&state.db, &did, handle).await?; + } + let identity = IdentityCookie { did: did.clone(), - handle: document - .as_ref() - .and_then(|d| d.handles().map(|h| h.to_string())), + handle, pds_url: Some(pds_endpoint), }; diff --git a/src/oauth/refresh.rs b/src/oauth/refresh.rs index 3c95d9e..0003783 100644 --- a/src/oauth/refresh.rs +++ b/src/oauth/refresh.rs @@ -41,9 +41,6 @@ pub async fn refresh( /// Refresh `session` in place if it's within 5 minutes of expiry, returning /// the (possibly updated) session and an optional `Set-Cookie` header value /// for the caller to attach. -/// -/// `pub` for future callers, e.g. an auto-refresh check in protected -/// mod-CRUD handlers. pub async fn try_refresh_session( state: &AppState, session: SessionCookie, @@ -63,7 +60,7 @@ pub async fn try_refresh_session( let document = state .identity_resolver - .resolve(&session.did) + .resolve(session.did.as_str()) .await .map_err(|e| AppError::Internal(anyhow::anyhow!("identity resolution failed: {e}")))?; diff --git a/src/oauth/session.rs b/src/oauth/session.rs index a652a97..cfeb5a4 100644 --- a/src/oauth/session.rs +++ b/src/oauth/session.rs @@ -14,6 +14,8 @@ use cookie::{Cookie, SameSite}; use rand::RngCore as _; use serde::{Deserialize, Serialize}; +use crate::atproto::id::{Did, Handle}; + /// Name of the encrypted session cookie. pub const SESSION_COOKIE_NAME: &str = "session"; /// Name of the readable identity cookie. @@ -23,7 +25,7 @@ pub const IDENTITY_COOKIE_NAME: &str = "identity"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionCookie { /// The user's DID. - pub did: String, + pub did: Did, /// The DPoP-bound OAuth access token. pub access_token: String, /// The OAuth refresh token, if any. @@ -45,13 +47,21 @@ impl SessionCookie { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IdentityCookie { /// The user's DID. - pub did: String, + pub did: Did, /// The user's handle, if resolved. - pub handle: Option, + pub handle: Option, /// The user's PDS URL, if resolved. pub pds_url: Option, } +impl IdentityCookie { + pub fn display_name(&self) -> &str { + self.handle + .as_ref() + .map_or(self.did.as_str(), Handle::as_str) + } +} + /// Cookie codec/transport errors. #[derive(Debug, thiserror::Error)] pub enum CookieError { @@ -241,7 +251,7 @@ mod tests { fn session_cookie_round_trips() { let secret = [7u8; 32]; let session = SessionCookie { - did: "did:plc:example".to_string(), + did: Did::new("did:plc:example"), access_token: "access-token".to_string(), refresh_token: Some("refresh-token".to_string()), expires_at: Utc::now() + Duration::minutes(10), @@ -262,7 +272,7 @@ mod tests { let secret = [1u8; 32]; let wrong_secret = [2u8; 32]; let session = SessionCookie { - did: "did:plc:example".to_string(), + did: Did::new("did:plc:example"), access_token: "access-token".to_string(), refresh_token: None, expires_at: Utc::now(), @@ -276,8 +286,8 @@ mod tests { #[test] fn identity_cookie_round_trips() { let identity = IdentityCookie { - did: "did:plc:example".to_string(), - handle: Some("alice.test".to_string()), + did: Did::new("did:plc:example"), + handle: Some(Handle::new("alice.test").unwrap()), pds_url: Some("https://pds.example".to_string()), }; diff --git a/src/state.rs b/src/state.rs index 3da3e65..6685b80 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::ops::Deref; use std::sync::{Arc, Mutex}; +use std::time::Instant; use atproto_identity::resolve::{ HickoryDnsResolver, InnerIdentityResolver, SharedIdentityResolver, @@ -29,10 +30,13 @@ pub struct Inner { /// DID/handle resolver. pub identity_resolver: SharedIdentityResolver, /// In-flight OAuth requests (PKCE verifier, CSRF state, per-flow DPoP - /// key), keyed by the CSRF `state` value, between `/login` and - /// `/callback`. In-memory and lost across restarts; an interrupted login - /// just needs to be retried. + /// key), keyed by the CSRF `state` value, between `/login` and `/callback`. pub oauth_requests: Mutex>, + /// Handles and DIDs that recently failed to resolve, so a stream of made-up + /// ones cannot turn every request into a network lookup. In-memory and + /// bounded: negative results are cheap to rediscover and must not fill + /// the disk. + pub resolve_misses: Mutex>, /// SQLite connection pool. pub db: SqlitePool, } @@ -68,6 +72,7 @@ impl AppState { http_client, identity_resolver, oauth_requests: Mutex::new(HashMap::new()), + resolve_misses: Mutex::new(HashMap::new()), db, }))) }