You've already forked pages
forked from didirus/AstralRinth
Fix slug/project ID collisions (#4844)
* wip: tool to create project with id * fix * fix id/slug collision for orgs
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
22
apps/labrinth/.sqlx/query-57c6a529e636f947f46c8e92265f678af507b0a6883dd7bb21f9757df85f3524.json
generated
Normal file
22
apps/labrinth/.sqlx/query-57c6a529e636f947f46c8e92265f678af507b0a6883dd7bb21f9757df85f3524.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
22
apps/labrinth/.sqlx/query-861109ec01101bac6935903fabd619ce98aa96afd9ff43c415212256e4423890.json
generated
Normal file
22
apps/labrinth/.sqlx/query-861109ec01101bac6935903fabd619ce98aa96afd9ff43c415212256e4423890.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
@@ -133,7 +133,7 @@ struct ProjectCreateData {
|
||||
pub organization_id: Option<models::ids::OrganizationId>,
|
||||
}
|
||||
|
||||
#[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(),
|
||||
|
||||
@@ -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<u64> =
|
||||
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),
|
||||
|
||||
@@ -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<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, CreateError> {
|
||||
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<PgPool>,
|
||||
@@ -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<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
path: web::Path<(ProjectId,)>,
|
||||
) -> Result<HttpResponse, CreateError> {
|
||||
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<HttpResponse, CreateError> {
|
||||
// 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<ProjectId> =
|
||||
serde_json::from_str(&format!("\"{}\"", create_data.slug)).ok();
|
||||
let slug_project_id_option: Option<ProjectId> = 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 =
|
||||
|
||||
@@ -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<u64> = 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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user