Compare commits

...

5 Commits

36 changed files with 5167 additions and 9 deletions

View File

@@ -26,7 +26,7 @@ thiserror = "2.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
async-trait = "0.1"
uuid = { version = "1.0", features = ["v4", "serde"] }
uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] }
axum = { version = "0.8", features = ["macros"] }
@@ -35,6 +35,8 @@ futures = "0.3"
dotenvy = "0.15"
async-nats = "0.38"
async-stream = "0.3"
reqwest = { version = "0.13", features = ["json"] }
url = { version = "2", features = ["serde"] }
domain = { path = "crates/domain" }
application = { path = "crates/application" }

View File

@@ -1,4 +1,21 @@
[package]
name = "activitypub-base"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
axum = { workspace = true }
reqwest = { workspace = true }
url = { workspace = true }
domain = { workspace = true }
activitypub_federation = "0.7.0-beta.11"
enum_delegate = "0.2"

View File

@@ -0,0 +1,615 @@
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
kinds::activity::{
AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType,
},
traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Announce")]
pub struct AnnounceType;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use crate::repository::{FollowerStatus, FollowingStatus};
// --- Follow ---
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FollowActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: FollowType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: ObjectId<DbActor>,
}
#[async_trait::async_trait]
impl Activity for FollowActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let target_url = self.object.inner();
let target_domain = match (target_url.host_str(), target_url.port()) {
(Some(host), Some(port)) => format!("{}:{}", host, port),
(Some(host), None) => host.to_string(),
_ => {
return Err(Error::bad_request(anyhow::anyhow!(
"invalid follow target URL"
)));
}
};
if target_domain != data.domain {
return Err(Error::bad_request(anyhow::anyhow!(
"follow target is not a local actor"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let _follower = self.actor.dereference(data).await?;
let local_actor = self.object.dereference(data).await?;
if data
.federation_repo
.is_actor_blocked(local_actor.user_id, self.actor.inner().as_str())
.await?
{
tracing::info!(actor = %self.actor.inner(), "ignoring follow from blocked actor");
return Ok(());
}
data.federation_repo
.add_follower(
local_actor.user_id,
self.actor.inner().as_str(),
FollowerStatus::Pending,
self.id.as_str(),
)
.await?;
tracing::info!(
follower = %self.actor.inner(),
local_user = %local_actor.user_id,
"follow request pending approval"
);
Ok(())
}
}
// --- Accept ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AcceptActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AcceptType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: FollowActivity,
}
#[async_trait::async_trait]
impl Activity for AcceptActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner())
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?;
data.federation_repo
.update_following_status(
local_user_id,
self.actor.inner().as_str(),
FollowingStatus::Accepted,
)
.await?;
tracing::info!(remote_actor = %self.actor.inner(), "follow accepted by remote");
Ok(())
}
}
// --- Reject ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RejectActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: RejectType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: FollowActivity,
}
#[async_trait::async_trait]
impl Activity for RejectActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.actor.inner()) {
data.federation_repo
.remove_following(user_id, self.actor.inner().as_str())
.await?;
}
tracing::info!(actor = %self.actor.inner(), "follow rejected");
Ok(())
}
}
// --- Undo ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UndoActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: UndoType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
}
#[async_trait::async_trait]
impl Activity for UndoActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring Undo from blocked domain");
return Ok(());
}
let obj_type = self
.object
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("");
match obj_type {
"Follow" => {
if let Some(obj_url) = self.object.get("object").and_then(|o| o.as_str()) {
if let Ok(url) = Url::parse(obj_url) {
if let Some(user_id) = crate::urls::extract_user_id_from_url(&url) {
data.federation_repo
.remove_follower(user_id, self.actor.inner().as_str())
.await?;
}
}
}
data.object_handler
.on_actor_removed(self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %self.actor.inner(), "unfollowed");
}
"Add" => {
let ap_id_str = self
.object
.get("object")
.and_then(|o| o.get("id"))
.and_then(|id| id.as_str())
.or_else(|| self.object.get("id").and_then(|id| id.as_str()));
if let Some(ap_id_str) = ap_id_str {
if let Ok(ap_id) = Url::parse(ap_id_str) {
data.object_handler
.on_delete(&ap_id, self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(ap_id = %ap_id_str, "undo Add (watchlist remove)");
}
}
}
other => {
tracing::debug!(kind = %other, "ignoring Undo of unknown activity type");
}
}
Ok(())
}
}
// --- Create ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: CreateType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for CreateActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let ap_id = self.id.clone();
let actor_url = self.actor.inner().clone();
data.object_handler
.on_create(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received create activity");
Ok(())
}
}
// --- Delete ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: DeleteType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for DeleteActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let actor_url = self.actor.inner().clone();
data.object_handler
.on_delete(&self.object, &actor_url)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(object = %self.object, "received delete activity");
Ok(())
}
}
// --- Update ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: UpdateType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for UpdateActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let ap_id = self.id.clone();
let actor_url = self.actor.inner().clone();
data.object_handler
.on_update(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received update activity");
Ok(())
}
}
// --- Announce ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnnounceActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AnnounceType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
pub(crate) published: Option<chrono::DateTime<chrono::Utc>>,
}
#[async_trait::async_trait]
impl Activity for AnnounceActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let object_domain = self.object.host_str().unwrap_or("");
if object_domain != data.domain {
return Ok(());
}
data.federation_repo
.add_announce(
self.id.as_str(),
self.object.as_str(),
self.actor.inner().as_str(),
self.published.unwrap_or_else(chrono::Utc::now),
)
.await?;
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce");
Ok(())
}
}
// --- Add ---
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Add")]
pub struct AddType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AddActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AddType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for AddActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring Add from blocked domain");
return Ok(());
}
let ap_id = self.id.clone();
let actor_url = self.actor.inner().clone();
data.object_handler
.on_create(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received Add activity");
Ok(())
}
}
// --- Block ---
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Block")]
pub struct BlockType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlockActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: BlockType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
}
#[async_trait::async_trait]
impl Activity for BlockActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
// They blocked us — remove them from our following list
if let Some(local_user_id) = crate::urls::extract_user_id_from_url(&self.object) {
let _ = data
.federation_repo
.remove_following(local_user_id, self.actor.inner().as_str())
.await;
}
tracing::info!(actor = %self.actor.inner(), "received block");
Ok(())
}
}
// --- Inbox dispatch enum ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "type")]
#[enum_delegate::implement(Activity)]
pub enum InboxActivities {
#[serde(rename = "Follow")]
Follow(FollowActivity),
#[serde(rename = "Accept")]
Accept(AcceptActivity),
#[serde(rename = "Reject")]
Reject(RejectActivity),
#[serde(rename = "Undo")]
Undo(UndoActivity),
#[serde(rename = "Create")]
Create(CreateActivity),
#[serde(rename = "Delete")]
Delete(DeleteActivity),
#[serde(rename = "Update")]
Update(UpdateActivity),
#[serde(rename = "Announce")]
Announce(AnnounceActivity),
#[serde(rename = "Add")]
Add(AddActivity),
#[serde(rename = "Block")]
Block(BlockActivity),
}

View File

@@ -0,0 +1,25 @@
use activitypub_federation::{
axum::json::FederationJson, config::Data, protocol::context::WithContext, traits::Object,
};
use axum::extract::Path;
use crate::actors::{Person, get_local_actor};
use crate::data::FederationData;
use crate::error::Error;
pub async fn actor_handler(
Path(username): Path<String>,
data: Data<FederationData>,
) -> Result<FederationJson<WithContext<Person>>, Error> {
let ap_user = data
.user_repo
.find_by_username(&username)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("user not found")))?;
let db_actor = get_local_actor(ap_user.id, &data).await?;
let person = db_actor.into_json(&data).await?;
Ok(FederationJson(WithContext::new_default(person)))
}

