015f300941651035945b5cdd98aeada97e4337aa / frogue / src / webui / federation.rs
Raw
//! Federation endpoints: ActivityPub actor and webfinger discovery.
//!
//! The actor route uses content negotiation via the AcceptAp guard: requests
//! with `Accept: application/activity+json` or `application/ld+json` are
//! routed to the AP actor; browsers fall through to the HTML profile.
use std::sync::Arc;
use actix_web::{HttpRequest, HttpResponse, get, guard, post, web};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::Deserialize;
use tokio::sync::RwLock;
use tracing::error;
use crate::AppState;
use crate::activitypub::{self, ApUrls, types};
use crate::auth::CurrentUser;
use crate::entities::prelude::{ApFollowers, ApRemoteActors, Repositories, Users};
use crate::entities::repositories;
use crate::entities::users;
use crate::entities::{ap_followers, ap_remote_actors};
use crate::state_read;
/// Guard function for ActivityPub content negotiation.
///
/// Returns true when the Accept header contains `application/activity+json` or
/// `application/ld+json` (Mastodon sends the latter with a profile parameter).
pub fn accept_ap() -> impl guard::Guard {
guard::fn_guard(|req| {
req.head()
.headers()
.get("accept")
.and_then(|v| v.to_str().ok())
.map(|accept| {
accept.contains("application/activity+json")
|| accept.contains("application/ld+json")
})
.unwrap_or(false)
})
}
#[derive(Deserialize)]
struct WebfingerQuery {
resource: Option<String>,
}
/// GET /.well-known/webfinger
#[get("/.well-known/webfinger")]
#[tracing::instrument(skip_all)]
pub async fn get_webfinger(
data: web::Data<Arc<RwLock<AppState>>>,
query: web::Query<WebfingerQuery>,
) -> HttpResponse {
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
let resource = match &query.resource {
Some(r) => r.clone(),
None => return HttpResponse::BadRequest().body("missing resource parameter"),
};
let public_url = state.public_url().to_string();
let db = state.db.clone();
drop(state);
activitypub::webfinger::webfinger(&db, &resource, &public_url).await
}
/// GET /~{user} with ActivityPub Accept header
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
pub async fn get_actor(
data: web::Data<Arc<RwLock<AppState>>>,
user_or_team: web::Path<String>,
) -> HttpResponse {
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
let user = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch or create the actor row (keypair generated lazily).
let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %user.name, "fetching or creating actor failed");
return HttpResponse::InternalServerError().finish();
}
};
let urls = ApUrls::new(state.public_url());
let actor_doc = types::Actor {
context: types::CONTEXT.to_vec(),
id: urls.actor(&user.name),
kind: "Person",
preferred_username: user.name.clone(),
name: Some(user.name.clone()),
summary: user.description.clone(),
inbox: urls.inbox(&user.name),
outbox: urls.outbox(&user.name),
followers: Some(urls.followers(&user.name)),
public_key: types::PublicKeyDoc {
id: urls.key_id(&user.name),
owner: urls.actor(&user.name),
public_key_pem: actor.public_key_pem,
},
attributed_to: None,
clone_uri: None,
push_uri: None,
published: None,
tickets_tracked_by: None,
send_patches_to: None,
tracks_tickets_for: None,
tracks_patches_for: None,
};
HttpResponse::Ok()
.content_type("application/activity+json; charset=utf-8")
.json(actor_doc)
}
/// POST /~{user}/inbox
#[tracing::instrument(skip_all, fields(user_or_team))]
pub async fn post_inbox(
state: web::Data<Arc<RwLock<AppState>>>,
user_or_team: web::Path<String>,
req: HttpRequest,
body: web::Bytes,
) -> HttpResponse {
let state = state_read(&state).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
let user = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch or create the actor row (needed for signing GET requests)
let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %user.name, "fetching or creating actor failed");
return HttpResponse::InternalServerError().finish();
}
};
activitypub::inbox::handle(
&state.db,
state.http_client(),
state.public_url(),
&actor,
&req,
&body,
)
.await
}
/// GET /~{user}/followers
#[get("/~{user_or_team}/followers")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
pub async fn get_followers(
state: web::Data<Arc<RwLock<AppState>>>,
user_or_team: web::Path<String>,
) -> HttpResponse {
let state = state_read(&state).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
let user = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
// Get the local actor
let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %user.name, "fetching actor failed");
return HttpResponse::InternalServerError().finish();
}
};
// Count followers and get their actor URLs
let followers: Vec<ap_remote_actors::Model> = match ApFollowers::find()
.filter(ap_followers::Column::LocalActorId.eq(actor.id))
.find_also_related(ApRemoteActors)
.all(&state.db)
.await
{
Ok(followers) => followers
.into_iter()
.filter_map(|(_, remote)| remote)
.collect(),
Err(err) => {
error!(%err, user = %user.name, "fetching followers failed");
return HttpResponse::InternalServerError().finish();
}
};
let urls = ApUrls::new(state.public_url());
let follower_urls: Vec<String> = followers.iter().map(|f| f.actor_url.clone()).collect();
let collection = types::OrderedCollection {
context: types::CONTEXT.to_vec(),
id: urls.followers(&user.name),
kind: "OrderedCollection",
total_items: follower_urls.len() as u64,
first: None,
ordered_items: if follower_urls.is_empty() {
None
} else {
Some(
follower_urls
.into_iter()
.map(serde_json::Value::String)
.collect(),
)
},
};
HttpResponse::Ok()
.content_type("application/activity+json; charset=utf-8")
.json(collection)
}
#[derive(serde::Deserialize)]
pub struct OutboxQuery {
page: Option<u64>,
}
#[get("/~{user_or_team}/outbox")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
pub async fn get_outbox(
data: web::Data<Arc<RwLock<AppState>>>,
user_or_team: web::Path<String>,
query: web::Query<OutboxQuery>,
) -> HttpResponse {
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
let user = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %user.name, "getting actor failed");
return HttpResponse::InternalServerError().finish();
}
};
let urls = ApUrls::new(state.public_url());
let outbox_url = urls.outbox(&user.name);
match activitypub::outbox::get_outbox(&state.db, actor.id, &outbox_url, query.page).await {
Ok(collection) => HttpResponse::Ok()
.content_type("application/activity+json; charset=utf-8")
.json(collection),
Err(err) => {
error!(%err, user = %user.name, "fetching outbox failed");
HttpResponse::InternalServerError().finish()
}
}
}
#[get("/~{user_or_team}/notes/{uuid}")]
pub async fn get_note(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String)>,
) -> HttpResponse {
let (user_or_team, uuid_str) = path.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
let uuid = match uuid::Uuid::parse_str(&uuid_str) {
Ok(uuid) => uuid,
Err(_) => return HttpResponse::NotFound().finish(),
};
let user = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team, "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
let urls = ApUrls::new(state.public_url());
let actor_url = urls.actor(&user.name);
let note_url = urls.note(&user.name, uuid);
match activitypub::notes::get_note(&state.db, ¬e_url, &actor_url).await {
Ok(Some(note)) => HttpResponse::Ok()
.content_type("application/activity+json; charset=utf-8")
.json(note),
Ok(None) => HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user.name, uuid = %uuid, "fetching note failed");
HttpResponse::InternalServerError().finish()
}
}
}
#[derive(serde::Deserialize)]
struct PostNoteForm {
content: String,
}
#[post("/~{user_or_team}/notes")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
pub async fn post_note(
data: web::Data<Arc<RwLock<AppState>>>,
user_or_team: web::Path<String>,
form: web::Form<PostNoteForm>,
session: actix_session::Session,
) -> HttpResponse {
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
let viewer_name = match session.get::<String>("user_name") {
Ok(Some(name)) => name,
Ok(None) => return HttpResponse::Unauthorized().finish(),
Err(err) => {
error!(%err, "reading session failed");
return HttpResponse::InternalServerError().finish();
}
};
// Check if the viewer is the owner
if viewer_name != *user_or_team {
return HttpResponse::Forbidden().finish();
}
let user = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team, "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %user.name, "getting actor failed");
return HttpResponse::InternalServerError().finish();
}
};
let urls = ApUrls::new(state.public_url());
let actor_url = urls.actor(&user.name);
match activitypub::notes::publish(
&state.db,
actor.id,
&actor_url,
state.public_url(),
&form.content,
)
.await
{
Ok(_note) => {
let profile_url = format!("/~{}", user.name);
HttpResponse::SeeOther()
.insert_header(("Location", profile_url))
.finish()
}
Err(err) => {
error!(%err, user = %user.name, "publishing note failed");
HttpResponse::InternalServerError().finish()
}
}
}
#[post("/~{user_or_team}/notes/{uuid}/react")]
pub async fn post_react(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String)>,
session: actix_session::Session,
) -> HttpResponse {
let (user_or_team, uuid_str) = path.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
let viewer_name = match session.get::<String>("user_name") {
Ok(Some(name)) => name,
Ok(None) => return HttpResponse::Unauthorized().finish(),
Err(err) => {
error!(%err, "reading session failed");
return HttpResponse::InternalServerError().finish();
}
};
let uuid = match uuid::Uuid::parse_str(&uuid_str) {
Ok(uuid) => uuid,
Err(_) => return HttpResponse::NotFound().finish(),
};
let user = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team, "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
let viewer = match Users::find()
.filter(users::Column::Name.eq(viewer_name.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::Unauthorized().finish(),
Err(err) => {
error!(%err, "looking up viewer failed");
return HttpResponse::InternalServerError().finish();
}
};
let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %user.name, "getting actor failed");
return HttpResponse::InternalServerError().finish();
}
};
// Query the note entity directly to get the ID
let note = match crate::entities::prelude::ApNotes::find()
.filter(crate::entities::ap_notes::Column::Uuid.eq(uuid))
.filter(crate::entities::ap_notes::Column::ActorId.eq(actor.id))
.one(&state.db)
.await
{
Ok(Some(note)) => note,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user.name, uuid = %uuid, "fetching note failed");
return HttpResponse::InternalServerError().finish();
}
};
use crate::entities::ap_reactions;
use sea_orm::ActiveModelTrait;
use sea_orm::Set;
let reaction = ap_reactions::ActiveModel {
note_id: Set(Some(note.id)),
kind: Set("like".to_string()),
emoji: Set(None),
remote_actor_id: Set(None),
local_user_id: Set(Some(viewer.id)),
activity_id: Set(None),
..Default::default()
};
match reaction.insert(&state.db).await {
Ok(_) => {
let profile_url = format!("/~{}", user.name);
HttpResponse::SeeOther()
.insert_header(("Location", profile_url))
.finish()
}
Err(err) if err.to_string().contains("UNIQUE constraint failed") => {
// Already liked, just redirect
let profile_url = format!("/~{}", user.name);
HttpResponse::SeeOther()
.insert_header(("Location", profile_url))
.finish()
}
Err(err) => {
error!(%err, "inserting reaction failed");
HttpResponse::InternalServerError().finish()
}
}
}
#[post("/~{user_or_team}/notes/{uuid}/unreact")]
pub async fn post_unreact(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String)>,
session: actix_session::Session,
) -> HttpResponse {
let (user_or_team, uuid_str) = path.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
let viewer_name = match session.get::<String>("user_name") {
Ok(Some(name)) => name,
Ok(None) => return HttpResponse::Unauthorized().finish(),
Err(err) => {
error!(%err, "reading session failed");
return HttpResponse::InternalServerError().finish();
}
};
let uuid = match uuid::Uuid::parse_str(&uuid_str) {
Ok(uuid) => uuid,
Err(_) => return HttpResponse::NotFound().finish(),
};
let user = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team, "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
let viewer = match Users::find()
.filter(users::Column::Name.eq(viewer_name.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::Unauthorized().finish(),
Err(err) => {
error!(%err, "looking up viewer failed");
return HttpResponse::InternalServerError().finish();
}
};
let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %user.name, "getting actor failed");
return HttpResponse::InternalServerError().finish();
}
};
// Query the note entity directly to get the ID
let note = match crate::entities::prelude::ApNotes::find()
.filter(crate::entities::ap_notes::Column::Uuid.eq(uuid))
.filter(crate::entities::ap_notes::Column::ActorId.eq(actor.id))
.one(&state.db)
.await
{
Ok(Some(note)) => note,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user.name, uuid = %uuid, "fetching note failed");
return HttpResponse::InternalServerError().finish();
}
};
use crate::entities::ap_reactions;
use sea_orm::ModelTrait;
let reaction = match crate::entities::prelude::ApReactions::find()
.filter(ap_reactions::Column::NoteId.eq(note.id))
.filter(ap_reactions::Column::LocalUserId.eq(Some(viewer.id)))
.filter(ap_reactions::Column::Kind.eq("like"))
.one(&state.db)
.await
{
Ok(Some(reaction)) => reaction,
Ok(None) => {
// No reaction to remove, just redirect
let profile_url = format!("/~{}", user.name);
return HttpResponse::SeeOther()
.insert_header(("Location", profile_url))
.finish();
}
Err(err) => {
error!(%err, "finding reaction failed");
return HttpResponse::InternalServerError().finish();
}
};
match reaction.delete(&state.db).await {
Ok(_) => {
let profile_url = format!("/~{}", user.name);
HttpResponse::SeeOther()
.insert_header(("Location", profile_url))
.finish()
}
Err(err) => {
error!(%err, "deleting reaction failed");
HttpResponse::InternalServerError().finish()
}
}
}
/// POST /remote-notes/{id}/react
///
/// React to a remote note with a Like activity.
/// Sends the activity to the remote actor's inbox and stores it locally.
#[post("/remote-notes/{id}/react")]
#[tracing::instrument(skip_all, fields(remote_note_id = %id))]
pub async fn post_react_remote(
id: web::Path<i64>,
data: web::Data<Arc<RwLock<AppState>>>,
session: actix_session::Session,
) -> HttpResponse {
let remote_note_id = id.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
let viewer_name = match session.get::<String>("user_name") {
Ok(Some(name)) => name,
Ok(None) => return HttpResponse::Unauthorized().finish(),
Err(err) => {
error!(%err, "reading session failed");
return HttpResponse::InternalServerError().finish();
}
};
let viewer = match Users::find()
.filter(users::Column::Name.eq(viewer_name.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::Unauthorized().finish(),
Err(err) => {
error!(%err, "looking up viewer failed");
return HttpResponse::InternalServerError().finish();
}
};
let actor = match activitypub::actor::get_or_create(&state.db, viewer.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %viewer.name, "getting actor failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch the remote note
let remote_note = match crate::entities::prelude::ApRemoteNotes::find_by_id(remote_note_id)
.one(&state.db)
.await
{
Ok(Some(note)) => note,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, "fetching remote note failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch the remote actor who authored the note
let remote_actor =
match crate::entities::prelude::ApRemoteActors::find_by_id(remote_note.remote_actor_id)
.one(&state.db)
.await
{
Ok(Some(actor)) => actor,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, "fetching remote actor failed");
return HttpResponse::InternalServerError().finish();
}
};
let urls = ApUrls::new(state.public_url());
let activity_uuid = uuid::Uuid::new_v4();
let activity_id = urls.activity(&viewer.name, activity_uuid);
// Build the Like activity
let like_activity = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": activity_id,
"type": "Like",
"actor": urls.actor(&viewer.name),
"object": remote_note.object_url
});
// Enqueue delivery to the remote actor's inbox
let inbox_url = remote_actor
.shared_inbox_url
.as_deref()
.unwrap_or(&remote_actor.inbox_url);
if let Err(err) = activitypub::delivery::enqueue_delivery(
&state.db,
actor.id,
activity_uuid,
serde_json::to_string(&like_activity).unwrap(),
remote_note.object_url.clone(),
inbox_url.to_string(),
)
.await
{
error!(%err, "enqueuing delivery failed");
return HttpResponse::InternalServerError().finish();
}
// Store the reaction locally
use crate::entities::ap_reactions;
use sea_orm::ActiveModelTrait;
use sea_orm::Set;
let reaction = ap_reactions::ActiveModel {
note_id: Set(None),
remote_note_id: Set(Some(remote_note_id)),
remote_note_object_url: Set(Some(remote_note.object_url.clone())),
kind: Set("like".to_string()),
emoji: Set(None),
remote_actor_id: Set(None),
local_user_id: Set(Some(viewer.id)),
activity_id: Set(Some(activity_id.clone())),
..Default::default()
};
match reaction.insert(&state.db).await {
Ok(_) => {
// Redirect back to the home feed
HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish()
}
Err(err) if err.to_string().contains("UNIQUE constraint failed") => {
// Already liked, just redirect
HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish()
}
Err(err) => {
error!(%err, "inserting reaction failed");
HttpResponse::InternalServerError().finish()
}
}
}
/// POST /remote-notes/{id}/unreact
///
/// Remove a reaction from a remote note by sending an Undo(Like) activity.
#[post("/remote-notes/{id}/unreact")]
#[tracing::instrument(skip_all, fields(remote_note_id = %id))]
pub async fn post_unreact_remote(
id: web::Path<i64>,
data: web::Data<Arc<RwLock<AppState>>>,
session: actix_session::Session,
) -> HttpResponse {
let remote_note_id = id.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
let viewer_name = match session.get::<String>("user_name") {
Ok(Some(name)) => name,
Ok(None) => return HttpResponse::Unauthorized().finish(),
Err(err) => {
error!(%err, "reading session failed");
return HttpResponse::InternalServerError().finish();
}
};
let viewer = match Users::find()
.filter(users::Column::Name.eq(viewer_name.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::Unauthorized().finish(),
Err(err) => {
error!(%err, "looking up viewer failed");
return HttpResponse::InternalServerError().finish();
}
};
let actor = match activitypub::actor::get_or_create(&state.db, viewer.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %viewer.name, "getting actor failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch the remote note
let remote_note = match crate::entities::prelude::ApRemoteNotes::find_by_id(remote_note_id)
.one(&state.db)
.await
{
Ok(Some(note)) => note,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, "fetching remote note failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch the remote actor who authored the note
let remote_actor =
match crate::entities::prelude::ApRemoteActors::find_by_id(remote_note.remote_actor_id)
.one(&state.db)
.await
{
Ok(Some(actor)) => actor,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, "fetching remote actor failed");
return HttpResponse::InternalServerError().finish();
}
};
use crate::entities::ap_reactions;
use sea_orm::ModelTrait;
// Find the reaction
let reaction = match crate::entities::prelude::ApReactions::find()
.filter(ap_reactions::Column::RemoteNoteId.eq(remote_note_id))
.filter(ap_reactions::Column::LocalUserId.eq(Some(viewer.id)))
.filter(ap_reactions::Column::Kind.eq("like"))
.one(&state.db)
.await
{
Ok(Some(reaction)) => reaction,
Ok(None) => {
// No reaction to remove, just redirect
return HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish();
}
Err(err) => {
error!(%err, "finding reaction failed");
return HttpResponse::InternalServerError().finish();
}
};
// If we have an activity_id, send an Undo(Like) activity
if let Some(activity_id) = &reaction.activity_id {
let urls = ApUrls::new(state.public_url());
let undo_uuid = uuid::Uuid::new_v4();
let undo_id = urls.activity(&viewer.name, undo_uuid);
let undo_activity = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": undo_id,
"type": "Undo",
"actor": urls.actor(&viewer.name),
"object": {
"id": activity_id,
"type": "Like",
"actor": urls.actor(&viewer.name),
"object": remote_note.object_url
}
});
let inbox_url = remote_actor
.shared_inbox_url
.as_deref()
.unwrap_or(&remote_actor.inbox_url);
if let Err(err) = activitypub::delivery::enqueue_delivery(
&state.db,
actor.id,
undo_uuid,
serde_json::to_string(&undo_activity).unwrap(),
remote_note.object_url.clone(),
inbox_url.to_string(),
)
.await
{
error!(%err, "enqueuing Undo delivery failed");
// Continue with deletion anyway
}
}
// Delete the reaction
match reaction.delete(&state.db).await {
Ok(_) => HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish(),
Err(err) => {
error!(%err, "deleting reaction failed");
HttpResponse::InternalServerError().finish()
}
}
}
/// POST /remote-notes/{id}/announce
///
/// Announce (boost) a remote note with an Announce activity.
/// Sends the activity to the remote actor's inbox and stores it locally.
#[post("/remote-notes/{id}/announce")]
#[tracing::instrument(skip_all, fields(remote_note_id = %id))]
pub async fn post_announce_remote(
id: web::Path<i64>,
data: web::Data<Arc<RwLock<AppState>>>,
session: actix_session::Session,
) -> HttpResponse {
let remote_note_id = id.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
let viewer_name = match session.get::<String>("user_name") {
Ok(Some(name)) => name,
Ok(None) => return HttpResponse::Unauthorized().finish(),
Err(err) => {
error!(%err, "reading session failed");
return HttpResponse::InternalServerError().finish();
}
};
let viewer = match Users::find()
.filter(users::Column::Name.eq(viewer_name.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::Unauthorized().finish(),
Err(err) => {
error!(%err, "looking up viewer failed");
return HttpResponse::InternalServerError().finish();
}
};
let actor = match activitypub::actor::get_or_create(&state.db, viewer.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %viewer.name, "getting actor failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch the remote note
let remote_note = match crate::entities::prelude::ApRemoteNotes::find_by_id(remote_note_id)
.one(&state.db)
.await
{
Ok(Some(note)) => note,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, "fetching remote note failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch the remote actor who authored the note
let remote_actor =
match crate::entities::prelude::ApRemoteActors::find_by_id(remote_note.remote_actor_id)
.one(&state.db)
.await
{
Ok(Some(actor)) => actor,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, "fetching remote actor failed");
return HttpResponse::InternalServerError().finish();
}
};
let urls = ApUrls::new(state.public_url());
let activity_uuid = uuid::Uuid::new_v4();
let activity_id = urls.activity(&viewer.name, activity_uuid);
// Build the Announce activity
let announce_activity = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": activity_id,
"type": "Announce",
"actor": urls.actor(&viewer.name),
"object": remote_note.object_url
});
// Enqueue delivery to the remote actor's inbox
let inbox_url = remote_actor
.shared_inbox_url
.as_deref()
.unwrap_or(&remote_actor.inbox_url);
if let Err(err) = activitypub::delivery::enqueue_delivery(
&state.db,
actor.id,
activity_uuid,
serde_json::to_string(&announce_activity).unwrap(),
remote_note.object_url.clone(),
inbox_url.to_string(),
)
.await
{
error!(%err, "enqueuing delivery failed");
return HttpResponse::InternalServerError().finish();
}
// Store the reaction locally
use crate::entities::ap_reactions;
use sea_orm::ActiveModelTrait;
use sea_orm::Set;
let reaction = ap_reactions::ActiveModel {
note_id: Set(None),
remote_note_id: Set(Some(remote_note_id)),
remote_note_object_url: Set(Some(remote_note.object_url.clone())),
kind: Set("announce".to_string()),
emoji: Set(None),
remote_actor_id: Set(None),
local_user_id: Set(Some(viewer.id)),
activity_id: Set(Some(activity_id.clone())),
..Default::default()
};
match reaction.insert(&state.db).await {
Ok(_) => {
// Redirect back to the home feed
HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish()
}
Err(err) if err.to_string().contains("UNIQUE constraint failed") => {
// Already announced, just redirect
HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish()
}
Err(err) => {
error!(%err, "inserting reaction failed");
HttpResponse::InternalServerError().finish()
}
}
}
/// POST /remote-notes/{id}/unannounce
///
/// Remove an announce (unboost) from a remote note by sending an Undo(Announce) activity.
#[post("/remote-notes/{id}/unannounce")]
#[tracing::instrument(skip_all, fields(remote_note_id = %id))]
pub async fn post_unannounce_remote(
id: web::Path<i64>,
data: web::Data<Arc<RwLock<AppState>>>,
session: actix_session::Session,
) -> HttpResponse {
let remote_note_id = id.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
let viewer_name = match session.get::<String>("user_name") {
Ok(Some(name)) => name,
Ok(None) => return HttpResponse::Unauthorized().finish(),
Err(err) => {
error!(%err, "reading session failed");
return HttpResponse::InternalServerError().finish();
}
};
let viewer = match Users::find()
.filter(users::Column::Name.eq(viewer_name.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::Unauthorized().finish(),
Err(err) => {
error!(%err, "looking up viewer failed");
return HttpResponse::InternalServerError().finish();
}
};
let actor = match activitypub::actor::get_or_create(&state.db, viewer.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %viewer.name, "getting actor failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch the remote note
let remote_note = match crate::entities::prelude::ApRemoteNotes::find_by_id(remote_note_id)
.one(&state.db)
.await
{
Ok(Some(note)) => note,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, "fetching remote note failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch the remote actor who authored the note
let remote_actor =
match crate::entities::prelude::ApRemoteActors::find_by_id(remote_note.remote_actor_id)
.one(&state.db)
.await
{
Ok(Some(actor)) => actor,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, "fetching remote actor failed");
return HttpResponse::InternalServerError().finish();
}
};
use crate::entities::ap_reactions;
use sea_orm::ModelTrait;
// Find the reaction
let reaction = match crate::entities::prelude::ApReactions::find()
.filter(ap_reactions::Column::RemoteNoteId.eq(remote_note_id))
.filter(ap_reactions::Column::LocalUserId.eq(Some(viewer.id)))
.filter(ap_reactions::Column::Kind.eq("announce"))
.one(&state.db)
.await
{
Ok(Some(reaction)) => reaction,
Ok(None) => {
// No reaction to remove, just redirect
return HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish();
}
Err(err) => {
error!(%err, "finding reaction failed");
return HttpResponse::InternalServerError().finish();
}
};
// If we have an activity_id, send an Undo(Announce) activity
if let Some(activity_id) = &reaction.activity_id {
let urls = ApUrls::new(state.public_url());
let undo_uuid = uuid::Uuid::new_v4();
let undo_id = urls.activity(&viewer.name, undo_uuid);
let undo_activity = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": undo_id,
"type": "Undo",
"actor": urls.actor(&viewer.name),
"object": {
"id": activity_id,
"type": "Announce",
"actor": urls.actor(&viewer.name),
"object": remote_note.object_url
}
});
let inbox_url = remote_actor
.shared_inbox_url
.as_deref()
.unwrap_or(&remote_actor.inbox_url);
if let Err(err) = activitypub::delivery::enqueue_delivery(
&state.db,
actor.id,
undo_uuid,
serde_json::to_string(&undo_activity).unwrap(),
remote_note.object_url.clone(),
inbox_url.to_string(),
)
.await
{
error!(%err, "enqueuing Undo delivery failed");
// Continue with deletion anyway
}
}
// Delete the reaction
match reaction.delete(&state.db).await {
Ok(_) => HttpResponse::SeeOther()
.insert_header(("Location", "/"))
.finish(),
Err(err) => {
error!(%err, "deleting reaction failed");
HttpResponse::InternalServerError().finish()
}
}
}
/// POST /~{user_or_team}/follow
///
/// Follow a remote actor by handle or actor URL.
/// If handle is provided, discovery is performed first.
#[post("/~{user_or_team}/follow")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
pub async fn post_follow(
data: web::Data<Arc<RwLock<AppState>>>,
user_or_team: web::Path<String>,
viewer: CurrentUser,
form: web::Form<FollowForm>,
) -> HttpResponse {
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
// Verify the viewer is the owner
if viewer.name != *user_or_team {
return HttpResponse::Forbidden().finish();
}
let user = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %user.name, "getting actor failed");
return HttpResponse::InternalServerError().finish();
}
};
// Determine the actor URL: either from form or via discovery
let actor_url = if let Some(handle) = &form.handle {
// Perform discovery
match activitypub::remote::discover_by_handle(
&state.db,
state.http_client(),
state.public_url(),
&actor,
&user.name,
handle,
)
.await
{
Ok(remote_actor) => remote_actor.actor_url,
Err(err) => {
error!(%err, user = %user.name, handle = %handle, "discovery failed");
let error_msg = url::form_urlencoded::byte_serialize(
format!("discovery failed: {}", err).as_bytes(),
)
.collect::<String>();
let profile_url = format!("/~{}?error={}", user.name, error_msg);
return HttpResponse::SeeOther()
.insert_header(("Location", profile_url))
.finish();
}
}
} else if let Some(actor_url) = &form.actor_url {
actor_url.clone()
} else {
let error_msg = url::form_urlencoded::byte_serialize(b"missing handle or actor_url")
.collect::<String>();
let profile_url = format!("/~{}?error={}", user.name, error_msg);
return HttpResponse::SeeOther()
.insert_header(("Location", profile_url))
.finish();
};
// Verify the remote actor is cached (i.e., was discovered)
let remote_actor = match ApRemoteActors::find()
.filter(ap_remote_actors::Column::ActorUrl.eq(&actor_url))
.one(&state.db)
.await
{
Ok(Some(remote)) => remote,
Ok(None) => {
let error_msg =
url::form_urlencoded::byte_serialize(b"actor not found").collect::<String>();
let profile_url = format!("/~{}?error={}", user.name, error_msg);
return HttpResponse::SeeOther()
.insert_header(("Location", profile_url))
.finish();
}
Err(err) => {
error!(%err, actor_url = %actor_url, "looking up remote actor failed");
return HttpResponse::InternalServerError().finish();
}
};
// Check if already following (idempotent)
use crate::entities::ap_following;
use crate::entities::prelude::ApFollowing;
use sea_orm::ColumnTrait;
use sea_orm::QueryFilter;
let existing = ApFollowing::find()
.filter(ap_following::Column::LocalActorId.eq(actor.id))
.filter(ap_following::Column::RemoteActorId.eq(remote_actor.id))
.one(&state.db)
.await;
match existing {
Ok(Some(_)) => {
// Already following, just redirect (idempotent)
let notice_msg =
url::form_urlencoded::byte_serialize(b"already following").collect::<String>();
let profile_url = format!("/~{}?notice={}", user.name, notice_msg);
return HttpResponse::SeeOther()
.insert_header(("Location", profile_url))
.finish();
}
Ok(None) => {
// Send the follow
match activitypub::following::send_follow(
&state.db,
state.public_url(),
&actor,
&remote_actor,
)
.await
{
Ok(()) => {
let notice_msg = url::form_urlencoded::byte_serialize(b"follow request sent")
.collect::<String>();
let profile_url = format!("/~{}?notice={}", user.name, notice_msg);
HttpResponse::SeeOther()
.insert_header(("Location", profile_url))
.finish()
}
Err(err) => {
error!(%err, user = %user.name, actor_url = %remote_actor.actor_url, "sending follow failed");
let error_msg = url::form_urlencoded::byte_serialize(
format!("follow failed: {}", err).as_bytes(),
)
.collect::<String>();
let profile_url = format!("/~{}?error={}", user.name, error_msg);
HttpResponse::SeeOther()
.insert_header(("Location", profile_url))
.finish()
}
}
}
Err(err) => {
error!(%err, "checking existing follow failed");
HttpResponse::InternalServerError().finish()
}
}
}
#[derive(Deserialize)]
struct FollowForm {
handle: Option<String>,
actor_url: Option<String>,
}
/// POST /~{user_or_team}/unfollow
///
/// Unfollow a remote actor by actor URL.
#[post("/~{user_or_team}/unfollow")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
pub async fn post_unfollow(
data: web::Data<Arc<RwLock<AppState>>>,
user_or_team: web::Path<String>,
viewer: CurrentUser,
form: web::Form<FollowForm>,
) -> HttpResponse {
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
// Verify the viewer is the owner
if viewer.name != *user_or_team {
return HttpResponse::Forbidden().finish();
}
let user = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
let actor_url = match &form.actor_url {
Some(url) => url.clone(),
None => {
let error_msg =
url::form_urlencoded::byte_serialize(b"actor_url is required").collect::<String>();
let profile_url = format!("/~{}?error={}", user_or_team.as_str(), error_msg);
return HttpResponse::SeeOther()
.insert_header(("Location", profile_url))
.finish();
}
};
let remote_actor = match ApRemoteActors::find()
.filter(ap_remote_actors::Column::ActorUrl.eq(&actor_url))
.one(&state.db)
.await
{
Ok(Some(remote)) => remote,
Ok(None) => {
let error_msg =
url::form_urlencoded::byte_serialize(b"actor not found").collect::<String>();
let profile_url = format!("/~{}?error={}", user_or_team.as_str(), error_msg);
return HttpResponse::SeeOther()
.insert_header(("Location", profile_url))
.finish();
}
Err(err) => {
error!(%err, actor_url = %actor_url, "looking up remote actor failed");
return HttpResponse::InternalServerError().finish();
}
};
let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %user.name, "getting actor failed");
return HttpResponse::InternalServerError().finish();
}
};
match activitypub::following::send_unfollow(
&state.db,
state.public_url(),
&actor,
&remote_actor,
)
.await
{
Ok(()) => {
let profile_url = format!("/~{}", user.name);
HttpResponse::SeeOther()
.insert_header(("Location", profile_url))
.finish()
}
Err(err) => {
error!(%err, user = %user.name, actor_url = %remote_actor.actor_url, "sending unfollow failed");
HttpResponse::InternalServerError().body(format!("unfollow failed: {}", err))
}
}
}
// ============================================================================
// Repository Actor Endpoints
// ============================================================================
/// GET /~{user_or_team}/{repository_slug} with ActivityPub Accept header
/// Serves the repository as a ForgeFed Repository actor.
#[tracing::instrument(skip_all)]
pub async fn get_repo_actor(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String)>,
) -> HttpResponse {
let (user_or_team, repository_slug) = path.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
// Look up the owner user
let owner = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the repository (must be public and not deleted for federation)
let repo = match Repositories::find()
.filter(repositories::Column::OwnerId.eq(owner.id))
.filter(repositories::Column::Slug.eq(repository_slug.as_str()))
.filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
.filter(repositories::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(repo)) => repo,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch or create the repository actor (keypair generated lazily)
let actor = match activitypub::actor::get_or_create_for_repo(&state.db, repo.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "fetching or creating repository actor failed");
return HttpResponse::InternalServerError().finish();
}
};
let urls = ApUrls::new(state.public_url());
let repo_actor_url = urls.repo_actor(&owner.name, &repository_slug);
let ssh_host = state.ssh.host.clone();
let push_uris = urls.repo_push_uris(&owner.name, &repository_slug, &ssh_host);
let published = repo.created_at.to_rfc3339();
let actor_doc = types::Actor {
context: types::FORGEFED_CONTEXT.to_vec(),
id: repo_actor_url.clone(),
kind: "Repository",
preferred_username: repository_slug.clone(),
name: Some(repository_slug.clone()),
summary: repo.description.clone(),
inbox: urls.repo_inbox(&owner.name, &repository_slug),
outbox: urls.repo_outbox(&owner.name, &repository_slug),
followers: Some(urls.repo_followers(&owner.name, &repository_slug)),
public_key: types::PublicKeyDoc {
id: urls.repo_key_id(&owner.name, &repository_slug),
owner: repo_actor_url.clone(),
public_key_pem: actor.public_key_pem,
},
attributed_to: Some(urls.actor(&owner.name)),
clone_uri: Some(urls.repo_clone_uri(&owner.name, &repository_slug)),
push_uri: Some(push_uris),
published: Some(published),
tickets_tracked_by: Some(repo_actor_url.clone()),
send_patches_to: Some(repo_actor_url.clone()),
tracks_tickets_for: Some(vec![repo_actor_url.clone()]),
tracks_patches_for: Some(vec![repo_actor_url]),
};
HttpResponse::Ok()
.content_type("application/activity+json; charset=utf-8")
.json(actor_doc)
}
/// POST /~{user_or_team}/{repository_slug}/inbox
/// Handles incoming ActivityPub activities for the repository actor.
#[tracing::instrument(skip_all, fields(user_or_team, repository_slug))]
pub async fn post_repo_inbox(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String)>,
req: HttpRequest,
body: web::Bytes,
) -> HttpResponse {
let (user_or_team, repository_slug) = path.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
// Look up the owner user
let owner = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the repository (must be public)
let repo = match Repositories::find()
.filter(repositories::Column::OwnerId.eq(owner.id))
.filter(repositories::Column::Slug.eq(repository_slug.as_str()))
.filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
.filter(repositories::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(repo)) => repo,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repositories failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch or create the repository actor
let actor = match activitypub::actor::get_or_create_for_repo(&state.db, repo.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "fetching repository actor failed");
return HttpResponse::InternalServerError().finish();
}
};
activitypub::inbox::handle(
&state.db,
state.http_client(),
state.public_url(),
&actor,
&req,
&body,
)
.await
}
/// GET /~{user_or_team}/{repository_slug}/followers
/// Serves the followers collection for the repository actor.
#[tracing::instrument(skip_all)]
pub async fn get_repo_followers(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String)>,
) -> HttpResponse {
let (user_or_team, repository_slug) = path.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
// Look up the owner user
let owner = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the repository (must be public)
let repo = match Repositories::find()
.filter(repositories::Column::OwnerId.eq(owner.id))
.filter(repositories::Column::Slug.eq(repository_slug.as_str()))
.filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
.filter(repositories::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(repo)) => repo,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
return HttpResponse::InternalServerError().finish();
}
};
// Get the repository actor
let actor = match activitypub::actor::get_or_create_for_repo(&state.db, repo.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "fetching repository actor failed");
return HttpResponse::InternalServerError().finish();
}
};
let followers: Vec<ap_remote_actors::Model> = match ApFollowers::find()
.filter(ap_followers::Column::LocalActorId.eq(actor.id))
.find_also_related(ApRemoteActors)
.all(&state.db)
.await
{
Ok(followers) => followers
.into_iter()
.filter_map(|(_, remote)| remote)
.collect(),
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "fetching followers failed");
return HttpResponse::InternalServerError().finish();
}
};
let urls = ApUrls::new(state.public_url());
let follower_urls: Vec<String> = followers.iter().map(|f| f.actor_url.clone()).collect();
let collection = types::OrderedCollection {
context: types::FORGEFED_CONTEXT.to_vec(),
id: urls.repo_followers(&owner.name, &repository_slug),
kind: "OrderedCollection",
total_items: follower_urls.len() as u64,
first: None,
ordered_items: if follower_urls.is_empty() {
None
} else {
Some(
follower_urls
.into_iter()
.map(serde_json::Value::String)
.collect(),
)
},
};
HttpResponse::Ok()
.content_type("application/activity+json; charset=utf-8")
.json(collection)
}
/// GET /~{user_or_team}/{repository_slug}/outbox
/// Serves the outbox collection for the repository actor.
#[tracing::instrument(skip_all)]
pub async fn get_repo_outbox(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String)>,
query: web::Query<crate::webui::federation::OutboxQuery>,
) -> HttpResponse {
let (user_or_team, repository_slug) = path.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
// Look up the owner user
let owner = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the repository (must be public)
let repo = match Repositories::find()
.filter(repositories::Column::OwnerId.eq(owner.id))
.filter(repositories::Column::Slug.eq(repository_slug.as_str()))
.filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
.filter(repositories::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(repo)) => repo,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
return HttpResponse::InternalServerError().finish();
}
};
// Get the repository actor
let actor = match activitypub::actor::get_or_create_for_repo(&state.db, repo.id).await {
Ok(actor) => actor,
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "fetching repository actor failed");
return HttpResponse::InternalServerError().finish();
}
};
let urls = ApUrls::new(state.public_url());
let outbox_url = urls.repo_outbox(&owner.name, &repository_slug);
match activitypub::outbox::get_outbox(&state.db, actor.id, &outbox_url, query.page).await {
Ok(collection) => HttpResponse::Ok()
.content_type("application/activity+json; charset=utf-8")
.json(collection),
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "fetching outbox failed");
HttpResponse::InternalServerError().finish()
}
}
}
/// GET /~{user_or_team}/{repository_slug}/commits/{hash}
/// Serves a commit as a ForgeFed Commit document.
#[tracing::instrument(skip_all)]
pub async fn get_repo_commit(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String, String)>,
) -> HttpResponse {
let (user_or_team, repository_slug, hash) = path.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
// Validate hash is 40-character hex
if hash.len() != 40 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return HttpResponse::NotFound().finish();
}
// Look up the owner user
let owner = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the repository (must be public)
let repo = match Repositories::find()
.filter(repositories::Column::OwnerId.eq(owner.id))
.filter(repositories::Column::Slug.eq(repository_slug.as_str()))
.filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
.filter(repositories::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(repo)) => repo,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
return HttpResponse::InternalServerError().finish();
}
};
// Get repository path
let repo_path = crate::repositories::RepositoryPath {
owner: owner.name.clone(),
slug: repo.slug.clone(),
};
let bare_path = repo_path.bare_path(&state.repo_root);
// Try to read the commit using git
let commit = match crate::repositories::git::commit(&bare_path, &hash).await {
Ok(Some(commit)) => commit,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, hash = %hash, "failed to read commit");
return HttpResponse::InternalServerError().finish();
}
};
let urls = ApUrls::new(state.public_url());
// Note: We don't have author email in the Commit struct, so we omit attributed_to
// In the future, this could be matched by author name or extended to include email
let attributed_to = None;
let commit_doc = types::Commit {
context: types::FORGEFED_CONTEXT.to_vec(),
id: urls.repo_commit(&owner.name, &repository_slug, &commit.full_hash),
kind: "Commit",
attributed_to,
hash: commit.full_hash,
created: commit.date,
summary: commit.subject,
};
HttpResponse::Ok()
.content_type("application/activity+json; charset=utf-8")
.json(commit_doc)
}
/// GET /~{user_or_team}/{repository_slug}/tickets/{number}
/// Serves a ticket as a ForgeFed Ticket document.
#[tracing::instrument(skip_all)]
pub async fn get_ticket_ap(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String, i64)>,
) -> HttpResponse {
let (user_or_team, repository_slug, ticket_number) = path.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
// Look up the owner user
let owner = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the repository (must be public)
let repo = match Repositories::find()
.filter(repositories::Column::OwnerId.eq(owner.id))
.filter(repositories::Column::Slug.eq(repository_slug.as_str()))
.filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
.filter(repositories::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(repo)) => repo,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the ticket
let ticket = match crate::tickets::store::find_ticket(&state.db, repo.id, ticket_number).await {
Ok(Some(ticket)) => ticket,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, ticket = %ticket_number, "looking up ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the creator (local tickets have creator_id, remote tickets have remote_actor_id)
let creator_name = match ticket.creator_id {
Some(creator_id) => {
match Users::find()
.filter(users::Column::Id.eq(creator_id))
.one(&state.db)
.await
{
Ok(Some(user)) => user.name,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user_id = creator_id, "looking up user failed");
return HttpResponse::InternalServerError().finish();
}
}
}
None => {
// Remote ticket - look up the remote actor
match ticket.remote_actor_id {
Some(remote_actor_id) => {
match crate::entities::prelude::ApRemoteActors::find_by_id(remote_actor_id)
.one(&state.db)
.await
{
Ok(Some(remote_actor)) => remote_actor
.preferred_username
.unwrap_or_else(|| remote_actor.actor_url.clone()),
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, remote_actor_id, "looking up remote actor failed");
return HttpResponse::InternalServerError().finish();
}
}
}
None => return HttpResponse::NotFound().finish(),
}
}
};
let urls = ApUrls::new(state.public_url());
let creator_url = match ticket.creator_id {
Some(_) => urls.actor(&creator_name),
None => creator_name, // For remote tickets, creator_name is already the actor URL
};
let ticket_doc = crate::activitypub::forge::builders::build_ticket_document(
&ticket,
&owner.name,
&repository_slug,
&creator_url,
&urls,
);
HttpResponse::Ok()
.content_type("application/activity+json; charset=utf-8")
.json(ticket_doc)
}
/// GET /~{user_or_team}/{repository_slug}/patches/{number}
/// Serves a patch as a ForgeFed Patch document.
#[tracing::instrument(skip_all)]
pub async fn get_patch_ap(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String, i64)>,
) -> HttpResponse {
let (user_or_team, repository_slug, patch_number) = path.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
// Look up the owner user
let owner = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the repository (must be public)
let repo = match Repositories::find()
.filter(repositories::Column::OwnerId.eq(owner.id))
.filter(repositories::Column::Slug.eq(repository_slug.as_str()))
.filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
.filter(repositories::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(repo)) => repo,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the patch
let patch = match crate::patches::store::find_by_number(&state.db, repo.id, patch_number).await
{
Ok(Some(patch)) => patch,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, patch = %patch_number, "looking up patch failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the creator (local patches have creator_id, remote patches don't)
let creator = match patch.creator_id {
Some(creator_id) => {
match Users::find()
.filter(users::Column::Id.eq(creator_id))
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user_id = creator_id, "looking up user failed");
return HttpResponse::InternalServerError().finish();
}
}
}
None => {
// TODO: Handle remote patches by looking up the remote actor
return HttpResponse::NotImplemented().finish();
}
};
// Get repository path
let repo_path = crate::repositories::RepositoryPath {
owner: owner.name.clone(),
slug: repo.slug.clone(),
};
let bare_path = repo_path.bare_path(&state.repo_root);
let urls = ApUrls::new(state.public_url());
match crate::activitypub::forge::builders::build_patch_document(
&patch,
&repo,
&owner.name,
&bare_path,
&creator,
&urls,
)
.await
{
Ok(patch_doc) => HttpResponse::Ok()
.content_type("application/activity+json; charset=utf-8")
.json(patch_doc),
Err(err) => {
error!(%err, patch = %patch_number, "building patch document failed");
HttpResponse::InternalServerError().finish()
}
}
}
/// GET /~{user_or_team}/{repository_slug}/tickets/{number}/replies
/// Serves the replies collection for a ticket.
#[tracing::instrument(skip_all)]
pub async fn get_ticket_replies(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String, i64)>,
) -> HttpResponse {
let (user_or_team, repository_slug, ticket_number) = path.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
// Look up the owner user
let owner = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the repository (must be public)
let repo = match Repositories::find()
.filter(repositories::Column::OwnerId.eq(owner.id))
.filter(repositories::Column::Slug.eq(repository_slug.as_str()))
.filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
.filter(repositories::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(repo)) => repo,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the ticket
let ticket = match crate::tickets::store::find_ticket(&state.db, repo.id, ticket_number).await {
Ok(Some(ticket)) => ticket,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, ticket = %ticket_number, "looking up ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
let urls = ApUrls::new(state.public_url());
match crate::activitypub::forge::builders::build_ticket_replies(
&state.db,
&ticket,
&owner.name,
&repository_slug,
&urls,
)
.await
{
Ok(replies_doc) => HttpResponse::Ok()
.content_type("application/activity+json; charset=utf-8")
.json(replies_doc),
Err(err) => {
error!(%err, ticket = %ticket_number, "building ticket replies failed");
HttpResponse::InternalServerError().finish()
}
}
}
/// GET /~{user_or_team}/{repository_slug}/patches/{number}/replies
/// Serves the replies collection for a patch.
#[tracing::instrument(skip_all)]
pub async fn get_patch_replies(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String, i64)>,
) -> HttpResponse {
let (user_or_team, repository_slug, patch_number) = path.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
// Look up the owner user
let owner = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the repository (must be public)
let repo = match Repositories::find()
.filter(repositories::Column::OwnerId.eq(owner.id))
.filter(repositories::Column::Slug.eq(repository_slug.as_str()))
.filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
.filter(repositories::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(repo)) => repo,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the patch
let patch = match crate::patches::store::find_by_number(&state.db, repo.id, patch_number).await
{
Ok(Some(patch)) => patch,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, patch = %patch_number, "looking up patch failed");
return HttpResponse::InternalServerError().finish();
}
};
let urls = ApUrls::new(state.public_url());
match crate::activitypub::forge::builders::build_patch_replies(
&state.db,
&patch,
&owner.name,
&repository_slug,
&urls,
)
.await
{
Ok(replies_doc) => HttpResponse::Ok()
.content_type("application/activity+json; charset=utf-8")
.json(replies_doc),
Err(err) => {
error!(%err, patch = %patch_number, "building patch replies failed");
HttpResponse::InternalServerError().finish()
}
}
}
/// GET /~{user_or_team}/{repository_slug}/tickets/{number}/comments/{uuid}
/// Serves a ticket comment as a ForgeFed Note document.
#[tracing::instrument(skip_all)]
pub async fn get_ticket_comment_ap(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String, i64, String)>,
) -> HttpResponse {
let (user_or_team, repository_slug, ticket_number, comment_uuid_str) = path.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
// Parse the comment UUID
let comment_uuid = match uuid::Uuid::parse_str(&comment_uuid_str) {
Ok(uuid) => uuid,
Err(_) => return HttpResponse::NotFound().finish(),
};
// Look up the owner user
let owner = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the repository (must be public)
let repo = match Repositories::find()
.filter(repositories::Column::OwnerId.eq(owner.id))
.filter(repositories::Column::Slug.eq(repository_slug.as_str()))
.filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
.filter(repositories::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(repo)) => repo,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the ticket
let ticket = match crate::tickets::store::find_ticket(&state.db, repo.id, ticket_number).await {
Ok(Some(ticket)) => ticket,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, ticket = %ticket_number, "looking up ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the comment
let comment = match crate::comments::store::find_by_uuid(&state.db, comment_uuid).await {
Ok(Some(comment)) => comment,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, uuid = %comment_uuid, "looking up comment failed");
return HttpResponse::InternalServerError().finish();
}
};
// Verify the comment belongs to this ticket
if comment.ticket_id != Some(ticket.id) {
return HttpResponse::NotFound().finish();
}
// Look up the comment author (must be local for now)
let author = match comment.author_id {
Some(author_id) => match Users::find()
.filter(users::Column::Id.eq(author_id))
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, author_id = author_id, "looking up comment author failed");
return HttpResponse::InternalServerError().finish();
}
},
None => {
// Remote-authored comments are not yet supported for serving
return HttpResponse::NotImplemented().finish();
}
};
let urls = ApUrls::new(state.public_url());
let note_doc = crate::activitypub::forge::builders::build_comment_note_document(
&comment,
&owner.name,
&repository_slug,
ticket.number,
true, // is_ticket
&author,
&urls,
);
HttpResponse::Ok()
.content_type("application/activity+json; charset=utf-8")
.json(note_doc)
}
/// GET /~{user_or_team}/{repository_slug}/patches/{number}/comments/{uuid}
/// Serves a patch comment as a ForgeFed Note document.
#[tracing::instrument(skip_all)]
pub async fn get_patch_comment_ap(
data: web::Data<Arc<RwLock<AppState>>>,
path: web::Path<(String, String, i64, String)>,
) -> HttpResponse {
let (user_or_team, repository_slug, patch_number, comment_uuid_str) = path.into_inner();
let state = state_read(&data).await;
if !activitypub::enabled(&state) {
return HttpResponse::NotFound().finish();
}
// Parse the comment UUID
let comment_uuid = match uuid::Uuid::parse_str(&comment_uuid_str) {
Ok(uuid) => uuid,
Err(_) => return HttpResponse::NotFound().finish(),
};
// Look up the owner user
let owner = match Users::find()
.filter(users::Column::Name.eq(user_or_team.as_str()))
.filter(users::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the repository (must be public)
let repo = match Repositories::find()
.filter(repositories::Column::OwnerId.eq(owner.id))
.filter(repositories::Column::Slug.eq(repository_slug.as_str()))
.filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
.filter(repositories::Column::DeletedAt.is_null())
.one(&state.db)
.await
{
Ok(Some(repo)) => repo,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the patch
let patch = match crate::patches::store::find_by_number(&state.db, repo.id, patch_number).await
{
Ok(Some(patch)) => patch,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, patch = %patch_number, "looking up patch failed");
return HttpResponse::InternalServerError().finish();
}
};
// Look up the comment
let comment = match crate::comments::store::find_by_uuid(&state.db, comment_uuid).await {
Ok(Some(comment)) => comment,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, uuid = %comment_uuid, "looking up comment failed");
return HttpResponse::InternalServerError().finish();
}
};
// Verify the comment belongs to this patch
if comment.patch_proposal_id != Some(patch.id) {
return HttpResponse::NotFound().finish();
}
// Look up the comment author (must be local for now)
let author = match comment.author_id {
Some(author_id) => match Users::find()
.filter(users::Column::Id.eq(author_id))
.one(&state.db)
.await
{
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::NotFound().finish(),
Err(err) => {
error!(%err, author_id = author_id, "looking up comment author failed");
return HttpResponse::InternalServerError().finish();
}
},
None => {
// Remote-authored comments are not yet supported for serving
return HttpResponse::NotImplemented().finish();
}
};
let urls = ApUrls::new(state.public_url());
let note_doc = crate::activitypub::forge::builders::build_comment_note_document(
&comment,
&owner.name,
&repository_slug,
patch.number,
false, // is_ticket
&author,
&urls,
);
HttpResponse::Ok()
.content_type("application/activity+json; charset=utf-8")
.json(note_doc)
}