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,14 +1,7 @@
<template> <template>
<NewModal ref="modal" header="Creating an organization"> <NewModal ref="modal" header="Creating an organization">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<Admonition <div class="flex flex-col gap-2">
v-if="disabled"
class="max-w-[30rem]"
:type="'info'"
header="Organization creation temporarily disabled"
body="Due to abuse, we've temporarily disabled the creation of new organizations. We're working hard to restore this feature, thank you for your patience."
/>
<div class="flex flex-col gap-2" :class="{ 'opacity-50': disabled }">
<label for="name"> <label for="name">
<span class="text-lg font-semibold text-contrast"> <span class="text-lg font-semibold text-contrast">
Name Name
@@ -22,11 +15,10 @@
maxlength="64" maxlength="64"
:placeholder="`Enter organization name...`" :placeholder="`Enter organization name...`"
autocomplete="off" autocomplete="off"
:disabled="disabled"
@input="updateSlug" @input="updateSlug"
/> />
</div> </div>
<div class="flex flex-col gap-2" :class="{ 'opacity-50': disabled }"> <div class="flex flex-col gap-2">
<label for="slug"> <label for="slug">
<span class="text-lg font-semibold text-contrast"> <span class="text-lg font-semibold text-contrast">
URL URL
@@ -41,12 +33,11 @@
type="text" type="text"
maxlength="64" maxlength="64"
autocomplete="off" autocomplete="off"
:disabled="disabled"
@input="setManualSlug" @input="setManualSlug"
/> />
</div> </div>
</div> </div>
<div class="flex flex-col gap-2" :class="{ 'opacity-50': disabled }"> <div class="flex flex-col gap-2">
<label for="additional-information" class="flex flex-col gap-1"> <label for="additional-information" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> <span class="text-lg font-semibold text-contrast">
Summary Summary
@@ -55,21 +46,16 @@
<span>A sentence or two that describes your organization.</span> <span>A sentence or two that describes your organization.</span>
</label> </label>
<div class="textarea-wrapper"> <div class="textarea-wrapper">
<textarea <textarea id="additional-information" v-model="description" maxlength="256" />
id="additional-information"
v-model="description"
:disabled="disabled"
maxlength="256"
/>
</div> </div>
</div> </div>
<p class="m-0 max-w-[30rem]" :class="{ 'opacity-50': disabled }"> <p class="m-0 max-w-[30rem]">
You will be the owner of this organization, but you can invite other members and transfer You will be the owner of this organization, but you can invite other members and transfer
ownership at any time. ownership at any time.
</p> </p>
<div class="flex gap-2"> <div class="flex gap-2">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button :disabled="disabled" @click="createOrganization"> <button @click="createOrganization">
<PlusIcon aria-hidden="true" /> <PlusIcon aria-hidden="true" />
Create organization Create organization
</button> </button>
@@ -87,7 +73,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { PlusIcon, XIcon } from '@modrinth/assets' import { PlusIcon, XIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui' import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
import { ref } from 'vue' import { ref } from 'vue'
const router = useNativeRouter() const router = useNativeRouter()
@@ -99,8 +85,6 @@ const description = ref<string>('')
const manualSlug = ref<boolean>(false) const manualSlug = ref<boolean>(false)
const modal = ref<InstanceType<typeof NewModal>>() const modal = ref<InstanceType<typeof NewModal>>()
const disabled = ref(true)
async function createOrganization(): Promise<void> { async function createOrganization(): Promise<void> {
startLoading() startLoading()
try { try {

View File

@@ -129,7 +129,7 @@ PYRO_API_KEY=none
BREX_API_URL=https://platform.brexapis.com/v2/ BREX_API_URL=https://platform.brexapis.com/v2/
BREX_API_KEY=none BREX_API_KEY=none
DELPHI_URL=none DELPHI_URL=http://localhost:59999
DELPHI_SLACK_WEBHOOK=none DELPHI_SLACK_WEBHOOK=none
AVALARA_1099_API_URL=https://www.track1099.com/api AVALARA_1099_API_URL=https://www.track1099.com/api

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT TRUE FROM mods WHERE organization_id = $1 AND status IN ('public', 'archived') LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "eb792d5033d7079fe3555593d8731f8853235275e4d5614636b5db524a4920d5"
}

View File

@@ -1,11 +1,12 @@
use crate::database; use crate::database;
use crate::database::models::DBCollection;
use crate::database::models::project_item::ProjectQueryResult; use crate::database::models::project_item::ProjectQueryResult;
use crate::database::models::version_item::VersionQueryResult; use crate::database::models::version_item::VersionQueryResult;
use crate::database::models::{DBCollection, DBOrganization, DBTeamMember};
use crate::database::redis::RedisPool; use crate::database::redis::RedisPool;
use crate::database::{DBProject, DBVersion, models}; use crate::database::{DBProject, DBVersion, models};
use crate::models::users::User; use crate::models::users::User;
use crate::routes::ApiError; use crate::routes::ApiError;
use futures::TryStreamExt;
use itertools::Itertools; use itertools::Itertools;
use sqlx::PgPool; use sqlx::PgPool;
@@ -132,8 +133,6 @@ pub async fn filter_enlisted_projects_ids(
if let Some(user) = user_option { if let Some(user) = user_option {
let user_id: models::ids::DBUserId = user.id.into(); let user_id: models::ids::DBUserId = user.id.into();
use futures::TryStreamExt;
sqlx::query!( sqlx::query!(
" "
SELECT m.id id, m.team_id team_id FROM team_members tm 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) 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 // Project can be displayed in search
// IMPORTANT: if this is changed, make sure to update the `mods_searchable_ids_gist` // IMPORTANT: if this is changed, make sure to:
// index in the DB to keep random project queries fast (see the // - update the `mods_searchable_ids_gist`
// `20250609134334_spatial-random-project-index.sql` migration) // 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 { pub fn is_searchable(&self) -> bool {
matches!(self, ProjectStatus::Approved | ProjectStatus::Archived) matches!(self, ProjectStatus::Approved | ProjectStatus::Archived)
} }

View File

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

View File

@@ -22,6 +22,7 @@ use common::environment::{
use common::{database::*, scopes::ScopeTest}; use common::{database::*, scopes::ScopeTest};
use labrinth::models::ids::ProjectId; use labrinth::models::ids::ProjectId;
use labrinth::models::pats::Scopes; use labrinth::models::pats::Scopes;
use labrinth::models::teams::ProjectPermissions;
use serde_json::json; use serde_json::json;
// For each scope, we (using test_scope): // For each scope, we (using test_scope):
// - create a PAT with a given set of scopes for a function // - create a PAT with a given set of scopes for a function
@@ -1093,6 +1094,7 @@ pub async fn organization_scopes() {
.await .await
.unwrap(); .unwrap();
let organization_id = success["id"].as_str().unwrap(); let organization_id = success["id"].as_str().unwrap();
let organization_team_id = success["team_id"].as_str().unwrap();
// Patch organization // Patch organization
let organization_edit = Scopes::ORGANIZATION_WRITE; let organization_edit = Scopes::ORGANIZATION_WRITE;
@@ -1154,6 +1156,27 @@ pub async fn organization_scopes() {
.await .await
.unwrap(); .unwrap();
// Add two members to the organization
api.add_user_to_team(
organization_team_id,
FRIEND_USER_ID,
Some(ProjectPermissions::all()),
None,
USER_USER_PAT,
)
.await;
api.join_team(organization_team_id, FRIEND_USER_PAT).await;
api.add_user_to_team(
organization_team_id,
ENEMY_USER_ID,
Some(ProjectPermissions::all()),
None,
USER_USER_PAT,
)
.await;
api.join_team(organization_team_id, ENEMY_USER_PAT).await;
// Organization reads // Organization reads
let organization_read = Scopes::ORGANIZATION_READ; let organization_read = Scopes::ORGANIZATION_READ;
let req_gen = |pat: Option<String>| async move { let req_gen = |pat: Option<String>| async move {

View File

@@ -176,7 +176,7 @@ async fn test_get_team_organization() {
&test_env.dummy.organization_zeta.organization_id; &test_env.dummy.organization_zeta.organization_id;
let zeta_team_id = &test_env.dummy.organization_zeta.team_id; let zeta_team_id = &test_env.dummy.organization_zeta.team_id;
// A non-member of the team should get basic info but not be able to see private data // A non-member of the team should get basic team info
let members = api let members = api
.get_team_members_deserialized_common( .get_team_members_deserialized_common(
zeta_team_id, zeta_team_id,
@@ -187,15 +187,6 @@ async fn test_get_team_organization() {
assert_eq!(members[0].user.id.0, USER_USER_ID_PARSED as u64); assert_eq!(members[0].user.id.0, USER_USER_ID_PARSED as u64);
assert!(members[0].permissions.is_none()); assert!(members[0].permissions.is_none());
let members = api
.get_organization_members_deserialized_common(
zeta_organization_id,
FRIEND_USER_PAT,
)
.await;
assert_eq!(members.len(), 1);
assert_eq!(members[0].user.id.0, USER_USER_ID_PARSED as u64);
// A non-accepted member of the team should: // A non-accepted member of the team should:
// - not be able to see private data about the team, but see all members including themselves // - not be able to see private data about the team, but see all members including themselves
// - should not appear in the team members list to enemy users // - should not appear in the team members list to enemy users
@@ -262,15 +253,6 @@ async fn test_get_team_organization() {
.await; .await;
assert_eq!(members.len(), 1); // Only USER_USER_ID should be in the team assert_eq!(members.len(), 1); // Only USER_USER_ID should be in the team
// enemy team check via association
let members = api
.get_organization_members_deserialized_common(
zeta_organization_id,
ENEMY_USER_PAT,
)
.await;
assert_eq!(members.len(), 1); // Only USER_USER_ID should be in the team
// An accepted member of the team should appear in the team members list // An accepted member of the team should appear in the team members list
// and should be able to see private data about the team // and should be able to see private data about the team
let resp = api.join_team(zeta_team_id, FRIEND_USER_PAT).await; let resp = api.join_team(zeta_team_id, FRIEND_USER_PAT).await;

View File

@@ -10,6 +10,12 @@ export type VersionEntry = {
} }
const VERSIONS: VersionEntry[] = [ const VERSIONS: VersionEntry[] = [
{
date: `2025-09-26T13:00:00+02:00`,
product: 'web',
body: `### Improvements
- Re-enabled the creation of Organizations.`,
},
{ {
date: `2025-09-25T19:15:00-07:00`, date: `2025-09-25T19:15:00-07:00`,
product: 'web', product: 'web',