feat(labrinth): hide orgs without a purpose, re-enable organization creation (#4426)

* chore(labrinth): set `DELPHI_URL` to a valid default in `.env.local`

* feat(labrinth): make orgs not publicly visible until they meet some conditions

* Revert "Org disabled frontend (#4424)"

This reverts commit 2492b11ec0.

* changelog: update for re-enabling organization creation

* chore: run `sqlx prepare`

* chore(labrinth): tweak tests to work with new org changes

* tweak: apply @triphora's suggestion

Co-authored-by: Emma Alexia <emma@modrinth.com>
Signed-off-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>

* tweak: document `is_visible_organization` relationship with `Project#is_searchable`

---------

Signed-off-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Co-authored-by: Emma Alexia <emma@modrinth.com>
This commit is contained in:
Alejandro González
2025-09-26 17:42:53 +02:00
committed by GitHub
parent 14af3d0763
commit bb9ce52c9d
10 changed files with 130 additions and 63 deletions

View File

@@ -1,11 +1,12 @@
use crate::database;
use crate::database::models::DBCollection;
use crate::database::models::project_item::ProjectQueryResult;
use crate::database::models::version_item::VersionQueryResult;
use crate::database::models::{DBCollection, DBOrganization, DBTeamMember};
use crate::database::redis::RedisPool;
use crate::database::{DBProject, DBVersion, models};
use crate::models::users::User;
use crate::routes::ApiError;
use futures::TryStreamExt;
use itertools::Itertools;
use sqlx::PgPool;
@@ -132,8 +133,6 @@ pub async fn filter_enlisted_projects_ids(
if let Some(user) = user_option {
let user_id: models::ids::DBUserId = user.id.into();
use futures::TryStreamExt;
sqlx::query!(
"
SELECT m.id id, m.team_id team_id FROM team_members tm
@@ -364,3 +363,36 @@ pub async fn filter_visible_collections(
Ok(return_collections)
}
pub async fn is_visible_organization(
organization: &DBOrganization,
viewing_user: &Option<User>,
pool: &PgPool,
redis: &RedisPool,
) -> Result<bool, ApiError> {
let members =
DBTeamMember::get_from_team_full(organization.team_id, pool, redis)
.await?;
// This is meant to match the same projects as the `Project::is_searchable` method, but we're not using
// it here because that'd entail pulling in all projects for the organization
let has_searchable_projects = sqlx::query_scalar!(
"SELECT TRUE FROM mods WHERE organization_id = $1 AND status IN ('public', 'archived') LIMIT 1",
organization.id as database::models::ids::DBOrganizationId
)
.fetch_optional(pool)
.await?
.flatten()
.unwrap_or(false);
let visible = has_searchable_projects
|| members.iter().filter(|member| member.accepted).count() > 1
|| viewing_user.as_ref().is_some_and(|viewing_user| {
viewing_user.role.is_mod()
|| members
.iter()
.any(|member| member.user_id == viewing_user.id.into())
});
Ok(visible)
}

View File

@@ -524,9 +524,13 @@ impl ProjectStatus {
}
// Project can be displayed in search
// IMPORTANT: if this is changed, make sure to update the `mods_searchable_ids_gist`
// index in the DB to keep random project queries fast (see the
// `20250609134334_spatial-random-project-index.sql` migration)
// IMPORTANT: if this is changed, make sure to:
// - update the `mods_searchable_ids_gist`
// index in the DB to keep random project queries fast (see the
// `20250609134334_spatial-random-project-index.sql` migration).
// - update the `is_visible_organization` function in
// `apps/labrinth/src/auth/checks.rs`, which duplicates this logic
// in a SQL query for efficiency.
pub fn is_searchable(&self) -> bool {
matches!(self, ProjectStatus::Approved | ProjectStatus::Archived)
}

View File

@@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use super::ApiError;
use crate::auth::checks::is_visible_organization;
use crate::auth::{filter_visible_projects, get_user_from_headers};
use crate::database::models::team_item::DBTeamMember;
use crate::database::models::{
@@ -70,7 +71,10 @@ pub async fn organization_projects_get(
.ok();
let organization_data = DBOrganization::get(&id, &**pool, &redis).await?;
if let Some(organization) = organization_data {
if let Some(organization) = organization_data
&& is_visible_organization(&organization, &current_user, &pool, &redis)
.await?
{
let project_ids = sqlx::query!(
"
SELECT m.id FROM organizations o
@@ -232,7 +236,9 @@ pub async fn organization_get(
let user_id = current_user.as_ref().map(|x| x.id.into());
let organization_data = DBOrganization::get(&id, &**pool, &redis).await?;
if let Some(data) = organization_data {
if let Some(data) = organization_data
&& is_visible_organization(&data, &current_user, &pool, &redis).await?
{
let members_data =
DBTeamMember::get_from_team_full(data.team_id, &**pool, &redis)
.await?;
@@ -328,6 +334,11 @@ pub async fn organizations_get(
}
for data in organizations_data {
if !is_visible_organization(&data, &current_user, &pool, &redis).await?
{
continue;
}
let members_data = team_groups.remove(&data.team_id).unwrap_or(vec![]);
let logged_in = current_user
.as_ref()

View File

@@ -1,4 +1,4 @@
use crate::auth::checks::is_visible_project;
use crate::auth::checks::{is_visible_organization, is_visible_project};
use crate::auth::get_user_from_headers;
use crate::database::DBProject;
use crate::database::models::notification_item::NotificationBuilder;
@@ -134,18 +134,21 @@ pub async fn team_members_get_organization(
crate::database::models::DBOrganization::get(&string, &**pool, &redis)
.await?;
if let Some(organization) = organization_data {
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::ORGANIZATION_READ,
)
.await
.map(|x| x.1)
.ok();
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::ORGANIZATION_READ,
)
.await
.map(|x| x.1)
.ok();
if let Some(organization) = organization_data
&& is_visible_organization(&organization, &current_user, &pool, &redis)
.await?
{
let members_data = DBTeamMember::get_from_team_full(
organization.team_id,
&**pool,