View File

@@ -0,0 +1,327 @@
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
http_signatures::generate_actor_keypair,
kinds::actor::PersonType,
protocol::{public_key::PublicKey, verification::verify_domains_match},
traits::{Actor, Object},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::data::FederationData;
use crate::error::Error;
use crate::repository::RemoteActor;
use crate::user::ApProfileField;
#[derive(Debug, Clone)]
pub struct DbActor {
pub user_id: uuid::Uuid,
pub username: String,
pub public_key_pem: String,
pub private_key_pem: Option<String>,
pub inbox_url: Url,
pub outbox_url: Url,
pub followers_url: Url,
pub following_url: Url,
pub ap_id: Url,
pub last_refreshed_at: DateTime<Utc>,
pub bio: Option<String>,
pub avatar_url: Option<Url>,
pub banner_url: Option<Url>,
pub also_known_as: Option<String>,
pub profile_url: Option<Url>,
pub attachment: Vec<ApProfileField>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApImageObject {
#[serde(rename = "type")]
pub kind: String,
pub url: Url,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Endpoints {
pub shared_inbox: Url,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileFieldObject {
#[serde(rename = "type")]
pub kind: String,
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Person {
#[serde(rename = "type")]
kind: PersonType,
id: ObjectId<DbActor>,
preferred_username: String,
inbox: Url,
outbox: Url,
followers: Url,
following: Url,
public_key: PublicKey,
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<ApImageObject>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
discoverable: Option<bool>,
manually_approves_followers: bool,
#[serde(skip_serializing_if = "Option::is_none", default)]
updated: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
endpoints: Option<Endpoints>,
#[serde(skip_serializing_if = "Option::is_none")]
image: Option<ApImageObject>,
#[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)]
also_known_as: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
attachment: Vec<ProfileFieldObject>,
}
pub async fn get_local_actor(
user_id: uuid::Uuid,
data: &Data<FederationData>,
) -> Result<DbActor, Error> {
let user = data
.user_repo
.find_by_id(user_id)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found: {}", user_id)))?;
let (public_key, private_key) = match data
.federation_repo
.get_local_actor_keypair(user_id)
.await?
{
Some(kp) => kp,
None => {
let kp = generate_actor_keypair()?;
data.federation_repo
.save_local_actor_keypair(user_id, kp.public_key.clone(), kp.private_key.clone())
.await?;
(kp.public_key, kp.private_key)
}
};
let ap_id = crate::urls::actor_url(&data.base_url, user_id);
let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid inbox url");
let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid outbox url");
let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid followers url");
let following_url = Url::parse(&format!("{}/following", &ap_id)).expect("valid following url");
Ok(DbActor {
user_id,
username: user.username,
public_key_pem: public_key,
private_key_pem: Some(private_key),
inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: user.bio,
avatar_url: user.avatar_url,
banner_url: user.banner_url,
also_known_as: user.also_known_as,
profile_url: user.profile_url,
attachment: user.attachment,
})
}
#[async_trait::async_trait]
impl Object for DbActor {
type DataType = FederationData;
type Kind = Person;
type Error = Error;
fn id(&self) -> &Url {
&self.ap_id
}
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {
Some(self.last_refreshed_at)
}
async fn read_from_id(
object_id: Url,
data: &Data<Self::DataType>,
) -> Result<Option<Self>, Self::Error> {
let user_id = match crate::urls::extract_user_id_from_url(&object_id) {
Some(id) => id,
None => return Ok(None),
};
let user = match data.user_repo.find_by_id(user_id).await {
Ok(Some(u)) => u,
_ => return Ok(None),
};
let keypair = data
.federation_repo
.get_local_actor_keypair(user_id)
.await?;
let (public_key, private_key) = match keypair {
Some(kp) => (kp.0, Some(kp.1)),
None => return Ok(None),
};
let ap_id = crate::urls::actor_url(&data.base_url, user_id);
let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid url");
let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid url");
let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid url");
let following_url = Url::parse(&format!("{}/following", &ap_id)).expect("valid url");
Ok(Some(DbActor {
user_id,
username: user.username,
public_key_pem: public_key,
private_key_pem: private_key,
inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: None,
avatar_url: None,
banner_url: None,
also_known_as: None,
profile_url: None,
attachment: vec![],
}))
}
async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
let public_key = PublicKey {
id: format!("{}#main-key", &self.ap_id),
owner: self.ap_id.clone(),
public_key_pem: self.public_key_pem.clone(),
};
let icon = self.avatar_url.map(|url| ApImageObject {
kind: "Image".to_string(),
url,
});
let image = self.banner_url.map(|url| ApImageObject {
kind: "Image".to_string(),
url,
});
let profile_url = self.profile_url;
let also_known_as: Vec<String> = self.also_known_as.into_iter().collect();
let attachment: Vec<ProfileFieldObject> = self
.attachment
.into_iter()
.map(|f| ProfileFieldObject {
kind: "PropertyValue".to_string(),
name: f.name,
value: f.value,
})
.collect();
let shared_inbox =
Url::parse(&format!("{}/inbox", data.base_url)).expect("base_url is always valid");
Ok(Person {
kind: Default::default(),
id: self.ap_id.clone().into(),
preferred_username: self.username.clone(),
inbox: self.inbox_url.clone(),
outbox: self.outbox_url.clone(),
followers: self.followers_url.clone(),
following: self.following_url.clone(),
public_key,
name: Some(self.username.clone()),
summary: self.bio.clone(),
icon,
url: profile_url,
discoverable: Some(true),
manually_approves_followers: true,
updated: Some(self.last_refreshed_at),
endpoints: Some(Endpoints { shared_inbox }),
image,
also_known_as,
attachment,
})
}
async fn verify(
json: &Self::Kind,
expected_domain: &Url,
_data: &Data<Self::DataType>,
) -> Result<(), Self::Error> {
verify_domains_match(json.id.inner(), expected_domain)?;
Ok(())
}
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
let actor = RemoteActor {
url: json.id.inner().to_string(),
handle: json.preferred_username.clone(),
inbox_url: json.inbox.to_string(),
shared_inbox_url: None,
display_name: json.name.clone(),
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
outbox_url: Some(json.outbox.to_string()),
};
data.federation_repo.upsert_remote_actor(actor).await?;
let url_str = json.id.inner().to_string();
let user_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url_str.as_bytes());
let ap_id = json.id.inner().clone();
let inbox_url = json.inbox.clone();
let outbox_url = json.outbox.clone();
let followers_url = json.followers.clone();
let following_url = json.following.clone();
Ok(DbActor {
user_id,
username: json.preferred_username.clone(),
public_key_pem: json.public_key.public_key_pem,
private_key_pem: None,
inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: None,
avatar_url: None,
banner_url: None,
also_known_as: None,
profile_url: None,
attachment: vec![],
})
}
}
impl Actor for DbActor {
fn public_key_pem(&self) -> &str {
&self.public_key_pem
}
fn private_key_pem(&self) -> Option<String> {
self.private_key_pem.clone()
}
fn inbox(&self) -> Url {
self.inbox_url.clone()
}
}
#[cfg(test)]
#[path = "tests/actors.rs"]
mod tests;

View File

@@ -0,0 +1,47 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use url::Url;
#[async_trait]
pub trait ApObjectHandler: Send + Sync {
/// Returns (ap_id, serialized object) for all local content owned by this user.
/// Used by outbox (count) and backfill (delivery). Must only return locally-authored content.
async fn get_local_objects_for_user(
&self,
user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>;
/// Returns up to `limit` objects ordered newest-first, published before `before`.
/// Returns (ap_id, object_json, published_at).
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<DateTime<Utc>>,
limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;
/// Incoming Create activity — persist remote content.
async fn on_create(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()>;
/// Incoming Update activity — update existing remote content.
async fn on_update(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()>;
/// Incoming Delete activity — remove specific remote content.
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// Actor unfollowed/was removed — clean up all their remote content.
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>;
/// Total number of locally-authored posts across all users.
async fn count_local_posts(&self) -> anyhow::Result<u64>;
}

View File

@@ -0,0 +1,48 @@
use std::sync::Arc;
use crate::content::ApObjectHandler;
use crate::repository::FederationRepository;
use crate::user::ApUserRepository;
use domain::ports::EventPublisher;
#[derive(Clone)]
pub struct FederationData {
pub(crate) federation_repo: Arc<dyn FederationRepository>,
pub(crate) user_repo: Arc<dyn ApUserRepository>,
pub(crate) object_handler: Arc<dyn ApObjectHandler>,
pub(crate) base_url: String,
pub(crate) domain: String,
pub(crate) allow_registration: bool,
pub(crate) software_name: String,
pub(crate) event_publisher: Option<Arc<dyn EventPublisher>>,
}
impl FederationData {
pub fn new(
federation_repo: Arc<dyn FederationRepository>,
user_repo: Arc<dyn ApUserRepository>,
object_handler: Arc<dyn ApObjectHandler>,
base_url: String,
allow_registration: bool,
software_name: String,
event_publisher: Option<Arc<dyn EventPublisher>>,
) -> Self {
let domain = base_url
.trim_start_matches("https://")
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or("")
.to_string();
Self {
federation_repo,
user_repo,
object_handler,
base_url,
domain,
allow_registration,
software_name,
event_publisher,
}
}
}

View File

@@ -0,0 +1,48 @@
use std::fmt::{Display, Formatter};
use axum::http::StatusCode;
#[derive(Debug)]
pub struct Error(pub(crate) anyhow::Error, pub(crate) StatusCode);
impl Error {
pub fn not_found(e: impl Into<anyhow::Error>) -> Self {
Self(e.into(), StatusCode::NOT_FOUND)
}
pub fn bad_request(e: impl Into<anyhow::Error>) -> Self {
Self(e.into(), StatusCode::BAD_REQUEST)
}
}
impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}
impl<T> From<T> for Error
where
T: Into<anyhow::Error>,
{
fn from(t: T) -> Self {
Error(t.into(), StatusCode::INTERNAL_SERVER_ERROR)
}
}
impl axum::response::IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
let status = self.1;
if status.is_server_error() {
tracing::error!(error = %self.0, status = status.as_u16(), "federation error");
} else {
tracing::debug!(error = %self.0, status = status.as_u16(), "federation response");
}
let body = if status.is_server_error() {
"internal server error".to_string()
} else {
self.0.to_string()
};
(status, body).into_response()
}
}

View File

@@ -0,0 +1,50 @@
use activitypub_federation::config::{Data, FederationConfig, FederationMiddleware, UrlVerifier};
use activitypub_federation::error::Error as FedError;
use url::Url;
use crate::data::FederationData;
#[derive(Clone)]
struct PermissiveVerifier;
#[async_trait::async_trait]
impl UrlVerifier for PermissiveVerifier {
async fn verify(&self, _url: &Url) -> Result<(), FedError> {
Ok(())
}
}
#[derive(Clone)]
pub struct ApFederationConfig(pub FederationConfig<FederationData>);
impl ApFederationConfig {
pub async fn new(data: FederationData, debug: bool) -> anyhow::Result<Self> {
let config = if debug {
FederationConfig::builder()
.domain(&data.domain)
.app_data(data)
.debug(true)
.http_signature_compat(true)
.url_verifier(Box::new(PermissiveVerifier))
.build()
.await?
} else {
FederationConfig::builder()
.domain(&data.domain)
.app_data(data)
.debug(false)
.http_signature_compat(true)
.build()
.await?
};
Ok(Self(config))
}
pub fn to_request_data(&self) -> Data<FederationData> {
self.0.to_request_data()
}
pub fn middleware(&self) -> FederationMiddleware<FederationData> {
FederationMiddleware::new(self.0.clone())
}
}

View File

@@ -0,0 +1,130 @@
use activitypub_federation::{axum::json::FederationJson, config::Data};
use axum::extract::{Path, Query};
use serde::Deserialize;
use serde_json::json;
use crate::data::FederationData;
use crate::error::Error;
const PAGE_SIZE: usize = 20;
#[derive(Deserialize)]
pub struct PageQuery {
page: Option<u32>,
}
pub async fn followers_handler(
Path(user_id_str): Path<String>,
Query(query): Query<PageQuery>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> {
let user_id = uuid::Uuid::parse_str(&user_id_str)
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
data.user_repo
.find_by_id(user_id)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let collection_id = format!("{}/users/{}/followers", data.base_url, user_id_str);
let total = data
.federation_repo
.count_followers(user_id)
.await
.map_err(Error::from)?;
if let Some(page) = query.page {
let page = page.max(1);
let offset = (page.saturating_sub(1) as usize) * PAGE_SIZE;
let followers = data
.federation_repo
.get_followers_page(user_id, offset as u32, PAGE_SIZE)
.await
.map_err(Error::from)?;
let has_next = offset + followers.len() < total;
let items: Vec<String> = followers.into_iter().map(|f| f.actor.url).collect();
let mut obj = json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollectionPage",
"id": format!("{}?page={}", collection_id, page),
"partOf": collection_id,
"totalItems": total,
"orderedItems": items,
});
if has_next {
obj["next"] = json!(format!("{}?page={}", collection_id, page + 1));
}
Ok(FederationJson(obj))
} else {
Ok(FederationJson(json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollection",
"id": collection_id,
"totalItems": total,
"first": format!("{}?page=1", collection_id),
})))
}
}
pub async fn following_handler(
Path(user_id_str): Path<String>,
Query(query): Query<PageQuery>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> {
let user_id = uuid::Uuid::parse_str(&user_id_str)
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
data.user_repo
.find_by_id(user_id)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let collection_id = format!("{}/users/{}/following", data.base_url, user_id_str);
let total = data
.federation_repo
.count_following(user_id)
.await
.map_err(Error::from)?;
if let Some(page) = query.page {
let page = page.max(1);
let offset = (page.saturating_sub(1) as usize) * PAGE_SIZE;
let following = data
.federation_repo
.get_following_page(user_id, offset as u32, PAGE_SIZE)
.await
.map_err(Error::from)?;
let has_next = offset + following.len() < total;
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
let mut obj = json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollectionPage",
"id": format!("{}?page={}", collection_id, page),
"partOf": collection_id,
"totalItems": total,
"orderedItems": items,
});
if has_next {
obj["next"] = json!(format!("{}?page={}", collection_id, page + 1));
}
Ok(FederationJson(obj))
} else {
Ok(FederationJson(json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollection",
"id": collection_id,
"totalItems": total,
"first": format!("{}?page=1", collection_id),
})))
}
}

