Serve actors by ID
This commit is contained in:
parent
7ea8938c49
commit
bb26926edb
13 changed files with 633 additions and 464 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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}");
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = "*"
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,3 +9,4 @@ path = "src/lib.rs"
|
||||||
reqwest = "*"
|
reqwest = "*"
|
||||||
sigh = "*"
|
sigh = "*"
|
||||||
serde_json = "*"
|
serde_json = "*"
|
||||||
|
derive_more = "*"
|
||||||
|
|
|
@ -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);
|
|
@ -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
28
lib/puppy/src/bites.rs
Normal 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
234
lib/puppy/src/follows.rs
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)]
|
OK
|
||||||
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 {
|
pub mod bites;
|
||||||
//! Timelines: where you go to view the posts.
|
pub mod post;
|
||||||
|
pub mod follows;
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
168
lib/puppy/src/post.rs
Normal file
168
lib/puppy/src/post.rs
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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),
|
||||||
|
|
Loading…
Reference in a new issue