Serve actors by ID

This commit is contained in:
Riley Apeldoorn 2024-04-27 09:32:00 +02:00
parent 7ea8938c49
commit bb26926edb
13 changed files with 633 additions and 464 deletions

2
Cargo.lock generated
View file

@ -478,6 +478,7 @@ checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984"
name = "fetch" name = "fetch"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"derive_more",
"reqwest", "reqwest",
"serde_json", "serde_json",
"sigh", "sigh",
@ -1441,6 +1442,7 @@ version = "0.0.0"
dependencies = [ dependencies = [
"axum", "axum",
"puppy", "puppy",
"serde_json",
"tokio", "tokio",
] ]

View file

@ -1,7 +1,8 @@
use puppy::{ use puppy::{
mixin_ap_actor,
model::{schema, Bite, FollowRequest, Follows, Profile, Username}, model::{schema, Bite, FollowRequest, Follows, Profile, Username},
post::Author, post::Author,
store::{self, Error}, store::{self, Error, OK},
Key, Store, Key, Store,
}; };
@ -11,6 +12,11 @@ fn main() -> store::Result<()> {
println!("creating actors"); println!("creating actors");
let riley = get_or_create_actor(&db, "riley")?; let riley = get_or_create_actor(&db, "riley")?;
let linen = get_or_create_actor(&db, "linen")?; 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 { if true {
println!("creating posts"); println!("creating posts");
puppy::post::create_post(&db, riley, "@linen <3")?; puppy::post::create_post(&db, riley, "@linen <3")?;
@ -72,7 +78,7 @@ fn get_or_create_actor(db: &Store, username: &str) -> Result<Key, Error> {
} }
Ok(None) => { Ok(None) => {
println!("'{username}' doesn't exist yet, creating"); 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 { if let Ok(ref key) = r {
println!("created '{username}' with key {key}"); println!("created '{username}' with key {key}");
} }

View file

@ -6,3 +6,4 @@ edition = "2021"
puppy = { path = "../../lib/puppy" } puppy = { path = "../../lib/puppy" }
tokio = { version = "*", features = ["full"] } tokio = { version = "*", features = ["full"] }
axum = "*" axum = "*"
serde_json = "*"

View file

@ -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] #[tokio::main]
async fn 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<String>| async move {
let object_id = raw_object_id.parse::<Key>().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(); let sock = tokio::net::TcpListener::bind("0.0.0.0:1312").await.unwrap();
axum::serve(sock, app).await.unwrap(); axum::serve(sock, app).await.unwrap();
} }

View file

@ -9,3 +9,4 @@ path = "src/lib.rs"
reqwest = "*" reqwest = "*"
sigh = "*" sigh = "*"
serde_json = "*" serde_json = "*"
derive_more = "*"

View file

@ -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<String>,
}
#[derive(Display, From, Into, Debug, Clone)]
pub struct Id(String);

View file

@ -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 { pub fn mixin(item: TokenStream) -> TokenStream {
let input = syn::parse_macro_input!(item as syn::DeriveInput); let input = syn::parse_macro_input!(item as syn::DeriveInput);

28
lib/puppy/src/bites.rs Normal file
View file

@ -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<Key> {
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<Vec<Bite>> {
db.incoming::<Bite>(victim).try_collect()
}

234
lib/puppy/src/follows.rs Normal file
View file

@ -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<bool> {
// The status is stored as a mixin, so we need to get it.
let Some(st) = db.get_mixin::<Status>(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::<Follows>(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<FollowRequest> {
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::<FollowRequest>(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<Vec<FollowRequest>> {
db.incoming::<FollowRequest>(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<Vec<Key>> {
db.outgoing::<Follows>(actor)
.map_ok(|a| a.followed)
.collect()
}
/// Get all actors following `actor`.
pub fn followers_of(db: &Store, actor: Key) -> store::Result<Vec<Key>> {
db.incoming::<Follows>(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::<FollowRequest>(alice, bob)?,
"(alice -> bob) ∈ follow-requested"
);
assert!(
!db.exists::<Follows>(alice, bob)?,
"(alice -> bob) ∉ follows"
);
let pending_for_bob = super::list_pending(&db, bob)?
.into_iter()
.map(|fr| fr.origin)
.collect::<Vec<_>>();
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::<Follows>(alice, bob)?,
"(alice -> bob) ∈ follows"
);
assert!(
!db.exists::<Follows>(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
})
}
}

View file

@ -1,6 +1,7 @@
#![feature(iterator_try_collect, try_blocks)] #![feature(iterator_try_collect, try_blocks)]
use model::{Profile, Username}; use model::{Channel, Id, Object, ObjectKind, Profile, Username};
pub use store::{self, Key, Store}; pub use store::{self, Key, Store};
use store::{Error, Transaction, OK};
pub mod model { pub mod model {
use bincode::{Decode, Encode}; use bincode::{Decode, Encode};
@ -22,6 +23,7 @@ pub mod model {
/// How many posts has this user made? /// How many posts has this user made?
pub post_count: usize, pub post_count: usize,
/// The name used for the profile's handle. /// The name used for the profile's handle.
#[index] // <- currently doesnt do anything but i have an idea
pub account_name: Username, pub account_name: Username,
/// The name displayed above their posts. /// The name displayed above their posts.
pub display_name: Option<String>, pub display_name: Option<String>,
@ -31,6 +33,46 @@ pub mod model {
pub about_fields: Vec<(String, String)>, 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". /// A unique name for an actor that is part of their "handle".
#[derive(Alias, Encode, Decode, Clone, PartialEq, Eq, Debug, Hash, Display)] #[derive(Alias, Encode, Decode, Clone, PartialEq, Eq, Debug, Hash, Display)]
pub struct Username(pub String); pub struct Username(pub String);
@ -42,8 +84,11 @@ pub mod model {
.has::<Profile>() .has::<Profile>()
.has::<Content>() .has::<Content>()
.has::<Status>() .has::<Status>()
.has::<Object>()
.has::<Channel>()
// Aliases // Aliases
.has::<Username>() .has::<Username>()
.has::<Id>()
// Arrows // Arrows
.has::<Bite>() .has::<Bite>()
.has::<FollowRequest>() .has::<FollowRequest>()
@ -52,10 +97,43 @@ pub mod model {
} }
} }
pub fn create_actor(db: &Store, username: impl ToString) -> store::Result<Key> { /// 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<fetch::Object> {
let Some(obj) = db.get_mixin::<Object>(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<Key> {
let key = Key::gen(); let key = Key::gen();
db.run(|tx| { db.run(|tx| {
let username: Username = username.to_string().into(); 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_alias(key, username.clone())?;
tx.add_mixin(key, Profile { tx.add_mixin(key, Profile {
post_count: 0, post_count: 0,
@ -68,440 +146,15 @@ pub fn create_actor(db: &Store, username: impl ToString) -> store::Result<Key> {
}) })
} }
pub mod bites { /// Add properties related to ActivityPub actors to a vertex.
//! The most essential feature of any social network. pub fn mixin_ap_actor(tx: &Transaction<'_>, vertex: Key, domain: &str) -> store::Result<()> {
let id = Id(format!("http://{domain}/o/{vertex}"));
use store::{Arrow, Key, Store}; tx.add_alias(vertex, id.clone())?;
tx.add_mixin(vertex, Channel { inbox: format!("{id}/inbox") })?;
/// *Bites you* tx.add_mixin(vertex, Object { id, kind: ObjectKind::Actor })?;
#[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<Key> {
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<Vec<Bite>> {
db.incoming::<Bite>(victim).try_collect()
}
}
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<String>,
/// Content warning for the post.
pub warning: Option<String>,
}
impl From<&str> for Content {
fn from(value: &str) -> Self {
value.to_string().into()
}
}
impl From<String> 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<Item>,
}
/// 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<Item = &Post> {
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<DateTime<Utc>>,
limit: Option<usize>,
) -> Result<Timeline> {
let posts = db.run(|tx| {
// Get all post content entries (the argument passed here is a range of chrono datetimes).
let iter = tx.range::<Content>(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<Content>) -> store::Result<Key> {
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<Content>,
) -> store::Result<Key> {
tx.update::<Profile>(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<Vec<(Key, Content)>> {
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<bool> {
// The status is stored as a mixin, so we need to get it.
let Some(st) = db.get_mixin::<Status>(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::<Follows>(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<FollowRequest> {
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::<FollowRequest>(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 OK
})
} }
pub fn reject(db: &Store, request: Key) -> store::Result<()> { pub mod bites;
db.run(|tx| { pub mod post;
tx.update(request, |_| Status::Rejected)?; pub mod follows;
OK
})
}
/// List all pending follow requests for a user.
pub fn list_pending(db: &Store, target: Key) -> store::Result<Vec<FollowRequest>> {
db.incoming::<FollowRequest>(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<Vec<Key>> {
db.outgoing::<Follows>(actor)
.map_ok(|a| a.followed)
.collect()
}
/// Get all actors following `actor`.
pub fn followers_of(db: &Store, actor: Key) -> store::Result<Vec<Key>> {
db.incoming::<Follows>(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::<FollowRequest>(alice, bob)?,
"(alice -> bob) ∈ follow-requested"
);
assert!(
!db.exists::<Follows>(alice, bob)?,
"(alice -> bob) ∉ follows"
);
let pending_for_bob = super::list_pending(&db, bob)?
.into_iter()
.map(|fr| fr.origin)
.collect::<Vec<_>>();
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::<Follows>(alice, bob)?,
"(alice -> bob) ∈ follows"
);
assert!(
!db.exists::<Follows>(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
})
}
}
}

168
lib/puppy/src/post.rs Normal file
View file

@ -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<String>,
/// Content warning for the post.
pub warning: Option<String>,
}
impl From<&str> for Content {
fn from(value: &str) -> Self {
value.to_string().into()
}
}
impl From<String> 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<Item>,
}
/// 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<Item = &Post> {
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<DateTime<Utc>>,
limit: Option<usize>,
) -> Result<Timeline> {
let posts = db.run(|tx| {
// Get all post content entries (the argument passed here is a range of chrono datetimes).
let iter = tx.range::<Content>(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<Content>) -> store::Result<Key> {
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<Content>,
) -> store::Result<Key> {
tx.update::<Profile>(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<Vec<(Key, Content)>> {
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)
})
}

View file

@ -1,35 +1,23 @@
use std::fmt::{Debug, Display}; use std::{
fmt::{Debug, Display},
str::FromStr,
};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use ulid::Ulid; use ulid::Ulid;
use crate::arrow::{ArrowKind, Basic, Multi}; use crate::Error;
/// A unique identifier for vertices in the database. /// A unique identifier for vertices in the database.
#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)] #[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct Key(pub(crate) [u8; 16]); 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 { impl Key {
/// Generate a new node identifier.
pub fn gen() -> Key { pub fn gen() -> Key {
Key(ulid::Ulid::new().to_bytes()) Key(ulid::Ulid::new().to_bytes())
} }
pub(crate) fn from_slice(buf: &[u8]) -> Key { /// Get the time at which this key was generated.
let mut key = [0; 16];
key.copy_from_slice(&buf);
Key(key)
}
pub fn timestamp(self) -> DateTime<Utc> { pub fn timestamp(self) -> DateTime<Utc> {
let ms = self.to_ulid().timestamp_ms(); let ms = self.to_ulid().timestamp_ms();
DateTime::from_timestamp_millis(ms as i64).unwrap() DateTime::from_timestamp_millis(ms as i64).unwrap()
@ -41,11 +29,17 @@ impl Key {
buf[16..].copy_from_slice(&other.0); buf[16..].copy_from_slice(&other.0);
buf 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) { pub(crate) fn split(buf: &[u8]) -> (Key, Key) {
let tail = Key::from_slice(&buf[..16]); let tail = Key::from_slice(&buf[..16]);
let head = Key::from_slice(&buf[16..]); let head = Key::from_slice(&buf[16..]);
(tail, head) (tail, head)
} }
// TODO: This doesn't belong here lmao
pub(crate) fn range(ts: DateTime<Utc>) -> ([u8; 16], [u8; 16]) { pub(crate) fn range(ts: DateTime<Utc>) -> ([u8; 16], [u8; 16]) {
let min = Ulid::from_parts(ts.timestamp_millis() as u64, u128::MIN).to_bytes(); 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(); 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 &self.0
} }
} }
impl FromStr for Key {
type Err = crate::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<Ulid>()
.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))
}
}

View file

@ -151,6 +151,8 @@ pub enum Error {
/// Returned if there is a conflict; for example, if the uniqueness property of an alias would /// Returned if there is a conflict; for example, if the uniqueness property of an alias would
/// be violated by inserting one. /// be violated by inserting one.
Conflict, Conflict,
/// A node key couldn't be decoded.
BadKey(ulid::DecodeError),
/// Signals a failure related to the data store's backend. /// Signals a failure related to the data store's backend.
Internal(rocksdb::Error), Internal(rocksdb::Error),
Encoding(bincode::error::EncodeError), Encoding(bincode::error::EncodeError),