View File

@@ -0,0 +1,18 @@
use activitypub_federation::{
axum::inbox::{ActivityData, receive_activity},
config::Data,
protocol::context::WithContext,
};
use crate::activities::InboxActivities;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
pub async fn inbox_handler(
data: Data<FederationData>,
activity_data: ActivityData,
) -> Result<(), Error> {
receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(activity_data, &data)
.await
}

View File

@@ -0,0 +1,27 @@
pub mod activities;
pub mod actor_handler;
pub mod actors;
pub mod content;
pub mod data;
pub mod error;
pub mod federation;
pub mod followers_handler;
pub mod inbox;
pub mod nodeinfo;
pub mod outbox;
pub mod repository;
pub mod service;
pub(crate) mod urls;
pub use urls::AS_PUBLIC;
pub mod user;
pub mod webfinger;
pub use content::ApObjectHandler;
pub use data::FederationData;
pub use error::Error;
pub use federation::ApFederationConfig;
pub use repository::{
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
pub use service::ActivityPubService;
pub use user::{ApProfileField, ApUser, ApUserRepository};

View File

@@ -0,0 +1,80 @@
use activitypub_federation::config::Data;
use axum::Json;
use serde::Serialize;
use crate::data::FederationData;
use crate::error::Error;
#[derive(Serialize)]
pub struct NodeInfoWellKnown {
pub links: Vec<NodeInfoLink>,
}
#[derive(Serialize)]
pub struct NodeInfoLink {
pub rel: String,
pub href: String,
}
#[derive(Serialize)]
pub struct NodeInfoSoftware {
pub name: String,
pub version: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeInfoUsage {
pub users: NodeInfoUsers,
pub local_posts: u64,
}
#[derive(Serialize)]
pub struct NodeInfoUsers {
pub total: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeInfo {
pub version: String,
pub software: NodeInfoSoftware,
pub protocols: Vec<String>,
pub usage: NodeInfoUsage,
pub open_registrations: bool,
}
pub async fn nodeinfo_well_known_handler(
data: Data<FederationData>,
) -> Result<Json<NodeInfoWellKnown>, Error> {
let href = format!("{}/nodeinfo/2.0", data.base_url);
Ok(Json(NodeInfoWellKnown {
links: vec![NodeInfoLink {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
href,
}],
}))
}
pub async fn nodeinfo_handler(data: Data<FederationData>) -> Result<Json<NodeInfo>, Error> {
let user_count = data.user_repo.count_users().await.unwrap_or(0);
let local_posts = data.object_handler.count_local_posts().await.unwrap_or(0);
Ok(Json(NodeInfo {
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: data.software_name.clone(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
protocols: vec!["activitypub".to_string()],
usage: NodeInfoUsage {
users: NodeInfoUsers { total: user_count },
local_posts,
},
open_registrations: data.allow_registration,
}))
}
#[cfg(test)]
#[path = "tests/nodeinfo.rs"]
mod tests;

View File

@@ -0,0 +1,138 @@
use axum::extract::{Path, Query};
use axum::response::IntoResponse;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType,
protocol::context::WithContext,
};
use crate::{activities::CreateActivity, data::FederationData, error::Error};
const PAGE_SIZE: usize = 20;
#[derive(Deserialize)]
pub struct OutboxQuery {
page: Option<bool>,
before: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderedCollection {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "type")]
kind: String,
id: String,
total_items: u64,
first: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderedCollectionPage {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "type")]
kind: String,
id: String,
part_of: String,
ordered_items: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
next: Option<String>,
}
pub async fn outbox_handler(
Path(user_id_str): Path<String>,
Query(query): Query<OutboxQuery>,
data: Data<FederationData>,
) -> Result<axum::response::Response, Error> {
let uuid = uuid::Uuid::parse_str(&user_id_str)
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
data.user_repo
.find_by_id(uuid)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
if query.page.unwrap_or(false) {
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
let items = data
.object_handler
.get_local_objects_page(uuid, before, PAGE_SIZE)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
let actor_url: Url = format!("{}/users/{}", data.base_url, user_id_str)
.parse()
.expect("valid url");
let has_more = items.len() == PAGE_SIZE;
let oldest_ts = items.last().map(|(_, _, ts)| *ts);
let followers_url = format!("{}/followers", actor_url);
let ordered_items: Vec<serde_json::Value> = items
.into_iter()
.map(|(ap_id, object, _)| {
let create_id = Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
serde_json::to_value(WithContext::new_default(CreateActivity {
id: create_id,
kind: CreateType::default(),
actor: ObjectId::from(actor_url.clone()),
object,
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![followers_url.clone()],
}))
.expect("serializable")
})
.collect();
let page_id = match &query.before {
Some(b) => format!("{}?page=true&before={}", outbox_url, b),
None => format!("{}?page=true", outbox_url),
};
let next = if has_more {
oldest_ts.map(|ts| {
// Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding
let ts_str = ts.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
format!("{}?page=true&before={}", outbox_url, ts_str)
})
} else {
None
};
Ok(axum::Json(OrderedCollectionPage {
context: "https://www.w3.org/ns/activitystreams".to_string(),
kind: "OrderedCollectionPage".to_string(),
id: page_id,
part_of: outbox_url,
ordered_items,
next,
})
.into_response())
} else {
let total = data
.object_handler
.get_local_objects_for_user(uuid)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?
.len() as u64;
Ok(axum::Json(OrderedCollection {
context: "https://www.w3.org/ns/activitystreams".to_string(),
kind: "OrderedCollection".to_string(),
id: outbox_url.clone(),
total_items: total,
first: format!("{}?page=true", outbox_url),
})
.into_response())
}
}

View File

@@ -0,0 +1,134 @@
use anyhow::Result;
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowerStatus {
Pending,
Accepted,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowingStatus {
Pending,
Accepted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteActor {
pub url: String,
pub handle: String,
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub outbox_url: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Follower {
pub actor: RemoteActor,
pub status: FollowerStatus,
}
#[derive(Debug, Clone)]
pub struct BlockedDomain {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
#[async_trait]
pub trait FederationRepository: Send + Sync {
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
follow_activity_id: &str,
) -> Result<()>;
async fn get_follower_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<()>;
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
async fn get_followers_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<Follower>>;
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize>;
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>>;
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()>;
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()>;
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>;
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>>;
async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()>;
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()>;
async fn get_following_outbox_url(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()>;
async fn count_announces(&self, object_url: &str) -> Result<usize>;
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>;
async fn remove_blocked_domain(&self, domain: &str) -> Result<()>;
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>>;
async fn is_domain_blocked(&self, domain: &str) -> Result<bool>;
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>>;
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
use super::*;
#[test]
fn person_serializes_with_enriched_fields() {
let person = Person {
kind: Default::default(),
id: "https://example.com/users/1"
.parse::<url::Url>()
.unwrap()
.into(),
preferred_username: "alice".to_string(),
inbox: "https://example.com/users/1/inbox".parse().unwrap(),
outbox: "https://example.com/users/1/outbox".parse().unwrap(),
followers: "https://example.com/users/1/followers".parse().unwrap(),
following: "https://example.com/users/1/following".parse().unwrap(),
public_key: PublicKey {
id: "https://example.com/users/1#main-key".to_string(),
owner: "https://example.com/users/1".parse().unwrap(),
public_key_pem: "pem".to_string(),
},
name: Some("Alice".to_string()),
summary: Some("Bio text".to_string()),
icon: Some(ApImageObject {
kind: "Image".to_string(),
url: "https://example.com/images/avatars/1".parse().unwrap(),
}),
url: Some("https://example.com/u/alice".parse().unwrap()),
discoverable: Some(true),
manually_approves_followers: true,
updated: Some(Utc::now()),
endpoints: Some(Endpoints {
shared_inbox: "https://example.com/inbox".parse().unwrap(),
}),
image: None,
also_known_as: vec![],
attachment: vec![],
};
let json = serde_json::to_value(&person).unwrap();
assert_eq!(json["discoverable"], true);
assert_eq!(json["summary"], "Bio text");
assert_eq!(json["icon"]["type"], "Image");
assert_eq!(json["manuallyApprovesFollowers"], true);
assert!(json.get("updated").is_some());
assert!(json.get("endpoints").is_some());
assert_eq!(
json["endpoints"]["sharedInbox"],
"https://example.com/inbox"
);
}

View File

@@ -0,0 +1,40 @@
use super::*;
#[test]
fn nodeinfo_well_known_serializes_correctly() {
let doc = NodeInfoWellKnown {
links: vec![NodeInfoLink {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
href: "https://example.com/nodeinfo/2.0".to_string(),
}],
};
let json = serde_json::to_value(&doc).unwrap();
assert_eq!(
json["links"][0]["rel"],
"http://nodeinfo.diaspora.software/ns/schema/2.0"
);
assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0");
}
#[test]
fn nodeinfo_serializes_camel_case() {
let doc = NodeInfo {
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: "my-app".to_string(),
version: "0.1.0".to_string(),
},
protocols: vec!["activitypub".to_string()],
usage: NodeInfoUsage {
users: NodeInfoUsers { total: 3 },
local_posts: 42,
},
open_registrations: false,
};
let json = serde_json::to_value(&doc).unwrap();
assert_eq!(json["version"], "2.0");
assert_eq!(json["software"]["name"], "my-app");
assert_eq!(json["usage"]["users"]["total"], 3);
assert_eq!(json["usage"]["localPosts"], 42);
assert_eq!(json["openRegistrations"], false);
}

View File

@@ -0,0 +1,45 @@
use super::*;
use crate::repository::{Follower, FollowerStatus, RemoteActor};
fn make_follower(inbox: &str, shared: Option<&str>) -> Follower {
Follower {
actor: RemoteActor {
url: format!("https://remote/{}", inbox),
handle: "user".to_string(),
inbox_url: inbox.to_string(),
shared_inbox_url: shared.map(|s| s.to_string()),
display_name: None,
avatar_url: None,
outbox_url: None,
},
status: FollowerStatus::Accepted,
}
}
#[test]
fn collect_inboxes_deduplicates_shared() {
let followers = vec![
make_follower(
"https://mastodon.social/users/a/inbox",
Some("https://mastodon.social/inbox"),
),
make_follower(
"https://mastodon.social/users/b/inbox",
Some("https://mastodon.social/inbox"),
),
make_follower("https://other.instance/users/c/inbox", None),
];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 2);
let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect();
assert!(strs.contains(&"https://mastodon.social/inbox"));
assert!(strs.contains(&"https://other.instance/users/c/inbox"));
}
#[test]
fn collect_inboxes_falls_back_to_individual_inbox() {
let followers = vec![make_follower("https://example.com/users/x/inbox", None)];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 1);
assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox");
}

View File

@@ -0,0 +1,30 @@
use url::Url;
use crate::error::Error;
pub const AS_PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public";
pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> {
let path = url.path();
path.strip_prefix("/users/")
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
}
pub fn activity_url(base_url: &str) -> Result<Url, Error> {
Url::parse(&format!("{}/activities/{}", base_url, uuid::Uuid::new_v4()))
.map_err(|e| Error::bad_request(anyhow::anyhow!(e)))
}
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/users/{}", base_url, user_id))
.expect("base_url is always a valid URL prefix")
}
/// Extract the username segment from a /users/:username URL.
pub fn extract_username_from_url(url: &Url) -> Option<String> {
url.path()
.strip_prefix("/users/")
.and_then(|s| s.split('/').next())
.map(|s| s.to_string())
}

View File

@@ -0,0 +1,27 @@
use async_trait::async_trait;
use url::Url;
#[derive(Debug, Clone)]
pub struct ApProfileField {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone)]
pub struct ApUser {
pub id: uuid::Uuid,
pub username: String,
pub bio: Option<String>,
pub avatar_url: Option<Url>,
pub banner_url: Option<Url>,
pub also_known_as: Option<String>,
pub profile_url: Option<Url>,
pub attachment: Vec<ApProfileField>,
}
#[async_trait]
pub trait ApUserRepository: Send + Sync {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>>;
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>>;
async fn count_users(&self) -> anyhow::Result<usize>;
}

View File

@@ -0,0 +1,38 @@
use activitypub_federation::{
config::Data,
fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name},
};
use axum::{
extract::Query,
http::header,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use crate::data::FederationData;
use crate::error::Error;
#[derive(Deserialize)]
pub struct WebfingerQuery {
resource: String,
}
pub async fn webfinger_handler(
Query(query): Query<WebfingerQuery>,
data: Data<FederationData>,
) -> Result<Response, Error> {
let name = extract_webfinger_name(&query.resource, &data)?;
let user = data
.user_repo
.find_by_username(name)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let ap_id = crate::urls::actor_url(&data.base_url, user.id);
let wf: Webfinger = build_webfinger_response(query.resource, ap_id);
let body = serde_json::to_string(&wf).map_err(|e| Error::from(anyhow::anyhow!(e)))?;
Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
}

View File

@@ -2,3 +2,18 @@
name = "activitypub"
version = "0.1.0"
edition = "2021"
[dependencies]
activitypub-base = { workspace = true }
activitypub_federation = "0.7.0-beta.11"
domain = { workspace = true }
postgres = { workspace = true }
sqlx = { workspace = true }
url = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }

