mirror of https://github.com/kitsune-soc/kitsune
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:
parent
8f67cea6c4
commit
055fa18fe0
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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>
|
|
@ -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"]
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
@ -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")]
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue