make post federation work

This commit is contained in:
Riley Apeldoorn 2024-05-03 18:35:05 +02:00
parent f0d7d793ca
commit 288c181cc9
10 changed files with 202 additions and 83 deletions

1
Cargo.lock generated
View file

@ -1236,6 +1236,7 @@ dependencies = [
"clap", "clap",
"cli-table", "cli-table",
"puppy", "puppy",
"tokio",
] ]
[[package]] [[package]]

View file

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

View file

@ -1,16 +1,10 @@
//! Control program for the ActivityPub federated social media server. //! Control program for the ActivityPub federated social media server.
#![feature(iterator_try_collect)] #![feature(iterator_try_collect)]
use puppy::{ use puppy::{actor::Actor, config::Config, Context};
actor::Actor,
config::Config,
data::{Bite, Profile},
post::Author,
store::util::IterExt as _,
Context,
};
fn main() -> puppy::Result<()> { #[tokio::main]
async fn main() -> puppy::Result<()> {
// puppy::store::Store::nuke(".state")?; // puppy::store::Store::nuke(".state")?;
let config = Config { let config = Config {
ap_domain: "test.piss-on.me".to_string(), ap_domain: "test.piss-on.me".to_string(),
@ -19,68 +13,69 @@ fn main() -> puppy::Result<()> {
port: 1312, port: 1312,
}; };
let cx = Context::load(config)?; let cx = Context::load(config)?;
let db = cx.store();
println!("creating actors");
let riley = get_or_create_actor(&cx, "riley")?; let riley = get_or_create_actor(&cx, "riley")?;
let linen = get_or_create_actor(&cx, "linen")?; let post = puppy::post::create_post(&cx, riley.key, "i like boys")?;
if true { puppy::post::federate_post(&cx, post).await
println!("creating posts");
puppy::post::create_post(&cx, riley.key, "@linen <3")?;
puppy::post::create_post(&cx, linen.key, "@riley <3")?;
}
if true { // let linen = get_or_create_actor(&cx, "linen")?;
println!("making riley follow linen"); // if true {
cx.run(|tx| { // println!("creating posts");
if !riley.follows(&tx, &linen)? { // puppy::post::create_post(&cx, riley.key, "@linen <3")?;
println!("follow relation does not exist yet"); // puppy::post::create_post(&cx, linen.key, "@riley <3")?;
if let Some(req) = linen // }
.pending_requests(&tx)
.find_ok(|r| r.origin == riley.key)?
{
println!("accepting the pending follow request");
linen.do_accept_request(&cx, req)
} else {
println!("no pending follow request; creating");
riley.do_follow_request(&cx, &linen).map(|_| ())
}
} else {
println!("riley already follows linen");
Ok(())
}
})?;
}
println!("\nPosts on the instance:"); // if true {
for post in puppy::post::fetch_timeline(&db, .., None)?.posts() { // println!("making riley follow linen");
let Author { ref handle, .. } = post.author; // cx.run(|tx| {
let content = post.content.content.as_ref().unwrap(); // if !riley.follows(&tx, &linen)? {
println!("- {:?} by {handle}:\n{content}", post.id) // println!("follow relation does not exist yet");
} // if let Some(req) = linen
// .pending_requests(&tx)
// .find_ok(|r| r.origin == riley.key)?
// {
// println!("accepting the pending follow request");
// linen.do_accept_request(&cx, req)
// } else {
// println!("no pending follow request; creating");
// riley.do_follow_request(&cx, &linen).map(|_| ())
// }
// } else {
// println!("riley already follows linen");
// Ok(())
// }
// })?;
// }
cx.run(|tx| { // println!("\nPosts on the instance:");
println!("\nLinen's followers:"); // for post in puppy::post::fetch_timeline(&db, .., None)?.posts() {
for id in linen.followers(&tx).try_collect::<Vec<_>>()? { // let Author { ref handle, .. } = post.author;
let Profile { account_name, .. } = db.get_mixin(id)?.unwrap(); // let content = post.content.content.as_ref().unwrap();
println!("- @{account_name} ({id})"); // println!("- {:?} by {handle}:\n{content}", post.id)
} // }
println!("\nRiley's following:"); // cx.run(|tx| {
for id in riley.following(&tx).try_collect::<Vec<_>>()? { // println!("\nLinen's followers:");
let Profile { account_name, .. } = db.get_mixin(id)?.unwrap(); // for id in linen.followers(&tx).try_collect::<Vec<_>>()? {
println!("- @{account_name} ({id})"); // let Profile { account_name, .. } = db.get_mixin(id)?.unwrap();
} // println!("- @{account_name} ({id})");
// }
if false { // println!("\nRiley's following:");
println!("Biting riley"); // for id in riley.following(&tx).try_collect::<Vec<_>>()? {
linen.do_bite(&cx, &riley)?; // let Profile { account_name, .. } = db.get_mixin(id)?.unwrap();
for Bite { id, biter, .. } in riley.bites_suffered(&tx).try_collect::<Vec<_>>()? { // println!("- @{account_name} ({id})");
let Profile { account_name, .. } = db.get_mixin(biter)?.unwrap(); // }
println!("riley was bitten by @{account_name} at {}", id.timestamp());
} // if false {
} // println!("Biting riley");
Ok(()) // linen.do_bite(&cx, &riley)?;
}) // for Bite { id, biter, .. } in riley.bites_suffered(&tx).try_collect::<Vec<_>>()? {
// let Profile { account_name, .. } = db.get_mixin(biter)?.unwrap();
// println!("riley was bitten by @{account_name} at {}", id.timestamp());
// }
// }
// Ok(())
// })
} }
fn get_or_create_actor(cx: &Context, username: &str) -> puppy::Result<Actor> { fn get_or_create_actor(cx: &Context, username: &str) -> puppy::Result<Actor> {

View file

@ -228,6 +228,7 @@ async fn dispatch_public(
) -> Result<Response, Message> { ) -> Result<Response, Message> {
match (req.method, req.path()) { match (req.method, req.path()) {
(GET, ["proxy"]) => ap::proxy(&cx, &req.params).await, (GET, ["proxy"]) => ap::proxy(&cx, &req.params).await,
(GET, ["outbox"]) => ap::outbox(&cx, &req.params).await,
(GET, [".well-known", "webfinger"]) => wf::resolve(&cx, &req.params), (GET, [".well-known", "webfinger"]) => wf::resolve(&cx, &req.params),
// TODO: nicer solution for this // TODO: nicer solution for this
(GET, VERIFIER_MOUNT) => Ok(ap::serve_verifier_actor(&verifier)), (GET, VERIFIER_MOUNT) => Ok(ap::serve_verifier_actor(&verifier)),

View file

@ -38,6 +38,29 @@ pub async fn proxy(cx: &Context, params: &[(&str, &str)]) -> Result<Response, Me
Ok(resp.map(Bytes::from).map(Full::new).into()) Ok(resp.map(Bytes::from).map(Full::new).into())
} }
pub async fn outbox(cx: &Context, params: &[(&str, &str)]) -> Result<Response, Message> {
// Extract our query parameters.
let Some(user) = params.iter().find_map(|(k, v)| (*k == "user").then_some(v)) else {
fuck!(400: "expected `user` query param");
};
let Some(content) = params
.iter()
.find_map(|(k, v)| (*k == "content").then_some(v))
else {
fuck!(400: "expected `url` query param");
};
let Ok(Some(actor)) = cx.run(|tx| Actor::by_username(&tx, user)) else {
fuck!(500: "failed actor by name {user}");
};
let post = puppy::post::create_post(&cx, actor.key, content.to_string()).unwrap();
puppy::post::federate_post(&cx, post).await.unwrap();
Ok(respond! {
code: 200
})
}
/// Handle POSTs to actor inboxes. Requires request signature. /// Handle POSTs to actor inboxes. Requires request signature.
pub fn inbox(cx: &Context, actor_id: &str, sig: Signer, body: Value) -> Response { pub fn inbox(cx: &Context, actor_id: &str, sig: Signer, body: Value) -> Response {
todo!() todo!()

View file

@ -1,4 +1,5 @@
use chrono::Utc; use chrono::Utc;
use http::Method;
use http_body_util::BodyExt as _; use http_body_util::BodyExt as _;
use reqwest::Body; use reqwest::Body;
use serde_json::Value; use serde_json::Value;
@ -36,7 +37,28 @@ impl Client {
/// Note that in order for the request to be considered valid by most implementations, `key.owner` /// Note that in order for the request to be considered valid by most implementations, `key.owner`
/// must equal `payload.actor`. /// must equal `payload.actor`.
pub async fn deliver(&self, key: &SigningKey, payload: &Activity, inbox: &str) { pub async fn deliver(&self, key: &SigningKey, payload: &Activity, inbox: &str) {
todo!() let system = Subsystem::Delivery;
let body = serde_json::to_string(&payload.to_json_ld()).unwrap();
let mut req = system
.new_request(inbox)
.unwrap()
.method(Method::POST)
.header("content-type", ACTIVITYPUB_TYPE)
.body(body)
.unwrap();
key.sign_with_digest(Options::LEGACY, &mut req)
.map_err(FetchError::Sig)
.expect("signature generation to work")
.commit(&mut req);
let req = dbg!(req);
let request = req.map(Body::from).try_into().unwrap();
let response = self.inner.execute(request).await.unwrap();
let body = dbg!(response).text().await.unwrap();
dbg!(body);
} }
/// A high-level function to resolve a single ActivityPub ID using a signed request. /// A high-level function to resolve a single ActivityPub ID using a signed request.
pub async fn resolve(&self, key: &SigningKey, url: &str) -> Result<Value, FetchError> { pub async fn resolve(&self, key: &SigningKey, url: &str) -> Result<Value, FetchError> {

View file

@ -72,6 +72,7 @@ pub enum Object {
Other { Other {
id: String, id: String,
kind: String, kind: String,
author: String,
content: Option<String>, content: Option<String>,
summary: Option<String>, summary: Option<String>,
}, },
@ -89,9 +90,19 @@ impl Object {
match self { match self {
Object::Activity(a) => a.to_json_ld(), Object::Activity(a) => a.to_json_ld(),
Object::Actor(a) => a.to_json_ld(), Object::Actor(a) => a.to_json_ld(),
Object::Other { id, kind, content, summary } => json!({ Object::Other {
id,
kind,
content,
summary,
author,
} => json!({
"to": [
"https://www.w3.org/ns/activitystreams#Public",
],
"id": id.to_string(), "id": id.to_string(),
"type": kind, "type": kind,
"attributedTo": author,
"content": content, "content": content,
"summary": summary, "summary": summary,
}), }),

View file

@ -184,7 +184,7 @@ impl SigningKey {
T: AsRef<[u8]>, T: AsRef<[u8]>,
{ {
// Calculate and insert digest if it isn't there yet, otherwise do nothing. // Calculate and insert digest if it isn't there yet, otherwise do nothing.
let digest = encode(sha256(req.body())); let digest = format!("sha-256={}", encode(sha256(req.body())));
req.headers_mut() req.headers_mut()
.entry("digest") .entry("digest")
.or_insert_with(|| digest.try_into().unwrap()); .or_insert_with(|| digest.try_into().unwrap());

View file

@ -16,11 +16,13 @@ pub use context::Context;
#[cfg(test)] #[cfg(test)]
pub use context::test_context; pub use context::test_context;
use data::{ActivityKind, Channel, Content, Create, Id, Object, ObjectKind, Profile, PublicKey}; use data::{
ActivityKind, AuthorOf, Channel, Content, Create, Id, Object, ObjectKind, Profile, PublicKey,
};
use store::Transaction; use store::Transaction;
pub use store::{self, Key, StoreError}; pub use store::{self, Key, StoreError};
pub use fetch; pub use fetch::{self, FetchError};
mod context; mod context;
pub mod data; pub mod data;
@ -76,10 +78,17 @@ pub fn get_local_ap_object(tx: &Transaction<'_>, key: Key) -> Result<fetch::obje
let Some(Content { content, warning, .. }) = tx.get_mixin(key)? else { let Some(Content { content, warning, .. }) = tx.get_mixin(key)? else {
panic!() panic!()
}; };
let Some(AuthorOf { author, .. }) = tx.incoming(key).next().transpose()? else {
panic!()
};
let Some(Id(author)) = tx.get_alias(author)? else {
todo!()
};
Ok(fetch::object::Object::Other { Ok(fetch::object::Object::Other {
id: obj.id.0.clone().into(), id: obj.id.0.clone().into(),
summary: warning, summary: warning,
content, content,
author,
kind, kind,
}) })
} }
@ -101,7 +110,6 @@ pub mod actor {
pub struct Actor { pub struct Actor {
/// The key identifying the actor in the data store. /// The key identifying the actor in the data store.
pub key: Key, pub key: Key,
local: bool,
} }
impl Actor { impl Actor {
@ -111,11 +119,7 @@ pub mod actor {
.lookup(Username(username.to_string())) .lookup(Username(username.to_string()))
.map_err(Error::Store)?; .map_err(Error::Store)?;
// For now, we only have local actors. // For now, we only have local actors.
Ok(maybe_key.map(|key| Actor { key, local: true })) Ok(maybe_key.map(|key| Actor { key }))
}
/// Check whether the actor is local or not.
pub fn is_local(self) -> bool {
self.local
} }
} }
@ -136,7 +140,7 @@ pub mod actor {
about_string: None, about_string: None,
about_fields: Vec::new(), about_fields: Vec::new(),
})?; })?;
Ok(Actor { key, local: true }) Ok(Actor { key })
}) })
} }
@ -189,6 +193,8 @@ pub type Result<T, E = Error> = std::result::Result<T, E>;
pub enum Error { pub enum Error {
/// An error internal to the store. /// An error internal to the store.
Store(StoreError), Store(StoreError),
/// An error generated by the [fetch] subsystem.
Fetch(FetchError),
/// Expected `node` to have some property that it doesn't have. /// Expected `node` to have some property that it doesn't have.
MissingData { MissingData {
/// The node that is missing the data. /// The node that is missing the data.

View file

@ -4,15 +4,22 @@ use std::ops::RangeBounds;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use either::Either::{Left, Right}; use either::Either::{Left, Right};
use fetch::signatures::Private; use fetch::{
object::{Activity, Object},
signatures::Private,
};
use store::{util::IterExt as _, Key, Store, StoreError, Transaction}; use store::{util::IterExt as _, Key, Store, StoreError, Transaction};
use crate::{ use crate::{
data::{self, AuthorOf, Content, Id, Object, ObjectKind, PrivateKey, Profile, PublicKey}, actor::{get_signing_key, Actor},
Context, data::{
self, ActivityKind, AuthorOf, Content, Create, Id, ObjectKind, PrivateKey, Profile,
PublicKey,
},
Context, Error,
}; };
#[derive(Clone, Debug)] #[derive(Clone, Copy, Debug)]
pub struct Post { pub struct Post {
pub key: Key, pub key: Key,
} }
@ -125,7 +132,7 @@ pub fn fetch_timeline(
Ok(Timeline { items: posts }) Ok(Timeline { items: posts })
} }
/// Create a new post. /// Create a new post entity.
pub fn create_post(cx: &Context, author: Key, content: impl Into<Content>) -> crate::Result<Post> { pub fn create_post(cx: &Context, author: Key, content: impl Into<Content>) -> crate::Result<Post> {
let content = content.into(); let content = content.into();
cx.run(|tx| { cx.run(|tx| {
@ -135,7 +142,7 @@ pub fn create_post(cx: &Context, author: Key, content: impl Into<Content>) -> cr
// Federation stuff // Federation stuff
let id = Id(cx.mk_url(key)); let id = Id(cx.mk_url(key));
tx.add_alias(key, id.clone())?; tx.add_alias(key, id.clone())?;
tx.add_mixin(key, Object { tx.add_mixin(key, data::Object {
kind: ObjectKind::Notelike("Note".to_string()), kind: ObjectKind::Notelike("Note".to_string()),
local: true, local: true,
id, id,
@ -144,6 +151,58 @@ pub fn create_post(cx: &Context, author: Key, content: impl Into<Content>) -> cr
}) })
} }
pub async fn federate_post(cx: &Context, post: Post) -> crate::Result<()> {
// Obtain all the data we need to construct our activity
let (Content { content, warning }, url, author, signing_key) = cx.run(|tx| try {
let Some(AuthorOf { author, .. }) = tx.incoming(post.key).next().transpose()? else {
panic!()
};
let signing_key = get_signing_key(tx, Actor { key: author })?;
let (c, data::Object { id, .. }) = tx.get_mixin_many(post.key)?;
(c, id, author, signing_key)
})?;
let activity_key = Key::gen();
// Insert a create activity into the database so we can serve it later
cx.run(|tx| try {
let id = Id(cx.mk_url(activity_key));
tx.add_alias(activity_key, id.clone())?;
tx.add_mixin(activity_key, data::Object {
kind: ObjectKind::Activity(ActivityKind::Create),
local: true,
id,
})?;
tx.create(Create {
id: activity_key,
actor: author,
object: post.key,
})?;
})?;
// Construct an ActivityPub message to send
let activity = Activity {
id: cx.mk_url(activity_key),
actor: signing_key.owner.clone(),
object: Box::new(Object::Other {
id: url.to_string(),
kind: "Note".to_string(),
author: cx.mk_url(author),
summary: warning,
content,
}),
kind: "Create".to_string(),
};
fetch::deliver(
&signing_key,
activity,
"https://crimew.gay/users/ezri/inbox",
)
.await;
Ok(())
}
/// Add a post's mixins and predicates to an existing `node`. /// Add a post's mixins and predicates to an existing `node`.
pub fn mixin_post( pub fn mixin_post(
tx: &Transaction<'_>, tx: &Transaction<'_>,