View File

@@ -0,0 +1,137 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use url::Url;
use activitypub_base::ApObjectHandler;
use crate::note::ThoughtNote;
use crate::urls::ThoughtsUrls;
pub struct ThoughtsObjectHandler {
pool: PgPool,
urls: ThoughtsUrls,
}
impl ThoughtsObjectHandler {
pub fn new(pool: PgPool, base_url: &str) -> Self {
Self { pool, urls: ThoughtsUrls::new(base_url) }
}
}
#[async_trait]
impl ApObjectHandler for ThoughtsObjectHandler {
async fn get_local_objects_for_user(&self, user_id: uuid::Uuid) -> Result<Vec<(Url, serde_json::Value)>> {
#[derive(sqlx::FromRow)]
struct Row { id: uuid::Uuid, content: String, created_at: DateTime<Utc>, in_reply_to_id: Option<uuid::Uuid>, content_warning: Option<String>, sensitive: bool, username: String }
let rows = sqlx::query_as::<_, Row>(
"SELECT t.id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'"
).bind(user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e))?;
let mut result = Vec::new();
for r in rows {
let note_url = self.urls.thought_url(r.id);
let actor_url = self.urls.user_url(&r.username);
let followers_url = self.urls.user_followers(&r.username);
let in_reply_to = r.in_reply_to_id.map(|id| self.urls.thought_url(id));
let note = ThoughtNote::new_public(note_url.clone(), actor_url, r.content, r.created_at, in_reply_to, r.sensitive, r.content_warning, followers_url);
result.push((note_url, serde_json::to_value(&note)?));
}
Ok(result)
}
async fn get_local_objects_page(
&self, user_id: uuid::Uuid, before: Option<DateTime<Utc>>, limit: usize,
) -> Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
#[derive(sqlx::FromRow)]
struct Row { id: uuid::Uuid, content: String, created_at: DateTime<Utc>, in_reply_to_id: Option<uuid::Uuid>, content_warning: Option<String>, sensitive: bool, username: String }
let rows = if let Some(before) = before {
sqlx::query_as::<_, Row>(
"SELECT t.id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2
ORDER BY t.created_at DESC LIMIT $3"
).bind(user_id).bind(before).bind(limit as i64).fetch_all(&self.pool).await
} else {
sqlx::query_as::<_, Row>(
"SELECT t.id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username
FROM thoughts t JOIN users u ON u.id=t.user_id
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
ORDER BY t.created_at DESC LIMIT $2"
).bind(user_id).bind(limit as i64).fetch_all(&self.pool).await
}.map_err(|e| anyhow!(e))?;
let mut result = Vec::new();
for r in rows {
let note_url = self.urls.thought_url(r.id);
let actor_url = self.urls.user_url(&r.username);
let followers_url = self.urls.user_followers(&r.username);
let in_reply_to = r.in_reply_to_id.map(|id| self.urls.thought_url(id));
let note = ThoughtNote::new_public(note_url.clone(), actor_url, r.content.clone(), r.created_at, in_reply_to, r.sensitive, r.content_warning, followers_url);
result.push((note_url, serde_json::to_value(&note)?, r.created_at));
}
Ok(result)
}
async fn on_create(&self, ap_id: &Url, actor_url: &Url, object: serde_json::Value) -> Result<()> {
let note: ThoughtNote = serde_json::from_value(object)?;
let actor_url_str = actor_url.to_string();
// Find or create a remote user placeholder
let existing: Option<uuid::Uuid> = sqlx::query_scalar(
"SELECT id FROM users WHERE ap_id=$1"
).bind(&actor_url_str).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?;
let user_id = match existing {
Some(id) => id,
None => {
let uid = uuid::Uuid::new_v4();
let handle = actor_url.path().trim_start_matches('/').replace('/', "_");
sqlx::query(
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT DO NOTHING"
).bind(uid).bind(&handle).bind(format!("{}@remote", uid)).bind(&actor_url_str)
.execute(&self.pool).await.map_err(|e| anyhow!(e))?;
uid
}
};
let thought_id = uuid::Uuid::new_v4();
let content: String = note.content.chars().take(500).collect();
sqlx::query(
"INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at)
VALUES($1,$2,$3,$4,'public',$5,false,$6,$7) ON CONFLICT(ap_id) DO NOTHING"
).bind(thought_id).bind(user_id).bind(&content).bind(ap_id.as_str())
.bind(note.sensitive).bind(note.summary).bind(note.published)
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn on_update(&self, ap_id: &Url, _actor_url: &Url, object: serde_json::Value) -> Result<()> {
let note: ThoughtNote = serde_json::from_value(object)?;
let content: String = note.content.chars().take(500).collect();
sqlx::query("UPDATE thoughts SET content=$2, updated_at=NOW() WHERE ap_id=$1")
.bind(ap_id.as_str()).bind(&content)
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false")
.bind(ap_id.as_str())
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
sqlx::query(
"DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)"
).bind(actor_url.as_str())
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn count_local_posts(&self) -> Result<u64> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE local=true")
.fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
Ok(n as u64)
}
}

