From 4a42bcd3696be931898b34364bb62d87878b93b3 Mon Sep 17 00:00:00 2001 From: silverpill Date: Mon, 7 Nov 2022 14:54:29 +0000 Subject: [PATCH] Add API methods for creating user-signed Move() activities --- docs/openapi.yaml | 37 ++++++++ src/activitypub/builders/mod.rs | 1 + src/activitypub/builders/move_person.rs | 77 ++++++++++++++++ src/mastodon_api/accounts/types.rs | 11 +++ src/mastodon_api/accounts/views.rs | 113 ++++++++++++++++++++++-- 5 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 src/activitypub/builders/move_person.rs diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b704256..127a6fd 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -175,6 +175,42 @@ paths: description: Canonical representation of activity. type: string example: '{"type":"Update"}' + /api/v1/accounts/move_followers: + post: + summary: Build Move(Person) activity for signing (experimental). + requestBody: + content: + application/json: + schema: + type: object + properties: + from_actor_id: + description: The actor ID to move from. + type: string + example: 'https://xyz.com/users/test' + followers_csv: + description: The list of followers in CSV format. + type: string + example: | + user1@example.org + user2@test.com + responses: + 200: + description: Successful operation + content: + application/json: + schema: + type: object + properties: + params: + description: Activity parameters + $ref: '#/components/schemas/ActivityParameters' + message: + description: Canonical representation of activity. + type: string + example: '{"type":"Move"}' + 400: + description: Invalid data. /api/v1/accounts/send_activity: post: summary: Send signed activity (experimental). @@ -1184,6 +1220,7 @@ components: description: Activity type type: string enum: + - move - update Attachment: type: object diff --git a/src/activitypub/builders/mod.rs b/src/activitypub/builders/mod.rs index 1efa3b8..ee47e90 100644 --- a/src/activitypub/builders/mod.rs +++ b/src/activitypub/builders/mod.rs @@ -6,6 +6,7 @@ pub mod delete_note; pub mod delete_person; pub mod follow; pub mod like_note; +pub mod move_person; pub mod remove_person; pub mod undo_announce_note; pub mod undo_follow; diff --git a/src/activitypub/builders/move_person.rs b/src/activitypub/builders/move_person.rs new file mode 100644 index 0000000..9180932 --- /dev/null +++ b/src/activitypub/builders/move_person.rs @@ -0,0 +1,77 @@ +use serde::Serialize; +use serde_json::Value; +use uuid::Uuid; + +use crate::activitypub::{ + actors::types::Actor, + constants::AP_CONTEXT, + deliverer::OutgoingActivity, + identifiers::{local_actor_id, local_object_id}, + vocabulary::MOVE, +}; +use crate::config::Instance; +use crate::errors::ConversionError; +use crate::models::users::types::User; + +#[derive(Serialize)] +pub struct MovePerson { + #[serde(rename = "@context")] + context: String, + + #[serde(rename = "type")] + activity_type: String, + + id: String, + actor: String, + object: String, + target: String, + + to: Vec, +} + +pub fn build_move_person( + instance_url: &str, + sender: &User, + from_actor_id: &str, + followers: &[String], + internal_activity_id: &Uuid, +) -> MovePerson { + let activity_id = local_object_id(instance_url, internal_activity_id); + let actor_id = local_actor_id(instance_url, &sender.profile.username); + MovePerson { + context: AP_CONTEXT.to_string(), + activity_type: MOVE.to_string(), + id: activity_id, + actor: actor_id.clone(), + object: from_actor_id.to_string(), + target: actor_id, + to: followers.to_vec(), + } +} + +pub fn prepare_signed_move_person( + instance: &Instance, + sender: &User, + from_actor_id: &str, + followers: Vec, + internal_activity_id: &Uuid, +) -> Result, ConversionError> { + let followers_ids: Vec = followers.iter() + .map(|actor| actor.id.clone()) + .collect(); + let activity = build_move_person( + &instance.url(), + sender, + from_actor_id, + &followers_ids, + internal_activity_id, + ); + let activity_value = serde_json::to_value(activity) + .map_err(|_| ConversionError)?; + Ok(OutgoingActivity { + instance: instance.clone(), + sender: sender.clone(), + activity: activity_value, + recipients: followers, + }) +} diff --git a/src/mastodon_api/accounts/types.rs b/src/mastodon_api/accounts/types.rs index 3258c34..52a3230 100644 --- a/src/mastodon_api/accounts/types.rs +++ b/src/mastodon_api/accounts/types.rs @@ -262,9 +262,20 @@ impl AccountUpdateData { } } +#[derive(Deserialize)] +pub struct MoveFollowersRequest { + pub from_actor_id: String, + pub followers_csv: String, +} + #[derive(Serialize, Deserialize)] #[serde(tag = "type", rename_all = "kebab-case")] pub enum ActivityParams { + Move { + internal_activity_id: Uuid, + from_actor_id: String, + followers: Vec, + }, Update { internal_activity_id: Uuid }, } diff --git a/src/mastodon_api/accounts/views.rs b/src/mastodon_api/accounts/views.rs index b885141..c801906 100644 --- a/src/mastodon_api/accounts/views.rs +++ b/src/mastodon_api/accounts/views.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use actix_web::{ get, patch, post, web, HttpRequest, HttpResponse, Scope, @@ -5,8 +7,13 @@ use actix_web::{ use actix_web_httpauth::extractors::bearer::BearerAuth; use uuid::Uuid; +use crate::activitypub::actors::types::ActorAddress; use crate::activitypub::builders::{ follow::prepare_follow, + move_person::{ + build_move_person, + prepare_signed_move_person, + }, undo_follow::prepare_undo_follow, update_person::{ build_update_person, @@ -45,7 +52,9 @@ use crate::mastodon_api::statuses::helpers::build_status_list; use crate::mastodon_api::statuses::types::Status; use crate::models::posts::queries::get_posts_by_author; use crate::models::profiles::queries::{ + get_profile_by_acct, get_profile_by_id, + get_profile_by_remote_actor_id, search_profiles_by_did, update_profile, }; @@ -95,6 +104,7 @@ use super::types::{ IdentityClaim, IdentityClaimQueryParams, IdentityProofData, + MoveFollowersRequest, RelationshipQueryParams, SearchAcctQueryParams, SearchDidQueryParams, @@ -254,6 +264,71 @@ async fn get_unsigned_update( Ok(HttpResponse::Ok().json(data)) } +#[post("/move_followers")] +async fn move_followers( + auth: BearerAuth, + config: web::Data, + db_pool: web::Data, + request_data: web::Json, +) -> Result { + let db_client = &mut **get_database_client(&db_pool).await?; + let current_user = get_current_user(db_client, auth.token()).await?; + // Old profile could be deleted + let maybe_from_profile = match get_profile_by_remote_actor_id( + db_client, + &request_data.from_actor_id, + ).await { + Ok(profile) => Some(profile), + Err(DatabaseError::NotFound(_)) => None, + Err(other_error) => return Err(other_error.into()), + }; + let mut followers = vec![]; + for follower_address in request_data.followers_csv.lines() { + let follower_acct = ActorAddress::from_str(follower_address)? + .acct(&config.instance().hostname()); + // TODO: fetch unknown profiles + let follower = get_profile_by_acct(db_client, &follower_acct).await?; + if let Some(remote_actor) = follower.actor_json { + // Add remote actor to activity recipients list + followers.push(remote_actor.id); + } else { + // Immediately move local followers + if let Some(ref from_profile) = maybe_from_profile { + match unfollow(db_client, &follower.id, &from_profile.id).await { + Ok(_) => (), + Err(DatabaseError::NotFound(_)) => (), + Err(other_error) => return Err(other_error.into()), + }; + }; + match follow(db_client, &follower.id, ¤t_user.id).await { + Ok(_) => (), + // Ignore if already following + Err(DatabaseError::AlreadyExists(_)) => (), + Err(other_error) => return Err(other_error.into()), + }; + }; + }; + let internal_activity_id = new_uuid(); + let activity = build_move_person( + &config.instance_url(), + ¤t_user, + &request_data.from_actor_id, + &followers, + &internal_activity_id, + ); + let canonical_json = canonicalize_object(&activity) + .map_err(|_| HttpError::InternalError)?; + let data = UnsignedActivity { + params: ActivityParams::Move { + internal_activity_id, + from_actor_id: request_data.from_actor_id.clone(), + followers, + }, + message: canonical_json, + }; + Ok(HttpResponse::Ok().json(data)) +} + #[post("/send_activity")] async fn send_signed_activity( auth: BearerAuth, @@ -268,13 +343,36 @@ async fn send_signed_activity( if !current_user.profile.identity_proofs.any(&signer) { return Err(ValidationError("unknown signer").into()); }; - let ActivityParams::Update { internal_activity_id } = data.params; - let mut outgoing_activity = prepare_signed_update_person( - db_client, - &config.instance(), - ¤t_user, - internal_activity_id, - ).await.map_err(|_| HttpError::InternalError)?; + let mut outgoing_activity = match &data.params { + ActivityParams::Move { + internal_activity_id, + from_actor_id, + followers: followers_ids, + } => { + let mut followers = vec![]; + for actor_id in followers_ids { + let remote_actor = get_profile_by_remote_actor_id(db_client, actor_id) + .await? + .actor_json.ok_or(HttpError::InternalError)?; + followers.push(remote_actor); + }; + prepare_signed_move_person( + &config.instance(), + ¤t_user, + from_actor_id, + followers, + internal_activity_id, + ).map_err(|_| HttpError::InternalError)? + }, + ActivityParams::Update { internal_activity_id } => { + prepare_signed_update_person( + db_client, + &config.instance(), + ¤t_user, + *internal_activity_id, + ).await.map_err(|_| HttpError::InternalError)? + }, + }; let canonical_json = canonicalize_object(&outgoing_activity.activity) .map_err(|_| HttpError::InternalError)?; let proof = match signer { @@ -707,6 +805,7 @@ pub fn account_api_scope() -> Scope { .service(verify_credentials) .service(update_credentials) .service(get_unsigned_update) + .service(move_followers) .service(send_signed_activity) .service(get_identity_claim) .service(create_identity_proof)