diff --git a/apps/labrinth/.sqlx/query-57895dd22cb3f6d954ed742a730edece682a771d504ef209cbece81c46e07efb.json b/apps/labrinth/.sqlx/query-57895dd22cb3f6d954ed742a730edece682a771d504ef209cbece81c46e07efb.json new file mode 100644 index 0000000000..c30df61c20 --- /dev/null +++ b/apps/labrinth/.sqlx/query-57895dd22cb3f6d954ed742a730edece682a771d504ef209cbece81c46e07efb.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO blocked_users (user_id, blocked_id)\n VALUES ($1, $2)\n ON CONFLICT (user_id, blocked_id) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "57895dd22cb3f6d954ed742a730edece682a771d504ef209cbece81c46e07efb" +} diff --git a/apps/labrinth/.sqlx/query-c08a594796f4af4b7cc536c672731756440e8d1165e88dc644f898a9e40abc48.json b/apps/labrinth/.sqlx/query-c08a594796f4af4b7cc536c672731756440e8d1165e88dc644f898a9e40abc48.json new file mode 100644 index 0000000000..1f32effbc3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c08a594796f4af4b7cc536c672731756440e8d1165e88dc644f898a9e40abc48.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM blocked_users\n WHERE user_id = $1 AND blocked_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c08a594796f4af4b7cc536c672731756440e8d1165e88dc644f898a9e40abc48" +} diff --git a/apps/labrinth/.sqlx/query-c88172a879d9c2e9cb7fa1480e274e650cd29bc04083708a5fc8580c25be7747.json b/apps/labrinth/.sqlx/query-c88172a879d9c2e9cb7fa1480e274e650cd29bc04083708a5fc8580c25be7747.json new file mode 100644 index 0000000000..0f2f18a875 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c88172a879d9c2e9cb7fa1480e274e650cd29bc04083708a5fc8580c25be7747.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT 1 FROM blocked_users\n WHERE user_id = $1 AND blocked_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c88172a879d9c2e9cb7fa1480e274e650cd29bc04083708a5fc8580c25be7747" +} diff --git a/apps/labrinth/.sqlx/query-d446d0fe439ea6d8ea7df14419955d93660afa7b634cb7b5edb77c277d89e986.json b/apps/labrinth/.sqlx/query-d446d0fe439ea6d8ea7df14419955d93660afa7b634cb7b5edb77c277d89e986.json new file mode 100644 index 0000000000..d31c29e724 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d446d0fe439ea6d8ea7df14419955d93660afa7b634cb7b5edb77c277d89e986.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT blocked_id FROM blocked_users\n WHERE user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "blocked_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d446d0fe439ea6d8ea7df14419955d93660afa7b634cb7b5edb77c277d89e986" +} diff --git a/apps/labrinth/migrations/20260722120000_blocked-users.sql b/apps/labrinth/migrations/20260722120000_blocked-users.sql new file mode 100644 index 0000000000..26c2f77ea2 --- /dev/null +++ b/apps/labrinth/migrations/20260722120000_blocked-users.sql @@ -0,0 +1,5 @@ +CREATE TABLE blocked_users ( + user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, + blocked_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, + PRIMARY KEY (user_id, blocked_id) +); diff --git a/apps/labrinth/src/database/models/blocked_user_item.rs b/apps/labrinth/src/database/models/blocked_user_item.rs new file mode 100644 index 0000000000..b805873045 --- /dev/null +++ b/apps/labrinth/src/database/models/blocked_user_item.rs @@ -0,0 +1,94 @@ +use crate::database::models::DBUserId; + +pub struct DBBlockedUser { + pub user_id: DBUserId, + pub blocked_id: DBUserId, +} + +impl DBBlockedUser { + pub async fn insert<'a, E>(&self, exec: E) -> Result<(), sqlx::Error> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + sqlx::query!( + " + INSERT INTO blocked_users (user_id, blocked_id) + VALUES ($1, $2) + ON CONFLICT (user_id, blocked_id) DO NOTHING + ", + self.user_id.0, + self.blocked_id.0, + ) + .execute(exec) + .await?; + + Ok(()) + } + + pub async fn is_blocked<'a, E>( + user_id: DBUserId, + blocked_id: DBUserId, + exec: E, + ) -> Result + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let blocked = sqlx::query_scalar!( + " + SELECT 1 FROM blocked_users + WHERE user_id = $1 AND blocked_id = $2 + ", + user_id.0, + blocked_id.0, + ) + .fetch_optional(exec) + .await?; + + Ok(blocked.is_some()) + } + + pub async fn get_blocked_for_user<'a, E>( + user_id: DBUserId, + exec: E, + ) -> Result, sqlx::Error> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + let blocked = sqlx::query_scalar!( + " + SELECT blocked_id FROM blocked_users + WHERE user_id = $1 + ", + user_id.0, + ) + .fetch_all(exec) + .await? + .into_iter() + .map(DBUserId) + .collect(); + + Ok(blocked) + } + + pub async fn remove<'a, E>( + user_id: DBUserId, + blocked_id: DBUserId, + exec: E, + ) -> Result<(), sqlx::Error> + where + E: crate::database::Executor<'a, Database = sqlx::Postgres>, + { + sqlx::query!( + " + DELETE FROM blocked_users + WHERE user_id = $1 AND blocked_id = $2 + ", + user_id.0, + blocked_id.0, + ) + .execute(exec) + .await?; + + Ok(()) + } +} diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index af7a0a6fc1..09eddcaed7 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -2,6 +2,7 @@ use thiserror::Error; pub mod affiliate_code_item; pub mod analytics_event_item; +pub mod blocked_user_item; pub mod categories; pub mod charge_item; pub mod collection_item; diff --git a/apps/labrinth/src/routes/internal/blocked_users.rs b/apps/labrinth/src/routes/internal/blocked_users.rs new file mode 100644 index 0000000000..67b305970d --- /dev/null +++ b/apps/labrinth/src/routes/internal/blocked_users.rs @@ -0,0 +1,41 @@ +use crate::database::PgPool; +use crate::database::models::DBUserId; +use crate::database::models::blocked_user_item::DBBlockedUser; +use crate::routes::ApiError; +use crate::util::guards::admin_key_guard; +use actix_web::{get, web}; +use ariadne::ids::base62_impl::parse_base62; +use serde::Serialize; + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(block_status); +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct BlockStatus { + pub blocked: bool, +} + +/// Check whether one user has blocked another. +#[utoipa::path(tag = "blocked_users", responses((status = OK, body = BlockStatus)))] +#[get("/block/{user_id}/{target_id}", guard = "admin_key_guard")] +pub async fn block_status( + info: web::Path<(String, String)>, + pool: web::Data, +) -> Result, ApiError> { + let (user_id, target_id) = info.into_inner(); + + let user_id = + DBUserId(parse_base62(&user_id).map_err(|_| { + ApiError::InvalidInput("invalid user_id".to_string()) + })? as i64); + let target_id = + DBUserId(parse_base62(&target_id).map_err(|_| { + ApiError::InvalidInput("invalid target_id".to_string()) + })? as i64); + + let blocked = + DBBlockedUser::is_blocked(user_id, target_id, &**pool).await?; + + Ok(web::Json(BlockStatus { blocked })) +} diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 872593aaf1..272ac2de8b 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod affiliate; pub mod attribution; pub mod billing; +pub mod blocked_users; pub mod campaign; pub mod delphi; pub mod external_notifications; @@ -29,6 +30,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { web::scope("/_internal") .wrap(default_cors()) .configure(admin::config) + .configure(blocked_users::config) .configure(session::config) .configure(flows::config) .configure(pats::config) @@ -65,6 +67,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { ), paths( admin::count_download, + blocked_users::block_status, admin::force_reindex, admin::force_reindex_project, session::list, diff --git a/apps/labrinth/src/routes/v3/blocked_users.rs b/apps/labrinth/src/routes/v3/blocked_users.rs new file mode 100644 index 0000000000..4090e3ac1c --- /dev/null +++ b/apps/labrinth/src/routes/v3/blocked_users.rs @@ -0,0 +1,121 @@ +use crate::auth::get_user_from_headers; +use crate::database::PgPool; +use crate::database::models::DBUser; +use crate::database::models::blocked_user_item::DBBlockedUser; +use crate::database::models::friend_item::DBFriend; +use crate::models::pats::Scopes; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use actix_web::{HttpRequest, delete, get, post, web}; +use ariadne::ids::UserId; +use eyre::eyre; +use xredis::RedisPool; + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(block_user); + cfg.service(unblock_user); + cfg.service(get_blocked_users); +} + +/// Block a user. +#[utoipa::path(tag = "blocked_users", responses((status = NO_CONTENT)))] +#[post("/block/{id}")] +pub async fn block_user( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result<(), ApiError> { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::USER_WRITE, + ) + .await? + .1; + + let user_id = info.into_inner().0; + let Some(blocked) = DBUser::get(&user_id, &**pool, &redis).await? else { + return Err(ApiError::NotFound); + }; + + if blocked.id == user.id.into() { + return Err(ApiError::Request(eyre!("you cannot block yourself"))); + } + + let mut transaction = pool.begin().await?; + + DBFriend::remove(user.id.into(), blocked.id, &mut transaction).await?; + + DBBlockedUser { + user_id: user.id.into(), + blocked_id: blocked.id, + } + .insert(&mut transaction) + .await?; + + transaction.commit().await?; + + Ok(()) +} + +/// Unblock a user. +#[utoipa::path(tag = "blocked_users", responses((status = NO_CONTENT)))] +#[delete("/block/{id}")] +pub async fn unblock_user( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result<(), ApiError> { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::USER_WRITE, + ) + .await? + .1; + + let user_id = info.into_inner().0; + let Some(blocked) = DBUser::get(&user_id, &**pool, &redis).await? else { + return Err(ApiError::NotFound); + }; + + DBBlockedUser::remove(user.id.into(), blocked.id, &**pool).await?; + + Ok(()) +} + +/// List the users blocked by the current user. +#[utoipa::path(tag = "blocked_users", responses((status = OK, body = Vec)))] +#[get("/blocks")] +pub async fn get_blocked_users( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result>, ApiError> { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::USER_READ, + ) + .await? + .1; + + let blocked = DBBlockedUser::get_blocked_for_user(user.id.into(), &**pool) + .await? + .into_iter() + .map(UserId::from) + .collect(); + + Ok(web::Json(blocked)) +} diff --git a/apps/labrinth/src/routes/v3/friends.rs b/apps/labrinth/src/routes/v3/friends.rs index d667459580..0f02c081da 100644 --- a/apps/labrinth/src/routes/v3/friends.rs +++ b/apps/labrinth/src/routes/v3/friends.rs @@ -1,5 +1,6 @@ use crate::auth::get_user_from_headers; use crate::database::PgPool; +use crate::database::models::blocked_user_item::DBBlockedUser; use crate::database::models::friend_item::DBFriend; use crate::database::models::{DBUser, DBUserId}; use crate::models::pats::Scopes; @@ -49,6 +50,18 @@ pub async fn add_friend( return Err(ApiError::NotFound); }; + if DBBlockedUser::is_blocked(friend.id, user.id.into(), &**pool).await? { + return Err(ApiError::InvalidInput( + "You've been blocked the other user!".to_string(), + )); + } else if DBBlockedUser::is_blocked(user.id.into(), friend.id, &**pool) + .await? + { + return Err(ApiError::InvalidInput( + "You've blocked the other user!".to_string(), + )); + } + let mut transaction = pool.begin().await?; if let Some(friend) = diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index e16172a0a9..919bffee4e 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -6,6 +6,7 @@ use serde_json::json; pub mod analytics_event; pub mod analytics_get; +pub mod blocked_users; pub mod collections; pub mod content; pub mod friends; @@ -66,6 +67,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(version_file::config) .configure(versions::config) .configure(friends::config) + .configure(blocked_users::config) .configure(content::config), ); } @@ -227,6 +229,9 @@ pub fn config(cfg: &mut web::ServiceConfig) { friends::add_friend, friends::remove_friend, friends::friends, + blocked_users::block_user, + blocked_users::unblock_user, + blocked_users::get_blocked_users, content::resolve_content, ), modifiers(&V3PathModifier, &SecurityAddon)