You've already forked AstralRinth
forked from didirus/AstralRinth
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:
9
apps/labrinth/migrations/20250927120742_user_limits.sql
Normal file
9
apps/labrinth/migrations/20250927120742_user_limits.sql
Normal 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);
|
||||
@@ -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;
|
||||
|
||||
125
apps/labrinth/src/database/models/user_limits.rs
Normal file
125
apps/labrinth/src/database/models/user_limits.rs
Normal 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),
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -16,4 +16,5 @@ pub mod sessions;
|
||||
pub mod shared_instances;
|
||||
pub mod teams;
|
||||
pub mod threads;
|
||||
pub mod user_limits;
|
||||
pub mod users;
|
||||
|
||||
99
apps/labrinth/src/models/v3/user_limits.rs
Normal file
99
apps/labrinth/src/models/v3/user_limits.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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(¤t_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))
|
||||
})?;
|
||||
|
||||
75
apps/labrinth/src/routes/v3/limits.rs
Normal file
75
apps/labrinth/src/routes/v3/limits.rs
Normal 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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(¤t_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))
|
||||
})?;
|
||||
|
||||
@@ -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(¤t_user, pool).await?;
|
||||
if limits.current >= limits.max {
|
||||
return Err(CreateError::LimitReached);
|
||||
}
|
||||
|
||||
let project_id: ProjectId =
|
||||
models::generate_project_id(transaction).await?.into();
|
||||
|
||||
46
apps/labrinth/tests/common/api_v3/limits.rs
Normal file
46
apps/labrinth/tests/common/api_v3/limits.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
32
apps/labrinth/tests/limits.rs
Normal file
32
apps/labrinth/tests/limits.rs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user