555287fbe2da54538fc82f014941f11804f9e7fe / frogue / src / activitypub / inbox.rs
Raw
//! ActivityPub inbox handler for processing incoming activities.
//!
//! The inbox receives activities from remote actors: Follow, Undo, Create, etc.
//! Each activity is verified (HTTP signature) and then processed according to
//! its type.
use actix_web::{HttpRequest, HttpResponse};
use chrono::{DateTime, Utc};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, Set,
};
use tracing::{debug, error, info, warn};
use uuid::Uuid;
use crate::activitypub::remote;
use crate::activitypub::signatures::{self, SignatureError};
use crate::activitypub::types::{InboxActivity, ObjectRef, ReactionActivity, UndoObject};
use crate::entities::prelude::{ApFollowers, ApRemoteActors, ApRemoteNotes};
use crate::entities::{ap_followers, ap_remote_actors, ap_remote_notes};
/// Handle an incoming inbox POST request.
///
/// Verifies the HTTP signature, parses the activity, and dispatches to the
/// appropriate handler based on activity type.
pub async fn handle(
db: &DatabaseConnection,
http_client: &reqwest_middleware::ClientWithMiddleware,
public_url: &str,
signer: &crate::entities::ap_actors::Model,
req: &HttpRequest,
body: &[u8],
) -> actix_web::HttpResponse {
// Verify HTTP signature
let verified_actor_url = match verify_inbox_request(db, http_client, req, body).await {
Ok(actor_url) => actor_url,
Err(signatures::SignatureError::ActorGone(key_id)) => {
info!(
key_id = %key_id,
path = %req.uri().path(),
"actor is gone (410), rejecting delivery"
);
return actix_web::HttpResponse::Gone().finish();
}
Err(e) => {
warn!(error = %e, path = %req.uri().path(), "inbox signature verification failed");
error!(
error = %e,
method = %req.method(),
path = %req.uri().path(),
headers = ?req.headers().keys().collect::<Vec<_>>(),
"signature verification details"
);
return actix_web::HttpResponse::Unauthorized().finish();
}
};
// Parse the activity
let activity: InboxActivity = match serde_json::from_slice(body) {
Ok(a) => a,
Err(e) => {
warn!(error = %e, "failed to parse inbox activity");
return actix_web::HttpResponse::BadRequest().finish();
}
};
// Verify actor attribution: the activity's actor field must match the verified actor URL
let activity_actor: Option<&str> = match &activity {
InboxActivity::Follow(f) => Some(f.actor.as_str()),
InboxActivity::Undo(u) => Some(u.actor.as_str()),
InboxActivity::Like(l) => Some(l.actor.as_str()),
InboxActivity::Announce(a) => Some(a.actor.as_str()),
InboxActivity::EmojiReact(e) => Some(e.actor.as_str()),
InboxActivity::Accept(a) => Some(a.actor.as_str()),
InboxActivity::Reject(r) => Some(r.actor.as_str()),
InboxActivity::Create(c) => Some(c.actor.as_str()),
InboxActivity::Update(u) => Some(u.actor.as_str()),
InboxActivity::Delete(d) => Some(d.actor.as_str()),
InboxActivity::Offer(o) => Some(o.actor.as_str()),
InboxActivity::Resolve(r) => Some(r.actor.as_str()),
InboxActivity::Edit(e) => Some(e.actor.as_str()),
InboxActivity::Add(a) => Some(a.actor.as_str()),
InboxActivity::Unknown => None,
};
if let Some(actor) = activity_actor
&& actor != verified_actor_url
{
warn!(
activity_actor = %actor,
verified_actor = %verified_actor_url,
"actor mismatch"
);
return actix_web::HttpResponse::Unauthorized().finish();
}
// Process the activity
match activity {
InboxActivity::Follow(follow) => {
handle_follow(db, http_client, public_url, &verified_actor_url, &follow).await
}
InboxActivity::Undo(undo) => handle_undo(db, &verified_actor_url, &undo).await,
InboxActivity::Create(create) => {
// Repository actors handle Create activities for comments
if signer.repo_id.is_some() {
return crate::activitypub::forge::inbound::handle_create(
db,
http_client,
public_url,
signer,
&verified_actor_url,
&create,
)
.await;
}
// User actors handle Create activities for notes
if signer.user_id.is_none() {
debug!("non-repository actor received Create activity, ignoring");
return HttpResponse::Accepted().finish();
}
// Get user name for building URLs
let user_name =
match crate::activitypub::actor::actor_url_for(db, public_url, signer).await {
Ok(Some(url)) => {
// Extract username from URL: {base}/~{user}
let prefix =
format!("{}/~", crate::activitypub::ApUrls::new(public_url).base());
url.strip_prefix(&prefix).unwrap_or("").to_string()
}
_ => return HttpResponse::Accepted().finish(),
};
match crate::activitypub::remote::notes::handle_create(
db,
http_client,
&verified_actor_url,
public_url,
&user_name,
signer,
&create,
)
.await
{
Ok(()) => HttpResponse::Accepted().finish(),
Err(e) => {
warn!(error = %e, "failed to handle Create");
HttpResponse::Accepted().finish()
}
}
}
InboxActivity::Update(update) => {
// Only user actors handle Update activities for notes
if signer.user_id.is_none() {
debug!("repository actor received Update activity, ignoring");
return HttpResponse::Accepted().finish();
}
// Get user name for building URLs
let user_name =
match crate::activitypub::actor::actor_url_for(db, public_url, signer).await {
Ok(Some(url)) => {
// Extract username from URL: {base}/~{user}
let prefix =
format!("{}/~", crate::activitypub::ApUrls::new(public_url).base());
url.strip_prefix(&prefix).unwrap_or("").to_string()
}
_ => return HttpResponse::Accepted().finish(),
};
match crate::activitypub::remote::notes::handle_update(
db,
http_client,
&verified_actor_url,
public_url,
&user_name,
signer,
&update,
)
.await
{
Ok(()) => HttpResponse::Accepted().finish(),
Err(e) => {
warn!(error = %e, "failed to handle Update");
HttpResponse::Accepted().finish()
}
}
}
InboxActivity::Delete(delete) => {
match crate::activitypub::remote::notes::handle_delete(db, &verified_actor_url, &delete)
.await
{
Ok(()) => HttpResponse::Accepted().finish(),
Err(e) => {
warn!(error = %e, "failed to handle Delete");
HttpResponse::Accepted().finish()
}
}
}
// Phase 5: Reactions
InboxActivity::Like(like) => {
handle_reaction(
db,
http_client,
public_url,
&verified_actor_url,
&like,
"like",
None,
)
.await
}
InboxActivity::Announce(announce) => {
// Check if the Announce contains an embedded Note from an accepted followee
if let crate::activitypub::types::AnnounceObject::Embedded(embedded) = &announce.object
{
// Only store if it's a Note
if embedded.kind == "Note" {
let object_id = embedded.id.clone();
let content = embedded.content.clone().unwrap_or_default();
let published = if let Some(pub_str) = &embedded.published {
match pub_str.parse::<DateTime<Utc>>() {
Ok(dt) => dt,
Err(e) => {
warn!(error = %e, "failed to parse published timestamp");
Utc::now()
}
}
} else {
Utc::now()
};
// Check if we already have this note stored (no-op if so)
let existing = match ApRemoteNotes::find()
.filter(ap_remote_notes::Column::ObjectUrl.eq(&object_id))
.one(db)
.await
{
Ok(result) => result,
Err(e) => {
warn!(error = %e, "failed to check for existing note");
return HttpResponse::Accepted().finish();
}
};
if existing.is_some() {
debug!(object_url = %object_id, "note already stored (Announce no-op)");
return HttpResponse::Accepted().finish();
}
// Store the note using the shared function
match crate::activitypub::remote::notes::store_note(
db,
&announce.actor,
object_id,
content,
published.into(),
"Note",
)
.await
{
Ok(()) => {
info!(actor_url = %announce.actor, "stored boosted note");
HttpResponse::Accepted().finish()
}
Err(e) => {
warn!(error = %e, "failed to store boosted note");
HttpResponse::Accepted().finish()
}
}
} else {
// Not a Note, treat as reaction on local note
handle_reaction(
db,
http_client,
public_url,
&verified_actor_url,
&ReactionActivity {
id: announce.id.clone(),
actor: announce.actor.clone(),
object: ObjectRef::Url(announce.object.id().to_string()),
content: None,
},
"announce",
None,
)
.await
}
} else {
// URL-only Announce, treat as reaction on local note
handle_reaction(
db,
http_client,
public_url,
&verified_actor_url,
&ReactionActivity {
id: announce.id.clone(),
actor: announce.actor.clone(),
object: ObjectRef::Url(announce.object.id().to_string()),
content: None,
},
"announce",
None,
)
.await
}
}
InboxActivity::EmojiReact(react) => {
handle_reaction(
db,
http_client,
public_url,
&verified_actor_url,
&react,
"emoji_react",
react.content.as_deref(),
)
.await
}
InboxActivity::Accept(accept) => {
// First, check if this is an Accept for a remote ticket Offer
let offer_id = accept.object.id().to_string();
info!(offer_id = %offer_id, signer_user_id = ?signer.user_id, "processing Accept activity");
if let Some(user_id) = signer.user_id {
info!(user_id = %user_id, offer_id = %offer_id, "looking for remote ticket");
if let Ok(Some(remote_ticket)) = crate::entities::ap_remote_tickets::Entity::find()
.filter(
crate::entities::ap_remote_tickets::Column::OfferActivityUrl.eq(&offer_id),
)
.filter(crate::entities::ap_remote_tickets::Column::AuthorId.eq(user_id))
.one(db)
.await
{
info!(remote_ticket_id = %remote_ticket.id, "found remote ticket, updating status");
// Update the remote ticket status to accepted
let mut active_model: crate::entities::ap_remote_tickets::ActiveModel =
remote_ticket.into();
active_model.status = Set("accepted".to_string());
if let Some(result_url) = &accept.result {
active_model.assigned_url = Set(Some(result_url.clone()));
}
if let Err(e) = active_model.update(db).await {
warn!(error = %e, offer_id = %offer_id, "failed to update remote ticket status");
} else {
info!(offer_id = %offer_id, "remote ticket accepted");
}
return HttpResponse::Accepted().finish();
} else {
info!(offer_id = %offer_id, user_id = %user_id, "no remote ticket found");
}
} else {
info!("signer has no user_id");
}
// Otherwise, handle as a Follow Accept
match crate::activitypub::following::handle_accept(db, &verified_actor_url, &accept)
.await
{
Ok(()) => HttpResponse::Accepted().finish(),
Err(e) => {
warn!(error = %e, "failed to handle Accept");
HttpResponse::Accepted().finish()
}
}
}
InboxActivity::Reject(reject) => {
// First, check if this is a Reject for a remote ticket Offer
let offer_id = reject.object.id().to_string();
if let Some(user_id) = signer.user_id
&& let Ok(Some(remote_ticket)) = crate::entities::ap_remote_tickets::Entity::find()
.filter(
crate::entities::ap_remote_tickets::Column::OfferActivityUrl.eq(&offer_id),
)
.filter(crate::entities::ap_remote_tickets::Column::AuthorId.eq(user_id))
.one(db)
.await
{
// Update the remote ticket status to rejected
let mut active_model: crate::entities::ap_remote_tickets::ActiveModel =
remote_ticket.into();
active_model.status = Set("rejected".to_string());
if let Err(e) = active_model.update(db).await {
warn!(error = %e, offer_id = %offer_id, "failed to update remote ticket status");
} else {
info!(offer_id = %offer_id, "remote ticket rejected");
}
return HttpResponse::Accepted().finish();
}
// Otherwise, handle as a Follow Reject
match crate::activitypub::following::handle_reject(db, &verified_actor_url, &reject)
.await
{
Ok(()) => HttpResponse::Accepted().finish(),
Err(e) => {
warn!(error = %e, "failed to handle Reject");
HttpResponse::Accepted().finish()
}
}
}
// ForgeFed activities - only handled by repository actors
InboxActivity::Offer(offer) => {
// Only repository actors handle Offer activities
if signer.repo_id.is_none() {
debug!("user actor received Offer activity, ignoring");
return HttpResponse::Accepted().finish();
}
crate::activitypub::forge::inbound::handle_offer(
db,
http_client,
public_url,
signer,
&verified_actor_url,
&offer,
)
.await
}
InboxActivity::Resolve(resolve) => {
// Only repository actors handle Resolve activities
if signer.repo_id.is_none() {
debug!("user actor received Resolve activity, ignoring");
return HttpResponse::Accepted().finish();
}
crate::activitypub::forge::inbound::handle_resolve(
db,
http_client,
public_url,
signer,
&verified_actor_url,
&resolve,
)
.await
}
InboxActivity::Edit(edit) => {
// Only repository actors handle Edit activities
if signer.repo_id.is_none() {
debug!("user actor received Edit activity, ignoring");
return HttpResponse::Accepted().finish();
}
crate::activitypub::forge::inbound::handle_edit(
db,
http_client,
public_url,
signer,
&verified_actor_url,
&edit,
)
.await
}
InboxActivity::Add(add) => {
// Only repository actors handle Add activities
if signer.repo_id.is_none() {
debug!("user actor received Add activity, ignoring");
return HttpResponse::Accepted().finish();
}
crate::activitypub::forge::inbound::handle_add(
db,
public_url,
signer,
&verified_actor_url,
&add,
)
.await
}
InboxActivity::Unknown => {
debug!("received unknown activity type");
actix_web::HttpResponse::Accepted().finish()
}
}
}
/// Handle a reaction (Like, Announce, EmojiReact) on a local note.
async fn handle_reaction(
db: &DatabaseConnection,
http_client: &reqwest_middleware::ClientWithMiddleware,
public_url: &str,
actor_url: &str,
reaction: &crate::activitypub::types::ReactionActivity,
kind: &str,
emoji: Option<&str>,
) -> HttpResponse {
// Extract the note URL from the object
let note_url = reaction.object.id();
// Parse the note URL to extract the UUID
// Expected format: {base}/~{user}/notes/{uuid}
let urls = crate::activitypub::ApUrls::new(public_url);
let base = urls.base();
if !note_url.starts_with(base) {
debug!(note_url, "reaction on non-local note");
return HttpResponse::Accepted().finish();
}
// Extract UUID from the note URL
let uuid_str = match note_url.strip_prefix(&format!(
"{}/notes/",
urls.actor(&extract_user_from_url(note_url, base))
)) {
Some(uuid) => uuid,
None => {
debug!(note_url, "could not extract UUID from note URL");
return HttpResponse::Accepted().finish();
}
};
let note_uuid = match uuid::Uuid::parse_str(uuid_str) {
Ok(uuid) => uuid,
Err(_) => {
debug!(uuid_str, "invalid UUID in note URL");
return HttpResponse::Accepted().finish();
}
};
// Find the note
let note = match crate::entities::prelude::ApNotes::find()
.filter(crate::entities::ap_notes::Column::Uuid.eq(note_uuid))
.one(db)
.await
{
Ok(Some(note)) => note,
Ok(None) => {
debug!(note_uuid = %note_uuid, "note not found");
return HttpResponse::Accepted().finish();
}
Err(e) => {
error!(error = %e, "failed to look up note");
return HttpResponse::InternalServerError().finish();
}
};
// Resolve the remote actor
let remote_actor = match remote::resolve_actor(db, http_client, actor_url).await {
Ok(actor) => actor,
Err(e) => {
error!(error = %e, actor_url, "failed to resolve remote actor");
return HttpResponse::InternalServerError().finish();
}
};
// Insert the reaction (idempotent via unique constraint)
let reaction_model = crate::entities::ap_reactions::ActiveModel {
note_id: Set(Some(note.id)),
kind: Set(kind.to_string()),
emoji: Set(emoji.map(|s| s.to_string())),
remote_actor_id: Set(Some(remote_actor.id)),
local_user_id: Set(None),
activity_id: Set(Some(reaction.id.clone())),
..Default::default()
};
match reaction_model.insert(db).await {
Ok(_) => {
info!(
actor_url = %actor_url,
note_uuid = %note_uuid,
kind = %kind,
"recorded reaction"
);
HttpResponse::Accepted().finish()
}
Err(e) if e.to_string().contains("UNIQUE constraint failed") => {
debug!(
actor_url = %actor_url,
note_uuid = %note_uuid,
kind = %kind,
"reaction already recorded"
);
HttpResponse::Accepted().finish()
}
Err(e) => {
error!(error = %e, "failed to insert reaction");
HttpResponse::InternalServerError().finish()
}
}
}
/// Extract the username from a note URL.
fn extract_user_from_url(note_url: &str, base: &str) -> String {
// Expected format: {base}/~{user}/notes/{uuid}
let suffix = ¬e_url[base.len()..];
if let Some(user_part) = suffix.strip_prefix("/~")
&& let Some(user) = user_part.split('/').next()
{
return user.to_string();
}
String::new()
}
/// Verify the HTTP signature on an inbox request.
async fn verify_inbox_request(
db: &DatabaseConnection,
http_client: &reqwest_middleware::ClientWithMiddleware,
req: &HttpRequest,
body: &[u8],
) -> Result<String, SignatureError> {
let method = req.method().as_str();
// Extract scheme, authority, path, and query from the request
let conn_info = req.connection_info();
let scheme = conn_info.scheme();
let authority = conn_info.host();
let path = req.uri().path();
let query = req.uri().query();
signatures::verify_request(
scheme,
authority,
path,
query,
method,
req.headers(),
body,
|key_id| async move {
match remote::resolve_key(db, http_client, &key_id).await {
Ok(Some((actor_url, public_key_pem))) => Ok(Some((actor_url, public_key_pem))),
Ok(None) => Ok(None),
Err(remote::ResolveError::Gone) => {
Err(signatures::SignatureError::ActorGone(key_id.clone()))
}
Err(e) => {
error!(key_id = %key_id, error = %e, "failed to resolve key");
Ok(None)
}
}
},
)
.await
}
/// Handle a Follow activity.
async fn handle_follow(
db: &DatabaseConnection,
http_client: &reqwest_middleware::ClientWithMiddleware,
public_url: &str,
actor_url: &str,
follow: &crate::activitypub::types::FollowActivity,
) -> HttpResponse {
// The object should be one of our local actors
let target_actor_url = match &follow.object {
crate::activitypub::types::ObjectRef::Url(url) => url.as_str(),
crate::activitypub::types::ObjectRef::Embedded(emb) => emb.id.as_str(),
};
tracing::info!(
target = "handle_follow",
target_actor_url,
actor_url,
"processing follow"
);
// Find the local actor
let local_actor =
match crate::activitypub::actor::get_by_url(db, target_actor_url, public_url).await {
Ok(Some(actor)) => actor,
Ok(None) => {
tracing::warn!(target_actor_url, "follow target not found");
return HttpResponse::NotFound().finish();
}
Err(e) => {
tracing::error!(error = %e, "failed to look up local actor");
return HttpResponse::InternalServerError().finish();
}
};
tracing::info!(local_actor_id = local_actor.id, "found local actor");
// Resolve or fetch the remote actor
let remote_actor = match remote::resolve_actor(db, http_client, actor_url).await {
Ok(actor) => actor,
Err(e) => {
tracing::error!(error = %e, actor_url, "failed to resolve remote actor");
return HttpResponse::InternalServerError().finish();
}
};
tracing::info!(remote_actor_id = remote_actor.id, "resolved remote actor");
// Check if we already have this follow
let existing = ApFollowers::find()
.filter(ap_followers::Column::LocalActorId.eq(local_actor.id))
.filter(ap_followers::Column::RemoteActorId.eq(remote_actor.id))
.one(db)
.await;
match existing {
Ok(Some(_)) => {
debug!("follow already recorded");
HttpResponse::Accepted().finish()
}
Ok(None) => {
// Insert the follow
let follow_model = ap_followers::ActiveModel {
local_actor_id: Set(local_actor.id),
remote_actor_id: Set(remote_actor.id),
follow_activity_id: Set(follow.id.clone()),
..Default::default()
};
match follow_model.insert(db).await {
Ok(_) => {
info!(
local_actor_url = target_actor_url,
remote_actor_url = actor_url,
"recorded follow"
);
// Enqueue Accept(Follow) activity for delivery
if let Err(e) = enqueue_accept_follow(
db,
public_url,
&local_actor,
&remote_actor,
&follow.id,
)
.await
{
error!(error = %e, "failed to enqueue Accept(Follow)");
}
HttpResponse::Accepted().finish()
}
Err(e) => {
error!(error = %e, "failed to insert follow");
HttpResponse::InternalServerError().finish()
}
}
}
Err(e) => {
error!(error = %e, "failed to check existing follow");
HttpResponse::InternalServerError().finish()
}
}
}
/// Handle an Undo activity.
async fn handle_undo(
db: &DatabaseConnection,
actor_url: &str,
undo: &crate::activitypub::types::UndoActivity,
) -> HttpResponse {
let undone_object = match undo.object.as_ref() {
UndoObject::Embedded(emb) => emb,
UndoObject::Url(url) => {
// For now, we only handle embedded objects
debug!(url, "undo of URL reference not supported");
return HttpResponse::Accepted().finish();
}
};
let undone_type = undone_object.kind.as_str();
let undone_id = undone_object.id.as_str();
match undone_type {
"Follow" => {
// Find the remote actor
let remote_actor = match ApRemoteActors::find()
.filter(ap_remote_actors::Column::ActorUrl.eq(actor_url))
.one(db)
.await
{
Ok(Some(actor)) => actor,
Ok(None) => {
debug!(actor_url, "undo from unknown remote actor");
return HttpResponse::Accepted().finish();
}
Err(e) => {
error!(error = %e, "failed to look up remote actor");
return HttpResponse::InternalServerError().finish();
}
};
// Find and delete the follow
let follow = ApFollowers::find()
.filter(ap_followers::Column::FollowActivityId.eq(undone_id))
.filter(ap_followers::Column::RemoteActorId.eq(remote_actor.id))
.one(db)
.await;
match follow {
Ok(Some(f)) => match f.delete(db).await {
Ok(_) => {
info!(
follow_activity_id = undone_id,
remote_actor_url = actor_url,
"removed follow via undo"
);
HttpResponse::Accepted().finish()
}
Err(e) => {
error!(error = %e, "failed to delete follow");
HttpResponse::InternalServerError().finish()
}
},
Ok(None) => {
debug!(follow_activity_id = undone_id, "undo of unknown follow");
HttpResponse::Accepted().finish()
}
Err(e) => {
error!(error = %e, "failed to look up follow");
HttpResponse::InternalServerError().finish()
}
}
}
"Like" | "Announce" | "EmojiReact" => {
// Find the remote actor
let remote_actor = match ApRemoteActors::find()
.filter(ap_remote_actors::Column::ActorUrl.eq(actor_url))
.one(db)
.await
{
Ok(Some(actor)) => actor,
Ok(None) => {
debug!(actor_url, "undo from unknown remote actor");
return HttpResponse::Accepted().finish();
}
Err(e) => {
error!(error = %e, "failed to look up remote actor");
return HttpResponse::InternalServerError().finish();
}
};
// Find and delete the reaction by activity_id
let reaction = crate::entities::prelude::ApReactions::find()
.filter(crate::entities::ap_reactions::Column::ActivityId.eq(undone_id))
.filter(crate::entities::ap_reactions::Column::RemoteActorId.eq(remote_actor.id))
.one(db)
.await;
match reaction {
Ok(Some(r)) => match r.delete(db).await {
Ok(_) => {
info!(
activity_id = undone_id,
remote_actor_url = actor_url,
kind = undone_type,
"removed reaction via undo"
);
HttpResponse::Accepted().finish()
}
Err(e) => {
error!(error = %e, "failed to delete reaction");
HttpResponse::InternalServerError().finish()
}
},
Ok(None) => {
debug!(activity_id = undone_id, "undo of unknown reaction");
HttpResponse::Accepted().finish()
}
Err(e) => {
error!(error = %e, "failed to look up reaction");
HttpResponse::InternalServerError().finish()
}
}
}
"Resolve" => {
// Find the ticket that was closed by this Resolve activity
let ticket = crate::entities::prelude::Tickets::find()
.filter(crate::entities::tickets::Column::ResolveActivityUrl.eq(undone_id))
.one(db)
.await;
match ticket {
Ok(Some(t)) => {
// Reopen the ticket
match crate::tickets::store::update_ticket(
db,
t.id,
&t.summary,
t.description.as_deref(),
crate::tickets::State::Open,
None, // Clear the resolve activity URL
)
.await
{
Ok(_) => {
info!(
ticket_id = t.id,
resolve_activity_id = undone_id,
"reopened ticket via undo of resolve"
);
HttpResponse::Accepted().finish()
}
Err(e) => {
error!(error = %e, "failed to reopen ticket");
HttpResponse::InternalServerError().finish()
}
}
}
Ok(None) => {
debug!(resolve_activity_id = undone_id, "undo of unknown resolve");
HttpResponse::Accepted().finish()
}
Err(e) => {
error!(error = %e, "failed to look up ticket");
HttpResponse::InternalServerError().finish()
}
}
}
_ => {
debug!(undone_type, "undo of unsupported activity type");
HttpResponse::Accepted().finish()
}
}
}
/// Enqueue an Accept(Follow) activity for delivery to the remote actor's inbox.
async fn enqueue_accept_follow(
db: &DatabaseConnection,
public_url: &str,
local_actor: &crate::entities::ap_actors::Model,
remote_actor: &crate::entities::ap_remote_actors::Model,
follow_id: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let urls = crate::activitypub::ApUrls::new(public_url);
// Get the actor URL
let actor_url = crate::activitypub::actor::actor_url_for(db, public_url, local_actor)
.await?
.ok_or("actor not found")?;
// Generate activity URL - need to handle both user and repo actors
let activity_uuid = Uuid::new_v4();
let activity_url = if let Some(user_id) = local_actor.user_id {
// User actor
let user = crate::entities::prelude::Users::find_by_id(user_id)
.one(db)
.await?
.ok_or("user not found")?;
urls.activity(&user.name, activity_uuid)
} else if let Some(repo_id) = local_actor.repo_id {
// Repository actor
let repo = crate::entities::prelude::Repositories::find_by_id(repo_id)
.one(db)
.await?
.ok_or("repository not found")?;
let owner = crate::entities::prelude::Users::find_by_id(repo.owner_id)
.one(db)
.await?
.ok_or("repository owner not found")?;
urls.repo_activity(&owner.name, &repo.slug, activity_uuid)
} else {
return Err("actor has neither user_id nor repo_id".into());
};
// Build the Accept(Follow) activity
let accept_activity = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": activity_url,
"type": "Accept",
"actor": actor_url,
"object": {
"id": follow_id,
"type": "Follow",
"actor": remote_actor.actor_url,
"object": actor_url
}
});
let activity_json = serde_json::to_string(&accept_activity)?;
// Use the shared enqueue_delivery helper
crate::activitypub::delivery::enqueue_delivery(
db,
local_actor.id,
activity_uuid,
activity_json,
remote_actor.actor_url.clone(),
remote_actor.inbox_url.clone(),
)
.await?;
info!(
inbox_url = %remote_actor.inbox_url,
"enqueued Accept(Follow) for delivery"
);
Ok(())
}