feat(labrinth): user blocking (#6837)

* feat(labrinth): user blocking

still wip, nothing is hooked up

* style(labrinth): cargo fmt

* refactor(labrinth): move block/unblock routes to public api

* feat(labrinth): block friend requests if blocked

* feat(labrinth): remove friend/request on block

* feat(labrinth): get blocked users endpoint
This commit is contained in:
Sychic
2026-07-27 19:41:57 +00:00
committed by GitHub
parent 224c28f29d
commit ebc7cac8b0
12 changed files with 358 additions and 0 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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)
);
@@ -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<bool, sqlx::Error>
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<Vec<DBUserId>, 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(())
}
}
+1
View File
@@ -2,6 +2,7 @@ use thiserror::Error;
pub mod affiliate_code_item; pub mod affiliate_code_item;
pub mod analytics_event_item; pub mod analytics_event_item;
pub mod blocked_user_item;
pub mod categories; pub mod categories;
pub mod charge_item; pub mod charge_item;
pub mod collection_item; pub mod collection_item;
@@ -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<PgPool>,
) -> Result<web::Json<BlockStatus>, 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 }))
}
+3
View File
@@ -2,6 +2,7 @@ pub mod admin;
pub mod affiliate; pub mod affiliate;
pub mod attribution; pub mod attribution;
pub mod billing; pub mod billing;
pub mod blocked_users;
pub mod campaign; pub mod campaign;
pub mod delphi; pub mod delphi;
pub mod external_notifications; pub mod external_notifications;
@@ -29,6 +30,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
web::scope("/_internal") web::scope("/_internal")
.wrap(default_cors()) .wrap(default_cors())
.configure(admin::config) .configure(admin::config)
.configure(blocked_users::config)
.configure(session::config) .configure(session::config)
.configure(flows::config) .configure(flows::config)
.configure(pats::config) .configure(pats::config)
@@ -65,6 +67,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
), ),
paths( paths(
admin::count_download, admin::count_download,
blocked_users::block_status,
admin::force_reindex, admin::force_reindex,
admin::force_reindex_project, admin::force_reindex_project,
session::list, session::list,
@@ -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<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> 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<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> 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<UserId>)))]
#[get("/blocks")]
pub async fn get_blocked_users(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<Vec<UserId>>, 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))
}
+13
View File
@@ -1,5 +1,6 @@
use crate::auth::get_user_from_headers; use crate::auth::get_user_from_headers;
use crate::database::PgPool; use crate::database::PgPool;
use crate::database::models::blocked_user_item::DBBlockedUser;
use crate::database::models::friend_item::DBFriend; use crate::database::models::friend_item::DBFriend;
use crate::database::models::{DBUser, DBUserId}; use crate::database::models::{DBUser, DBUserId};
use crate::models::pats::Scopes; use crate::models::pats::Scopes;
@@ -49,6 +50,18 @@ pub async fn add_friend(
return Err(ApiError::NotFound); 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?; let mut transaction = pool.begin().await?;
if let Some(friend) = if let Some(friend) =
+5
View File
@@ -6,6 +6,7 @@ use serde_json::json;
pub mod analytics_event; pub mod analytics_event;
pub mod analytics_get; pub mod analytics_get;
pub mod blocked_users;
pub mod collections; pub mod collections;
pub mod content; pub mod content;
pub mod friends; pub mod friends;
@@ -66,6 +67,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(version_file::config) .configure(version_file::config)
.configure(versions::config) .configure(versions::config)
.configure(friends::config) .configure(friends::config)
.configure(blocked_users::config)
.configure(content::config), .configure(content::config),
); );
} }
@@ -227,6 +229,9 @@ pub fn config(cfg: &mut web::ServiceConfig) {
friends::add_friend, friends::add_friend,
friends::remove_friend, friends::remove_friend,
friends::friends, friends::friends,
blocked_users::block_user,
blocked_users::unblock_user,
blocked_users::get_blocked_users,
content::resolve_content, content::resolve_content,
), ),
modifiers(&V3PathModifier, &SecurityAddon) modifiers(&V3PathModifier, &SecurityAddon)