Implement follow requests (#283)

* implement follow requests

* be quiet clippy

* link header implementation

* clippy complaints

* link header for timelines and posts

* hide link pagination behind a feature

* better code layout and new header fn

* pass path to link header constructor and better erro handling in pagination

* fix duplicate user_data in statuses

* make new_link_header as an associated function
This commit is contained in:
Radek Stępień 2023-08-13 09:44:09 +02:00 committed by GitHub
parent 8f67cea6c4
commit 055fa18fe0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 773 additions and 40 deletions

View File

@ -1,10 +1,10 @@
use crate::schema::accounts_follows;
use diesel::{Identifiable, Insertable, Queryable};
use diesel::{Identifiable, Insertable, Queryable, Selectable};
use iso8601_timestamp::Timestamp;
use serde::{Deserialize, Serialize};
use speedy_uuid::Uuid;
#[derive(Clone, Deserialize, Serialize, Identifiable, Queryable)]
#[derive(Clone, Deserialize, Serialize, Identifiable, Selectable, Queryable)]
#[diesel(table_name = accounts_follows)]
pub struct Follow {
pub id: Uuid,

View File

@ -17,6 +17,10 @@ const routes = [
path: '/oauth-callback',
component: () => import('./views/OAuthCallback.vue'),
},
{
path: "/:catchAll(.*)",
component: () => import('./views/NotFound.vue')
}
];
export const router = createRouter({

View File

@ -0,0 +1,97 @@
<template>
<div>
<!-- ADD BACKGROUND IMAGE AS A <img> ELEMENT -->
<div class="main-container">
<div class="main-intro">
<h2 class="main-intro-header">
<svg class="main-intro-header-logo">
<use xlink:href="/header.svg#logo" />
</svg>
</h2>
<p>
Whoops! It seems that you are lost
</p>
<strong class="about-link">
<router-link to="/">Back to the main page</router-link>
</strong>
</div>
</div>
<GenericFooter />
</div>
</template>
<script setup lang="ts">
import AuthForms from '../components/AuthForms.vue';
import GenericFooter from '../components/GenericFooter.vue';
import { useInstanceInfo } from '../graphql/instance-info';
const instanceInfo = useInstanceInfo();
</script>
<style scoped lang="scss">
@use '../styles/colours' as *;
.main {
&-container {
display: flex;
align-items: center;
height: 80vh;
width: 95vw;
margin: 0 auto;
padding: 0 4vw;
@media only screen and (max-width: 1023px) {
flex-direction: column;
height: auto;
justify-content: center;
padding: 3vh 4vw;
}
}
&-intro {
display: flex;
flex-direction: column;
justify-content: center;
width: 60%;
height: auto;
padding: 1vh 2vw;
@media only screen and (max-width: 1367px) {
width: 55%;
}
@media only screen and (max-width: 1023px) {
width: 75%;
margin-bottom: 4vh;
text-align: center;
}
& .stat-highlight {
color: $shade1dark;
}
&-header {
font-size: 42px;
font-weight: bold;
color: $shade2light;
&-logo {
color: $shade2light;
width: 500px;
max-width: 100%;
}
}
&-description,
&-more {
width: fit-content;
font-size: 18px;
line-height: 143%;
margin: 10px 0;
}
}
}
</style>

View File

@ -22,5 +22,6 @@ RUN yarn install && yarn build
FROM alpine:latest
WORKDIR app
COPY --from=build /app/target/debug/kitsune .
COPY --from=build /app/public public
COPY --from=frontend /app kitsune-fe
ENTRYPOINT ["./kitsune"]

View File

@ -22,5 +22,6 @@ RUN yarn install && yarn build
FROM alpine:latest
WORKDIR app
COPY --from=build /app/target/release/kitsune .
COPY --from=build /app/public public
COPY --from=frontend /app kitsune-fe
ENTRYPOINT ["./kitsune"]

View File

@ -1,12 +1,18 @@
use crate::{
consts::API_DEFAULT_LIMIT,
error::Result,
http::extractor::{AuthExtractor, MastodonAuthExtractor},
http::{
extractor::{AuthExtractor, MastodonAuthExtractor},
pagination::{LinkHeader, PaginatedJsonResponse},
},
mapping::MastodonMapper,
service::account::{AccountService, GetPosts},
service::{
account::{AccountService, GetPosts},
url::UrlService,
},
};
use axum::{
extract::{Path, Query, State},
extract::{OriginalUri, Path, Query, State},
Json,
};
use futures_util::{FutureExt, TryStreamExt};
@ -22,6 +28,7 @@ fn default_limit() -> usize {
#[derive(Deserialize, IntoParams)]
pub struct GetQuery {
max_id: Option<Uuid>,
since_id: Option<Uuid>,
min_id: Option<Uuid>,
#[serde(default = "default_limit")]
limit: usize,
@ -42,22 +49,24 @@ pub struct GetQuery {
pub async fn get(
State(account): State<AccountService>,
State(mastodon_mapper): State<MastodonMapper>,
State(url_service): State<UrlService>,
Path(account_id): Path<Uuid>,
auth_data: Option<MastodonAuthExtractor>,
OriginalUri(original_uri): OriginalUri,
Query(query): Query<GetQuery>,
user_data: Option<MastodonAuthExtractor>,
) -> Result<Json<Vec<Status>>> {
let fetching_account_id = auth_data.map(|user_data| user_data.0.account.id);
) -> Result<PaginatedJsonResponse<Status>> {
let fetching_account_id = user_data.as_ref().map(|user_data| user_data.0.account.id);
let get_posts = GetPosts::builder()
.account_id(account_id)
.fetching_account_id(fetching_account_id)
.max_id(query.max_id)
.since_id(query.since_id)
.min_id(query.min_id)
.limit(query.limit)
.build();
let statuses: Vec<Status> = account
let mut statuses: Vec<Status> = account
.get_posts(get_posts)
.await?
.and_then(|post| {
@ -72,5 +81,17 @@ pub async fn get(
.try_collect()
.await?;
Ok(Json(statuses))
if query.min_id.is_some() {
statuses.reverse();
}
let link_header = LinkHeader::new(
&statuses,
query.limit,
&url_service.base_url(),
original_uri.path(),
|s| s.id,
);
Ok((link_header, Json(statuses)))
}

View File

@ -0,0 +1,56 @@
use axum::{
debug_handler,
extract::{Path, State},
Json,
};
use kitsune_type::mastodon::relationship::Relationship;
use speedy_uuid::Uuid;
use crate::{
error::{ApiError, Result},
http::extractor::{AuthExtractor, MastodonAuthExtractor},
mapping::MastodonMapper,
service::account::{AccountService, FollowRequest},
};
#[debug_handler(state = crate::state::Zustand)]
#[utoipa::path(
post,
path = "/api/v1/follow_requests/{id}/authorize",
security(
("oauth_token" = [])
),
responses(
(status = 200, description = "Follow request accepted", body = Relationship),
(status = 404, description = "No pending follow request from that account ID")
),
)]
pub async fn post(
State(account_service): State<AccountService>,
State(mastodon_mapper): State<MastodonMapper>,
AuthExtractor(user_data): MastodonAuthExtractor,
Path(id): Path<Uuid>,
) -> Result<Json<Relationship>> {
if user_data.account.id == id {
return Err(ApiError::BadRequest.into());
}
let follow_request = FollowRequest::builder()
.account_id(user_data.account.id)
.follower_id(id)
.build();
let follow_accounts = account_service
.accept_follow_request(follow_request)
.await?;
if let Some(follow_accounts) = follow_accounts {
Ok(Json(
mastodon_mapper
.map((&follow_accounts.0, &follow_accounts.1))
.await?,
))
} else {
Err(ApiError::BadRequest.into())
}
}

View File

@ -0,0 +1,92 @@
use crate::{
consts::API_DEFAULT_LIMIT,
error::Result,
http::{
extractor::{AuthExtractor, MastodonAuthExtractor},
pagination::{LinkHeader, PaginatedJsonResponse},
},
mapping::MastodonMapper,
service::{
account::{AccountService, GetFollowRequests},
url::UrlService,
},
state::Zustand,
};
use axum::{
debug_handler,
extract::{OriginalUri, State},
routing, Json, Router,
};
use axum_extra::extract::Query;
use futures_util::TryStreamExt;
use kitsune_type::mastodon::Account;
use serde::Deserialize;
use speedy_uuid::Uuid;
use utoipa::IntoParams;
pub mod accept;
pub mod reject;
fn default_limit() -> usize {
API_DEFAULT_LIMIT
}
#[derive(Deserialize, IntoParams)]
pub struct GetQuery {
max_id: Option<Uuid>,
since_id: Option<Uuid>,
#[serde(default = "default_limit")]
limit: usize,
}
#[debug_handler(state = crate::state::Zustand)]
#[utoipa::path(
get,
path = "/api/v1/follow_requests",
security(
("oauth_token" = [])
),
params(GetQuery),
responses(
(status = 200, description = "List of accounts requesting a follow", body = Relationship)
),
)]
pub async fn get(
State(account_service): State<AccountService>,
State(mastodon_mapper): State<MastodonMapper>,
State(url_service): State<UrlService>,
OriginalUri(original_uri): OriginalUri,
Query(query): Query<GetQuery>,
AuthExtractor(user_data): MastodonAuthExtractor,
) -> Result<PaginatedJsonResponse<Account>> {
let get_follow_requests = GetFollowRequests::builder()
.account_id(user_data.account.id)
.limit(query.limit)
.since_id(query.since_id)
.max_id(query.max_id)
.build();
let accounts: Vec<Account> = account_service
.get_follow_requests(get_follow_requests)
.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)))
}
pub fn routes() -> Router<Zustand> {
Router::new()
.route("/", routing::get(get))
.route("/:id/authorize", routing::post(accept::post))
.route("/:id/reject", routing::post(reject::post))
}

View File

@ -0,0 +1,56 @@
use axum::{
debug_handler,
extract::{Path, State},
Json,
};
use kitsune_type::mastodon::relationship::Relationship;
use speedy_uuid::Uuid;
use crate::{
error::{ApiError, Result},
http::extractor::{AuthExtractor, MastodonAuthExtractor},
mapping::MastodonMapper,
service::account::{AccountService, FollowRequest},
};
#[debug_handler(state = crate::state::Zustand)]
#[utoipa::path(
post,
path = "/api/v1/follow_requests/{id}/reject",
security(
("oauth_token" = [])
),
responses(
(status = 200, description = "Follow request rejected", body = Relationship),
(status = 404, description = "No pending follow request from that account ID")
),
)]
pub async fn post(
State(account_service): State<AccountService>,
State(mastodon_mapper): State<MastodonMapper>,
AuthExtractor(user_data): MastodonAuthExtractor,
Path(id): Path<Uuid>,
) -> Result<Json<Relationship>> {
if user_data.account.id == id {
return Err(ApiError::BadRequest.into());
}
let follow_request = FollowRequest::builder()
.account_id(user_data.account.id)
.follower_id(id)
.build();
let follow_accounts = account_service
.reject_follow_request(follow_request)
.await?;
if let Some(follow_accounts) = follow_accounts {
Ok(Json(
mastodon_mapper
.map((&follow_accounts.0, &follow_accounts.1))
.await?,
))
} else {
Err(ApiError::BadRequest.into())
}
}

