Skip to content

Commit a3a346c

Browse files
committed
implement backend limits on project creation
1 parent bb9ce52 commit a3a346c

File tree

13 files changed

+218
-14
lines changed

13 files changed

+218
-14
lines changed

.cargo/config.toml

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,3 @@
1-
# Enable Cranelift for debug builds, improving iterative compile times
2-
[unstable]
3-
codegen-backend = true
4-
5-
[profile.dev]
6-
codegen-backend = "cranelift"
7-
81
[build]
92
rustflags = ["--cfg", "tokio_unstable"]
103

CLAUDE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Architecture
2+
3+
## Labrinth
4+
5+
Labrinth is the backend API service for Modrinth.
6+
7+
### Testing
8+
9+
Before a pull request can be opened, run `cargo clippy -p labrinth --all-targets` and make sure there are ZERO warnings, otherwise CI will fail.
10+
11+
Use `cargo test -p labrinth --all-targets` to test your changes. All tests must pass, otherwise CI will fail.
12+
13+
Read the root `docker-compose.yml` to see what running services are available while developing. Use `docker exec` to access these services.
14+
15+
### Clickhouse
16+
17+
Use `docker exec labrinth-clickhouse clickhouse-client` to access the Clickhouse instance. We use the `staging_ariadne` database to store data in testing.
18+
19+
### Postgres
20+
21+
Use `docker exec labrinth-postgres psql -U postgres` to access the PostgreSQL instance.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ALTER TABLE users
2+
ADD COLUMN max_projects BIGINT,
3+
ADD COLUMN max_organizations BIGINT,
4+
ADD COLUMN max_collections BIGINT;

apps/labrinth/src/database/models/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub mod shared_instance_item;
3030
pub mod team_item;
3131
pub mod thread_item;
3232
pub mod user_item;
33+
pub mod user_limits;
3334
pub mod user_subscription_item;
3435
pub mod users_compliance;
3536
pub mod users_notifications_preferences_item;

apps/labrinth/src/database/models/user_item.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ pub struct DBUser {
5151
pub allow_friend_requests: bool,
5252

5353
pub is_subscribed_to_newsletter: bool,
54+
55+
pub max_projects: Option<u64>,
56+
pub max_organizations: Option<u64>,
57+
pub max_collections: Option<u64>,
5458
}
5559

