Files
AstralRinth/apps/labrinth/src/routes/v3/users.rs
Emma Alexia b8982a6d17 Hopefully fix collection visibility once and for all (#4070)
* Hopefully fix collection visibility once and for all

Follow up to #3408 and #3864

* Use same unlisted approach for collections as is used for projects
2025-07-27 18:23:49 +00:00

796 lines
22 KiB
Rust

use std::{collections::HashMap, sync::Arc};
use super::{ApiError, oauth_clients::get_user_clients};
use crate::{
auth::{
filter_visible_collections, filter_visible_projects,
get_user_from_headers,
},
database::{models::DBUser, redis::RedisPool},
file_hosting::{FileHost, FileHostPublicity},
models::{
notifications::Notification,
pats::Scopes,
projects::Project,
users::{Badges, Role},
},
queue::session::AuthQueue,
util::{
img::delete_old_images, routes::read_limited_from_payload,
validate::validation_errors_to_string,
},
};
use actix_web::{HttpRequest, HttpResponse, web};
use ariadne::ids::UserId;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("user", web::get().to(user_auth_get));
cfg.route("users", web::get().to(users_get));
cfg.route("user_email", web::get().to(admin_user_email));
cfg.service(
web::scope("user")
.route("{user_id}/projects", web::get().to(projects_list))
.route("{id}", web::get().to(user_get))
.route("{user_id}/collections", web::get().to(collections_list))
.route("{user_id}/organizations", web::get().to(orgs_list))
.route("{id}", web::patch().to(user_edit))
.route("{id}/icon", web::patch().to(user_icon_edit))
.route("{id}/icon", web::delete().to(user_icon_delete))
.route("{id}", web::delete().to(user_delete))
.route("{id}/follows", web::get().to(user_follows))
.route("{id}/notifications", web::get().to(user_notifications))
.route("{id}/oauth_apps", web::get().to(get_user_clients)),
);
}
#[derive(Deserialize)]
pub struct UserEmailQuery {
pub email: String,
}
pub async fn admin_user_email(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
email: web::Query<UserEmailQuery>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::SESSION_ACCESS,
)
.await
.map(|x| x.1)?;
if !user.role.is_admin() {
return Err(ApiError::CustomAuthentication(
"You do not have permission to get a user from their email!"
.to_string(),
));
}
let user_id = sqlx::query!(
"
SELECT id FROM users
WHERE LOWER(email) = LOWER($1)
",
email.email
)
.fetch_optional(&**pool)
.await?
.map(|x| x.id)
.ok_or_else(|| {
ApiError::InvalidInput(
"The email provided is not associated with a user!".to_string(),
)
})?;
let user = DBUser::get_id(
crate::database::models::DBUserId(user_id),
&**pool,
&redis,
)
.await?;
if let Some(user) = user {
Ok(HttpResponse::Ok().json(user))
} else {
Err(ApiError::NotFound)
}
}
pub async fn projects_list(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
.ok();
let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
let project_data = DBUser::get_projects(id, &**pool, &redis).await?;
let projects: Vec<_> = crate::database::DBProject::get_many_ids(
&project_data,
&**pool,
&redis,
)
.await?;
let projects =
filter_visible_projects(projects, &user, &pool, true).await?;
Ok(HttpResponse::Ok().json(projects))
} else {
Err(ApiError::NotFound)
}
}
pub async fn user_auth_get(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let (scopes, mut user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::USER_READ,
)
.await?;
if !scopes.contains(Scopes::USER_READ_EMAIL) {
user.email = None;
}
if !scopes.contains(Scopes::PAYOUTS_READ) {
user.payout_data = None;
}
Ok(HttpResponse::Ok().json(user))
}
#[derive(Serialize, Deserialize)]
pub struct UserIds {
pub ids: String,
}
pub async fn users_get(
web::Query(ids): web::Query<UserIds>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user_ids = serde_json::from_str::<Vec<String>>(&ids.ids)?;
let users_data = DBUser::get_many(&user_ids, &**pool, &redis).await?;
let users: Vec<crate::models::users::User> =
users_data.into_iter().map(From::from).collect();
Ok(HttpResponse::Ok().json(users))
}
pub async fn user_get(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_data = DBUser::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(data) = user_data {
let auth_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::SESSION_ACCESS,
)
.await
.map(|x| x.1)
.ok();
let response: crate::models::users::User =
if auth_user.is_some_and(|x| x.role.is_admin()) {
crate::models::users::User::from_full(data)
} else {
data.into()
};
Ok(HttpResponse::Ok().json(response))
} else {
Err(ApiError::NotFound)
}
}
pub async fn collections_list(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::COLLECTION_READ,
)
.await
.map(|x| x.1)
.ok();
let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
let collection_data = DBUser::get_collections(id, &**pool).await?;
let response: Vec<_> = crate::database::models::DBCollection::get_many(
&collection_data,
&**pool,
&redis,
)
.await?;
let collections =
filter_visible_collections(response, &user, true).await?;
Ok(HttpResponse::Ok().json(collections))
} else {
Err(ApiError::NotFound)
}
}
pub async fn orgs_list(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await
.map(|x| x.1)
.ok();
let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
let org_data = DBUser::get_organizations(id, &**pool).await?;
let organizations_data =
crate::database::models::organization_item::DBOrganization::get_many_ids(
&org_data, &**pool, &redis,
)
.await?;
let team_ids = organizations_data
.iter()
.map(|x| x.team_id)
.collect::<Vec<_>>();
let teams_data =
crate::database::models::DBTeamMember::get_from_team_full_many(
&team_ids, &**pool, &redis,
)
.await?;
let users = DBUser::get_many_ids(
&teams_data.iter().map(|x| x.user_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let mut organizations = vec![];
let mut team_groups = HashMap::new();
for item in teams_data {
team_groups.entry(item.team_id).or_insert(vec![]).push(item);
}
for data in organizations_data {
let members_data =
team_groups.remove(&data.team_id).unwrap_or(vec![]);
let logged_in = user
.as_ref()
.and_then(|user| {
members_data
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members: Vec<_> = members_data
.into_iter()
.filter(|x| logged_in || x.accepted || id == x.user_id)
.filter_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
crate::models::teams::TeamMember::from(
data,
user.clone(),
!logged_in,
)
})
})
.collect();
let organization = crate::models::organizations::Organization::from(
data,
team_members,
);
organizations.push(organization);
}
Ok(HttpResponse::Ok().json(organizations))
} else {
Err(ApiError::NotFound)
}
}
#[derive(Serialize, Deserialize, Validate)]
pub struct EditUser {
#[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_USERNAME))]
pub username: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
#[validate(length(max = 160))]
pub bio: Option<Option<String>>,
pub role: Option<Role>,
pub badges: Option<Badges>,
#[validate(length(max = 160))]
pub venmo_handle: Option<String>,
pub allow_friend_requests: Option<bool>,
}
pub async fn user_edit(
req: HttpRequest,
info: web::Path<(String,)>,
new_user: web::Json<EditUser>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let (scopes, user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::USER_WRITE,
)
.await?;
new_user.validate().map_err(|err| {
ApiError::Validation(validation_errors_to_string(err, None))
})?;
let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(actual_user) = id_option {
let id = actual_user.id;
let user_id: UserId = id.into();
if user.id == user_id || user.role.is_mod() {
let mut transaction = pool.begin().await?;
if let Some(username) = &new_user.username {
let existing_user_id_option =
DBUser::get(username, &**pool, &redis).await?;
if existing_user_id_option
.map(|x| UserId::from(x.id))
.is_none_or(|id| id == user.id)
{
sqlx::query!(
"
UPDATE users
SET username = $1
WHERE (id = $2)
",
username,
id as crate::database::models::ids::DBUserId,
)
.execute(&mut *transaction)
.await?;
} else {
return Err(ApiError::InvalidInput(format!(
"Username {username} is taken!"
)));
}
}
if let Some(bio) = &new_user.bio {
sqlx::query!(
"
UPDATE users
SET bio = $1
WHERE (id = $2)
",
bio.as_deref(),
id as crate::database::models::ids::DBUserId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(role) = &new_user.role {
if !user.role.is_admin() {
return Err(ApiError::CustomAuthentication(
"You do not have the permissions to edit the role of this user!"
.to_string(),
));
}
let role = role.to_string();
sqlx::query!(
"
UPDATE users
SET role = $1
WHERE (id = $2)
",
role,
id as crate::database::models::ids::DBUserId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(badges) = &new_user.badges {
if !user.role.is_admin() {
return Err(ApiError::CustomAuthentication(
"You do not have the permissions to edit the badges of this user!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE users
SET badges = $1
WHERE (id = $2)
",
badges.bits() as i64,
id as crate::database::models::ids::DBUserId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(venmo_handle) = &new_user.venmo_handle {
if !scopes.contains(Scopes::PAYOUTS_WRITE) {
return Err(ApiError::CustomAuthentication(
"You do not have the permissions to edit the venmo handle of this user!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE users
SET venmo_handle = $1
WHERE (id = $2)
",
venmo_handle,
id as crate::database::models::ids::DBUserId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(allow_friend_requests) = &user.allow_friend_requests {
sqlx::query!(
"
UPDATE users
SET allow_friend_requests = $1
WHERE (id = $2)
",
allow_friend_requests,
id as crate::database::models::ids::DBUserId,
)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
DBUser::clear_caches(&[(id, Some(actual_user.username))], &redis)
.await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::CustomAuthentication(
"You do not have permission to edit this user!".to_string(),
))
}
} else {
Err(ApiError::NotFound)
}
}
#[derive(Serialize, Deserialize)]
pub struct Extension {
pub ext: String,
}
#[allow(clippy::too_many_arguments)]
pub async fn user_icon_edit(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
mut payload: web::Payload,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::USER_WRITE,
)
.await?
.1;
let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(actual_user) = id_option {
if user.id != actual_user.id.into() && !user.role.is_mod() {
return Err(ApiError::CustomAuthentication(
"You don't have permission to edit this user's icon."
.to_string(),
));
}
delete_old_images(
actual_user.avatar_url,
actual_user.raw_avatar_url,
FileHostPublicity::Public,
&***file_host,
)
.await?;
let bytes = read_limited_from_payload(
&mut payload,
262144,
"Icons must be smaller than 256KiB",
)
.await?;
let user_id: UserId = actual_user.id.into();
let upload_result = crate::util::img::upload_image_optimized(
&format!("data/{user_id}"),
FileHostPublicity::Public,
bytes.freeze(),
&ext.ext,
Some(96),
Some(1.0),
&***file_host,
)
.await?;
sqlx::query!(
"
UPDATE users
SET avatar_url = $1, raw_avatar_url = $2
WHERE (id = $3)
",
upload_result.url,
upload_result.raw_url,
actual_user.id as crate::database::models::ids::DBUserId,
)
.execute(&**pool)
.await?;
DBUser::clear_caches(&[(actual_user.id, None)], &redis).await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::NotFound)
}
}
pub async fn user_icon_delete(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::USER_WRITE,
)
.await?
.1;
let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(actual_user) = id_option {
if user.id != actual_user.id.into() && !user.role.is_mod() {
return Err(ApiError::CustomAuthentication(
"You don't have permission to edit this user's icon."
.to_string(),
));
}
delete_old_images(
actual_user.avatar_url,
actual_user.raw_avatar_url,
FileHostPublicity::Public,
&***file_host,
)
.await?;
sqlx::query!(
"
UPDATE users
SET avatar_url = NULL, raw_avatar_url = NULL
WHERE (id = $1)
",
actual_user.id as crate::database::models::ids::DBUserId,
)
.execute(&**pool)
.await?;
DBUser::clear_caches(&[(actual_user.id, None)], &redis).await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::NotFound)
}
}
pub async fn user_delete(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::USER_DELETE,
)
.await?
.1;
let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
if !user.role.is_admin() && user.id != id.into() {
return Err(ApiError::CustomAuthentication(
"You do not have permission to delete this user!".to_string(),
));
}
let mut transaction = pool.begin().await?;
let result = DBUser::remove(id, &mut transaction, &redis).await?;
transaction.commit().await?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::NotFound)
}
} else {
Err(ApiError::NotFound)
}
}
pub async fn user_follows(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::USER_READ,
)
.await?
.1;
let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
if !user.role.is_admin() && user.id != id.into() {
return Err(ApiError::CustomAuthentication(
"You do not have permission to see the projects this user follows!".to_string(),
));
}
let project_ids = DBUser::get_follows(id, &**pool).await?;
let projects: Vec<_> = crate::database::DBProject::get_many_ids(
&project_ids,
&**pool,
&redis,
)
.await?
.into_iter()
.map(Project::from)
.collect();
Ok(HttpResponse::Ok().json(projects))
} else {
Err(ApiError::NotFound)
}
}
pub async fn user_notifications(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::NOTIFICATION_READ,
)
.await?
.1;
let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
if !user.role.is_admin() && user.id != id.into() {
return Err(ApiError::CustomAuthentication(
"You do not have permission to see the notifications of this user!".to_string(),
));
}
let mut notifications: Vec<Notification> =
crate::database::models::notification_item::DBNotification::get_many_user(
id, &**pool, &redis,
)
.await?
.into_iter()
.map(Into::into)
.collect();
notifications.sort_by(|a, b| b.created.cmp(&a.created));
Ok(HttpResponse::Ok().json(notifications))
} else {
Err(ApiError::NotFound)
}
}