View File

@@ -0,0 +1,7 @@
pub mod handler;
pub mod note;
pub mod urls;
pub use handler::ThoughtsObjectHandler;
pub use note::ThoughtNote;
pub use urls::ThoughtsUrls;

View File

@@ -0,0 +1,62 @@
use activitypub_base::AS_PUBLIC;
use activitypub_federation::kinds::object::NoteType;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
/// AP Note representing a Thought.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThoughtNote {
#[serde(rename = "type")]
pub kind: NoteType,
pub id: Url,
pub attributed_to: Url,
pub content: String,
pub published: DateTime<Utc>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub cc: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<Url>,
pub sensitive: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
impl ThoughtNote {
pub fn new_public(
id: Url, actor_url: Url, content: String, published: DateTime<Utc>,
in_reply_to: Option<Url>, sensitive: bool, summary: Option<String>,
followers_url: Url,
) -> Self {
Self {
kind: Default::default(),
id, attributed_to: actor_url, content, published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![followers_url.to_string()],
in_reply_to, sensitive, summary,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn note_serializes_with_public_audience() {
let note = ThoughtNote::new_public(
"https://example.com/thoughts/1".parse().unwrap(),
"https://example.com/users/alice".parse().unwrap(),
"Hello world".to_string(),
chrono::Utc::now(),
None, false, None,
"https://example.com/users/alice/followers".parse().unwrap(),
);
let json = serde_json::to_string(&note).unwrap();
assert!(json.contains(AS_PUBLIC));
assert!(json.contains("Hello world"));
}
}

View File

@@ -0,0 +1,49 @@
use url::Url;
pub struct ThoughtsUrls {
pub base_url: String,
}
impl ThoughtsUrls {
pub fn new(base_url: &str) -> Self {
Self { base_url: base_url.trim_end_matches('/').to_string() }
}
pub fn user_url(&self, username: &str) -> Url {
Url::parse(&format!("{}/users/{}", self.base_url, username)).expect("valid URL")
}
pub fn thought_url(&self, thought_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/thoughts/{}", self.base_url, thought_id)).expect("valid URL")
}
pub fn user_inbox(&self, username: &str) -> Url {
Url::parse(&format!("{}/users/{}/inbox", self.base_url, username)).expect("valid URL")
}
pub fn user_outbox(&self, username: &str) -> Url {
Url::parse(&format!("{}/users/{}/outbox", self.base_url, username)).expect("valid URL")
}
pub fn user_followers(&self, username: &str) -> Url {
Url::parse(&format!("{}/users/{}/followers", self.base_url, username)).expect("valid URL")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_url_format() {
let urls = ThoughtsUrls::new("https://example.com");
assert_eq!(urls.user_url("alice").as_str(), "https://example.com/users/alice");
}
#[test]
fn thought_url_format() {
let urls = ThoughtsUrls::new("https://example.com");
let id = uuid::Uuid::nil();
assert!(urls.thought_url(id).as_str().starts_with("https://example.com/thoughts/"));
}
}

View File

@@ -2,3 +2,17 @@
name = "postgres-federation"
version = "0.1.0"
edition = "2021"
[dependencies]
activitypub-base = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
url = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }
sqlx = { workspace = true, features = ["migrate"] }

View File

@@ -0,0 +1,362 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use activitypub_base::{
ApUser, ApUserRepository,
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
// ── PostgresFederationRepository ─────────────────────────────────────────────
pub struct PostgresFederationRepository {
pool: PgPool,
}
impl PostgresFederationRepository {
pub fn new(pool: PgPool) -> Self { Self { pool } }
}
fn status_str(s: &FollowerStatus) -> &'static str {
match s { FollowerStatus::Pending => "pending", FollowerStatus::Accepted => "accepted", FollowerStatus::Rejected => "rejected" }
}
fn str_status(s: &str) -> FollowerStatus {
match s { "accepted" => FollowerStatus::Accepted, "rejected" => FollowerStatus::Rejected, _ => FollowerStatus::Pending }
}
fn map_remote_actor(
url: String, handle: String, inbox_url: String,
shared_inbox_url: Option<String>, display_name: Option<String>,
avatar_url: Option<String>, outbox_url: Option<String>,
) -> RemoteActor {
RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url }
}
#[async_trait]
impl FederationRepository for PostgresFederationRepository {
async fn add_follower(
&self, local_user_id: uuid::Uuid, remote_actor_url: &str,
status: FollowerStatus, follow_activity_id: &str,
) -> Result<()> {
sqlx::query(
"INSERT INTO federation_followers(local_user_id,remote_actor_url,status,follow_activity_id)
VALUES($1,$2,$3,$4)
ON CONFLICT(local_user_id,remote_actor_url) DO UPDATE
SET status=EXCLUDED.status, follow_activity_id=EXCLUDED.follow_activity_id"
)
.bind(local_user_id).bind(remote_actor_url).bind(status_str(&status)).bind(follow_activity_id)
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn get_follower_follow_activity_id(
&self, local_user_id: uuid::Uuid, remote_actor_url: &str,
) -> Result<Option<String>> {
sqlx::query_scalar::<_, String>(
"SELECT follow_activity_id FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2"
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
}
async fn remove_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<()> {
sqlx::query("DELETE FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2")
.bind(local_user_id).bind(remote_actor_url)
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
#[derive(sqlx::FromRow)]
struct Row { remote_actor_url: String, status: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
FROM federation_followers f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1"
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| Follower {
actor: map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url),
status: str_status(&r.status),
}).collect())
}
async fn get_followers_page(
&self, local_user_id: uuid::Uuid, offset: u32, limit: usize,
) -> Result<Vec<Follower>> {
#[derive(sqlx::FromRow)]
struct Row { remote_actor_url: String, status: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
FROM federation_followers f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1 AND f.status='accepted'
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
).bind(local_user_id).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| Follower {
actor: map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url),
status: str_status(&r.status),
}).collect())
}
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let n: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM federation_followers WHERE local_user_id=$1 AND status='accepted'"
).bind(local_user_id).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
Ok(n as usize)
}
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
#[derive(sqlx::FromRow)]
struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
FROM federation_followers f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1 AND f.status='pending'"
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
).collect())
}
async fn update_follower_status(
&self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowerStatus,
) -> Result<()> {
sqlx::query("UPDATE federation_followers SET status=$3 WHERE local_user_id=$1 AND remote_actor_url=$2")
.bind(local_user_id).bind(remote_actor_url).bind(status_str(&status))
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn add_following(
&self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str,
) -> Result<()> {
self.upsert_remote_actor(actor.clone()).await?;
sqlx::query(
"INSERT INTO federation_following(local_user_id,remote_actor_url,follow_activity_id,outbox_url)
VALUES($1,$2,$3,$4)
ON CONFLICT(local_user_id,remote_actor_url) DO UPDATE
SET follow_activity_id=EXCLUDED.follow_activity_id"
)
.bind(local_user_id).bind(&actor.url).bind(follow_activity_id).bind(&actor.outbox_url)
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn get_follow_activity_id(
&self, local_user_id: uuid::Uuid, remote_actor_url: &str,
) -> Result<Option<String>> {
sqlx::query_scalar::<_, String>(
"SELECT follow_activity_id FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2"
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
}
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
sqlx::query("DELETE FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2")
.bind(local_user_id).bind(actor_url)
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
#[derive(sqlx::FromRow)]
struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
FROM federation_following f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1"
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
).collect())
}
async fn get_following_page(
&self, local_user_id: uuid::Uuid, offset: u32, limit: usize,
) -> Result<Vec<RemoteActor>> {
#[derive(sqlx::FromRow)]
struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
FROM federation_following f
LEFT JOIN remote_actors r ON r.url=f.remote_actor_url
WHERE f.local_user_id=$1
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
).bind(local_user_id).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
map_remote_actor(r.remote_actor_url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
).collect())
}
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let n: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM federation_following WHERE local_user_id=$1"
).bind(local_user_id).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
Ok(n as usize)
}
async fn update_following_status(
&self, _local_user_id: uuid::Uuid, _remote_actor_url: &str, _status: FollowingStatus,
) -> Result<()> {
Ok(())
}
async fn get_following_outbox_url(
&self, local_user_id: uuid::Uuid, remote_actor_url: &str,
) -> Result<Option<String>> {
sqlx::query_scalar::<_, String>(
"SELECT outbox_url FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2"
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
}
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
sqlx::query(
"INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,outbox_url,last_fetched_at)
VALUES($1,$2,$3,$4,$5,'',$6,$7,NOW())
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
inbox_url=EXCLUDED.inbox_url,shared_inbox_url=EXCLUDED.shared_inbox_url,
avatar_url=EXCLUDED.avatar_url,outbox_url=EXCLUDED.outbox_url,last_fetched_at=NOW()"
)
.bind(&actor.url).bind(&actor.handle).bind(&actor.display_name)
.bind(&actor.inbox_url).bind(&actor.shared_inbox_url)
.bind(&actor.avatar_url).bind(&actor.outbox_url)
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
#[derive(sqlx::FromRow)]
struct Row { url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
sqlx::query_as::<_, Row>(
"SELECT url,handle,inbox_url,shared_inbox_url,display_name,avatar_url,outbox_url FROM remote_actors WHERE url=$1"
).bind(actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)).map(|o| o.map(|r|
map_remote_actor(r.url, r.handle, r.inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url)
))
}
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<(String, String)>> {
#[derive(sqlx::FromRow)]
struct Row { public_key: Option<String>, private_key: Option<String> }
let row = sqlx::query_as::<_, Row>(
"SELECT public_key, private_key FROM users WHERE id=$1 AND local=true"
).bind(user_id).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?;
Ok(row.and_then(|r| match (r.public_key, r.private_key) {
(Some(pub_k), Some(priv_k)) => Some((pub_k, priv_k)),
_ => None,
}))
}
async fn save_local_actor_keypair(
&self, user_id: uuid::Uuid, public_key: String, private_key: String,
) -> Result<()> {
sqlx::query("UPDATE users SET public_key=$2, private_key=$3, updated_at=NOW() WHERE id=$1")
.bind(user_id).bind(&public_key).bind(&private_key)
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn add_announce(
&self, activity_id: &str, object_url: &str, actor_url: &str, announced_at: DateTime<Utc>,
) -> Result<()> {
sqlx::query(
"INSERT INTO federation_announces(activity_id,object_url,actor_url,announced_at)
VALUES($1,$2,$3,$4) ON CONFLICT(activity_id) DO NOTHING"
).bind(activity_id).bind(object_url).bind(actor_url).bind(announced_at)
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn count_announces(&self, object_url: &str) -> Result<usize> {
let n: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM federation_announces WHERE object_url=$1"
).bind(object_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
Ok(n as usize)
}
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
sqlx::query(
"INSERT INTO federation_blocked_domains(domain,reason) VALUES($1,$2) ON CONFLICT(domain) DO NOTHING"
).bind(domain).bind(reason).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
sqlx::query("DELETE FROM federation_blocked_domains WHERE domain=$1")
.bind(domain).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
#[derive(sqlx::FromRow)]
struct Row { domain: String, reason: Option<String>, blocked_at: DateTime<Utc> }
sqlx::query_as::<_, Row>("SELECT domain,reason,blocked_at FROM federation_blocked_domains ORDER BY domain")
.fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
BlockedDomain { domain: r.domain, reason: r.reason, blocked_at: r.blocked_at.to_rfc3339() }
).collect())
}
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM federation_blocked_domains WHERE domain=$1")
.bind(domain).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
Ok(n > 0)
}
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
sqlx::query(
"INSERT INTO federation_blocked_actors(local_user_id,actor_url) VALUES($1,$2) ON CONFLICT DO NOTHING"
).bind(local_user_id).bind(actor_url).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
sqlx::query("DELETE FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2")
.bind(local_user_id).bind(actor_url).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
}
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
sqlx::query_scalar::<_, String>(
"SELECT actor_url FROM federation_blocked_actors WHERE local_user_id=$1 ORDER BY created_at DESC"
).bind(local_user_id).fetch_all(&self.pool).await.map_err(|e| anyhow!(e))
}
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
let n: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2"
).bind(local_user_id).bind(actor_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
Ok(n > 0)
}
}
// ── PostgresApUserRepository ──────────────────────────────────────────────────
pub struct PostgresApUserRepository {
pool: PgPool,
base_url: String,
}
impl PostgresApUserRepository {
pub fn new(pool: PgPool, base_url: String) -> Self { Self { pool, base_url } }
fn row_to_ap_user(&self, id: uuid::Uuid, username: String, bio: Option<String>, avatar_url: Option<String>) -> ApUser {
let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, username)).ok();
let avatar_url = avatar_url.and_then(|u| url::Url::parse(&u).ok());
ApUser { id, username, bio, avatar_url, banner_url: None, also_known_as: None, profile_url, attachment: vec![] }
}
}
#[async_trait]
impl ApUserRepository for PostgresApUserRepository {
async fn find_by_id(&self, id: uuid::Uuid) -> Result<Option<ApUser>> {
#[derive(sqlx::FromRow)]
struct Row { id: uuid::Uuid, username: String, bio: Option<String>, avatar_url: Option<String> }
let row = sqlx::query_as::<_, Row>(
"SELECT id,username,bio,avatar_url FROM users WHERE id=$1 AND local=true"
).bind(id).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?;
Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url)))
}
async fn find_by_username(&self, username: &str) -> Result<Option<ApUser>> {
#[derive(sqlx::FromRow)]
struct Row { id: uuid::Uuid, username: String, bio: Option<String>, avatar_url: Option<String> }
let row = sqlx::query_as::<_, Row>(
"SELECT id,username,bio,avatar_url FROM users WHERE username=$1 AND local=true"
).bind(username).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?;
Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url)))
}
async fn count_users(&self) -> Result<usize> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE local=true")
.fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
Ok(n as usize)
}
}