5660
impl DBUser {
@@ -65,13 +69,15 @@ impl DBUser {
6569
avatar_url, raw_avatar_url, bio, created,
6670
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
6771
email_verified, password, paypal_id, paypal_country, paypal_email,
68-
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
72+
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,
73+
max_projects, max_organizations, max_collections
6974
)
7075
VALUES (
7176
$1, $2, $3, $4, $5,
7277
$6, $7,
7378
$8, $9, $10, $11, $12, $13,
74-
$14, $15, $16, $17, $18, $19, $20, $21, $22
79+
$14, $15, $16, $17, $18, $19, $20, $21, $22,
80+
$23, $24, $25
7581
)
7682
",
7783
self.id as DBUserId,
@@ -96,6 +102,9 @@ impl DBUser {
96102
self.stripe_customer_id,
97103
self.allow_friend_requests,
98104
self.is_subscribed_to_newsletter,
105+
self.max_projects.map(|x| x as i64),
106+
self.max_organizations.map(|x| x as i64),
107+
self.max_collections.map(|x| x as i64),
99108
)
100109
.execute(&mut **transaction)
101110
.await?;
@@ -181,7 +190,8 @@ impl DBUser {
181190
created, role, badges,
182191
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
183192
email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,
184-
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
193+
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,
194+
max_projects, max_organizations, max_collections
185195
FROM users
186196
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
187197
",
@@ -216,6 +226,9 @@ impl DBUser {
216226
totp_secret: u.totp_secret,
217227
allow_friend_requests: u.allow_friend_requests,
218228
is_subscribed_to_newsletter: u.is_subscribed_to_newsletter,
229+
max_projects: u.max_projects.map(|x| x as u64),
230+
max_organizations: u.max_organizations.map(|x| x as u64),
231+
max_collections: u.max_collections.map(|x| x as u64),
219232
};
220233

221234
acc.insert(u.id, (Some(u.username), user));
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
use serde::{Deserialize, Serialize};
2+
use sqlx::PgPool;
3+
4+
use crate::{database::models::DBUserId, models::users::User};
5+
6+
#[derive(Debug, Clone, Serialize, Deserialize)]
7+
pub struct UserLimits {
8+
pub current: UserLimitCount,
9+
pub max: UserLimitCount,
10+
}
11+
12+
#[derive(Debug, Clone, Serialize, Deserialize)]
13+
pub struct UserLimitCount {
14+
pub projects: u64,
15+
pub organizations: u64,
16+
pub collections: u64,
17+
}
18+
19+
impl UserLimits {
20+
pub async fn get(user: &User, pool: &PgPool) -> Result<Self, sqlx::Error> {
21+
let current = sqlx::query!(
22+
"SELECT
23+
(SELECT COUNT(*) FROM mods m
24+
JOIN teams t ON m.team_id = t.id
25+
JOIN team_members tm ON t.id = tm.team_id
26+
WHERE tm.user_id = $1) as projects,
27+
28+
(SELECT COUNT(*) FROM organizations o
29+
JOIN teams t ON o.team_id = t.id
30+
JOIN team_members tm ON t.id = tm.team_id
31+
WHERE tm.user_id = $1) as organizations,
32+
33+
(SELECT COUNT(*) FROM collections
34+
WHERE user_id = $1) as collections",
35+
DBUserId::from(user.id) as DBUserId,
36+
)
37+
.fetch_one(&*pool)
38+
.await?;
39+
40+
let current = UserLimitCount {
41+
projects: current.projects.map(|x| x as u64).unwrap_or(0),
42+
organizations: current.organizations.map(|x| x as u64).unwrap_or(0),
43+
collections: current.collections.map(|x| x as u64).unwrap_or(0),
44+
};
45+
46+
if user.role.is_admin() {
47+
Ok(Self {
48+
current,
49+
max: UserLimitCount {
50+
projects: u64::MAX,
51+
organizations: u64::MAX,
52+
collections: u64::MAX,
53+
},
54+
})
55+
} else {
56+
// TODO: global config for max
57+
Ok(Self {
58+
current,
59+
max: UserLimitCount {
60+
projects: user.max_projects.unwrap_or(256),
61+
organizations: user.max_organizations.unwrap_or(16),
62+
collections: user.max_collections.unwrap_or(64),
63+
},
64+
})
65+
}
66+
}
67+
}

apps/labrinth/src/models/v3/users.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ pub struct User {
5050

5151
// DEPRECATED. Always returns None
5252
pub github_id: Option<u64>,
53+
54+
pub max_projects: Option<u64>,
55+
pub max_organizations: Option<u64>,
56+
pub max_collections: Option<u64>,
5357
}
5458

5559
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -81,6 +85,9 @@ impl From<DBUser> for User {
8185
github_id: None,
8286
stripe_customer_id: None,
8387
allow_friend_requests: None,
88+
max_projects: data.max_projects,
89+
max_organizations: data.max_organizations,
90+
max_collections: data.max_collections,
8491
}
8592
}
8693
}
@@ -133,6 +140,9 @@ impl User {
133140
}),
134141
stripe_customer_id: db_user.stripe_customer_id,
135142
allow_friend_requests: Some(db_user.allow_friend_requests),
143+
max_projects: db_user.max_projects,
144+
max_organizations: db_user.max_organizations,
145+
max_collections: db_user.max_collections,
136146
}
137147
}
138148
}

