Add API endpoints for making and deleting reposts

This commit is contained in:
silverpill 2021-11-24 16:39:30 +00:00
parent 9b52fb730a
commit 47826628cd
12 changed files with 269 additions and 8 deletions

View file

@ -119,6 +119,8 @@ GET /api/v1/statuses/{status_id}
GET /api/v1/statuses/{status_id}/context
POST /api/v1/statuses/{status_id}/favourite
POST /api/v1/statuses/{status_id}/unfavourite
POST /api/v1/statuses/{status_id}/reblog
POST /api/v1/statuses/{status_id}/unreblog
GET /api/v1/timelines/home
```

View file

@ -0,0 +1,3 @@
ALTER TABLE post ADD COLUMN repost_of_id UUID REFERENCES post (id) ON DELETE CASCADE;
ALTER TABLE post ADD COLUMN repost_count INTEGER NOT NULL CHECK (repost_count >= 0) DEFAULT 0;
ALTER TABLE post ADD CONSTRAINT post_author_id_repost_of_id_key UNIQUE (author_id, repost_of_id);

View file

@ -34,14 +34,17 @@ CREATE TABLE post (
author_id UUID NOT NULL REFERENCES actor_profile (id) ON DELETE CASCADE,
content TEXT NOT NULL,
in_reply_to_id UUID REFERENCES post (id) ON DELETE CASCADE,
repost_of_id UUID REFERENCES post (id) ON DELETE CASCADE,
visilibity SMALLINT NOT NULL,
reply_count INTEGER NOT NULL CHECK (reply_count >= 0) DEFAULT 0,
reaction_count INTEGER NOT NULL CHECK (reaction_count >= 0) DEFAULT 0,
repost_count INTEGER NOT NULL CHECK (repost_count >= 0) DEFAULT 0,
object_id VARCHAR(200) UNIQUE,
ipfs_cid VARCHAR(200),
token_id INTEGER,
token_tx_id VARCHAR(200),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
UNIQUE (author_id, repost_of_id)
);
CREATE TABLE post_reaction (

View file

@ -247,6 +247,7 @@ pub async fn process_note(
let post_data = PostCreateData {
content,
in_reply_to_id,
repost_of_id: None,
visibility,
attachments: attachments,
mentions: mentions,

View file

@ -16,7 +16,10 @@ use crate::errors::{HttpError, ValidationError};
use crate::ethereum::gate::is_allowed_user;
use crate::mastodon_api::statuses::types::Status;
use crate::mastodon_api::oauth::auth::get_current_user;
use crate::models::posts::helpers::get_actions_for_posts;
use crate::models::posts::helpers::{
get_actions_for_posts,
get_reposted_posts,
};
use crate::models::posts::queries::get_posts_by_author;
use crate::models::profiles::queries::{
get_followers,
@ -266,6 +269,7 @@ async fn get_account_statuses(
false,
false,
).await?;
get_reposted_posts(db_client, posts.iter_mut().collect()).await?;
if let Some(user) = maybe_current_user {
get_actions_for_posts(
db_client,

View file

@ -36,14 +36,17 @@ pub struct Status {
pub account: Account,
pub content: String,
pub in_reply_to_id: Option<Uuid>,
pub reblog: Option<Box<Status>>,
pub visibility: String,
pub replies_count: i32,
pub favourites_count: i32,
pub reblogs_count: i32,
pub media_attachments: Vec<Attachment>,
mentions: Vec<Mention>,
// Authorized user attributes
pub favourited: bool,
pub reblogged: bool,
// Extra fields
pub ipfs_cid: Option<String>,
@ -61,6 +64,12 @@ impl Status {
.map(|item| Mention::from_profile(item, instance_url))
.collect();
let account = Account::from_profile(post.author, instance_url);
let reblog = if let Some(repost_of) = post.repost_of {
let status = Status::from_post(*repost_of, instance_url);
Some(Box::new(status))
} else {
None
};
let visibility = match post.visibility {
Visibility::Public => "public",
Visibility::Direct => "direct",
@ -72,12 +81,15 @@ impl Status {
account: account,
content: post.content,
in_reply_to_id: post.in_reply_to_id,
reblog: reblog,
visibility: visibility.to_string(),
replies_count: post.reply_count,
favourites_count: post.reaction_count,
reblogs_count: post.repost_count,
media_attachments: attachments,
mentions: mentions,
favourited: post.actions.map_or(false, |actions| actions.favourited),
favourited: post.actions.as_ref().map_or(false, |actions| actions.favourited),
reblogged: post.actions.as_ref().map_or(false, |actions| actions.reposted),
ipfs_cid: post.ipfs_cid,
token_id: post.token_id,
token_tx_id: post.token_tx_id,
@ -102,6 +114,7 @@ impl From<StatusData> for PostCreateData {
Self {
content: value.status,
in_reply_to_id: value.in_reply_to_id,
repost_of_id: None,
visibility: Visibility::Public,
attachments: value.media_ids.unwrap_or(vec![]),
mentions: vec![],

View file

@ -24,12 +24,15 @@ use crate::models::posts::mentions::{find_mentioned_profiles, replace_mentions};
use crate::models::profiles::queries::get_followers;
use crate::models::posts::helpers::{
get_actions_for_posts,
get_reposted_posts,
};
use crate::models::posts::queries::{
create_post,
get_post_by_id,
get_thread,
find_reposts_by_user,
update_post,
delete_post,
};
use crate::models::posts::types::PostCreateData;
use crate::models::reactions::queries::{
@ -123,6 +126,7 @@ async fn get_status(
if !can_view_post(maybe_current_user.as_ref(), &post) {
return Err(HttpError::NotFoundError("post"));
};
get_reposted_posts(db_client, vec![&mut post]).await?;
if let Some(user) = maybe_current_user {
get_actions_for_posts(db_client, &user.id, vec![&mut post]).await?;
}
@ -147,6 +151,7 @@ async fn get_context(
&status_id,
maybe_current_user.as_ref().map(|user| &user.id),
).await?;
get_reposted_posts(db_client, posts.iter_mut().collect()).await?;
if let Some(user) = maybe_current_user {
get_actions_for_posts(
db_client,
@ -182,6 +187,7 @@ async fn favourite(
Err(other_error) => return Err(other_error.into()),
};
let mut post = get_post_by_id(db_client, &status_id).await?;
get_reposted_posts(db_client, vec![&mut post]).await?;
get_actions_for_posts(db_client, &current_user.id, vec![&mut post]).await?;
if reaction_created {
@ -221,6 +227,48 @@ async fn unfavourite(
other_result => other_result?,
}
let mut post = get_post_by_id(db_client, &status_id).await?;
get_reposted_posts(db_client, vec![&mut post]).await?;
get_actions_for_posts(db_client, &current_user.id, vec![&mut post]).await?;
let status = Status::from_post(post, &config.instance_url());
Ok(HttpResponse::Ok().json(status))
}
#[post("/{status_id}/reblog")]
async fn reblog(
auth: BearerAuth,
config: web::Data<Config>,
db_pool: web::Data<Pool>,
web::Path(status_id): web::Path<Uuid>,
) -> Result<HttpResponse, HttpError> {
let db_client = &mut **get_database_client(&db_pool).await?;
let current_user = get_current_user(db_client, auth.token()).await?;
let repost_data = PostCreateData {
repost_of_id: Some(status_id),
..Default::default()
};
create_post(db_client, &current_user.id, repost_data).await?;
let mut post = get_post_by_id(db_client, &status_id).await?;
get_reposted_posts(db_client, vec![&mut post]).await?;
get_actions_for_posts(db_client, &current_user.id, vec![&mut post]).await?;
let status = Status::from_post(post, &config.instance_url());
Ok(HttpResponse::Ok().json(status))
}
#[post("/{status_id}/unreblog")]
async fn unreblog(
auth: BearerAuth,
config: web::Data<Config>,
db_pool: web::Data<Pool>,
web::Path(status_id): web::Path<Uuid>,
) -> Result<HttpResponse, HttpError> {
let db_client = &mut **get_database_client(&db_pool).await?;
let current_user = get_current_user(db_client, auth.token()).await?;
let reposts = find_reposts_by_user(db_client, &current_user.id, &[status_id]).await?;
let repost_id = reposts.first().ok_or(HttpError::NotFoundError("post"))?;
// Ignore returned data because reposts don't have attached files
delete_post(db_client, repost_id).await?;
let mut post = get_post_by_id(db_client, &status_id).await?;
get_reposted_posts(db_client, vec![&mut post]).await?;
get_actions_for_posts(db_client, &current_user.id, vec![&mut post]).await?;
let status = Status::from_post(post, &config.instance_url());
Ok(HttpResponse::Ok().json(status))
@ -284,6 +332,7 @@ async fn make_permanent(
// Update post
post.ipfs_cid = Some(post_metadata_cid);
update_post(db_client, &post).await?;
get_reposted_posts(db_client, vec![&mut post]).await?;
get_actions_for_posts(db_client, &current_user.id, vec![&mut post]).await?;
let status = Status::from_post(post, &config.instance_url());
Ok(HttpResponse::Ok().json(status))
@ -326,6 +375,8 @@ pub fn status_api_scope() -> Scope {
.service(get_context)
.service(favourite)
.service(unfavourite)
.service(reblog)
.service(unreblog)
.service(make_permanent)
.service(get_signature)
}

View file

@ -6,7 +6,10 @@ use crate::database::{Pool, get_database_client};
use crate::errors::HttpError;
use crate::mastodon_api::oauth::auth::get_current_user;
use crate::mastodon_api::statuses::types::Status;
use crate::models::posts::helpers::get_actions_for_posts;
use crate::models::posts::helpers::{
get_actions_for_posts,
get_reposted_posts,
};
use crate::models::posts::queries::get_home_timeline;
/// https://docs.joinmastodon.org/methods/timelines/
@ -19,6 +22,7 @@ async fn home_timeline(
let db_client = &**get_database_client(&db_pool).await?;
let current_user = get_current_user(db_client, auth.token()).await?;
let mut posts = get_home_timeline(db_client, &current_user.id).await?;
get_reposted_posts(db_client, posts.iter_mut().collect()).await?;
get_actions_for_posts(
db_client,
&current_user.id,

View file

@ -4,18 +4,55 @@ use uuid::Uuid;
use crate::errors::DatabaseError;
use crate::models::reactions::queries::find_favourited_by_user;
use crate::models::users::types::User;
use super::queries::{get_posts, find_reposted_by_user};
use super::types::{Post, PostActions, Visibility};
pub async fn get_reposted_posts(
db_client: &impl GenericClient,
posts: Vec<&mut Post>,
) -> Result<(), DatabaseError> {
let reposted_ids: Vec<Uuid> = posts.iter()
.filter_map(|post| post.repost_of_id)
.collect();
let mut reposted = get_posts(db_client, reposted_ids).await?;
for post in posts {
if let Some(ref repost_of_id) = post.repost_of_id {
let index = reposted.iter()
.position(|post| post.id == *repost_of_id)
.ok_or(DatabaseError::NotFound("post"))?;
let repost_of = reposted.swap_remove(index);
post.repost_of = Some(Box::new(repost_of));
};
};
Ok(())
}
pub async fn get_actions_for_posts(
db_client: &impl GenericClient,
user_id: &Uuid,
posts: Vec<&mut Post>,
) -> Result<(), DatabaseError> {
let posts_ids: Vec<Uuid> = posts.iter().map(|post| post.id).collect();
let posts_ids: Vec<Uuid> = posts.iter()
.map(|post| post.id)
.chain(
posts.iter()
.filter_map(|post| post.repost_of.as_ref())
.map(|post| post.id)
)
.collect();
let favourites = find_favourited_by_user(db_client, user_id, &posts_ids).await?;
let reposted = find_reposted_by_user(db_client, user_id, &posts_ids).await?;
for post in posts {
if let Some(ref mut repost_of) = post.repost_of {
let actions = PostActions {
favourited: favourites.contains(&repost_of.id),
reposted: reposted.contains(&repost_of.id),
};
repost_of.actions = Some(actions);
};
let actions = PostActions {
favourited: favourites.contains(&post.id),
reposted: reposted.contains(&post.id),
};
post.actions = Some(actions);
}

View file

@ -5,6 +5,7 @@ use postgres_types::ToSql;
use tokio_postgres::GenericClient;
use uuid::Uuid;
use crate::database::catch_unique_violation;
use crate::errors::DatabaseError;
use crate::models::attachments::types::DbMediaAttachment;
use crate::models::cleanup::{
@ -76,6 +77,33 @@ pub async fn get_home_timeline(
Ok(posts)
}
pub async fn get_posts(
db_client: &impl GenericClient,
posts_ids: Vec<Uuid>,
) -> Result<Vec<Post>, DatabaseError> {
let statement = format!(
"
SELECT
post, actor_profile,
{related_attachments},
{related_mentions}
FROM post
JOIN actor_profile ON post.author_id = actor_profile.id
WHERE post.id = ANY($1)
",
related_attachments=RELATED_ATTACHMENTS,
related_mentions=RELATED_MENTIONS,
);
let rows = db_client.query(
statement.as_str(),
&[&posts_ids],
).await?;
let posts: Vec<Post> = rows.iter()
.map(Post::try_from)
.collect::<Result<_, _>>()?;
Ok(posts)
}
pub async fn get_posts_by_author(
db_client: &impl GenericClient,
account_id: &Uuid,
@ -126,28 +154,43 @@ pub async fn create_post(
let transaction = db_client.transaction().await?;
let post_id = uuid::Uuid::new_v4();
let created_at = data.created_at.unwrap_or(Utc::now());
let post_row = transaction.query_one(
// Reposting of other reposts or non-public posts is not allowed
let insert_statement = format!(
"
INSERT INTO post (
id, author_id, content,
in_reply_to_id,
repost_of_id,
visibility,
object_id,
created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
SELECT $1, $2, $3, $4, $5, $6, $7, $8
WHERE NOT EXISTS (
SELECT 1 FROM post
WHERE post.id = $5 AND (
post.repost_of_id IS NOT NULL
OR post.visibility != {visibility_public}
)
)
RETURNING post
",
visibility_public=i16::from(&Visibility::Public),
);
let maybe_post_row = transaction.query_opt(
insert_statement.as_str(),
&[
&post_id,
&author_id,
&data.content,
&data.in_reply_to_id,
&data.repost_of_id,
&data.visibility,
&data.object_id,
&created_at,
],
).await?;
).await.map_err(catch_unique_violation("post"))?;
let post_row = maybe_post_row.ok_or(DatabaseError::NotFound("post"))?;
let db_post: DbPost = post_row.try_get("post")?;
// Create links to attachments
let attachments_rows = transaction.query(
@ -201,6 +244,9 @@ pub async fn create_post(
).await?;
}
}
if let Some(repost_of_id) = &db_post.repost_of_id {
update_repost_count(&transaction, repost_of_id, 1).await?;
};
transaction.commit().await?;
let post = Post::new(db_post, author, db_attachments, db_mentions)?;
@ -426,6 +472,68 @@ pub async fn update_reaction_count(
Ok(())
}
pub async fn update_repost_count(
db_client: &impl GenericClient,
post_id: &Uuid,
change: i32,
) -> Result<(), DatabaseError> {
let updated_count = db_client.execute(
"
UPDATE post
SET repost_count = repost_count + $1
WHERE id = $2
",
&[&change, &post_id],
).await?;
if updated_count == 0 {
return Err(DatabaseError::NotFound("post"));
}
Ok(())
}
/// Finds reposts of given posts and returns their IDs
pub async fn find_reposts_by_user(
db_client: &impl GenericClient,
user_id: &Uuid,
posts_ids: &[Uuid],
) -> Result<Vec<Uuid>, DatabaseError> {
let rows = db_client.query(
"
SELECT post.id
FROM post
WHERE post.author_id = $1 AND post.repost_of_id = ANY($2)
",
&[&user_id, &posts_ids],
).await?;
let reposts: Vec<Uuid> = rows.iter()
.map(|row| row.try_get("id"))
.collect::<Result<_, _>>()?;
Ok(reposts)
}
/// Finds items reposted by user among given posts
pub async fn find_reposted_by_user(
db_client: &impl GenericClient,
user_id: &Uuid,
posts_ids: &[Uuid],
) -> Result<Vec<Uuid>, DatabaseError> {
let rows = db_client.query(
"
SELECT post.id
FROM post
WHERE post.id = ANY($2) AND EXISTS (
SELECT 1 FROM post AS repost
WHERE repost.author_id = $1 AND repost.repost_of_id = post.id
)
",
&[&user_id, &posts_ids],
).await?;
let reposted: Vec<Uuid> = rows.iter()
.map(|row| row.try_get("id"))
.collect::<Result<_, _>>()?;
Ok(reposted)
}
pub async fn get_token_waitlist(
db_client: &impl GenericClient,
) -> Result<Vec<Uuid>, DatabaseError> {
@ -490,6 +598,9 @@ pub async fn delete_post(
if let Some(parent_id) = &db_post.in_reply_to_id {
update_reply_count(&transaction, parent_id, -1).await?;
}
if let Some(repost_of_id) = &db_post.repost_of_id {
update_repost_count(&transaction, repost_of_id, -1).await?;
};
update_post_count(&transaction, &db_post.author_id, -1).await?;
let orphaned_files = find_orphaned_files(&transaction, files).await?;
let orphaned_ipfs_objects = find_orphaned_ipfs_objects(&transaction, ipfs_objects).await?;

View file

@ -18,6 +18,10 @@ pub enum Visibility {
Direct,
}
impl Default for Visibility {
fn default() -> Self { Self::Public }
}
impl From<&Visibility> for i16 {
fn from(value: &Visibility) -> i16 {
match value {
@ -50,9 +54,11 @@ pub struct DbPost {
pub author_id: Uuid,
pub content: String,
pub in_reply_to_id: Option<Uuid>,
pub repost_of_id: Option<Uuid>,
pub visibility: Visibility,
pub reply_count: i32,
pub reaction_count: i32,
pub repost_count: i32,
pub object_id: Option<String>,
pub ipfs_cid: Option<String>,
pub token_id: Option<i32>,
@ -63,6 +69,7 @@ pub struct DbPost {
// List of user's actions
pub struct PostActions {
pub favourited: bool,
pub reposted: bool,
}
pub struct Post {
@ -70,9 +77,11 @@ pub struct Post {
pub author: DbActorProfile,
pub content: String,
pub in_reply_to_id: Option<Uuid>,
pub repost_of_id: Option<Uuid>,
pub visibility: Visibility,
pub reply_count: i32,
pub reaction_count: i32,
pub repost_count: i32,
pub attachments: Vec<DbMediaAttachment>,
pub mentions: Vec<DbActorProfile>,
pub object_id: Option<String>,
@ -80,7 +89,9 @@ pub struct Post {
pub token_id: Option<i32>,
pub token_tx_id: Option<String>,
pub created_at: DateTime<Utc>,
pub actions: Option<PostActions>,
pub repost_of: Option<Box<Post>>,
}
impl Post {
@ -102,9 +113,11 @@ impl Post {
author: db_author,
content: db_post.content,
in_reply_to_id: db_post.in_reply_to_id,
repost_of_id: db_post.repost_of_id,
visibility: db_post.visibility,
reply_count: db_post.reply_count,
reaction_count: db_post.reaction_count,
repost_count: db_post.repost_count,
attachments: db_attachments,
mentions: db_mentions,
object_id: db_post.object_id,
@ -113,6 +126,7 @@ impl Post {
token_tx_id: db_post.token_tx_id,
created_at: db_post.created_at,
actions: None,
repost_of: None,
};
Ok(post)
}
@ -137,9 +151,11 @@ impl Default for Post {
author: Default::default(),
content: "".to_string(),
in_reply_to_id: None,
repost_of_id: None,
visibility: Visibility::Public,
reply_count: 0,
reaction_count: 0,
repost_count: 0,
attachments: vec![],
mentions: vec![],
object_id: None,
@ -148,6 +164,7 @@ impl Default for Post {
token_tx_id: None,
created_at: Utc::now(),
actions: None,
repost_of: None,
}
}
}
@ -166,9 +183,11 @@ impl TryFrom<&Row> for Post {
}
}
#[derive(Default)]
pub struct PostCreateData {
pub content: String,
pub in_reply_to_id: Option<Uuid>,
pub repost_of_id: Option<Uuid>,
pub visibility: Visibility,
pub attachments: Vec<Uuid>,
pub mentions: Vec<Uuid>,
@ -198,6 +217,7 @@ mod tests {
let mut post_data_1 = PostCreateData {
content: " ".to_string(),
in_reply_to_id: None,
repost_of_id: None,
visibility: Visibility::Public,
attachments: vec![],
mentions: vec![],
@ -212,6 +232,7 @@ mod tests {
let mut post_data_2 = PostCreateData {
content: "test ".to_string(),
in_reply_to_id: None,
repost_of_id: None,
visibility: Visibility::Public,
attachments: vec![],
mentions: vec![],

View file

@ -279,6 +279,17 @@ pub async fn delete_profile(
",
&[&profile_id],
).await?;
transaction.execute(
"
UPDATE post
SET repost_count = repost_count - 1
FROM post AS repost
WHERE
repost.repost_of_id = post.id
AND repost.author_id = $1
",
&[&profile_id],
).await?;
// Delete profile
let deleted_count = transaction.execute(
"