diff --git a/apps/labrinth/.sqlx/query-ed7cc47dc2acfcaf27c4e763390371dccddbeea902928f1382c9505742f0a9a9.json b/apps/labrinth/.sqlx/query-201bcc1b6c7b62b3bf274f3e9d3a7d9872fed4bd3d1dcb522dfbfb85980e3d8e.json similarity index 54% rename from apps/labrinth/.sqlx/query-ed7cc47dc2acfcaf27c4e763390371dccddbeea902928f1382c9505742f0a9a9.json rename to apps/labrinth/.sqlx/query-201bcc1b6c7b62b3bf274f3e9d3a7d9872fed4bd3d1dcb522dfbfb85980e3d8e.json index d688ab9f..60d5fcac 100644 --- a/apps/labrinth/.sqlx/query-ed7cc47dc2acfcaf27c4e763390371dccddbeea902928f1382c9505742f0a9a9.json +++ b/apps/labrinth/.sqlx/query-201bcc1b6c7b62b3bf274f3e9d3a7d9872fed4bd3d1dcb522dfbfb85980e3d8e.json @@ -1,15 +1,15 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE organizations\n SET slug = $1\n WHERE (id = $2)\n ", + "query": "\n UPDATE organizations\n SET slug = LOWER($1)\n WHERE (id = $2)\n ", "describe": { "columns": [], "parameters": { "Left": [ - "Varchar", + "Text", "Int8" ] }, "nullable": [] }, - "hash": "ed7cc47dc2acfcaf27c4e763390371dccddbeea902928f1382c9505742f0a9a9" + "hash": "201bcc1b6c7b62b3bf274f3e9d3a7d9872fed4bd3d1dcb522dfbfb85980e3d8e" } diff --git a/apps/labrinth/.sqlx/query-38f651362c0778254c28ccd4745af611f4deb6e72f52b8cf65d0515f0fe14779.json b/apps/labrinth/.sqlx/query-38f651362c0778254c28ccd4745af611f4deb6e72f52b8cf65d0515f0fe14779.json deleted file mode 100644 index 0170be2e..00000000 --- a/apps/labrinth/.sqlx/query-38f651362c0778254c28ccd4745af611f4deb6e72f52b8cf65d0515f0fe14779.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT EXISTS(SELECT 1 FROM organizations WHERE LOWER(slug) = LOWER($1))\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "38f651362c0778254c28ccd4745af611f4deb6e72f52b8cf65d0515f0fe14779" -} diff --git a/apps/labrinth/.sqlx/query-57c6a529e636f947f46c8e92265f678af507b0a6883dd7bb21f9757df85f3524.json b/apps/labrinth/.sqlx/query-57c6a529e636f947f46c8e92265f678af507b0a6883dd7bb21f9757df85f3524.json new file mode 100644 index 00000000..d910f59b --- /dev/null +++ b/apps/labrinth/.sqlx/query-57c6a529e636f947f46c8e92265f678af507b0a6883dd7bb21f9757df85f3524.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(\n SELECT 1 FROM mods\n WHERE\n slug = LOWER($1)\n OR text_id_lower = LOWER($1)\n )\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "57c6a529e636f947f46c8e92265f678af507b0a6883dd7bb21f9757df85f3524" +} diff --git a/apps/labrinth/.sqlx/query-861109ec01101bac6935903fabd619ce98aa96afd9ff43c415212256e4423890.json b/apps/labrinth/.sqlx/query-861109ec01101bac6935903fabd619ce98aa96afd9ff43c415212256e4423890.json new file mode 100644 index 00000000..52339c46 --- /dev/null +++ b/apps/labrinth/.sqlx/query-861109ec01101bac6935903fabd619ce98aa96afd9ff43c415212256e4423890.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(\n SELECT 1 FROM organizations\n WHERE\n LOWER(slug) = LOWER($1)\n OR text_id_lower = LOWER($1)\n )\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "861109ec01101bac6935903fabd619ce98aa96afd9ff43c415212256e4423890" +} diff --git a/apps/labrinth/.sqlx/query-b26cbb11458743ba0677f4ca24ceaff0f9766ddac4a076010c98cf086dd1d7af.json b/apps/labrinth/.sqlx/query-b26cbb11458743ba0677f4ca24ceaff0f9766ddac4a076010c98cf086dd1d7af.json deleted file mode 100644 index 63d2ba63..00000000 --- a/apps/labrinth/.sqlx/query-b26cbb11458743ba0677f4ca24ceaff0f9766ddac4a076010c98cf086dd1d7af.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT EXISTS(SELECT 1 FROM organizations WHERE id=$1)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - null - ] - }, - "hash": "b26cbb11458743ba0677f4ca24ceaff0f9766ddac4a076010c98cf086dd1d7af" -} diff --git a/apps/labrinth/migrations/20251201171345_store_ids_as_text.sql b/apps/labrinth/migrations/20251201171345_store_ids_as_text.sql new file mode 100644 index 00000000..baf8419e --- /dev/null +++ b/apps/labrinth/migrations/20251201171345_store_ids_as_text.sql @@ -0,0 +1,51 @@ +-- copy of existing `from/to_base62` functions from `base62-helper-functions.sql` +-- but with `IMMUTABLE` so we can use them in generated columns + +CREATE OR REPLACE FUNCTION from_base62(input VARCHAR) +RETURNS BIGINT AS $$ +DECLARE + base INT := 62; + chars VARCHAR := '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + result BIGINT := 0; + i INT; + char VARCHAR; + index INT; +BEGIN + FOR i IN 1..LENGTH(input) LOOP + char := SUBSTRING(input FROM i FOR 1); + index := POSITION(char IN chars) - 1; + IF index < 0 THEN + RAISE EXCEPTION 'Error: Invalid character in input string'; + END IF; + result := result * base + index; + END LOOP; + + RETURN result; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE OR REPLACE FUNCTION to_base62(input BIGINT) +RETURNS VARCHAR AS $$ +DECLARE + base INT := 62; + chars VARCHAR := '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + result VARCHAR := ''; + remainder INT; +BEGIN + WHILE input > 0 LOOP + remainder := input % base; + result := SUBSTRING(chars FROM remainder + 1 FOR 1) || result; + input := input / base; + END LOOP; + + RETURN result; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +ALTER TABLE mods + ADD COLUMN text_id TEXT GENERATED ALWAYS AS (to_base62(id)) STORED, + ADD COLUMN text_id_lower TEXT GENERATED ALWAYS AS (lower(to_base62(id))) STORED; + +ALTER TABLE organizations + ADD COLUMN text_id TEXT GENERATED ALWAYS AS (to_base62(id)) STORED, + ADD COLUMN text_id_lower TEXT GENERATED ALWAYS AS (lower(to_base62(id))) STORED; diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs index 9a456243..92514f8f 100644 --- a/apps/labrinth/src/routes/v2/project_creation.rs +++ b/apps/labrinth/src/routes/v2/project_creation.rs @@ -133,7 +133,7 @@ struct ProjectCreateData { pub organization_id: Option, } -#[post("project")] +#[post("/project")] pub async fn project_create( req: HttpRequest, payload: Multipart, @@ -245,7 +245,7 @@ pub async fn project_create( .await?; // Call V3 project creation - let response = v3::project_creation::project_create( + let response = v3::project_creation::project_create_internal( req, payload, client.clone(), diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 5a1219e0..c6db5375 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -22,7 +22,6 @@ use crate::util::validate::validation_errors_to_string; use crate::{database, models}; use actix_web::{HttpRequest, HttpResponse, web}; use ariadne::ids::UserId; -use ariadne::ids::base62_impl::parse_base62; use futures::TryStreamExt; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -487,25 +486,17 @@ pub async fn organizations_edit( )); } - let name_organization_id_option: Option = - parse_base62(slug).ok(); - if let Some(name_organization_id) = name_organization_id_option - { - let results = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM organizations WHERE id=$1) - ", - name_organization_id as i64 - ) - .fetch_one(&mut *transaction) - .await?; - - if results.exists.unwrap_or(true) { - return Err(ApiError::InvalidInput( - "slug collides with other organization's id!" - .to_string(), - )); - } + let existing = DBOrganization::get( + &slug.to_lowercase(), + &mut *transaction, + &redis, + ) + .await?; + if existing.is_some() { + return Err(ApiError::InvalidInput( + "Slug collides with other organization's id!" + .to_string(), + )); } // Make sure the new name is different from the old one @@ -513,7 +504,12 @@ pub async fn organizations_edit( if !slug.eq(&organization_item.slug.clone()) { let results = sqlx::query!( " - SELECT EXISTS(SELECT 1 FROM organizations WHERE LOWER(slug) = LOWER($1)) + SELECT EXISTS( + SELECT 1 FROM organizations + WHERE + LOWER(slug) = LOWER($1) + OR text_id_lower = LOWER($1) + ) ", slug ) @@ -522,7 +518,7 @@ pub async fn organizations_edit( if results.exists.unwrap_or(true) { return Err(ApiError::InvalidInput( - "slug collides with other organization's id!" + "Slug collides with other organization's id!" .to_string(), )); } @@ -531,7 +527,7 @@ pub async fn organizations_edit( sqlx::query!( " UPDATE organizations - SET slug = $1 + SET slug = LOWER($1) WHERE (id = $2) ", Some(slug), diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index e03d2dd5..4984adbd 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -20,13 +20,14 @@ 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::guards::admin_key_guard; use crate::util::img::upload_image_optimized; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; use actix_multipart::{Field, Multipart}; use actix_web::http::StatusCode; use actix_web::web::{self, Data}; -use actix_web::{HttpRequest, HttpResponse}; +use actix_web::{HttpRequest, HttpResponse, post}; use ariadne::ids::UserId; use ariadne::ids::base62_impl::to_base62; use chrono::Utc; @@ -42,7 +43,7 @@ use thiserror::Error; use validator::Validate; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { - cfg.route("project", web::post().to(project_create)); + cfg.service(project_create).service(project_create_with_id); } #[derive(Error, Debug)] @@ -259,7 +260,27 @@ pub async fn undo_uploads( Ok(()) } +#[post("/project")] pub async fn project_create( + req: HttpRequest, + payload: Multipart, + client: Data, + redis: Data, + file_host: Data>, + session_queue: Data, +) -> Result { + project_create_internal( + req, + payload, + client, + redis, + file_host, + session_queue, + ) + .await +} + +pub async fn project_create_internal( req: HttpRequest, mut payload: Multipart, client: Data, @@ -270,6 +291,9 @@ pub async fn project_create( let mut transaction = client.begin().await?; let mut uploaded_files = Vec::new(); + let project_id: ProjectId = + models::generate_project_id(&mut transaction).await?.into(); + let result = project_create_inner( req, &mut payload, @@ -279,6 +303,7 @@ pub async fn project_create( &client, &redis, &session_queue, + project_id, ) .await; @@ -296,6 +321,53 @@ pub async fn project_create( result } + +/// Allows creating a project with a specific ID. +/// +/// This is a testing endpoint only accessible behind an admin key. +#[post("/project/{id}", guard = "admin_key_guard")] +pub async fn project_create_with_id( + req: HttpRequest, + mut payload: Multipart, + client: Data, + redis: Data, + file_host: Data>, + session_queue: Data, + path: web::Path<(ProjectId,)>, +) -> Result { + let mut transaction = client.begin().await?; + let mut uploaded_files = Vec::new(); + + let (project_id,) = path.into_inner(); + + let result = project_create_inner( + req, + &mut payload, + &mut transaction, + &***file_host, + &mut uploaded_files, + &client, + &redis, + &session_queue, + project_id, + ) + .await; + + if result.is_err() { + let undo_result = undo_uploads(&***file_host, &uploaded_files).await; + let rollback_result = transaction.rollback().await; + + undo_result?; + if let Err(e) = rollback_result { + return Err(e.into()); + } + } else { + transaction.commit().await?; + } + + result +} + /* Project Creation Steps: @@ -338,6 +410,7 @@ async fn project_create_inner( pool: &PgPool, redis: &RedisPool, session_queue: &AuthQueue, + project_id: ProjectId, ) -> Result { // The base URL for files uploaded to S3 let cdn_url = dotenvy::var("CDN_URL")?; @@ -357,8 +430,6 @@ async fn project_create_inner( return Err(CreateError::LimitReached); } - let project_id: ProjectId = - models::generate_project_id(transaction).await?.into(); let all_loaders = models::loader_fields::Loader::list(&mut **transaction, redis).await?; @@ -401,8 +472,10 @@ async fn project_create_inner( CreateError::InvalidInput(validation_errors_to_string(err, None)) })?; - let slug_project_id_option: Option = - serde_json::from_str(&format!("\"{}\"", create_data.slug)).ok(); + let slug_project_id_option: Option = serde_json::from_str( + &format!("\"{}\"", create_data.slug.to_lowercase()), + ) + .ok(); if let Some(slug_project_id) = slug_project_id_option { let slug_project_id: models::ids::DBProjectId = diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 43e67dff..0b93d989 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -31,7 +31,6 @@ use crate::util::img::{delete_old_images, upload_image_optimized}; use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; use actix_web::{HttpRequest, HttpResponse, web}; -use ariadne::ids::base62_impl::parse_base62; use chrono::Utc; use futures::TryStreamExt; use itertools::Itertools; @@ -628,22 +627,16 @@ pub async fn project_edit( )); } - let slug_project_id_option: Option = parse_base62(slug).ok(); - if let Some(slug_project_id) = slug_project_id_option { - let results = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1) - ", - slug_project_id as i64 - ) - .fetch_one(&mut *transaction) - .await?; - - if results.exists.unwrap_or(true) { - return Err(ApiError::InvalidInput( - "Slug collides with other project's id!".to_string(), - )); - } + let existing = db_models::DBProject::get( + &slug.to_lowercase(), + &mut *transaction, + &redis, + ) + .await?; + if existing.is_some() { + return Err(ApiError::InvalidInput( + "Slug collides with other project's id!".to_string(), + )); } // Make sure the new slug is different from the old one @@ -651,7 +644,12 @@ pub async fn project_edit( if !slug.eq(&project_item.inner.slug.clone().unwrap_or_default()) { let results = sqlx::query!( " - SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1)) + SELECT EXISTS( + SELECT 1 FROM mods + WHERE + slug = LOWER($1) + OR text_id_lower = LOWER($1) + ) ", slug )