View File

@@ -0,0 +1,54 @@
-- Add avatar_url and outbox_url to remote_actors (FederationRepository::RemoteActor needs them)
ALTER TABLE remote_actors
ADD COLUMN IF NOT EXISTS avatar_url TEXT,
ADD COLUMN IF NOT EXISTS outbox_url TEXT;
-- Federation followers: remote actors following local users
CREATE TABLE IF NOT EXISTS federation_followers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
remote_actor_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
follow_activity_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (local_user_id, remote_actor_url)
);
-- Federation following: local users following remote actors
CREATE TABLE IF NOT EXISTS federation_following (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
remote_actor_url TEXT NOT NULL,
follow_activity_id TEXT NOT NULL,
outbox_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (local_user_id, remote_actor_url)
);
-- Announces (boosts of remote objects via AP)
CREATE TABLE IF NOT EXISTS federation_announces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
activity_id TEXT NOT NULL UNIQUE,
object_url TEXT NOT NULL,
actor_url TEXT NOT NULL,
announced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Blocked domains (instance-level)
CREATE TABLE IF NOT EXISTS federation_blocked_domains (
domain TEXT PRIMARY KEY,
reason TEXT,
blocked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Blocked actors (per local user)
CREATE TABLE IF NOT EXISTS federation_blocked_actors (
local_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
actor_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (local_user_id, actor_url)
);
CREATE INDEX IF NOT EXISTS idx_fed_followers_user ON federation_followers(local_user_id);
CREATE INDEX IF NOT EXISTS idx_fed_following_user ON federation_following(local_user_id);
CREATE INDEX IF NOT EXISTS idx_fed_announces_object ON federation_announces(object_url);

View File

@@ -30,6 +30,11 @@ dotenvy = { workspace = true }
async-trait = { workspace = true }
sha2 = "0.10"
hex = "0.4"
activitypub = { workspace = true }
activitypub-base = { workspace = true }
postgres-federation = { workspace = true }
url = { workspace = true }
activitypub_federation = "0.7.0-beta.11"
[dev-dependencies]
http-body-util = "0.1"

View File

@@ -9,6 +9,9 @@ use async_trait::async_trait;
use sqlx::PgPool;
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
use postgres_search::PgSearchRepository;
use activitypub_base::{ApFederationConfig, FederationData};
use activitypub::ThoughtsObjectHandler;
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
use state::AppState;
struct NoOpEventPublisher;
@@ -35,6 +38,28 @@ pub async fn build_state(pool: PgPool, jwt_secret: String) -> AppState {
}
};
let base_url = std::env::var("BASE_URL")
.unwrap_or_else(|_| "http://localhost:3000".into());
let allow_registration = std::env::var("ALLOW_REGISTRATION")
.map(|v| v == "true")
.unwrap_or(true);
let fed_debug = std::env::var("RUST_ENV")
.map(|v| v != "production")
.unwrap_or(true);
let fed_data = FederationData::new(
Arc::new(PostgresFederationRepository::new(pool.clone())),
Arc::new(PostgresApUserRepository::new(pool.clone(), base_url.clone())),
Arc::new(ThoughtsObjectHandler::new(pool.clone(), &base_url)),
base_url,
allow_registration,
"thoughts".to_string(),
None,
);
let fed_config = ApFederationConfig::new(fed_data, fed_debug).await
.expect("federation config failed");
AppState {
users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())),
thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())),
@@ -52,5 +77,6 @@ pub async fn build_state(pool: PgPool, jwt_secret: String) -> AppState {
auth: Arc::new(auth::JwtAuthService::new(jwt_secret, 86400 * 30)),
hasher: Arc::new(auth::Argon2PasswordHasher),
events: event_publisher,
fed_config,
}
}

