From 5cc9e951aec429bd327032433573ae6c7933a4b3 Mon Sep 17 00:00:00 2001 From: Zeerooth Date: Sat, 14 Oct 2023 11:56:56 +0200 Subject: [PATCH] see who boosted/favourited a post in mastodon api (#367) --- crates/kitsune-core/src/service/post.rs | 137 +++++++++++++++++- .../mastodon/api/v1/statuses/favourited_by.rs | 84 +++++++++++ .../handler/mastodon/api/v1/statuses/mod.rs | 4 + .../mastodon/api/v1/statuses/reblogged_by.rs | 84 +++++++++++ 4 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 kitsune/src/http/handler/mastodon/api/v1/statuses/favourited_by.rs create mode 100644 kitsune/src/http/handler/mastodon/api/v1/statuses/reblogged_by.rs diff --git a/crates/kitsune-core/src/service/post.rs b/crates/kitsune-core/src/service/post.rs index c89e4f730..bfa49e3a2 100644 --- a/crates/kitsune-core/src/service/post.rs +++ b/crates/kitsune-core/src/service/post.rs @@ -3,6 +3,7 @@ use super::{ job::{Enqueue, JobService}, notification::NotificationService, url::UrlService, + LimitContext, }; use crate::{ error::{ApiError, Error, Result}, @@ -20,8 +21,8 @@ use crate::{ }; use async_stream::try_stream; use diesel::{ - BelongingToDsl, BoolExpressionMethods, ExpressionMethods, OptionalExtension, QueryDsl, - SelectableHelper, + BelongingToDsl, BoolExpressionMethods, ExpressionMethods, JoinOnDsl, OptionalExtension, + QueryDsl, SelectableHelper, }; use diesel_async::{AsyncPgConnection, RunQueryDsl}; use futures_util::{stream::BoxStream, Stream, StreamExt, TryStreamExt}; @@ -29,6 +30,7 @@ use garde::Validate; use iso8601_timestamp::Timestamp; use kitsune_db::{ model::{ + account::Account, favourite::{Favourite, NewFavourite}, media_attachment::NewPostMediaAttachment, mention::NewMention, @@ -253,6 +255,44 @@ pub struct UnrepostPost { post_id: Uuid, } +#[derive(Clone, TypedBuilder, Validate)] +#[garde(context(LimitContext as ctx))] +pub struct GetAccountsInteractingWithPost { + /// ID of the account whose posts are getting fetched + #[garde(skip)] + post_id: Uuid, + + /// ID of the account that is requesting the posts + #[builder(default)] + #[garde(skip)] + fetching_account_id: Option, + + /// Limit of returned posts + #[garde(range(max = ctx.limit))] + limit: usize, + + /// Smallest ID, return results starting from this ID + /// + /// Used for pagination + #[builder(default)] + #[garde(skip)] + min_id: Option, + + /// Smallest ID, return highest results + /// + /// Used for pagination + #[builder(default)] + #[garde(skip)] + since_id: Option, + + /// Largest ID + /// + /// Used for pagination + #[builder(default)] + #[garde(skip)] + max_id: Option, +} + #[derive(Clone, TypedBuilder)] pub struct PostService { db_pool: PgPool, @@ -901,6 +941,99 @@ impl PostService { Ok(post) } + /// Get accounts that favourited a post + /// + /// Does checks whether the user has access to the post + /// + /// # Panics + /// + /// This should never panic. If it does, please open an issue. + pub async fn favourited_by( + &self, + get_favourites: GetAccountsInteractingWithPost, + ) -> Result> + '_> { + get_favourites.validate(&LimitContext::default())?; + + let mut query = posts_favourites::table + .inner_join(accounts::table.on(posts_favourites::account_id.eq(accounts::id))) + .filter(posts_favourites::post_id.eq(get_favourites.post_id)) + .select(Account::as_select()) + .order(accounts::id.desc()) + .limit(get_favourites.limit as i64) + .into_boxed(); + + if let Some(max_id) = get_favourites.max_id { + query = query.filter(posts_favourites::id.lt(max_id)); + } + if let Some(since_id) = get_favourites.since_id { + query = query.filter(posts_favourites::id.gt(since_id)); + } + if let Some(min_id) = get_favourites.min_id { + query = query + .filter(posts_favourites::id.gt(min_id)) + .order(posts_favourites::id.asc()); + } + + self.db_pool + .with_connection(|db_conn| { + async move { + Ok::<_, Error>(query.load_stream(db_conn).await?.map_err(Error::from)) + } + .scoped() + }) + .await + .map_err(Error::from) + } + + /// Get accounts that reblogged a post + /// + /// Does checks whether the user has access to the post + /// + /// # Panics + /// + /// This should never panic. If it does, please open an issue. + pub async fn reblogged_by( + &self, + get_reblogs: GetAccountsInteractingWithPost, + ) -> Result> + '_> { + get_reblogs.validate(&LimitContext::default())?; + + let permission_check = PermissionCheck::builder() + .fetching_account_id(get_reblogs.fetching_account_id) + .build(); + + let mut query = posts::table + .add_post_permission_check(permission_check) + .filter(posts::reposted_post_id.eq(get_reblogs.post_id)) + .into_boxed(); + + if let Some(max_id) = get_reblogs.max_id { + query = query.filter(posts::id.lt(max_id)); + } + if let Some(since_id) = get_reblogs.since_id { + query = query.filter(posts::id.gt(since_id)); + } + if let Some(min_id) = get_reblogs.min_id { + query = query.filter(posts::id.gt(min_id)).order(posts::id.asc()); + } + + let query = query + .inner_join(accounts::table.on(accounts::id.eq(posts::account_id))) + .select(Account::as_select()) + .order(accounts::id.desc()) + .limit(get_reblogs.limit as i64); + + self.db_pool + .with_connection(|db_conn| { + async move { + Ok::<_, Error>(query.load_stream(db_conn).await?.map_err(Error::from)) + } + .scoped() + }) + .await + .map_err(Error::from) + } + /// Get a post by its ID /// /// Does checks whether the user is allowed to fetch the post diff --git a/kitsune/src/http/handler/mastodon/api/v1/statuses/favourited_by.rs b/kitsune/src/http/handler/mastodon/api/v1/statuses/favourited_by.rs new file mode 100644 index 000000000..b5be474f4 --- /dev/null +++ b/kitsune/src/http/handler/mastodon/api/v1/statuses/favourited_by.rs @@ -0,0 +1,84 @@ +use crate::{ + consts::default_limit, + error::Result, + http::{ + extractor::MastodonAuthExtractor, + pagination::{LinkHeader, PaginatedJsonResponse}, + }, +}; +use axum::{ + debug_handler, + extract::{OriginalUri, Path, State}, + Json, +}; +use axum_extra::extract::Query; +use futures_util::TryStreamExt; +use kitsune_core::{ + mapping::MastodonMapper, + service::{ + post::{GetAccountsInteractingWithPost, PostService}, + url::UrlService, + }, +}; +use kitsune_type::mastodon::Account; +use serde::Deserialize; +use speedy_uuid::Uuid; +use utoipa::IntoParams; + +#[derive(Deserialize, IntoParams)] +pub struct GetQuery { + min_id: Option, + max_id: Option, + since_id: Option, + #[serde(default = "default_limit")] + limit: usize, +} + +#[debug_handler(state = crate::state::Zustand)] +#[utoipa::path( + get, + path = "/api/v1/statuses/{id}/favourited_by", + security( + ("oauth_token" = []) + ), + params(GetQuery), + responses( + (status = 200, description = "List of accounts that favourited the status", body = Vec) + ), +)] +pub async fn get( + State(post_service): State, + State(mastodon_mapper): State, + State(url_service): State, + OriginalUri(original_uri): OriginalUri, + Query(query): Query, + user_data: Option, + Path(id): Path, +) -> Result> { + let fetching_account_id = user_data.map(|user_data| user_data.0.account.id); + let get_favourites = GetAccountsInteractingWithPost::builder() + .post_id(id) + .fetching_account_id(fetching_account_id) + .limit(query.limit) + .since_id(query.since_id) + .min_id(query.min_id) + .max_id(query.max_id) + .build(); + + let accounts: Vec = post_service + .favourited_by(get_favourites) + .await? + .and_then(|acc| mastodon_mapper.map(acc)) + .try_collect() + .await?; + + let link_header = LinkHeader::new( + &accounts, + query.limit, + &url_service.base_url(), + original_uri.path(), + |a| a.id, + ); + + Ok((link_header, Json(accounts))) +} diff --git a/kitsune/src/http/handler/mastodon/api/v1/statuses/mod.rs b/kitsune/src/http/handler/mastodon/api/v1/statuses/mod.rs index 4ff9f906c..c14cf4979 100644 --- a/kitsune/src/http/handler/mastodon/api/v1/statuses/mod.rs +++ b/kitsune/src/http/handler/mastodon/api/v1/statuses/mod.rs @@ -20,7 +20,9 @@ use utoipa::ToSchema; pub mod context; pub mod favourite; +pub mod favourited_by; pub mod reblog; +pub mod reblogged_by; pub mod source; pub mod unfavourite; pub mod unreblog; @@ -181,7 +183,9 @@ pub fn routes() -> Router { .route("/:id", routing::get(get).delete(delete).put(put)) .route("/:id/context", routing::get(context::get)) .route("/:id/favourite", routing::post(favourite::post)) + .route("/:id/favourited_by", routing::get(favourited_by::get)) .route("/:id/reblog", routing::post(reblog::post)) + .route("/:id/reblogged_by", routing::get(reblogged_by::get)) .route("/:id/source", routing::get(source::get)) .route("/:id/unfavourite", routing::post(unfavourite::post)) .route("/:id/unreblog", routing::post(unreblog::post)) diff --git a/kitsune/src/http/handler/mastodon/api/v1/statuses/reblogged_by.rs b/kitsune/src/http/handler/mastodon/api/v1/statuses/reblogged_by.rs new file mode 100644 index 000000000..42710a705 --- /dev/null +++ b/kitsune/src/http/handler/mastodon/api/v1/statuses/reblogged_by.rs @@ -0,0 +1,84 @@ +use crate::{ + consts::default_limit, + error::Result, + http::{ + extractor::MastodonAuthExtractor, + pagination::{LinkHeader, PaginatedJsonResponse}, + }, +}; +use axum::{ + debug_handler, + extract::{OriginalUri, Path, State}, + Json, +}; +use axum_extra::extract::Query; +use futures_util::TryStreamExt; +use kitsune_core::{ + mapping::MastodonMapper, + service::{ + post::{GetAccountsInteractingWithPost, PostService}, + url::UrlService, + }, +}; +use kitsune_type::mastodon::Account; +use serde::Deserialize; +use speedy_uuid::Uuid; +use utoipa::IntoParams; + +#[derive(Deserialize, IntoParams)] +pub struct GetQuery { + min_id: Option, + max_id: Option, + since_id: Option, + #[serde(default = "default_limit")] + limit: usize, +} + +#[debug_handler(state = crate::state::Zustand)] +#[utoipa::path( + get, + path = "/api/v1/statuses/{id}/reblogged_by", + security( + ("oauth_token" = []) + ), + params(GetQuery), + responses( + (status = 200, description = "List of accounts that reblogged the status", body = Vec) + ), +)] +pub async fn get( + State(post_service): State, + State(mastodon_mapper): State, + State(url_service): State, + OriginalUri(original_uri): OriginalUri, + Query(query): Query, + user_data: Option, + Path(id): Path, +) -> Result> { + let fetching_account_id = user_data.map(|user_data| user_data.0.account.id); + let get_reblogs = GetAccountsInteractingWithPost::builder() + .post_id(id) + .fetching_account_id(fetching_account_id) + .limit(query.limit) + .since_id(query.since_id) + .min_id(query.min_id) + .max_id(query.max_id) + .build(); + + let accounts: Vec = post_service + .reblogged_by(get_reblogs) + .await? + .and_then(|acc| mastodon_mapper.map(acc)) + .try_collect() + .await?; + + let link_header = LinkHeader::new( + &accounts, + query.limit, + &url_service.base_url(), + original_uri.path(), + |a| a.id, + ); + + Ok((link_header, Json(accounts))) +}