View File

@ -3,6 +3,7 @@ use axum::Router;
pub mod accounts;
pub mod apps;
pub mod follow_requests;
pub mod instance;
pub mod media;
pub mod statuses;
@ -12,6 +13,7 @@ pub fn routes() -> Router<Zustand> {
Router::new()
.nest("/apps", apps::routes())
.nest("/accounts", accounts::routes())
.nest("/follow_requests", follow_requests::routes())
.nest("/instance", instance::routes())
.nest("/media", media::routes())
.nest("/statuses", statuses::routes())

View File

@ -1,12 +1,18 @@
use crate::{
consts::API_DEFAULT_LIMIT,
error::Result,
http::extractor::{AuthExtractor, MastodonAuthExtractor},
http::{
extractor::{AuthExtractor, MastodonAuthExtractor},
pagination::{LinkHeader, PaginatedJsonResponse},
},
mapping::MastodonMapper,
service::timeline::{GetHome, TimelineService},
service::{
timeline::{GetHome, TimelineService},
url::UrlService,
},
};
use axum::{
extract::{Query, State},
extract::{OriginalUri, Query, State},
Json,
};
use futures_util::TryStreamExt;
@ -22,6 +28,7 @@ fn default_limit() -> usize {
#[derive(Deserialize, IntoParams)]
pub struct GetQuery {
max_id: Option<Uuid>,
since_id: Option<Uuid>,
min_id: Option<Uuid>,
#[serde(default = "default_limit")]
limit: usize,
@ -41,22 +48,37 @@ pub struct GetQuery {
pub async fn get(
State(mastodon_mapper): State<MastodonMapper>,
State(timeline): State<TimelineService>,
State(url_service): State<UrlService>,
OriginalUri(original_uri): OriginalUri,
Query(query): Query<GetQuery>,
AuthExtractor(user_data): MastodonAuthExtractor,
) -> Result<Json<Vec<Status>>> {
) -> Result<PaginatedJsonResponse<Status>> {
let get_home = GetHome::builder()
.fetching_account_id(user_data.account.id)
.max_id(query.max_id)
.since_id(query.since_id)
.min_id(query.min_id)
.limit(query.limit)
.build();
let statuses: Vec<Status> = timeline
let mut statuses: Vec<Status> = timeline
.get_home(get_home)
.await?
.and_then(|post| mastodon_mapper.map((&user_data.account, post)))
.try_collect()
.await?;
Ok(Json(statuses))
if query.min_id.is_some() {
statuses.reverse();
}
let link_header = LinkHeader::new(
&statuses,
query.limit,
&url_service.base_url(),
original_uri.path(),
|s| s.id,
);
Ok((link_header, Json(statuses)))
}

View File

@ -1,12 +1,18 @@
use crate::{
consts::API_DEFAULT_LIMIT,
error::Result,
http::extractor::{AuthExtractor, MastodonAuthExtractor},
http::{
extractor::{AuthExtractor, MastodonAuthExtractor},
pagination::{LinkHeader, PaginatedJsonResponse},
},
mapping::MastodonMapper,
service::timeline::{GetPublic, TimelineService},
service::{
timeline::{GetPublic, TimelineService},
url::UrlService,
},
};
use axum::{
extract::{Query, State},
extract::{OriginalUri, Query, State},
Json,
};
use futures_util::{FutureExt, TryStreamExt};
@ -26,6 +32,7 @@ pub struct GetQuery {
#[serde(default)]
remote: bool,
max_id: Option<Uuid>,
since_id: Option<Uuid>,
min_id: Option<Uuid>,
#[serde(default = "default_limit")]
limit: usize,
@ -42,18 +49,21 @@ pub struct GetQuery {
pub async fn get(
State(mastodon_mapper): State<MastodonMapper>,
State(timeline): State<TimelineService>,
State(url_service): State<UrlService>,
OriginalUri(original_uri): OriginalUri,
Query(query): Query<GetQuery>,
user_data: Option<MastodonAuthExtractor>,
) -> Result<Json<Vec<Status>>> {
) -> Result<PaginatedJsonResponse<Status>> {
let get_public = GetPublic::builder()
.only_local(query.local)
.only_remote(query.remote)
.max_id(query.max_id)
.since_id(query.since_id)
.min_id(query.min_id)
.limit(query.limit)
.build();
let statuses: Vec<Status> = timeline
let mut statuses: Vec<Status> = timeline
.get_public(get_public)
.await?
.and_then(|post| {
@ -68,5 +78,17 @@ pub async fn get(
.try_collect()
.await?;
Ok(Json(statuses))
if query.min_id.is_some() {
statuses.reverse();
}
let link_header = LinkHeader::new(
&statuses,
query.limit,
&url_service.base_url(),
original_uri.path(),
|s| s.id,
);
Ok((link_header, Json(statuses)))
}

View File

@ -22,6 +22,8 @@ mod handler;
mod middleware;
mod openapi;
mod page;
#[cfg(feature = "mastodon-api")]
mod pagination;
mod responder;
mod util;
@ -66,8 +68,7 @@ pub fn create_router(state: Zustand, server_config: &ServerConfiguration) -> Rou
router = router
.merge(SwaggerUi::new("/swagger-ui").url("/api-doc/openapi.json", api_docs()))
.fallback_service(
ServeDir::new(frontend_dir.as_str())
.not_found_service(ServeFile::new(frontend_index_path)),
ServeDir::new(frontend_dir.as_str()).fallback(ServeFile::new(frontend_index_path)),
);
#[cfg(feature = "metrics")]

View File

@ -90,6 +90,9 @@ struct CommonApiDocs;
mastodon::api::v1::accounts::update_credentials::patch,
mastodon::api::v1::accounts::verify_credentials::get,
mastodon::api::v1::apps::post,
mastodon::api::v1::follow_requests::get,
mastodon::api::v1::follow_requests::accept::post,
mastodon::api::v1::follow_requests::reject::post,
mastodon::api::v1::instance::get,
mastodon::api::v1::media::post,
mastodon::api::v1::media::put,

View File

@ -0,0 +1,81 @@
use std::fmt::Display;
use axum::{
response::{IntoResponseParts, ResponseParts},
Json,
};
use http::{Error as HttpError, HeaderValue};
use crate::error::Error;
pub type PaginatedJsonResponse<T> = (
Option<LinkHeader<Vec<(&'static str, String)>>>,
Json<Vec<T>>,
);
pub struct LinkHeader<T>(pub T);
impl<T, K, V> IntoResponseParts for LinkHeader<T>
where
T: IntoIterator<Item = (K, V)>,
K: Display,
V: Display,
{
type Error = Error;
fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
let value = self
.0
.into_iter()
.map(|(key, value)| format!("<{value}>; rel=\"{key}\""))
.collect::<Vec<String>>()
.join(", ");
res.headers_mut().insert(
"Link",
HeaderValue::from_str(&value).map_err(HttpError::from)?,
);
Ok(res)
}
}
impl LinkHeader<Vec<(&'static str, String)>> {
pub fn new<I, D: Display, F: Fn(&I) -> D>(
collection: &[I],
limit: usize,
base_url: &str,
uri_path: &str,
get_key: F,
) -> Option<LinkHeader<Vec<(&'static str, String)>>> {
if collection.is_empty() {
None
} else {
let next = (
"next",
format!(
"{}{}?limit={}&max_id={}",
base_url,
uri_path,
limit,
get_key(collection.last().unwrap())
),
);
let prev = (
"prev",
format!(
"{}{}?limit={}&since_id={}",
base_url,
uri_path,
limit,
get_key(collection.first().unwrap())
),
);
if collection.len() >= limit && limit > 0 {
Some(LinkHeader(vec![next, prev]))
} else {
Some(LinkHeader(vec![prev]))
}
}
}
}

View File

@ -3,6 +3,7 @@ pub mod create;
pub mod delete;
pub mod favourite;
pub mod follow;
pub mod reject;
pub mod unfavourite;
pub mod unfollow;
pub mod update;

View File

@ -0,0 +1,86 @@
use crate::{
error::Result,
job::{JobRunnerContext, Runnable},
try_join,
};
use async_trait::async_trait;
use diesel::{
ExpressionMethods, JoinOnDsl, NullableExpressionMethods, OptionalExtension, QueryDsl,
SelectableHelper,
};
use diesel_async::RunQueryDsl;
use iso8601_timestamp::Timestamp;
use kitsune_db::{
model::{account::Account, follower::Follow, user::User},
schema::{accounts, accounts_follows, users},
};
use kitsune_type::ap::{ap_context, helper::StringOrObject, Activity, ActivityType, ObjectField};
use serde::{Deserialize, Serialize};
use speedy_uuid::Uuid;
#[derive(Debug, Deserialize, Serialize)]
pub struct DeliverReject {
pub follow_id: Uuid,
}
#[async_trait]
impl Runnable for DeliverReject {
type Context = JobRunnerContext;
type Error = eyre::Report;
#[instrument(skip_all, fields(follow_id = %self.follow_id))]
async fn run(&self, ctx: &Self::Context) -> Result<(), Self::Error> {
let mut db_conn = ctx.state.db_conn.get().await?;
let Some(follow) = accounts_follows::table
.find(self.follow_id)
.get_result::<Follow>(&mut db_conn)
.await
.optional()?
else {
return Ok(());
};
let follower_inbox_url_fut = accounts::table
.find(follow.follower_id)
.select(accounts::inbox_url.assume_not_null())
.get_result::<String>(&mut db_conn);
let followed_info_fut = accounts::table
.find(follow.account_id)
.inner_join(users::table.on(accounts::id.eq(users::account_id)))
.select(<(Account, User)>::as_select())
.get_result::<(Account, User)>(&mut db_conn);
let delete_fut = diesel::delete(&follow).execute(&mut db_conn);
let (follower_inbox_url, (followed_account, followed_user), _delete_result) =
try_join!(follower_inbox_url_fut, followed_info_fut, delete_fut)?;
let followed_account_url = ctx.state.service.url.user_url(followed_account.id);
// Constructing this here is against our idea of the `IntoActivity` and `IntoObject` traits
// But I'm not sure how I could encode these into the form of these two traits
// So we make an exception for this
//
// If someone has a better idea, please open an issue
let reject_activity = Activity {
context: ap_context(),
id: format!("{}#reject", follow.url),
r#type: ActivityType::Reject,
actor: StringOrObject::String(followed_account_url),
object: ObjectField::Url(follow.url),
published: Timestamp::now_utc(),
};
ctx.deliverer
.deliver(
&follower_inbox_url,
&followed_account,
&followed_user,
&reject_activity,
)
.await?;
Ok(())
}
}

View File

@ -1,8 +1,8 @@
use self::{
deliver::{
accept::DeliverAccept, create::DeliverCreate, delete::DeliverDelete,
favourite::DeliverFavourite, follow::DeliverFollow, unfavourite::DeliverUnfavourite,
unfollow::DeliverUnfollow, update::DeliverUpdate,
favourite::DeliverFavourite, follow::DeliverFollow, reject::DeliverReject,
unfavourite::DeliverUnfavourite, unfollow::DeliverUnfollow, update::DeliverUpdate,
},
mailing::confirmation::SendConfirmationMail,
};
@ -71,6 +71,7 @@ impl_from! {
DeliverDelete(DeliverDelete),
DeliverFavourite(DeliverFavourite),
DeliverFollow(DeliverFollow),
DeliverReject(DeliverReject),
DeliverUnfavourite(DeliverUnfavourite),
DeliverUnfollow(DeliverUnfollow),
DeliverUpdate(DeliverUpdate),
@ -90,6 +91,7 @@ impl Runnable for Job {
Self::DeliverDelete(job) => job.run(ctx).await,
Self::DeliverFavourite(job) => job.run(ctx).await,
Self::DeliverFollow(job) => job.run(ctx).await,
Self::DeliverReject(job) => job.run(ctx).await,
Self::DeliverUnfavourite(job) => job.run(ctx).await,
Self::DeliverUnfollow(job) => job.run(ctx).await,
Self::DeliverUpdate(job) => job.run(ctx).await,

View File

@ -8,7 +8,9 @@ use crate::{
consts::{API_DEFAULT_LIMIT, API_MAX_LIMIT},
error::{Error, Result},
job::deliver::{
accept::DeliverAccept,
follow::DeliverFollow,
reject::DeliverReject,
unfollow::DeliverUnfollow,
update::{DeliverUpdate, UpdateEntity},
},
@ -19,7 +21,8 @@ use crate::{
use bytes::Bytes;
use derive_builder::Builder;
use diesel::{
BoolExpressionMethods, ExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper,
BoolExpressionMethods, ExpressionMethods, JoinOnDsl, OptionalExtension, QueryDsl,
SelectableHelper,
};
use diesel_async::RunQueryDsl;
use futures_util::{Stream, TryStreamExt};
@ -73,12 +76,40 @@ pub struct GetPosts {
#[builder(default = API_DEFAULT_LIMIT)]
limit: usize,
/// Smallest ID
/// Smallest ID, return results starting from this ID
///
/// Used for pagination
#[builder(default)]
min_id: Option<Uuid>,
/// Smallest ID, return highest results
///
/// Used for pagination
#[builder(default)]
since_id: Option<Uuid>,
/// Largest ID
///
/// Used for pagination
#[builder(default)]
max_id: Option<Uuid>,
}
#[derive(Clone, TypedBuilder)]
pub struct GetFollowRequests {
/// ID of the account whose follow requests are getting fetched
account_id: Uuid,
/// Limit of returned posts
#[builder(default = API_DEFAULT_LIMIT)]
limit: usize,
/// Smallest ID
///
/// Used for pagination
#[builder(default)]
since_id: Option<Uuid>,
/// Largest ID
///
/// Used for pagination
@ -95,6 +126,15 @@ pub struct Unfollow {
follower_id: Uuid,
}
#[derive(Clone, TypedBuilder)]
pub struct FollowRequest {
/// Account that is the target of the follow request
account_id: Uuid,
/// Account that is sending the follow request
follower_id: Uuid,
}
#[derive(Builder)]
#[builder(pattern = "owned")]
pub struct Update<A, H> {
@ -253,7 +293,7 @@ impl AccountService {
.build()
.unwrap();
let mut posts_query = posts::table
let mut query = posts::table
.filter(posts::account_id.eq(get_posts.account_id))
.add_post_permission_check(permission_check)
.select(Post::as_select())
@ -261,18 +301,17 @@ impl AccountService {
.limit(min(get_posts.limit, API_MAX_LIMIT) as i64)
.into_boxed();
if let Some(min_id) = get_posts.min_id {
posts_query = posts_query.filter(posts::id.gt(min_id));
}
if let Some(max_id) = get_posts.max_id {
posts_query = posts_query.filter(posts::id.lt(max_id));
query = query.filter(posts::id.lt(max_id));
}
if let Some(since_id) = get_posts.since_id {
query = query.filter(posts::id.gt(since_id));
}
if let Some(min_id) = get_posts.min_id {
query = query.filter(posts::id.gt(min_id)).order(posts::id.asc());
}
Ok(posts_query
.load_stream(&mut db_conn)
.await?
.map_err(Error::from))
Ok(query.load_stream(&mut db_conn).await?.map_err(Error::from))
}
/// Undo the follow of an account
@ -322,6 +361,140 @@ impl AccountService {
Ok((account, follower))
}
pub async fn get_follow_requests(
&self,
get_follow_requests: GetFollowRequests,
) -> Result<impl Stream<Item = Result<Account>> + '_> {
let mut db_conn = self.db_conn.get().await?;
let mut query = accounts_follows::table
.inner_join(accounts::table.on(accounts_follows::follower_id.eq(accounts::id)))
.filter(
accounts_follows::account_id
.eq(get_follow_requests.account_id)
.and(accounts_follows::approved_at.is_null()),
)
.select(Account::as_select())
.order(accounts::id.desc())
.limit(min(get_follow_requests.limit, API_MAX_LIMIT) as i64)
.into_boxed();
if let Some(since_id) = get_follow_requests.since_id {
query = query.filter(accounts::id.gt(since_id));
}
if let Some(max_id) = get_follow_requests.max_id {
query = query.filter(accounts::id.lt(max_id));
}
Ok(query.load_stream(&mut db_conn).await?.map_err(Error::from))
}
pub async fn accept_follow_request(
&self,
follow_request: FollowRequest,
) -> Result<Option<(Account, Account)>> {
let mut db_conn = self.db_conn.get().await?;
let account_fut = accounts::table
.find(follow_request.account_id)
.select(Account::as_select())
.get_result(&mut db_conn);
let follower_fut = accounts::table
.find(follow_request.follower_id)
.select(Account::as_select())
.get_result(&mut db_conn);
let (account, follower) = try_join!(account_fut, follower_fut)?;
let follow = accounts_follows::table
.filter(
accounts_follows::account_id
.eq(account.id)
.and(accounts_follows::follower_id.eq(follower.id)),
)
.get_result::<DbFollow>(&mut db_conn)
.await
.optional()?;
if let Some(follow) = follow {
let now = Timestamp::now_utc();
diesel::update(&follow)
.set((
accounts_follows::approved_at.eq(now),
accounts_follows::updated_at.eq(now),
))
.execute(&mut db_conn)
.await?;
if !account.local {
self.job_service
.enqueue(
Enqueue::builder()
.job(DeliverAccept {
follow_id: follow.id,
})
.build(),
)
.await?;
}
} else {
return Ok(None);
}
Ok(Some((account, follower)))
}
pub async fn reject_follow_request(
&self,
follow_request: FollowRequest,
) -> Result<Option<(Account, Account)>> {
let mut db_conn = self.db_conn.get().await?;
let account_fut = accounts::table
.find(follow_request.account_id)
.select(Account::as_select())
.get_result(&mut db_conn);
let follower_fut = accounts::table
.find(follow_request.follower_id)
.select(Account::as_select())
.get_result(&mut db_conn);
let (account, follower) = try_join!(account_fut, follower_fut)?;
let follow = accounts_follows::table
.filter(
accounts_follows::account_id
.eq(account.id)
.and(accounts_follows::follower_id.eq(follower.id)),
)
.get_result::<DbFollow>(&mut db_conn)
.await
.optional()?;
if let Some(follow) = follow {
if account.local {
diesel::delete(&follow).execute(&mut db_conn).await?;
} else {
self.job_service
.enqueue(
Enqueue::builder()
.job(DeliverReject {
follow_id: follow.id,
})
.build(),
)
.await?;
}
} else {
return Ok(None);
}
Ok(Some((account, follower)))
}
pub async fn update<A, H>(&self, mut update: Update<A, H>) -> Result<Account>
where
A: Stream<Item = kitsune_storage::Result<Bytes>> + Send + 'static,

View File

@ -25,6 +25,9 @@ pub struct GetHome {
#[builder(default)]
max_id: Option<Uuid>,
#[builder(default)]
since_id: Option<Uuid>,
#[builder(default)]
min_id: Option<Uuid>,
}
@ -37,6 +40,9 @@ pub struct GetPublic {
#[builder(default)]
max_id: Option<Uuid>,
#[builder(default)]
since_id: Option<Uuid>,
#[builder(default)]
min_id: Option<Uuid>,
@ -97,8 +103,11 @@ impl TimelineService {
if let Some(max_id) = get_home.max_id {
query = query.filter(posts::id.lt(max_id));
}
if let Some(since_id) = get_home.since_id {
query = query.filter(posts::id.gt(since_id));
}
if let Some(min_id) = get_home.min_id {
query = query.filter(posts::id.gt(min_id));
query = query.filter(posts::id.gt(min_id)).order(posts::id.asc());
}
Ok(query.load_stream(&mut db_conn).await?.map_err(Error::from))
@ -130,8 +139,11 @@ impl TimelineService {
if let Some(max_id) = get_public.max_id {
query = query.filter(posts::id.lt(max_id));
}
if let Some(since_id) = get_public.since_id {
query = query.filter(posts::id.gt(since_id));
}
if let Some(min_id) = get_public.min_id {
query = query.filter(posts::id.gt(min_id));
query = query.filter(posts::id.gt(min_id)).order(posts::id.asc());
}
if get_public.only_local {