use std::sync::Arc;
use activitypub_federation::{
activity_sending::SendActivityTask, fetch::object_id::ObjectId, protocol::context::WithContext,
traits::Actor,
};
use axum::{Router, routing::get, routing::post};
use url::Url;
use crate::{
activities::{
AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity,
UpdateActivity,
},
actors::{DbActor, get_local_actor},
content::ApObjectHandler,
data::FederationData,
federation::ApFederationConfig,
followers_handler::{followers_handler, following_handler},
inbox::inbox_handler,
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
outbox::outbox_handler,
repository::{
BlockedDomain, FederationRepository, FollowerStatus, FollowingStatus, RemoteActor,
},
urls::activity_url,
user::ApUserRepository,
webfinger::webfinger_handler,
};
fn content_to_html(text: &str) -> String {
let escaped = text
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """);
let paragraphs: Vec<&str> = escaped.split('\n').filter(|s| !s.is_empty()).collect();
if paragraphs.is_empty() {
format!("
{}
", escaped)
} else {
paragraphs
.iter()
.map(|p| format!("{}
", p))
.collect::>()
.join("")
}
}
fn extract_hashtag_tags(content: &str, base_url: &str) -> Vec {
let mut seen = std::collections::HashSet::new();
let mut tags = Vec::new();
for word in content.split_whitespace() {
let tag = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '#');
if let Some(name) = tag.strip_prefix('#')
&& !name.is_empty()
&& seen.insert(name.to_lowercase())
{
let lower = name.to_lowercase();
tags.push(serde_json::json!({
"type": "Hashtag",
"name": format!("#{}", lower),
"href": format!("{}/tags/{}", base_url, lower),
}));
}
}
tags
}
fn thought_note_json(
thought: &domain::models::thought::Thought,
local_actor: &crate::actors::DbActor,
base_url: &str,
) -> anyhow::Result<(url::Url, serde_json::Value)> {
let ap_id = url::Url::parse(&format!("{}/thoughts/{}", base_url, thought.id))?;
// Build to/cc based on visibility per AP spec.
let (to, cc) = match thought.visibility {
domain::models::thought::Visibility::Public => (
vec![crate::urls::AS_PUBLIC.to_string()],
vec![local_actor.followers_url.to_string()],
),
domain::models::thought::Visibility::Unlisted => (
vec![local_actor.followers_url.to_string()],
vec![crate::urls::AS_PUBLIC.to_string()],
),
domain::models::thought::Visibility::Followers => {
(vec![local_actor.followers_url.to_string()], vec![])
}
domain::models::thought::Visibility::Direct => (vec![], vec![]),
};
let mut note = serde_json::json!({
"type": "Note",
"id": ap_id.to_string(),
"url": ap_id.to_string(),
"attributedTo": local_actor.ap_id.to_string(),
"content": content_to_html(thought.content.as_str()),
"published": thought.created_at.to_rfc3339(),
"to": to,
"cc": cc,
"sensitive": thought.sensitive,
});
if let Some(ref cw) = thought.content_warning {
note["summary"] = serde_json::json!(cw);
}
if let Some(ref reply_url) = thought.in_reply_to_url {
note["inReplyTo"] = serde_json::json!(reply_url);
}
if let Some(updated_at) = thought.updated_at {
note["updated"] = serde_json::json!(updated_at.to_rfc3339());
}
let hashtag_tags = extract_hashtag_tags(thought.content.as_str(), base_url);
if !hashtag_tags.is_empty() {
note["tag"] = serde_json::json!(hashtag_tags);
}
Ok((ap_id, note))
}
fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec {
let mut seen = std::collections::HashSet::new();
let mut inboxes = Vec::new();
for f in followers {
let inbox_str = f
.actor
.shared_inbox_url
.as_deref()
.unwrap_or(&f.actor.inbox_url);
if seen.insert(inbox_str.to_string())
&& let Ok(url) = Url::parse(inbox_str)
{
inboxes.push(url);
}
}
inboxes
}
pub(crate) async fn send_with_retry(
sends: Vec,
data: &activitypub_federation::config::Data,
) -> Vec {
let mut failures = vec![];
for send in sends {
let mut delay = std::time::Duration::from_secs(1);
for attempt in 1..=3u32 {
match send.clone().sign_and_send(data).await {
Ok(()) => break,
Err(e) if attempt < 3 => {
tracing::warn!(attempt, error = %e, "delivery failed, retrying");
tokio::time::sleep(delay).await;
delay *= 2;
}
Err(e) => {
tracing::error!(attempt, error = %e, "delivery failed permanently");
failures.push(anyhow::anyhow!(e));
}
}
}
}
failures
}
pub struct ActivityPubService {
federation_config: ApFederationConfig,
base_url: String,
}
impl ActivityPubService {
#[allow(clippy::too_many_arguments)]
pub async fn new(
repo: Arc,
user_repo: Arc,
object_handler: Arc,
base_url: String,
allow_registration: bool,
software_name: String,
debug: bool,
event_publisher: Option>,
) -> anyhow::Result {
let data = FederationData::new(
repo,
user_repo,
object_handler,
base_url.clone(),
allow_registration,
software_name,
event_publisher,
);
let federation_config = ApFederationConfig::new(data, debug).await?;
Ok(Self {
federation_config,
base_url,
})
}
pub fn federation_config(&self) -> &ApFederationConfig {
&self.federation_config
}
pub fn request_data(&self) -> activitypub_federation::config::Data {
self.federation_config.to_request_data()
}
/// Returns `(local_actor, deduplicated_inboxes)` for all accepted followers,
/// excluding blocked actors and blocked domains.
/// Returns `None` if there are no eligible followers.
async fn accepted_follower_inboxes(
&self,
data: &activitypub_federation::config::Data,
local_user_id: uuid::Uuid,
) -> anyhow::Result