View File

@@ -17,7 +17,7 @@ async fn main() {
sqlx::migrate!("../adapters/postgres/migrations").run(&pool).await.expect("Migrations failed");
let state = presentation::build_state(pool, jwt_secret).await;
let app = presentation::routes::router()
let app = presentation::routes::router(&state.fed_config)
.with_state(state)
.layer(CorsLayer::permissive());

View File

@@ -2,19 +2,26 @@ use axum::{
routing::{delete, get, patch, post, put},
Router,
};
use activitypub_base::{
actor_handler::actor_handler,
followers_handler::{followers_handler, following_handler},
inbox::inbox_handler,
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
outbox::outbox_handler,
webfinger::webfinger_handler,
ApFederationConfig,
};
use activitypub_federation::config::FederationMiddleware;
use crate::{handlers::*, state::AppState};
pub fn router() -> Router<AppState> {
Router::new()
pub fn router(fed_config: &ApFederationConfig) -> Router<AppState> {
let api_routes = Router::new()
// auth
.route("/auth/register", post(auth::post_register))
.route("/auth/login", post(auth::post_login))
// users — static paths before parameterised
.route("/users/me", patch(users::patch_profile))
.route("/users/me/top-friends", put(social::put_top_friends))
.route("/users/{username}", get(users::get_user))
.route("/users/{username}/following", get(feed::get_following_handler))
.route("/users/{username}/followers", get(feed::get_followers_handler))
.route("/users/{username}/top-friends", get(social::get_top_friends_handler))
// follows & blocks (use {id} param)
.route(
@@ -56,5 +63,20 @@ pub fn router() -> Router<AppState> {
"/api-keys",
get(api_keys::get_api_keys).post(api_keys::post_api_key),
)
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler))
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler));
let ap_routes = Router::new()
.route("/.well-known/webfinger", get(webfinger_handler))
.route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler))
.route("/nodeinfo/2.0", get(nodeinfo_handler))
.route("/users/{username}", get(actor_handler))
.route("/users/{username}/inbox", post(inbox_handler))
.route("/users/{username}/outbox", get(outbox_handler))
.route("/users/{username}/followers", get(followers_handler))
.route("/users/{username}/following", get(following_handler));
Router::new()
.merge(api_routes)
.merge(ap_routes)
.layer(FederationMiddleware::new(fed_config.0.clone()))
}

View File

@@ -1,5 +1,6 @@
use std::sync::Arc;
use domain::ports::*;
use activitypub_base::ApFederationConfig;
#[derive(Clone)]
pub struct AppState {
@@ -19,4 +20,5 @@ pub struct AppState {
pub auth: Arc<dyn AuthService>,
pub hasher: Arc<dyn PasswordHasher>,
pub events: Arc<dyn EventPublisher>,
pub fed_config: ApFederationConfig,
}

File diff suppressed because it is too large Load Diff