feat: start refactor of ap module to federation module

This commit is contained in:
nullishamy 2025-05-03 16:01:23 +01:00
parent a924415a74
commit ecb706e93f
Signed by: amy
SSH key fingerprint: SHA256:WmV0uk6WgAQvDJlM8Ld4mFPHZo02CLXXP5VkwQ5xtyk
16 changed files with 564 additions and 430 deletions

View file

@ -1,11 +1,11 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ServerConfig {
pub host: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Config {
pub server: ServerConfig,
}

View file

@ -0,0 +1,106 @@
use crate::ap::http::HttpClient;
use crate::types::ap;
use std::fmt::Debug;
use serde::Serialize;
use thiserror::Error;
use tracing::{Level, error, event, info};
use super::outbox::PreparedActivity;
pub struct HttpWrapper<'a> {
client: &'a HttpClient,
key_id: &'a str,
}
#[derive(Error, Debug)]
pub enum HttpError {
#[error("entity of type `{0}` @ URL `{1}` could not be loaded")]
LoadFailure(String, String),
#[error("entity of type `{0}` @ URL `{1}` could not be parsed ({2})")]
ParseFailure(String, String, String),
}
impl<'a> HttpWrapper<'a> {
pub fn new(client: &'a HttpClient, key_id: &'a str) -> HttpWrapper<'a> {
Self { client, key_id }
}
pub fn client(&self) -> &'a HttpClient {
self.client
}
async fn get<T: serde::de::DeserializeOwned + Debug>(
&self,
ty: &str,
url: &str,
) -> Result<T, HttpError> {
let ty = ty.to_string();
event!(Level::INFO, url, "loading {}", ty);
let http_result = self
.client
.get(url)
.sign(self.key_id)
.activity()
.send()
.await;
if let Err(e) = http_result {
error!("could not load url {}: {:#?}", url, e);
return Err(HttpError::LoadFailure(ty, url.to_string()));
}
let raw_body = http_result.unwrap().text().await;
if let Err(e) = raw_body {
error!("could not get text for url {}: {:#?}", url, e);
return Err(HttpError::LoadFailure(ty, url.to_string()));
}
let raw_body = raw_body.unwrap();
info!("raw body {}", raw_body);
let decoded = serde_json::from_str::<T>(&raw_body);
if let Err(e) = decoded {
error!(
"could not parse {} for url {}: {:#?} {}",
ty, url, e, &raw_body
);
return Err(HttpError::ParseFailure(ty, url.to_string(), e.to_string()));
}
Ok(decoded.unwrap())
}
pub async fn get_person(&self, url: &str) -> Result<ap::Person, HttpError> {
self.get("Person", url).await
}
pub async fn post_activity<T : Serialize + Debug>(
&self,
inbox: &str,
activity: PreparedActivity<T>
) -> Result<String, HttpError> {
let http_result = self
.client
.post(inbox)
.sign(self.key_id)
.json(activity)
.activity()
.send()
.await;
if let Err(e) = http_result {
error!("could not load url {}: {:#?}", inbox, e);
return Err(HttpError::LoadFailure("Activity".to_string(), inbox.to_string()));
}
let raw_body = http_result.unwrap().text().await;
if let Err(e) = raw_body {
error!("could not get text for url {}: {:#?}", inbox, e);
return Err(HttpError::LoadFailure("Activity".to_string(), inbox.to_string()));
}
let raw_body = raw_body.unwrap();
Ok(raw_body.to_string())
}
}

View file

