diff --git a/thoughts-backend/api/src/routers/user.rs b/thoughts-backend/api/src/routers/user.rs index f594493..b8ef3e3 100644 --- a/thoughts-backend/api/src/routers/user.rs +++ b/thoughts-backend/api/src/routers/user.rs @@ -5,7 +5,7 @@ use axum::{ routing::{get, post}, Router, }; -use serde_json::json; +use serde_json::{json, Value}; use app::persistence::{ follow, @@ -144,6 +144,40 @@ async fn user_follow_delete( 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, + Path(username): Path, + Json(activity): Json, +) -> Result { + 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( get, path = "/{param}", @@ -227,4 +261,5 @@ pub fn create_user_router() -> Router { "/{username}/follow", post(user_follow_post).delete(user_follow_delete), ) + .route("/{username}/inbox", post(user_inbox_post)) } diff --git a/thoughts-backend/app/src/persistence/follow.rs b/thoughts-backend/app/src/persistence/follow.rs index 345c08c..08af323 100644 --- a/thoughts-backend/app/src/persistence/follow.rs +++ b/thoughts-backend/app/src/persistence/follow.rs @@ -1,8 +1,30 @@ 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; +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> { if follower_id == followed_id { return Err(DbErr::Custom("Users cannot follow themselves".to_string())); diff --git a/thoughts-backend/doc/src/user.rs b/thoughts-backend/doc/src/user.rs index 82cd577..f456d4e 100644 --- a/thoughts-backend/doc/src/user.rs +++ b/thoughts-backend/doc/src/user.rs @@ -15,7 +15,8 @@ use models::schemas::{ get_user_by_param, user_thoughts_get, user_follow_post, - user_follow_delete + user_follow_delete, + user_inbox_post, ), components(schemas( CreateUserParams, diff --git a/thoughts-backend/tests/api/activitypub.rs b/thoughts-backend/tests/api/activitypub.rs index 2181d9d..907a7ab 100644 --- a/thoughts-backend/tests/api/activitypub.rs +++ b/thoughts-backend/tests/api/activitypub.rs @@ -1,8 +1,8 @@ use crate::api::main::{create_user_with_password, setup}; use axum::http::{header, StatusCode}; use http_body_util::BodyExt; -use serde_json::Value; -use utils::testing::{make_get_request, make_request_with_headers}; +use serde_json::{json, Value}; +use utils::testing::{make_get_request, make_post_request, make_request_with_headers}; #[tokio::test] async fn test_webfinger_discovery() { @@ -57,3 +57,47 @@ async fn test_user_actor_endpoint() { assert_eq!(v["preferredUsername"], "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" + ); +}