feat(activitypub): implement user inbox for receiving follow activities and add corresponding tests
This commit is contained in:
@@ -5,7 +5,7 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use app::persistence::{
|
use app::persistence::{
|
||||||
follow,
|
follow,
|
||||||
@@ -144,6 +144,40 @@ async fn user_follow_delete(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/{username}/inbox",
|
||||||
|
request_body = Object,
|
||||||
|
description = "The ActivityPub inbox for receiving activities.",
|
||||||
|
responses(
|
||||||
|
(status = 202, description = "Activity accepted"),
|
||||||
|
(status = 400, description = "Bad Request"),
|
||||||
|
(status = 404, description = "User not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn user_inbox_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(username): Path<String>,
|
||||||
|
Json(activity): Json<Value>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = get_user_by_username(&state.conn, &username)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
let activity_type = activity["type"].as_str().unwrap_or_default();
|
||||||
|
let actor_id = activity["actor"].as_str().unwrap_or_default();
|
||||||
|
|
||||||
|
tracing::debug!(target: "activitypub", "Received activity '{}' from actor '{}' in {}'s inbox", activity_type, actor_id, username);
|
||||||
|
|
||||||
|
// For now, we only handle the "Follow" activity
|
||||||
|
if activity_type == "Follow" {
|
||||||
|
follow::add_follower(&state.conn, user.id, actor_id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per the ActivityPub spec, we should return a 202 Accepted status
|
||||||
|
Ok(StatusCode::ACCEPTED)
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/{param}",
|
path = "/{param}",
|
||||||
@@ -227,4 +261,5 @@ pub fn create_user_router() -> Router<AppState> {
|
|||||||
"/{username}/follow",
|
"/{username}/follow",
|
||||||
post(user_follow_post).delete(user_follow_delete),
|
post(user_follow_post).delete(user_follow_delete),
|
||||||
)
|
)
|
||||||
|
.route("/{username}/inbox", post(user_inbox_post))
|
||||||
}
|
}
|
||||||
|
@@ -1,8 +1,30 @@
|
|||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set};
|
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set};
|
||||||
|
|
||||||
use crate::error::UserError;
|
use crate::{error::UserError, persistence::user::get_user_by_username};
|
||||||
use models::domains::follow;
|
use models::domains::follow;
|
||||||
|
|
||||||
|
pub async fn add_follower(
|
||||||
|
db: &DbConn,
|
||||||
|
followed_id: i32,
|
||||||
|
follower_actor_id: &str,
|
||||||
|
) -> Result<(), UserError> {
|
||||||
|
let follower_username = follower_actor_id
|
||||||
|
.split('/')
|
||||||
|
.last()
|
||||||
|
.ok_or_else(|| UserError::Internal("Invalid follower actor ID".to_string()))?;
|
||||||
|
|
||||||
|
let follower = get_user_by_username(db, follower_username)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
follow_user(db, follower.id, followed_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn follow_user(db: &DbConn, follower_id: i32, followed_id: i32) -> Result<(), DbErr> {
|
pub async fn follow_user(db: &DbConn, follower_id: i32, followed_id: i32) -> Result<(), DbErr> {
|
||||||
if follower_id == followed_id {
|
if follower_id == followed_id {
|
||||||
return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
|
return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
|
||||||
|
@@ -15,7 +15,8 @@ use models::schemas::{
|
|||||||
get_user_by_param,
|
get_user_by_param,
|
||||||
user_thoughts_get,
|
user_thoughts_get,
|
||||||
user_follow_post,
|
user_follow_post,
|
||||||
user_follow_delete
|
user_follow_delete,
|
||||||
|
user_inbox_post,
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
CreateUserParams,
|
CreateUserParams,
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
use crate::api::main::{create_user_with_password, setup};
|
use crate::api::main::{create_user_with_password, setup};
|
||||||
use axum::http::{header, StatusCode};
|
use axum::http::{header, StatusCode};
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use serde_json::Value;
|
use serde_json::{json, Value};
|
||||||
use utils::testing::{make_get_request, make_request_with_headers};
|
use utils::testing::{make_get_request, make_post_request, make_request_with_headers};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_webfinger_discovery() {
|
async fn test_webfinger_discovery() {
|
||||||
@@ -57,3 +57,47 @@ async fn test_user_actor_endpoint() {
|
|||||||
assert_eq!(v["preferredUsername"], "testuser");
|
assert_eq!(v["preferredUsername"], "testuser");
|
||||||
assert_eq!(v["id"], "http://localhost:3000/users/testuser");
|
assert_eq!(v["id"], "http://localhost:3000/users/testuser");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_user_inbox_follow() {
|
||||||
|
let app = setup().await;
|
||||||
|
// user1 will be followed
|
||||||
|
create_user_with_password(&app.db, "user1", "password123").await;
|
||||||
|
// user2 will be the follower
|
||||||
|
create_user_with_password(&app.db, "user2", "password123").await;
|
||||||
|
|
||||||
|
// Construct a follow activity from user2, targeting user1
|
||||||
|
let follow_activity = json!({
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"id": "http://localhost:3000/some-unique-id",
|
||||||
|
"type": "Follow",
|
||||||
|
"actor": "http://localhost:3000/users/user2", // The actor is user2
|
||||||
|
"object": "http://localhost:3000/users/user1"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// POST the activity to user1's inbox
|
||||||
|
let response = make_post_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user1/inbox",
|
||||||
|
follow_activity,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||||
|
|
||||||
|
// Verify that user2 is now following user1 in the database
|
||||||
|
let followers = app::persistence::follow::get_followed_ids(&app.db, 2)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(followers.contains(&1), "User2 should be following user1");
|
||||||
|
|
||||||
|
let following = app::persistence::follow::get_followed_ids(&app.db, 1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!following.contains(&2),
|
||||||
|
"User1 should now be followed by user2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user