apps/labrinth/src/routes/internal/flows.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ impl TempUser {
237237
badges: Badges::default(),
238238
allow_friend_requests: true,
239239
is_subscribed_to_newsletter: false,
240+
max_projects: None,
241+
max_organizations: None,
242+
max_collections: None,
240243
}
241244
.insert(transaction)
242245
.await?;
@@ -1425,6 +1428,9 @@ pub async fn create_account_with_password(
14251428
is_subscribed_to_newsletter: new_account
14261429
.sign_up_newsletter
14271430
.unwrap_or(false),
1431+
max_projects: None,
1432+
max_organizations: None,
1433+
max_collections: None,
14281434
}
14291435
.insert(&mut transaction)
14301436
.await?;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
use crate::{
2+
auth::get_user_from_headers,
3+
database::models::{User, user_limits::UserLimits},
4+
models::pats::Scopes,
5+
routes::ApiError,
6+
};
7+
use actix_web::{HttpRequest, HttpResponse, web};
8+
use futures::TryStreamExt;
9+
use serde::{Deserialize, Serialize};
10+
use sqlx::PgPool;
11+
12+
pub fn config(cfg: &mut web::ServiceConfig) {
13+
cfg.service(get_limits);
14+
}
15+
16+
#[get("limits")]
17+
async fn get_limits(
18+
req: HttpRequest,
19+
pool: web::Data<PgPool>,
20+
redis: web::Data<RedisPool>,
21+
session_queue: web::Data<AuthQueue>,
22+
) -> Result<web::Json<UserLimits>, ApiError> {
23+
let (_, user) = get_user_from_headers(
24+
&req,
25+
&**pool,
26+
&redis,
27+
&session_queue,
28+
Scopes::empty(),
29+
)
30+
.await?;
31+
32+
let limits = UserLimits::get(&user, &pool).await?;
33+
Ok(web::Json(limits))
34+
}

apps/labrinth/src/routes/v3/project_creation.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::database::models::loader_fields::{
44
Loader, LoaderField, LoaderFieldEnumValue,
55
};
66
use crate::database::models::thread_item::ThreadBuilder;
7+
use crate::database::models::user_limits::UserLimits;
78
use crate::database::models::{self, DBUser, image_item};
89
use crate::database::redis::RedisPool;
910
use crate::file_hosting::{FileHost, FileHostPublicity, FileHostingError};
@@ -86,6 +87,8 @@ pub enum CreateError {
8687
CustomAuthenticationError(String),
8788
#[error("Image Parsing Error: {0}")]
8889
ImageError(#[from] ImageError),
90+
#[error("Project limit reached")]
91+
LimitReached,
8992
}
9093

9194
impl actix_web::ResponseError for CreateError {
@@ -117,6 +120,7 @@ impl actix_web::ResponseError for CreateError {
117120
CreateError::ValidationError(..) => StatusCode::BAD_REQUEST,
118121
CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST,
119122
CreateError::ImageError(..) => StatusCode::BAD_REQUEST,
123+
CreateError::LimitReached => StatusCode::BAD_REQUEST,
120124
}
121125
}
122126

@@ -143,6 +147,7 @@ impl actix_web::ResponseError for CreateError {
143147
CreateError::ValidationError(..) => "invalid_input",
144148
CreateError::FileValidationError(..) => "invalid_input",
145149
CreateError::ImageError(..) => "invalid_image",
150+
CreateError::LimitReached => "limit_reached",
146151
},
147152
description: self.to_string(),
148153
})
@@ -294,9 +299,11 @@ pub async fn project_create(
294299
/*
295300
296301
Project Creation Steps:
297-
Get logged in user
302+
- Get logged in user
298303
Must match the author in the version creation
299304
305+
- Check they have not exceeded their project limit
306+
300307
1. Data
301308
- Gets "data" field from multipart form; must be first
302309
- Verification: string lengths
@@ -336,15 +343,19 @@ async fn project_create_inner(
336343
let cdn_url = dotenvy::var("CDN_URL")?;
337344

338345
// The currently logged in user
339-
let current_user = get_user_from_headers(
346+
let (_, current_user) = get_user_from_headers(
340347
&req,
341348
pool,
342349
redis,
343350
session_queue,
344351
Scopes::PROJECT_CREATE,
345352
)
346-
.await?
347-
.1;
353+
.await?;
354+
355+
let limits = UserLimits::get(&current_user, &pool).await?;
356+
if limits.current.projects + 1 >= limits.max.projects {
357+
return Err(CreateError::LimitReached);
358+
}
348359

349360
let project_id: ProjectId =
350361
models::generate_project_id(transaction).await?.into();

0 commit comments

Comments
 (0)