@ -0,0 +1,141 @@
use crate::types::{ap, as_context, db, get, make, Object, ObjectUri, ObjectUuid};
use crate::ap::http::HttpClient;
use super::http::HttpWrapper;
use super::outbox::OutboxRequest;
use super::QueueMessage;
use chrono::DateTime;
use tracing::warn;
#[derive(Debug)]
pub enum InboxRequest {
Delete(ap::DeleteActivity, db::User),
Follow {
activity: ap::FollowActivity,
followed: db::User,
conn: sqlx::SqliteConnection,
outbound: super::QueueHandle
},
Create(ap::CreateActivity, db::User, sqlx::SqliteConnection),
Like(ap::LikeActivity, db::User),
Boost(ap::BoostActivity, db::User)
}
fn key_id(user: &db::User) -> String {
format!("https://ferri.amy.mov/users/{}#main-key", user.id.0)
}
pub async fn handle_inbox_request(
req: InboxRequest,
http: &HttpClient,
) {
match req {
InboxRequest::Delete(_, _) => {
todo!()
},
InboxRequest::Follow { activity, followed, mut conn, outbound } => {
let kid = key_id(&followed);
let http = HttpWrapper::new(http, &kid);
let follower = http.get_person(&activity.actor).await.unwrap();
let follow = db::Follow {
id: ObjectUri(
format!("https://ferri.amy.mov/activities/{}", crate::new_id())
),
follower: follower.obj.id.clone(),
followed: followed.actor.id.clone()
};
make::new_follow(follow, &mut conn).await.unwrap();
let activity = ap::AcceptActivity {
obj: Object {
context: as_context(),
id: ObjectUri(
format!("https://ferri.amy.mov/activities/{}", crate::new_id())
)
},
ty: ap::ActivityType::Accept,
object: activity.obj.id.0.clone(),
actor: followed.actor.id.0.clone()
};
let msg = QueueMessage::Outbound(
OutboxRequest::Accept(activity, kid, follower)
);
outbound.send(msg).await;
},
InboxRequest::Create(activity, user, mut conn) => {
let id = key_id(&user);
let http = HttpWrapper::new(http, &id);
let person = http.get_person(&activity.actor).await.unwrap();
let rmt = person.remote_info();
let post = activity.object;
let post_id = crate::ap::new_id();
let created_at = DateTime::parse_from_rfc3339(&activity.ts)
.map(|dt| dt.to_utc())
.unwrap();
let actor_uri = person.obj.id;
let actor = db::Actor {
id: actor_uri,
inbox: person.inbox,
outbox: person.outbox
};
make::new_actor(actor.clone(), &mut conn)
.await
.unwrap();
let user = get::user_by_actor_uri(actor.id.clone(), &mut conn)
.await
.unwrap_or_else(|_| {
db::User {
id: ObjectUuid(crate::new_id()),
actor,
username: person.preferred_username,
display_name: person.name,
acct: rmt.acct,
remote: rmt.is_remote,
url: rmt.web_url,
created_at: crate::ap::now(),
icon_url: person.icon.map(|ic| ic.url)
.unwrap_or("https//ferri.amy.mov/assets/pfp.png".to_string()),
posts: db::UserPosts {
last_post_at: None
}
}
});
make::new_user(user.clone(), &mut conn)
.await
.unwrap();
let post = db::Post {
id: ObjectUuid(post_id),
uri: post.obj.id,
user,
content: post.content,
created_at,
boosted_post: None
};
make::new_post(post, &mut conn)
.await
.unwrap();
},
InboxRequest::Like(_, _) => {
warn!("unimplemented Like in inbox");
},
InboxRequest::Boost(_, _) => {
warn!("unimplemented Boost in inbox");
},
}
}

View file

@ -0,0 +1,15 @@
/*
What should we handle here:
- Inbound/Outbound queues
- Accepting events from the API to process
- Which entails the logic for all of that
- Remote actioning (webfinger, httpwrapper if possible)
*/
mod request_queue;
pub use request_queue::*;
pub mod inbox;
pub mod outbox;
pub mod http;

View file

@ -0,0 +1,53 @@
use serde::{Deserialize, Serialize};
use tracing::info;
use std::fmt::Debug;
use crate::{ap::http::HttpClient, federation::http::HttpWrapper, types::{ap, ObjectContext}};
#[derive(Debug)]
pub enum OutboxRequest {
// FIXME: Make the String (key_id) nicer
// Probably store it in the DB and pass a db::User here
Accept(ap::AcceptActivity, String, ap::Person)
}
#[derive(Serialize, Deserialize, Debug)]
pub struct PreparedActivity<T: Serialize + Debug> {
#[serde(rename = "@context")]
context: ObjectContext,
id: String,
#[serde(rename = "type")]
ty: ap::ActivityType,
actor: String,
object: T,
published: String,
}
pub async fn handle_outbox_request(
req: OutboxRequest,
http: &HttpClient
) {
match req {
OutboxRequest::Accept(activity, key_id, person) => {
let http = HttpWrapper::new(http, &key_id);
info!("accepting {}", activity.object);
let activity = PreparedActivity {
context: activity.obj.context,
id: activity.obj.id.0,
ty: activity.ty,
actor: activity.actor,
object: activity.object,
published: crate::ap::new_ts()
};
let res = http
.post_activity(&person.inbox, activity)
.await
.unwrap();
info!("accept res {}", res);
},
}
}

View file

