Hard caps on creating projects/orgs/collections (#4430)

* implement backend limits on project creation

* implement collection, org creation hard caps

* Fix limit api

* Fix clippy

* Fix limits

* Update sqlx queries

* Address PR comments on user limit structure

* sqlx prepare and clippy

* fix test maybe
This commit is contained in:
aecsocket
2025-09-28 11:01:00 +01:00
committed by GitHub
parent 3f55711f9e
commit f466470d06
503 changed files with 14260 additions and 11 deletions

View File

@@ -0,0 +1,9 @@
CREATE TABLE user_limits (
-- if NULL, this is a global default
user_id BIGINT REFERENCES users(id),
projects INTEGER NOT NULL,
organizations INTEGER NOT NULL,
collections INTEGER NOT NULL
);
INSERT INTO user_limits (user_id, projects, organizations, collections)
VALUES (NULL, 256, 16, 32);

View File

@@ -30,6 +30,7 @@ pub mod shared_instance_item;
pub mod team_item;
pub mod thread_item;
pub mod user_item;
pub mod user_limits;
pub mod user_subscription_item;
pub mod users_compliance;
pub mod users_notifications_preferences_item;

View File

@@ -0,0 +1,125 @@
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use crate::database::models::DBUserId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DBUserLimits {
pub user_id: Option<DBUserId>,
pub projects: u64,
pub organizations: u64,
pub collections: u64,
}
impl DBUserLimits {
pub async fn upsert(&self, pool: &PgPool) -> Result<(), sqlx::Error> {
sqlx::query!(
"INSERT INTO user_limits (user_id, projects, organizations, collections)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE
SET projects = EXCLUDED.projects,
organizations = EXCLUDED.organizations,
collections = EXCLUDED.collections",
self.user_id.map(|id| id.0),
self.projects as i64,
self.organizations as i64,
self.collections as i64
)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_defaults(pool: &PgPool) -> Result<Self, sqlx::Error> {
let row = sqlx::query!(
"SELECT projects, organizations, collections
FROM user_limits
WHERE user_id IS NULL"
)
.fetch_one(pool)
.await?;
Ok(Self {
user_id: None,
projects: row.projects as u64,
organizations: row.organizations as u64,
collections: row.collections as u64,
})
}
pub async fn get(
user_id: DBUserId,
pool: &PgPool,
) -> Result<Self, sqlx::Error> {
let row = sqlx::query!(
"SELECT projects, organizations, collections
FROM user_limits
WHERE user_id = $1",
user_id as DBUserId
)
.fetch_optional(pool)
.await?;
if let Some(row) = row {
Ok(Self {
user_id: Some(user_id),
projects: row.projects as u64,
organizations: row.organizations as u64,
collections: row.collections as u64,
})
} else {
Self::get_defaults(pool).await
}
}
}
// impl UserLimits {
// pub async fn get(user: &User, pool: &PgPool) -> Result<Self, sqlx::Error> {
// let current = sqlx::query!(
// "SELECT
// (SELECT COUNT(*) FROM mods m
// JOIN teams t ON m.team_id = t.id
// JOIN team_members tm ON t.id = tm.team_id
// WHERE tm.user_id = $1) as projects,
// (SELECT COUNT(*) FROM organizations o
// JOIN teams t ON o.team_id = t.id
// JOIN team_members tm ON t.id = tm.team_id
// WHERE tm.user_id = $1) as organizations,
// (SELECT COUNT(*) FROM collections
// WHERE user_id = $1) as collections",
// DBUserId::from(user.id) as DBUserId,
// )
// .fetch_one(pool)
// .await?;
// let current = UserLimitCount {
// projects: current.projects.map_or(0, |x| x as u64),
// organizations: current.organizations.map_or(0, |x| x as u64),
// collections: current.collections.map_or(0, |x| x as u64),
// };
// if user.role.is_admin() {
// Ok(Self {
// current,
// max: UserLimitCount {
// projects: u64::MAX,
// organizations: u64::MAX,
// collections: u64::MAX,
// },
// })
// } else {
// // TODO: global config for max
// Ok(Self {
// current,
// max: UserLimitCount {
// projects: user.max_projects.unwrap_or(256),
// organizations: user.max_organizations.unwrap_or(16),
// collections: user.max_collections.unwrap_or(64),
// },
// })
// }
// }
// }

View File

@@ -16,4 +16,5 @@ pub mod sessions;
pub mod shared_instances;
pub mod teams;
pub mod threads;
pub mod user_limits;
pub mod users;

View File

@@ -0,0 +1,99 @@
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use crate::{
database::models::{DBUserId, user_limits::DBUserLimits},
models::users::User,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserLimits {
pub current: u64,
pub max: u64,
}
impl UserLimits {
fn adjust_for_user(self, user: &User) -> Self {
if user.role.is_admin() {
Self {
current: self.current,
max: u64::MAX,
}
} else {
self
}
}
pub async fn get_for_projects(
user: &User,
pool: &PgPool,
) -> Result<Self, sqlx::Error> {
let user_id = DBUserId::from(user.id);
let db_limits =
DBUserLimits::get(DBUserId::from(user.id), pool).await?;
let current = sqlx::query_scalar!(
"SELECT COUNT(*) FROM mods m
JOIN teams t ON m.team_id = t.id
JOIN team_members tm ON t.id = tm.team_id
WHERE tm.user_id = $1",
user_id as DBUserId,
)
.fetch_one(pool)
.await?
.map_or(0, |x| x as u64);
Ok(Self {
current,
max: db_limits.projects,
}
.adjust_for_user(user))
}
pub async fn get_for_organizations(
user: &User,
pool: &PgPool,
) -> Result<Self, sqlx::Error> {
let user_id = DBUserId::from(user.id);
let db_limits =
DBUserLimits::get(DBUserId::from(user.id), pool).await?;
let current = sqlx::query_scalar!(
"SELECT COUNT(*) FROM organizations o
JOIN teams t ON o.team_id = t.id
JOIN team_members tm ON t.id = tm.team_id
WHERE tm.user_id = $1",
user_id as DBUserId,
)
.fetch_one(pool)
.await?
.map_or(0, |x| x as u64);
Ok(Self {
current,
max: db_limits.organizations,
}
.adjust_for_user(user))
}
pub async fn get_for_collections(
user: &User,
pool: &PgPool,
) -> Result<Self, sqlx::Error> {
let user_id = DBUserId::from(user.id);
let db_limits =
DBUserLimits::get(DBUserId::from(user.id), pool).await?;
let current = sqlx::query_scalar!(
"SELECT COUNT(*) FROM collections
WHERE user_id = $1",
user_id as DBUserId,
)
.fetch_one(pool)
.await?
.map_or(0, |x| x as u64);
Ok(Self {
current,
max: db_limits.collections,
}
.adjust_for_user(user))
}
}

View File

@@ -8,6 +8,7 @@ use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::collections::{Collection, CollectionStatus};
use crate::models::ids::{CollectionId, ProjectId};
use crate::models::pats::Scopes;
use crate::models::v3::user_limits::UserLimits;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::routes::v3::project_creation::CreateError;
@@ -76,6 +77,12 @@ pub async fn collection_create(
.await?
.1;
let limits =
UserLimits::get_for_collections(&current_user, &client).await?;
if limits.current >= limits.max {
return Err(CreateError::LimitReached);
}
collection_create_data.validate().map_err(|err| {
CreateError::InvalidInput(validation_errors_to_string(err, None))
})?;

View File

@@ -0,0 +1,75 @@
use crate::{
auth::get_user_from_headers,
database::redis::RedisPool,
models::{pats::Scopes, v3::user_limits::UserLimits},
queue::session::AuthQueue,
routes::ApiError,
};
use actix_web::{HttpRequest, web};
use sqlx::PgPool;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("limits")
.route("projects", web::get().to(get_project_limits))
.route("organizations", web::get().to(get_organization_limits))
.route("collections", web::get().to(get_collection_limits)),
);
}
async fn get_project_limits(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<UserLimits>, ApiError> {
let (_, user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::empty(),
)
.await?;
let limits = UserLimits::get_for_projects(&user, &pool).await?;
Ok(web::Json(limits))
}
async fn get_organization_limits(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<UserLimits>, ApiError> {
let (_, user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::empty(),
)
.await?;
let limits = UserLimits::get_for_organizations(&user, &pool).await?;
Ok(web::Json(limits))
}
async fn get_collection_limits(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<UserLimits>, ApiError> {
let (_, user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::empty(),
)
.await?;
let limits = UserLimits::get_for_collections(&user, &pool).await?;
Ok(web::Json(limits))
}

View File

@@ -7,6 +7,7 @@ pub mod analytics_get;
pub mod collections;
pub mod friends;
pub mod images;
pub mod limits;
pub mod notifications;
pub mod organizations;
pub mod payouts;
@@ -30,6 +31,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("v3")
.wrap(default_cors())
.configure(limits::config)
.configure(analytics_get::config)
.configure(collections::config)
.configure(images::config)

View File

@@ -13,6 +13,7 @@ use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::ids::OrganizationId;
use crate::models::pats::Scopes;
use crate::models::teams::{OrganizationPermissions, ProjectPermissions};
use crate::models::v3::user_limits::UserLimits;
use crate::queue::session::AuthQueue;
use crate::routes::v3::project_creation::CreateError;
use crate::util::img::delete_old_images;
@@ -136,6 +137,12 @@ pub async fn organization_create(
.await?
.1;
let limits =
UserLimits::get_for_organizations(&current_user, &pool).await?;
if limits.current >= limits.max {
return Err(CreateError::LimitReached);
}
new_organization.validate().map_err(|err| {
CreateError::ValidationError(validation_errors_to_string(err, None))
})?;

View File

@@ -17,6 +17,7 @@ use crate::models::projects::{
};
use crate::models::teams::{OrganizationPermissions, ProjectPermissions};
use crate::models::threads::ThreadType;
use crate::models::v3::user_limits::UserLimits;
use crate::queue::session::AuthQueue;
use crate::search::indexing::IndexingError;
use crate::util::img::upload_image_optimized;
@@ -86,6 +87,8 @@ pub enum CreateError {
CustomAuthenticationError(String),
#[error("Image Parsing Error: {0}")]
ImageError(#[from] ImageError),
#[error("Project limit reached")]
LimitReached,
}
impl actix_web::ResponseError for CreateError {
@@ -117,6 +120,7 @@ impl actix_web::ResponseError for CreateError {
CreateError::ValidationError(..) => StatusCode::BAD_REQUEST,
CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST,
CreateError::ImageError(..) => StatusCode::BAD_REQUEST,
CreateError::LimitReached => StatusCode::BAD_REQUEST,
}
}
@@ -143,6 +147,7 @@ impl actix_web::ResponseError for CreateError {
CreateError::ValidationError(..) => "invalid_input",
CreateError::FileValidationError(..) => "invalid_input",
CreateError::ImageError(..) => "invalid_image",
CreateError::LimitReached => "limit_reached",
},
description: self.to_string(),
})
@@ -294,9 +299,11 @@ pub async fn project_create(
/*
Project Creation Steps:
Get logged in user
- Get logged in user
Must match the author in the version creation
- Check they have not exceeded their project limit
1. Data
- Gets "data" field from multipart form; must be first
- Verification: string lengths
@@ -336,15 +343,19 @@ async fn project_create_inner(
let cdn_url = dotenvy::var("CDN_URL")?;
// The currently logged in user
let current_user = get_user_from_headers(
let (_, current_user) = get_user_from_headers(
&req,
pool,
redis,
session_queue,
Scopes::PROJECT_CREATE,
)
.await?
.1;
.await?;
let limits = UserLimits::get_for_projects(&current_user, pool).await?;
if limits.current >= limits.max {
return Err(CreateError::LimitReached);
}
let project_id: ProjectId =
models::generate_project_id(transaction).await?.into();

View File

@@ -0,0 +1,46 @@
use actix_http::StatusCode;
use actix_web::test;
use labrinth::models::v3::user_limits::UserLimits;
use crate::{
assert_status,
common::{
api_common::{Api, AppendsOptionalPat},
api_v3::ApiV3,
},
};
impl ApiV3 {
pub async fn get_project_limits(&self, pat: Option<&str>) -> UserLimits {
let req = test::TestRequest::get()
.uri("/v3/limits/projects")
.append_pat(pat)
.to_request();
let resp = self.call(req).await;
assert_status!(&resp, StatusCode::OK);
test::read_body_json(resp).await
}
pub async fn get_organization_limits(
&self,
pat: Option<&str>,
) -> UserLimits {
let req = test::TestRequest::get()
.uri("/v3/limits/organizations")
.append_pat(pat)
.to_request();
let resp = self.call(req).await;
assert_status!(&resp, StatusCode::OK);
test::read_body_json(resp).await
}
pub async fn get_collection_limits(&self, pat: Option<&str>) -> UserLimits {
let req = test::TestRequest::get()
.uri("/v3/limits/collections")
.append_pat(pat)
.to_request();
let resp = self.call(req).await;
assert_status!(&resp, StatusCode::OK);
test::read_body_json(resp).await
}
}

View File

@@ -8,6 +8,7 @@ use labrinth::LabrinthConfig;
use std::rc::Rc;
pub mod collections;
pub mod limits;
pub mod oauth;
pub mod oauth_clients;
pub mod organization;

View File

@@ -0,0 +1,32 @@
use common::api_v3::ApiV3;
use common::database::USER_USER_PAT;
use common::environment::{TestEnvironment, with_test_environment};
use crate::common::api_common::ApiProject;
pub mod common;
#[actix_rt::test]
pub async fn limits() {
with_test_environment(
None,
|test_env: TestEnvironment<ApiV3>| async move {
let api = &test_env.api;
let project_limits = api.get_project_limits(USER_USER_PAT).await;
assert_eq!(project_limits.current, 2);
assert!(project_limits.max < u64::MAX);
api.add_public_project(
"limit-test-project",
None,
None,
USER_USER_PAT,
)
.await;
let project_limits = api.get_project_limits(USER_USER_PAT).await;
assert_eq!(project_limits.current, 3);
},
)
.await;
}