diff --git a/Cargo.lock b/Cargo.lock index 0e68bac..51d98d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -478,6 +478,7 @@ checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" name = "fetch" version = "0.0.0" dependencies = [ + "derive_more", "reqwest", "serde_json", "sigh", @@ -1441,6 +1442,7 @@ version = "0.0.0" dependencies = [ "axum", "puppy", + "serde_json", "tokio", ] diff --git a/bin/pupctl/src/main.rs b/bin/pupctl/src/main.rs index 06041a1..aa5e656 100644 --- a/bin/pupctl/src/main.rs +++ b/bin/pupctl/src/main.rs @@ -1,7 +1,8 @@ use puppy::{ + mixin_ap_actor, model::{schema, Bite, FollowRequest, Follows, Profile, Username}, post::Author, - store::{self, Error}, + store::{self, Error, OK}, Key, Store, }; @@ -11,6 +12,11 @@ fn main() -> store::Result<()> { println!("creating actors"); let riley = get_or_create_actor(&db, "riley")?; let linen = get_or_create_actor(&db, "linen")?; + // db.run(|tx| { + // mixin_ap_actor(tx, riley, "test.pup.riley.lgbt")?; + // mixin_ap_actor(tx, linen, "test.pup.riley.lgbt")?; + // OK + // })?; if true { println!("creating posts"); puppy::post::create_post(&db, riley, "@linen <3")?; @@ -72,7 +78,7 @@ fn get_or_create_actor(db: &Store, username: &str) -> Result { } Ok(None) => { println!("'{username}' doesn't exist yet, creating"); - let r = puppy::create_actor(&db, username); + let r = puppy::create_local_actor(&db, username, "test.pup.riley.lgbt"); if let Ok(ref key) = r { println!("created '{username}' with key {key}"); } diff --git a/bin/server/Cargo.toml b/bin/server/Cargo.toml index 89fdc95..c15089f 100644 --- a/bin/server/Cargo.toml +++ b/bin/server/Cargo.toml @@ -6,3 +6,4 @@ edition = "2021" puppy = { path = "../../lib/puppy" } tokio = { version = "*", features = ["full"] } axum = "*" +serde_json = "*" diff --git a/bin/server/src/main.rs b/bin/server/src/main.rs index 1737e4e..039364e 100644 --- a/bin/server/src/main.rs +++ b/bin/server/src/main.rs @@ -1,8 +1,17 @@ -use axum::{routing::get, Router}; +use axum::{extract::Path, routing::get, Json, Router}; +use puppy::{get_local_ap_object, model::schema, Key, Store}; #[tokio::main] async fn main() { - let app = Router::new().route("/", get(|| async { "Hello, World!" })); + let db = Store::open(".state", schema()).unwrap(); + let app = Router::new().route( + "/o/:ulid", + get(|Path(raw_object_id): Path| async move { + let object_id = raw_object_id.parse::().unwrap(); + let obj = get_local_ap_object(&db, object_id).unwrap().to_json_ld(); + Json(obj) + }), + ); let sock = tokio::net::TcpListener::bind("0.0.0.0:1312").await.unwrap(); axum::serve(sock, app).await.unwrap(); } diff --git a/lib/fetch/Cargo.toml b/lib/fetch/Cargo.toml index 8211b67..2963368 100644 --- a/lib/fetch/Cargo.toml +++ b/lib/fetch/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" path = "src/lib.rs" [dependencies] -reqwest = "*" -sigh = "*" -serde_json = "*" +reqwest = "*" +sigh = "*" +serde_json = "*" +derive_more = "*" diff --git a/lib/fetch/src/lib.rs b/lib/fetch/src/lib.rs index e69de29..d2fee5f 100644 --- a/lib/fetch/src/lib.rs +++ b/lib/fetch/src/lib.rs @@ -0,0 +1,49 @@ +use derive_more::{Display, From, Into}; +use serde_json::{json, Value}; + +pub enum Object { + Activity(Activity), + Actor(Actor), + Object { id: Id }, +} + +impl Object { + pub fn to_json(self) -> Value { + match self { + Object::Activity(_) => todo!(), + Object::Actor(a) => json!({ + "id": a.id.to_string(), + "inbox": a.inbox.to_string(), + "preferredUsername": a.account_name, + "name": a.display_name, + }), + Object::Object { id } => json!({ + "id": id.to_string() + }), + } + } + pub fn to_json_ld(self) -> Value { + let mut json = self.to_json(); + json["@context"] = json!([]); + json + } +} + +pub struct Activity { + pub id: Id, +} + +/// An actor is an entity capable of producing Takes. +pub struct Actor { + /// The URL pointing to this object. + pub id: Id, + /// Where others should send activities. + pub inbox: Id, + /// Note: this maps to the `preferredUsername` property. + pub account_name: String, + /// Note: this maps to the `name` property. + pub display_name: Option, +} + +#[derive(Display, From, Into, Debug, Clone)] +pub struct Id(String); diff --git a/lib/macro/src/lib.rs b/lib/macro/src/lib.rs index fef6117..b45e35b 100644 --- a/lib/macro/src/lib.rs +++ b/lib/macro/src/lib.rs @@ -49,7 +49,7 @@ fn make_alias_impl(name: &syn::Ident, field: &syn::Field) -> TokenStream { }) } -#[proc_macro_derive(Mixin)] +#[proc_macro_derive(Mixin, attributes(index))] pub fn mixin(item: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(item as syn::DeriveInput); diff --git a/lib/puppy/src/bites.rs b/lib/puppy/src/bites.rs new file mode 100644 index 0000000..032dc84 --- /dev/null +++ b/lib/puppy/src/bites.rs @@ -0,0 +1,28 @@ +//! The most essential feature of any social network. + +use store::{Arrow, Key, Store}; + +/// *Bites you* +#[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)] +pub struct Bite { + #[identity] + pub id: Key, + #[origin] + pub biter: Key, + #[target] + pub victim: Key, +} + +pub fn bite_actor(db: &Store, biter: Key, victim: Key) -> store::Result { + db.run(|tx| { + let id = Key::gen(); + // TODO: Create an `Activity` arrow with the same ID. + tx.create(Bite { id, biter, victim })?; + Ok(id) + }) +} + +/// Who has bitten `victim`? +pub fn bites_on(db: &Store, victim: Key) -> store::Result> { + db.incoming::(victim).try_collect() +} diff --git a/lib/puppy/src/follows.rs b/lib/puppy/src/follows.rs new file mode 100644 index 0000000..b9ec706 --- /dev/null +++ b/lib/puppy/src/follows.rs @@ -0,0 +1,234 @@ +//! Follow requests and related stuff. + +use bincode::{Decode, Encode}; +use store::{util::IterExt, Arrow, Error, Key, Mixin, Store, OK}; + +/// A predicate; `follower` "follows" `followed`. +#[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)] +pub struct Follows { + #[origin] + pub follower: Key, + #[target] + pub followed: Key, +} + +/// An instance of a request from some `origin` user to follow a `target` user. +/// +/// This should not be used to determine whether two actors are following each other. For that, use +/// [`Follows`], a basic arrow for exactly this purpose. *This* arrow is used to identify specific +/// instances of *requests*, and serves mostly as a historical reference and for synchronizing with +/// other servers. +/// +/// Mixins always present for the `id`: +/// +/// - [`Status`], carrying the status of the request. +#[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)] +pub struct FollowRequest { + /// The unique ID of this particular request. + #[identity] + pub id: Key, + /// The "follower", the user that made the request. + pub origin: Key, + /// The one the request is made to. + pub target: Key, +} + +impl FollowRequest { + /// Determine if this follow request is pending. + pub fn is_pending(&self, db: &Store) -> store::Result { + // The status is stored as a mixin, so we need to get it. + let Some(st) = db.get_mixin::(self.id)? else { + // If we don't have a status for a follow request, something is borked. + return Err(Error::Missing); + }; + // If the status of the follow request is pending, it can't also be true that the follows + // relation already exists. + debug_assert! { + !(st == Status::Pending) + || db.exists::(self.origin, self.target).map(|x| !x)?, + "fr.is_pending -> !(fr.origin follows fr.target)" + }; + Ok(st == Status::Pending) + } +} + +/// The status of a [`FollowRequest`]. +/// +/// Valid state transitions: +/// +/// ```text +/// ┌──────────────▶ Rejected +/// │ +/// │ +/// │ +/// +/// None ─────────▶ Pending ────────▶ Accepted +/// +/// │ │ +/// │ │ +/// │ │ +/// ▼ │ +/// Withdrawn ◀────────────┘ +/// ``` +/// +/// In addition, a follow request will be deleted if either endpoint is removed from the graph. +#[derive(Mixin, Encode, Decode, Eq, PartialEq, Clone)] +pub enum Status { + /// The follow request was previously pending or accepted, but since withdrawn. + /// + /// This can happen when someone cancels their follow request or unfollows the target. + Withdrawn, + /// The follow request was accepted. + Accepted, + /// The follow request was denied. + Rejected, + /// The follow request is still under review. + Pending, +} + +/// Request to follow another actor. +pub fn request(db: &Store, requester: Key, target: Key) -> store::Result { + db.run(|tx| { + let req = FollowRequest { + id: Key::gen(), + origin: requester, + target, + }; + tx.create(req)?; + tx.add_mixin(req.id, Status::Pending)?; + Ok(req) + }) +} + +/// Accept the open follow request from `requester` to `target`, if one exists. +pub fn accept(db: &Store, requester: Key, target: Key) -> store::Result<()> { + db.run(|tx| { + // TODO: This logic is a little broken but it'll do for now. i'll fix it later. + let fr = tx + .between::(requester, target) + .filter(|fr| fr.as_ref().is_ok_and(|f| f.target == target)) + // We'll want the latest one, because that one was inserted last so it'll be the most + // recent + .last() + .ok_or_else(|| Error::Missing)??; + // Only apply the update if the last follow request is still in a pending state. + if let Some(Status::Pending) = db.get_mixin(fr.id)? { + tx.update(fr.id, |_| Status::Accepted)?; + tx.create(Follows { + follower: requester, + followed: target, + })?; + } + OK + }) +} + +pub fn reject(db: &Store, request: Key) -> store::Result<()> { + db.run(|tx| { + tx.update(request, |_| Status::Rejected)?; + OK + }) +} + +/// List all pending follow requests for a user. +pub fn list_pending(db: &Store, target: Key) -> store::Result> { + db.incoming::(target) + .filter_bind_results(|req| Ok(if req.is_pending(db)? { Some(req) } else { None })) + .collect() +} + +/// Get all actors followed by `actor`. +pub fn following_of(db: &Store, actor: Key) -> store::Result> { + db.outgoing::(actor) + .map_ok(|a| a.followed) + .collect() +} + +/// Get all actors following `actor`. +pub fn followers_of(db: &Store, actor: Key) -> store::Result> { + db.incoming::(actor) + .map_ok(|a| a.follower) + .collect() +} + +#[cfg(test)] +mod tests { + use store::{Key, Store, OK}; + + use crate::{ + create_actor, + model::{schema, FollowRequest, Follows}, + }; + + fn make_test_actors(db: &Store) -> store::Result<(Key, Key)> { + let alice = create_actor(&db, "alice")?; + let bob = create_actor(&db, "bob")?; + eprintln!("alice={alice}, bob={bob}"); + Ok((alice, bob)) + } + + #[test] + fn create_fr() -> store::Result<()> { + Store::test(schema(), |db| { + let (alice, bob) = make_test_actors(&db)?; + super::request(&db, alice, bob)?; + assert!( + db.exists::(alice, bob)?, + "(alice -> bob) ∈ follow-requested" + ); + assert!( + !db.exists::(alice, bob)?, + "(alice -> bob) ∉ follows" + ); + let pending_for_bob = super::list_pending(&db, bob)? + .into_iter() + .map(|fr| fr.origin) + .collect::>(); + assert_eq!(pending_for_bob, vec![alice], "bob.pending = {{alice}}"); + OK + }) + } + + #[test] + fn accept_fr() -> store::Result<()> { + Store::test(schema(), |db| { + let (alice, bob) = make_test_actors(&db)?; + super::request(&db, alice, bob)?; + super::accept(&db, alice, bob)?; + + assert!( + db.exists::(alice, bob)?, + "(alice -> bob) ∈ follows" + ); + assert!( + !db.exists::(bob, alice)?, + "(bob -> alice) ∉ follows" + ); + + let pending_for_bob = super::list_pending(&db, bob)?; + assert!(pending_for_bob.is_empty(), "bob.pending = ∅"); + + let followers_of_bob = super::followers_of(&db, bob)?; + assert_eq!(followers_of_bob, vec![alice], "bob.followers = {{alice}}"); + + OK + }) + } + + #[test] + fn listing_follow_relations() -> store::Result<()> { + Store::test(schema(), |db| { + let (alice, bob) = make_test_actors(&db)?; + super::request(&db, alice, bob)?; + super::accept(&db, alice, bob)?; + + let followers_of_bob = super::followers_of(&db, bob)?; + assert_eq!(followers_of_bob, vec![alice], "bob.followers = {{alice}}"); + + let following_of_alice = super::following_of(&db, alice)?; + assert_eq!(following_of_alice, vec![bob], "alice.following = {{bob}}"); + + OK + }) + } +} diff --git a/lib/puppy/src/lib.rs b/lib/puppy/src/lib.rs index 03cfc40..99ee9c4 100644 --- a/lib/puppy/src/lib.rs +++ b/lib/puppy/src/lib.rs @@ -1,6 +1,7 @@ #![feature(iterator_try_collect, try_blocks)] -use model::{Profile, Username}; +use model::{Channel, Id, Object, ObjectKind, Profile, Username}; pub use store::{self, Key, Store}; +use store::{Error, Transaction, OK}; pub mod model { use bincode::{Decode, Encode}; @@ -22,6 +23,7 @@ pub mod model { /// How many posts has this user made? pub post_count: usize, /// The name used for the profile's handle. + #[index] // <- currently doesnt do anything but i have an idea pub account_name: Username, /// The name displayed above their posts. pub display_name: Option, @@ -31,6 +33,46 @@ pub mod model { pub about_fields: Vec<(String, String)>, } + /// Properties of ActivityPub objects. + #[derive(Mixin, Encode, Decode, Debug, Clone)] + pub struct Object { + #[index] + pub id: Id, + pub kind: ObjectKind, + } + + /// Allows case analysis on the type of ActivityPub objects. + #[derive(Encode, Decode, Debug, Clone)] + pub enum ObjectKind { + Actor, + Activity(ActivityKind), + Notelike(String), + } + + /// The type of an activity. + #[derive(Encode, Decode, Debug, Clone)] + pub enum ActivityKind { + /// Used for posting stuff! + Create = 0, + /// Represents a follow request. + Follow = 1, + /// Used to signal that a follow request was accepted. + Accept = 2, + /// Used to reject a follow request. + Reject = 3, + /// See [`bites`](crate::bites). + Bite = 4, + } + + #[derive(Mixin, Encode, Decode, Debug, Clone)] + pub struct Channel { + pub inbox: String, + } + + /// An ActivityPub ID, used to look up remote objects by their canonical URL. + #[derive(Alias, Encode, Decode, Clone, PartialEq, Eq, Debug, Hash, Display)] + pub struct Id(pub String); + /// A unique name for an actor that is part of their "handle". #[derive(Alias, Encode, Decode, Clone, PartialEq, Eq, Debug, Hash, Display)] pub struct Username(pub String); @@ -42,8 +84,11 @@ pub mod model { .has::() .has::() .has::() + .has::() + .has::() // Aliases .has::() + .has::() // Arrows .has::() .has::() @@ -52,10 +97,43 @@ pub mod model { } } -pub fn create_actor(db: &Store, username: impl ToString) -> store::Result { +/// Retrieve an ActivityPub object from the database. +/// +/// Fails with `Error::Missing` if the required properties are not present. +pub fn get_local_ap_object(db: &Store, key: Key) -> store::Result { + let Some(obj) = db.get_mixin::(key)? else { + return Err(Error::Missing); + }; + match obj.kind { + ObjectKind::Actor => { + let Some(Profile { account_name, display_name, .. }) = db.get_mixin(key)? else { + return Err(Error::Missing); + }; + let Some(Channel { inbox }) = db.get_mixin(key)? else { + return Err(Error::Missing); + }; + Ok(fetch::Object::Actor(fetch::Actor { + id: obj.id.0.into(), + inbox: inbox.into(), + account_name: account_name.0, + display_name, + })) + } + ObjectKind::Activity(_) => { + todo!() + } + ObjectKind::Notelike(_) => todo!(), + } +} + +/// Create a fresh local actor. +pub fn create_local_actor(db: &Store, domain: &str, username: impl ToString) -> store::Result { let key = Key::gen(); db.run(|tx| { let username: Username = username.to_string().into(); + // Federation stuff + mixin_ap_actor(tx, key, domain)?; + // Social properties tx.add_alias(key, username.clone())?; tx.add_mixin(key, Profile { post_count: 0, @@ -68,440 +146,15 @@ pub fn create_actor(db: &Store, username: impl ToString) -> store::Result { }) } -pub mod bites { - //! The most essential feature of any social network. - - use store::{Arrow, Key, Store}; - - /// *Bites you* - #[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)] - pub struct Bite { - #[identity] - pub id: Key, - #[origin] - pub biter: Key, - #[target] - pub victim: Key, - } - - pub fn bite_actor(db: &Store, biter: Key, victim: Key) -> store::Result { - db.run(|tx| { - let id = Key::gen(); - tx.create(Bite { id, biter, victim })?; - Ok(id) - }) - } - - /// Who has bitten `victim`? - pub fn bites_on(db: &Store, victim: Key) -> store::Result> { - db.incoming::(victim).try_collect() - } +/// Add properties related to ActivityPub actors to a vertex. +pub fn mixin_ap_actor(tx: &Transaction<'_>, vertex: Key, domain: &str) -> store::Result<()> { + let id = Id(format!("http://{domain}/o/{vertex}")); + tx.add_alias(vertex, id.clone())?; + tx.add_mixin(vertex, Channel { inbox: format!("{id}/inbox") })?; + tx.add_mixin(vertex, Object { id, kind: ObjectKind::Actor })?; + OK } -pub mod post { - //! Timelines: where you go to view the posts. - - use std::ops::RangeBounds; - - use bincode::{Decode, Encode}; - use chrono::{DateTime, Utc}; - use either::Either::{Left, Right}; - use store::{util::IterExt as _, Arrow, Error, Key, Mixin, Result, Store, Transaction}; - - use crate::model::Profile; - - /// The contents of a post. - #[derive(Mixin, Encode, Decode, Debug, Clone, Default)] - pub struct Content { - /// Main post body. - pub content: Option, - /// Content warning for the post. - pub warning: Option, - } - - impl From<&str> for Content { - fn from(value: &str) -> Self { - value.to_string().into() - } - } - - impl From for Content { - fn from(value: String) -> Self { - Content { - content: Some(value), - warning: None, - } - } - } - - /// The relation that `author` has constructed and published `object`. - #[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)] - pub struct AuthorOf { - #[origin] - pub author: Key, - #[target] - pub object: Key, - } - - /// A piece of content posted by someone. - #[derive(Clone, Debug)] - pub struct Post { - /// The post's internal ID. - pub id: Key, - /// The actual post contents. - pub content: Content, - /// Metadata about the post's author. - pub author: Author, - } - - /// Information about a [`Post`]'s author. - #[derive(Clone, Debug)] - pub struct Author { - /// The identifier of the author. - pub id: Key, - /// The name to display along with the post. - pub display_name: String, - /// An informal identifier for a particular author. - pub handle: String, - } - - /// An ordered list of [`Post`]s for viewing. - #[derive(Debug)] - pub struct Timeline { - items: Vec, - } - - /// Discrete events that can be displayed to a user as part of a timeline. - #[derive(Debug)] - enum Item { - Post(Post), - } - - impl Item { - /// Get the timeline item if it is a [`Post`]. - pub fn as_post(&self) -> Option<&Post> { - match self { - Item::Post(ref post) => Some(post), - } - } - } - - impl Timeline { - /// Get all the posts in the timeline. - pub fn posts(&self) -> impl Iterator { - self.items.iter().filter_map(|x| x.as_post()) - } - } - - /// Gets at most `limit` of the posts known to the instance that were inserted within `time_range`. - pub fn fetch_timeline( - db: &Store, - time_range: impl RangeBounds>, - limit: Option, - ) -> Result { - let posts = db.run(|tx| { - // Get all post content entries (the argument passed here is a range of chrono datetimes). - let iter = tx.range::(time_range); - let iter = match limit { - Some(n) => Left(iter.take(n)), - None => Right(iter), - }; - // Then, we're gonna map each of them to their author, and get the profile information needed to - // render the post (mostly display name and handle). - iter.bind_results(|(id, content)| { - // Take the first author. There is nothing stopping a post from having multiple authors, but - // let's take it one step at a time. - let (author, Some(Profile { display_name, account_name, .. })) = tx - .join_on(|a: AuthorOf| a.author, tx.incoming(id))? - .swap_remove(0) - else { - // We expect all posts to have at least one author, so we should complain if there is one - // that doesn't (for now). For robustness, the `.collect()` down there should be replaced - // with a strategy where we log a warning instead of failing, but in the current state of - // the project, failing fast is a good thing. - return Err(Error::Missing); - }; - Ok(Item::Post(Post { - id, - author: Author { - id: author, - handle: format!("@{account_name}"), - display_name: display_name.unwrap_or(account_name.0), - }, - content, - })) - }) - .collect() - })?; - Ok(Timeline { items: posts }) - } - - /// Create a new post. - pub fn create_post(db: &Store, author: Key, content: impl Into) -> store::Result { - db.run(|tx| mixin_post(tx, Key::gen(), author, content)) - } - - /// Add a post's mixins and predicates to an existing `node`. - pub fn mixin_post( - tx: &Transaction<'_>, - node: Key, - author: Key, - content: impl Into, - ) -> store::Result { - tx.update::(author, |mut profile| { - profile.post_count += 1; - profile - })?; - tx.add_mixin(node, content.into())?; - tx.create(AuthorOf { author, object: node })?; - Ok(node) - } - - pub fn list_posts_by_author(db: &Store, author: Key) -> store::Result> { - db.run(|tx| { - let posts = tx - .join_on(|a: AuthorOf| a.object, tx.outgoing(author))? - .into_iter() - .filter_map(|(k, opt)| try { (k, opt?) }) - .collect(); - Ok(posts) - }) - } -} - -pub mod follows { - //! Follow requests and related stuff. - - use bincode::{Decode, Encode}; - use store::{util::IterExt, Arrow, Error, Key, Mixin, Store, OK}; - - /// A predicate; `follower` "follows" `followed`. - #[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)] - pub struct Follows { - #[origin] - pub follower: Key, - #[target] - pub followed: Key, - } - - /// An instance of a request from some `origin` user to follow a `target` user. - /// - /// This should not be used to determine whether two actors are following each other. For that, use - /// [`Follows`], a basic arrow for exactly this purpose. *This* arrow is used to identify specific - /// instances of *requests*, and serves mostly as a historical reference and for synchronizing with - /// other servers. - /// - /// Mixins always present for the `id`: - /// - /// - [`Status`], carrying the status of the request. - #[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)] - pub struct FollowRequest { - /// The unique ID of this particular request. - #[identity] - pub id: Key, - /// The "follower", the user that made the request. - pub origin: Key, - /// The one the request is made to. - pub target: Key, - } - - impl FollowRequest { - /// Determine if this follow request is pending. - pub fn is_pending(&self, db: &Store) -> store::Result { - // The status is stored as a mixin, so we need to get it. - let Some(st) = db.get_mixin::(self.id)? else { - // If we don't have a status for a follow request, something is borked. - return Err(Error::Missing); - }; - // If the status of the follow request is pending, it can't also be true that the follows - // relation already exists. - debug_assert! { - !(st == Status::Pending) - || db.exists::(self.origin, self.target).map(|x| !x)?, - "fr.is_pending -> !(fr.origin follows fr.target)" - }; - Ok(st == Status::Pending) - } - } - - /// The status of a [`FollowRequest`]. - /// - /// Valid state transitions: - /// - /// ```text - /// ┌──────────────▶ Rejected - /// │ - /// │ - /// │ - /// - /// None ─────────▶ Pending ────────▶ Accepted - /// - /// │ │ - /// │ │ - /// │ │ - /// ▼ │ - /// Withdrawn ◀────────────┘ - /// ``` - /// - /// In addition, a follow request will be deleted if either endpoint is removed from the graph. - #[derive(Mixin, Encode, Decode, Eq, PartialEq, Clone)] - pub enum Status { - /// The follow request was previously pending or accepted, but since withdrawn. - /// - /// This can happen when someone cancels their follow request or unfollows the target. - Withdrawn, - /// The follow request was accepted. - Accepted, - /// The follow request was denied. - Rejected, - /// The follow request is still under review. - Pending, - } - - /// Request to follow another actor. - pub fn request(db: &Store, requester: Key, target: Key) -> store::Result { - db.run(|tx| { - let req = FollowRequest { - id: Key::gen(), - origin: requester, - target, - }; - tx.create(req)?; - tx.add_mixin(req.id, Status::Pending)?; - Ok(req) - }) - } - - /// Accept the open follow request from `requester` to `target`, if one exists. - pub fn accept(db: &Store, requester: Key, target: Key) -> store::Result<()> { - db.run(|tx| { - // TODO: This logic is a little broken but it'll do for now. i'll fix it later. - let fr = tx - .between::(requester, target) - .filter(|fr| fr.as_ref().is_ok_and(|f| f.target == target)) - // We'll want the latest one, because that one was inserted last so it'll be the most - // recent - .last() - .ok_or_else(|| Error::Missing)??; - // Only apply the update if the last follow request is still in a pending state. - if let Some(Status::Pending) = db.get_mixin(fr.id)? { - tx.update(fr.id, |_| Status::Accepted)?; - tx.create(Follows { - follower: requester, - followed: target, - })?; - } - OK - }) - } - - pub fn reject(db: &Store, request: Key) -> store::Result<()> { - db.run(|tx| { - tx.update(request, |_| Status::Rejected)?; - OK - }) - } - - /// List all pending follow requests for a user. - pub fn list_pending(db: &Store, target: Key) -> store::Result> { - db.incoming::(target) - .filter_bind_results(|req| Ok(if req.is_pending(db)? { Some(req) } else { None })) - .collect() - } - - /// Get all actors followed by `actor`. - pub fn following_of(db: &Store, actor: Key) -> store::Result> { - db.outgoing::(actor) - .map_ok(|a| a.followed) - .collect() - } - - /// Get all actors following `actor`. - pub fn followers_of(db: &Store, actor: Key) -> store::Result> { - db.incoming::(actor) - .map_ok(|a| a.follower) - .collect() - } - - #[cfg(test)] - mod tests { - use store::{Key, Store, OK}; - - use crate::{ - create_actor, - model::{schema, FollowRequest, Follows}, - }; - - fn make_test_actors(db: &Store) -> store::Result<(Key, Key)> { - let alice = create_actor(&db, "alice")?; - let bob = create_actor(&db, "bob")?; - eprintln!("alice={alice}, bob={bob}"); - Ok((alice, bob)) - } - - #[test] - fn create_fr() -> store::Result<()> { - Store::test(schema(), |db| { - let (alice, bob) = make_test_actors(&db)?; - super::request(&db, alice, bob)?; - assert!( - db.exists::(alice, bob)?, - "(alice -> bob) ∈ follow-requested" - ); - assert!( - !db.exists::(alice, bob)?, - "(alice -> bob) ∉ follows" - ); - let pending_for_bob = super::list_pending(&db, bob)? - .into_iter() - .map(|fr| fr.origin) - .collect::>(); - assert_eq!(pending_for_bob, vec![alice], "bob.pending = {{alice}}"); - OK - }) - } - - #[test] - fn accept_fr() -> store::Result<()> { - Store::test(schema(), |db| { - let (alice, bob) = make_test_actors(&db)?; - super::request(&db, alice, bob)?; - super::accept(&db, alice, bob)?; - - assert!( - db.exists::(alice, bob)?, - "(alice -> bob) ∈ follows" - ); - assert!( - !db.exists::(bob, alice)?, - "(bob -> alice) ∉ follows" - ); - - let pending_for_bob = super::list_pending(&db, bob)?; - assert!(pending_for_bob.is_empty(), "bob.pending = ∅"); - - let followers_of_bob = super::followers_of(&db, bob)?; - assert_eq!(followers_of_bob, vec![alice], "bob.followers = {{alice}}"); - - OK - }) - } - - #[test] - fn listing_follow_relations() -> store::Result<()> { - Store::test(schema(), |db| { - let (alice, bob) = make_test_actors(&db)?; - super::request(&db, alice, bob)?; - super::accept(&db, alice, bob)?; - - let followers_of_bob = super::followers_of(&db, bob)?; - assert_eq!(followers_of_bob, vec![alice], "bob.followers = {{alice}}"); - - let following_of_alice = super::following_of(&db, alice)?; - assert_eq!(following_of_alice, vec![bob], "alice.following = {{bob}}"); - - OK - }) - } - } -} +pub mod bites; +pub mod post; +pub mod follows; diff --git a/lib/puppy/src/post.rs b/lib/puppy/src/post.rs new file mode 100644 index 0000000..ea58633 --- /dev/null +++ b/lib/puppy/src/post.rs @@ -0,0 +1,168 @@ +//! Timelines: where you go to view the posts. + +use std::ops::RangeBounds; + +use bincode::{Decode, Encode}; +use chrono::{DateTime, Utc}; +use either::Either::{Left, Right}; +use store::{util::IterExt as _, Arrow, Error, Key, Mixin, Result, Store, Transaction}; + +use crate::model::Profile; + +/// The contents of a post. +#[derive(Mixin, Encode, Decode, Debug, Clone, Default)] +pub struct Content { + /// Main post body. + pub content: Option, + /// Content warning for the post. + pub warning: Option, +} + +impl From<&str> for Content { + fn from(value: &str) -> Self { + value.to_string().into() + } +} + +impl From for Content { + fn from(value: String) -> Self { + Content { + content: Some(value), + warning: None, + } + } +} + +/// The relation that `author` has constructed and published `object`. +#[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)] +pub struct AuthorOf { + #[origin] + pub author: Key, + #[target] + pub object: Key, +} + +/// A piece of content posted by someone. +#[derive(Clone, Debug)] +pub struct Post { + /// The post's internal ID. + pub id: Key, + /// The actual post contents. + pub content: Content, + /// Metadata about the post's author. + pub author: Author, +} + +/// Information about a [`Post`]'s author. +#[derive(Clone, Debug)] +pub struct Author { + /// The identifier of the author. + pub id: Key, + /// The name to display along with the post. + pub display_name: String, + /// An informal identifier for a particular author. + pub handle: String, +} + +/// An ordered list of [`Post`]s for viewing. +#[derive(Debug)] +pub struct Timeline { + items: Vec, +} + +/// Discrete events that can be displayed to a user as part of a timeline. +#[derive(Debug)] +enum Item { + Post(Post), +} + +impl Item { + /// Get the timeline item if it is a [`Post`]. + pub fn as_post(&self) -> Option<&Post> { + match self { + Item::Post(ref post) => Some(post), + } + } +} + +impl Timeline { + /// Get all the posts in the timeline. + pub fn posts(&self) -> impl Iterator { + self.items.iter().filter_map(|x| x.as_post()) + } +} + +/// Gets at most `limit` of the posts known to the instance that were inserted within `time_range`. +pub fn fetch_timeline( + db: &Store, + time_range: impl RangeBounds>, + limit: Option, +) -> Result { + let posts = db.run(|tx| { + // Get all post content entries (the argument passed here is a range of chrono datetimes). + let iter = tx.range::(time_range); + let iter = match limit { + Some(n) => Left(iter.take(n)), + None => Right(iter), + }; + // Then, we're gonna map each of them to their author, and get the profile information needed to + // render the post (mostly display name and handle). + iter.bind_results(|(id, content)| { + // Take the first author. There is nothing stopping a post from having multiple authors, but + // let's take it one step at a time. + let (author, Some(Profile { display_name, account_name, .. })) = tx + .join_on(|a: AuthorOf| a.author, tx.incoming(id))? + .swap_remove(0) + else { + // We expect all posts to have at least one author, so we should complain if there is one + // that doesn't (for now). For robustness, the `.collect()` down there should be replaced + // with a strategy where we log a warning instead of failing, but in the current state of + // the project, failing fast is a good thing. + return Err(Error::Missing); + }; + Ok(Item::Post(Post { + id, + author: Author { + id: author, + handle: format!("@{account_name}"), + display_name: display_name.unwrap_or(account_name.0), + }, + content, + })) + }) + .collect() + })?; + Ok(Timeline { items: posts }) +} + +/// Create a new post. +pub fn create_post(db: &Store, author: Key, content: impl Into) -> store::Result { + db.run(|tx| mixin_post(tx, Key::gen(), author, content)) +} + +/// Add a post's mixins and predicates to an existing `node`. +pub fn mixin_post( + tx: &Transaction<'_>, + node: Key, + author: Key, + content: impl Into, +) -> store::Result { + tx.update::(author, |mut profile| { + profile.post_count += 1; + profile + })?; + tx.add_mixin(node, content.into())?; + tx.create(AuthorOf { author, object: node })?; + Ok(node) +} + +pub fn list_posts_by_author(db: &Store, author: Key) -> store::Result> { + db.run(|tx| { + let posts = tx + .join_on(|a: AuthorOf| a.object, tx.outgoing(author))? + .into_iter() + .filter_map(|(k, opt)| try { (k, opt?) }) + .collect(); + Ok(posts) + }) +} diff --git a/lib/store/src/key.rs b/lib/store/src/key.rs index bf91394..d21f271 100644 --- a/lib/store/src/key.rs +++ b/lib/store/src/key.rs @@ -1,35 +1,23 @@ -use std::fmt::{Debug, Display}; +use std::{ + fmt::{Debug, Display}, + str::FromStr, +}; use chrono::{DateTime, Utc}; use ulid::Ulid; -use crate::arrow::{ArrowKind, Basic, Multi}; +use crate::Error; /// A unique identifier for vertices in the database. #[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct Key(pub(crate) [u8; 16]); -impl Display for Key { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Display::fmt(&Ulid::from_bytes(self.0), f) - } -} - -impl Debug for Key { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Key({})", Ulid::from_bytes(self.0)) - } -} - impl Key { + /// Generate a new node identifier. pub fn gen() -> Key { Key(ulid::Ulid::new().to_bytes()) } - pub(crate) fn from_slice(buf: &[u8]) -> Key { - let mut key = [0; 16]; - key.copy_from_slice(&buf); - Key(key) - } + /// Get the time at which this key was generated. pub fn timestamp(self) -> DateTime { let ms = self.to_ulid().timestamp_ms(); DateTime::from_timestamp_millis(ms as i64).unwrap() @@ -41,11 +29,17 @@ impl Key { buf[16..].copy_from_slice(&other.0); buf } + pub(crate) fn from_slice(buf: &[u8]) -> Key { + let mut key = [0; 16]; + key.copy_from_slice(&buf); + Key(key) + } pub(crate) fn split(buf: &[u8]) -> (Key, Key) { let tail = Key::from_slice(&buf[..16]); let head = Key::from_slice(&buf[16..]); (tail, head) } + // TODO: This doesn't belong here lmao pub(crate) fn range(ts: DateTime) -> ([u8; 16], [u8; 16]) { let min = Ulid::from_parts(ts.timestamp_millis() as u64, u128::MIN).to_bytes(); let max = Ulid::from_parts(ts.timestamp_millis() as u64, u128::MAX).to_bytes(); @@ -61,3 +55,25 @@ impl AsRef<[u8]> for Key { &self.0 } } + +impl FromStr for Key { + type Err = crate::Error; + + fn from_str(s: &str) -> Result { + s.parse::() + .map(|x| Key(x.to_bytes())) + .map_err(|err| Error::BadKey(err)) + } +} + +impl Display for Key { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.to_ulid(), f) + } +} + +impl Debug for Key { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Key({})", Ulid::from_bytes(self.0)) + } +} diff --git a/lib/store/src/lib.rs b/lib/store/src/lib.rs index f8af4cf..37b6a8b 100644 --- a/lib/store/src/lib.rs +++ b/lib/store/src/lib.rs @@ -151,6 +151,8 @@ pub enum Error { /// Returned if there is a conflict; for example, if the uniqueness property of an alias would /// be violated by inserting one. Conflict, + /// A node key couldn't be decoded. + BadKey(ulid::DecodeError), /// Signals a failure related to the data store's backend. Internal(rocksdb::Error), Encoding(bincode::error::EncodeError),