@ -0,0 +1,72 @@
use tokio::sync::mpsc;
use tracing::{info, span, Instrument, Level};
use crate::ap::http::HttpClient;
use crate::config::Config;
use crate::federation::inbox::handle_inbox_request;
use crate::federation::outbox::handle_outbox_request;
use super::inbox::InboxRequest;
use super::outbox::OutboxRequest;
#[derive(Debug)]
pub enum QueueMessage {
Heartbeat,
Inbound(InboxRequest),
Outbound(OutboxRequest),
}
pub struct RequestQueue {
name: &'static str,
send: mpsc::Sender<QueueMessage>,
recv: mpsc::Receiver<QueueMessage>,
}
#[derive(Clone, Debug)]
pub struct QueueHandle {
send: mpsc::Sender<QueueMessage>,
}
impl QueueHandle {
pub async fn send(&self, msg: QueueMessage) {
self.send.send(msg).await.unwrap();
}
}
impl RequestQueue {
pub fn new(name: &'static str) -> Self {
let (send, recv) = mpsc::channel(1024);
Self { name, send, recv }
}
pub fn spawn(self, config: Config) -> QueueHandle {
info!("starting up queue '{}'", self.name);
let span = span!(Level::INFO, "queue", queue_name = self.name);
let fut = async move {
info!("using config {:#?}, queue is up", config);
let mut recv = self.recv;
let http = HttpClient::new();
while let Some(req) = recv.recv().await {
info!(?req, "got a message into the queue");
match req {
QueueMessage::Heartbeat => {
info!("heartbeat on queue");
},
QueueMessage::Inbound(inbox_request) => {
handle_inbox_request(inbox_request, &http).await;
},
QueueMessage::Outbound(outbox_request) => {
handle_outbox_request(outbox_request, &http).await;
},
}
}
}.instrument(span);
tokio::spawn(fut);
QueueHandle { send: self.send }
}
}

View file

@ -1,6 +1,7 @@
pub mod ap;
pub mod config;
pub mod types;
pub mod federation;
use rand::{Rng, distributions::Alphanumeric};
@ -11,3 +12,7 @@ pub fn gen_token(len: usize) -> String {
.map(char::from)
.collect()
}
pub fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
}

View file

@ -8,6 +8,7 @@ pub async fn new_user(user: db::User, conn: &mut SqliteConnection) -> Result<db:
INSERT INTO user (id, acct, url, created_at, remote,
username, actor_id, display_name, icon_url)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(actor_id) DO NOTHING
"#,
user.id.0,
user.acct,
@ -34,6 +35,7 @@ pub async fn new_actor(
r#"
INSERT INTO actor (id, inbox, outbox)
VALUES (?1, ?2, ?3)
ON CONFLICT(id) DO NOTHING
"#,
actor.id.0,
actor.inbox,
@ -45,3 +47,54 @@ pub async fn new_actor(
Ok(actor)
}
pub async fn new_follow(
follow: db::Follow,
conn: &mut SqliteConnection,
) -> Result<db::Follow, DbError> {
sqlx::query!(
r#"
INSERT INTO follow (id, follower_id, followed_id)
VALUES (?1, ?2, ?3)
"#,
follow.id.0,
follow.follower.0,
follow.follower.0,
)
.execute(conn)
.await
.map_err(|e| DbError::CreationError(e.to_string()))?;
Ok(follow)
}
pub async fn new_post(
post: db::Post,
conn: &mut SqliteConnection,
) -> Result<db::Post, DbError> {
let ts = post.created_at.to_rfc3339();
let boosted = post.boosted_post.as_ref().map(|b| &b.0);
sqlx::query!(
r#"
INSERT INTO post (id, uri, user_id, content, created_at, boosted_post_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(uri) DO NOTHING
"#,
post.id.0,
post.uri.0,
post.user.id.0,
post.content,
ts,
boosted
)
.execute(conn)
.await
.map_err(|e| DbError::CreationError(e.to_string()))?;
Ok(post)
}

View file

@ -63,6 +63,13 @@ pub mod db {
use super::*;
use chrono::{DateTime, Utc};
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct Follow {
pub id: ObjectUri,
pub follower: ObjectUri,
pub followed: ObjectUri,
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct Actor {
pub id: ObjectUri,
@ -105,12 +112,14 @@ pub mod db {
pub mod ap {
use super::*;
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub enum ActivityType {
Create,
Note,
Delete,
Undo,
Accept,
Announce,
Like,
@ -261,6 +270,33 @@ pub mod ap {
pub icon: Option<PersonIcon>
}
pub struct RemoteInfo {
pub is_remote: bool,
pub web_url: String,
pub acct: String
}
impl Person {
pub fn remote_info(&self) -> RemoteInfo {
let url = Url::parse(&self.obj.id.0).unwrap();
let host = url.host_str().unwrap();
let (acct, remote) = if host != "ferri.amy.mov" {
(format!("{}@{}", self.preferred_username, host), true)
} else {
(self.preferred_username.clone(), false)
};
let url = format!("https://ferri.amy.mov/{}", acct);
RemoteInfo {
acct: acct.to_string(),
web_url: url,
is_remote: remote,
}
}
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct UserKey {
pub id: String,