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,
})))
}