diff --git a/README.md b/README.md index 62d39ef..5149661 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,9 @@ PATCH /api/v1/accounts/update_credentials GET /api/v1/accounts/relationships POST /api/v1/accounts/{account_id}/follow POST /api/v1/accounts/{account_id}/unfollow +GET /api/v1/accounts/{account_id}/statuses +GET /api/v1/accounts/{account_id}/followers +GET /api/v1/accounts/{account_id}/following GET /api/v1/directory GET /api/v1/instance GET /api/v1/markers @@ -145,7 +148,7 @@ GET /api/v1/statuses/{status_id}/signature POST /api/v1/statuses/{status_id}/token_minted ``` -[OpenAPI spec](./docs/openapi.yaml) +[OpenAPI spec](./docs/openapi.yaml) (incomplete) ## CLI commands diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 7596db6..9cb9db3 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -144,6 +144,66 @@ paths: type: array items: $ref: '#/components/schemas/Status' + /api/v1/accounts/{account_id}/followers: + get: + summary: Actors which follow the given actor. + parameters: + - $ref: '#/components/parameters/account_id' + - name: max_id + in: query + description: Return results with relationship ID older than this value. + required: false + schema: + type: integer + - name: limit + in: query + description: Maximum number of results to return. + required: false + schema: + type: integer + default: 40 + responses: + 200: + description: Successful operation + content: + application/json: + schema: + description: Profile list + type: array + items: + $ref: '#/components/schemas/Account' + 404: + description: Profile not found + /api/v1/accounts/{account_id}/following: + get: + summary: Actors which the given actor is following. + parameters: + - $ref: '#/components/parameters/account_id' + - name: max_id + in: query + description: Return results with relationship ID older than this value. + required: false + schema: + type: integer + - name: limit + in: query + description: Maximum number of results to return. + required: false + schema: + type: integer + default: 40 + responses: + 200: + description: Successful operation + content: + application/json: + schema: + description: Profile list + type: array + items: + $ref: '#/components/schemas/Account' + 404: + description: Profile not found /api/v1/statuses/{status_id}: delete: summary: Delete post diff --git a/src/mastodon_api/accounts/types.rs b/src/mastodon_api/accounts/types.rs index 4d72c21..56b1398 100644 --- a/src/mastodon_api/accounts/types.rs +++ b/src/mastodon_api/accounts/types.rs @@ -174,6 +174,16 @@ impl AccountUpdateData { } } +fn default_page_size() -> i64 { 40 } + +#[derive(Deserialize)] +pub struct FollowListQueryParams { + pub max_id: Option, + + #[serde(default = "default_page_size")] + pub limit: i64, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/mastodon_api/accounts/views.rs b/src/mastodon_api/accounts/views.rs index 9180fe6..9604a1c 100644 --- a/src/mastodon_api/accounts/views.rs +++ b/src/mastodon_api/accounts/views.rs @@ -31,6 +31,7 @@ use crate::models::relationships::queries::{ follow, get_follow_request_by_path, get_followers, + get_following, get_relationship, get_relationships, unfollow, @@ -45,7 +46,12 @@ use crate::utils::crypto::{ serialize_private_key, }; use crate::utils::files::FileError; -use super::types::{Account, AccountCreateData, AccountUpdateData}; +use super::types::{ + Account, + AccountCreateData, + AccountUpdateData, + FollowListQueryParams, +}; #[post("")] pub async fn create_account( @@ -154,7 +160,7 @@ async fn update_credentials( // Federate let activity = create_activity_update_person(¤t_user, &config.instance_url()) .map_err(|_| HttpError::InternalError)?; - let followers = get_followers(db_client, ¤t_user.id).await?; + let followers = get_followers(db_client, ¤t_user.id, None, None).await?; let mut recipients: Vec = Vec::new(); for follower in followers { if let Some(remote_actor) = follower.actor_json { @@ -298,6 +304,62 @@ async fn get_account_statuses( Ok(HttpResponse::Ok().json(statuses)) } +#[get("/{account_id}/followers")] +async fn get_account_followers( + auth: BearerAuth, + config: web::Data, + db_pool: web::Data, + web::Path(account_id): web::Path, + query_params: web::Query, +) -> Result { + let db_client = &**get_database_client(&db_pool).await?; + let current_user = get_current_user(db_client, auth.token()).await?; + let profile = get_profile_by_id(db_client, &account_id).await?; + if profile.id != current_user.id { + // Social graph is hidden + let accounts: Vec = vec![]; + return Ok(HttpResponse::Ok().json(accounts)); + }; + let followers = get_followers( + db_client, + &profile.id, + query_params.max_id, + Some(query_params.limit), + ).await?; + let accounts: Vec = followers.into_iter() + .map(|profile| Account::from_profile(profile, &config.instance_url())) + .collect(); + Ok(HttpResponse::Ok().json(accounts)) +} + +#[get("/{account_id}/following")] +async fn get_account_following( + auth: BearerAuth, + config: web::Data, + db_pool: web::Data, + web::Path(account_id): web::Path, + query_params: web::Query, +) -> Result { + let db_client = &**get_database_client(&db_pool).await?; + let current_user = get_current_user(db_client, auth.token()).await?; + let profile = get_profile_by_id(db_client, &account_id).await?; + if profile.id != current_user.id { + // Social graph is hidden + let accounts: Vec = vec![]; + return Ok(HttpResponse::Ok().json(accounts)); + }; + let following = get_following( + db_client, + &profile.id, + query_params.max_id, + Some(query_params.limit), + ).await?; + let accounts: Vec = following.into_iter() + .map(|profile| Account::from_profile(profile, &config.instance_url())) + .collect(); + Ok(HttpResponse::Ok().json(accounts)) +} + pub fn account_api_scope() -> Scope { web::scope("/api/v1/accounts") // Routes without account ID @@ -310,4 +372,6 @@ pub fn account_api_scope() -> Scope { .service(follow_account) .service(unfollow_account) .service(get_account_statuses) + .service(get_account_followers) + .service(get_account_following) } diff --git a/src/mastodon_api/statuses/helpers.rs b/src/mastodon_api/statuses/helpers.rs index 0c18227..5be1efc 100644 --- a/src/mastodon_api/statuses/helpers.rs +++ b/src/mastodon_api/statuses/helpers.rs @@ -12,7 +12,7 @@ pub async fn get_note_audience( current_user: &User, post: &Post, ) -> Result, DatabaseError> { - let mut audience = get_followers(db_client, ¤t_user.id).await?; + let mut audience = get_followers(db_client, ¤t_user.id, None, None).await?; if let Some(in_reply_to_id) = post.in_reply_to_id { // TODO: use post.in_reply_to ? let in_reply_to_author = get_post_author(db_client, &in_reply_to_id).await?; @@ -51,7 +51,7 @@ pub async fn get_announce_audience( current_user: &User, post: &Post, ) -> Result { - let followers = get_followers(db_client, ¤t_user.id).await?; + let followers = get_followers(db_client, ¤t_user.id, None, None).await?; let mut recipients: Vec = Vec::new(); for profile in followers { if let Some(remote_actor) = profile.actor_json { diff --git a/src/models/relationships/queries.rs b/src/models/relationships/queries.rs index 9e965c2..9e22a4b 100644 --- a/src/models/relationships/queries.rs +++ b/src/models/relationships/queries.rs @@ -242,6 +242,8 @@ pub async fn get_follow_request_by_path( pub async fn get_followers( db_client: &impl GenericClient, profile_id: &Uuid, + max_relationship_id: Option, + limit: Option, ) -> Result, DatabaseError> { let rows = db_client.query( " @@ -249,10 +251,39 @@ pub async fn get_followers( FROM actor_profile JOIN relationship ON (actor_profile.id = relationship.source_id) - WHERE relationship.target_id = $1 + WHERE + relationship.target_id = $1 + AND ($2::integer IS NULL OR relationship.id < $2) ORDER BY relationship.id DESC + LIMIT $3 ", - &[&profile_id], + &[&profile_id, &max_relationship_id, &limit], + ).await?; + let profiles = rows.iter() + .map(|row| row.try_get("actor_profile")) + .collect::>()?; + Ok(profiles) +} + +pub async fn get_following( + db_client: &impl GenericClient, + profile_id: &Uuid, + max_relationship_id: Option, + limit: Option, +) -> Result, DatabaseError> { + let rows = db_client.query( + " + SELECT actor_profile + FROM actor_profile + JOIN relationship + ON (actor_profile.id = relationship.target_id) + WHERE + relationship.source_id = $1 + AND ($2::integer IS NULL OR relationship.id < $2) + ORDER BY relationship.id DESC + LIMIT $3 + ", + &[&profile_id, &max_relationship_id, &limit], ).await?; let profiles = rows.iter() .map(|row| row.try_get("actor_profile"))