diff --git a/.env b/.env index a51124391..859e88dbe 100644 --- a/.env +++ b/.env @@ -1,6 +1,8 @@ INDEX_CURSEFORGE=false DEBUG=true +CDN_URL=cdn.modrinth.com + MONGODB_ADDR=mongodb://localhost:27017 MEILISEARCH_ADDR=http://localhost:7700 diff --git a/Cargo.toml b/Cargo.toml index 95da4728b..b674992ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ edition = "2018" actix-web = "2.0" actix-rt = "1.1.1" actix-files = "0.2.2" +actix-multipart = "0.2.0" reqwest = {version="0.10.6", features=["json"]} diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index 4333ccfaa..806f99f1c 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -1,4 +1,5 @@ mod mod_item; +mod team_item; mod version_item; use crate::database::DatabaseError::NotFound; @@ -8,7 +9,11 @@ use bson::doc; use bson::Document; pub use mod_item::Mod; use mongodb::Database; +pub use team_item::Team; +pub use team_item::TeamMember; +pub use version_item::FileHash; pub use version_item::Version; +pub use version_item::VersionFile; #[async_trait] pub trait Item { diff --git a/src/database/models/mod_item.rs b/src/database/models/mod_item.rs index 5fdf0f19d..5eff048c1 100644 --- a/src/database/models/mod_item.rs +++ b/src/database/models/mod_item.rs @@ -1,3 +1,4 @@ +use crate::database::models::team_item::Team; use crate::database::models::Item; use crate::database::Result; use bson::{Bson, Document}; @@ -5,15 +6,23 @@ use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize)] pub struct Mod { + /// The ID for the mod, must be serializable to base62 pub id: i32, + //Todo: Move to own table + /// The team that owns the mod + pub team: Team, pub title: String, pub description: String, + pub body_url: String, pub published: String, - pub author: String, pub downloads: i32, pub categories: Vec, - pub body_path: String, - pub icon_path: String, + ///A vector of Version IDs specifying the mod version of a dependency + pub version_ids: Vec, + pub icon_url: Option, + pub issues_url: Option, + pub source_url: Option, + pub wiki_url: Option, } impl Item for Mod { fn get_collection() -> &'static str { diff --git a/src/database/models/team_item.rs b/src/database/models/team_item.rs new file mode 100644 index 000000000..dc1b77739 --- /dev/null +++ b/src/database/models/team_item.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +/// A team of users who control a mod +#[derive(Serialize, Deserialize)] +pub struct Team { + /// The id of the team + pub id: i32, + /// A list of the members of the team + pub members: Vec, +} + +/// A member of a team +#[derive(Serialize, Deserialize, Clone)] +pub struct TeamMember { + /// The ID of the user associated with the member + pub user_id: i32, + /// The name of the user + pub name: String, + pub role: String, +} diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index 507c6d8d3..4521cf033 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -3,19 +3,39 @@ use crate::database::Result; use bson::{Bson, Document}; use serde::{Deserialize, Serialize}; +//TODO: Files should probably be moved to their own table #[derive(Deserialize, Serialize)] pub struct Version { + ///The unqiue VersionId of this version pub version_id: i32, + /// The ModId of the mod that this version belongs to pub mod_id: i32, - pub title: String, - pub changelog_path: String, - pub files_path: Vec, + pub name: String, + pub number: String, + pub changelog_url: Option, pub date_published: String, - pub author: String, pub downloads: i32, - pub dependencies: Vec, + pub files: Vec, + pub dependencies: Vec, pub game_versions: Vec, + pub loaders: Vec, + pub version_type: String, } + +#[derive(Serialize, Deserialize)] +pub struct VersionFile { + pub game_versions: Vec, + pub hashes: Vec, + pub url: String, +} + +/// A hash of a mod's file +#[derive(Serialize, Deserialize)] +pub struct FileHash { + pub algorithm: String, + pub hash: String, +} + impl Item for Version { fn get_collection() -> &'static str { "versions" diff --git a/src/file_hosting/authorization.rs b/src/file_hosting/authorization.rs index 882d642e6..ee5e291e4 100644 --- a/src/file_hosting/authorization.rs +++ b/src/file_hosting/authorization.rs @@ -24,7 +24,7 @@ pub struct AuthorizationData { pub recommended_part_size: i32, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct UploadUrlData { pub bucket_id: String, diff --git a/src/file_hosting/mod.rs b/src/file_hosting/mod.rs index b72d08647..cc29a34f0 100644 --- a/src/file_hosting/mod.rs +++ b/src/file_hosting/mod.rs @@ -28,6 +28,7 @@ mod tests { #[actix_rt::test] async fn test_authorization() { + println!("{}", dotenv::var("BACKBLAZE_BUCKET_ID").unwrap()); let authorization_data = authorize_account( dotenv::var("BACKBLAZE_KEY_ID").unwrap(), dotenv::var("BACKBLAZE_KEY").unwrap(), diff --git a/src/main.rs b/src/main.rs index e34d4758f..0330acbb3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,9 +19,28 @@ async fn main() -> std::io::Result<()> { check_env_vars(); + //Database Connecter let client = database::connect() .await .expect("Database connection failed"); + let client_ref = web::Data::new(client.clone()); + + //File Hosting Initializer + let authorization_data = file_hosting::authorize_account( + dotenv::var("BACKBLAZE_KEY_ID").unwrap(), + dotenv::var("BACKBLAZE_KEY").unwrap(), + ) + .await + .unwrap(); + let upload_url_data = file_hosting::get_upload_url( + authorization_data.clone(), + dotenv::var("BACKBLAZE_BUCKET_ID").unwrap(), + ) + .await + .unwrap(); + + let authorization_data_ref = web::Data::new(authorization_data); + let upload_url_data_ref = web::Data::new(upload_url_data); // Get executable path let mut exe_path = env::current_exe()?.parent().unwrap().to_path_buf(); @@ -48,6 +67,9 @@ async fn main() -> std::io::Result<()> { App::new() .wrap(Logger::default()) .wrap(Logger::new("%a %{User-Agent}i")) + .data(client_ref.clone()) + .data(authorization_data_ref.clone()) + .data(upload_url_data_ref.clone()) .service(routes::index_get) .service(routes::mod_search) .default_service(web::get().to(routes::not_found)) diff --git a/src/models/ids.rs b/src/models/ids.rs index 397a40886..b80a2ec0f 100644 --- a/src/models/ids.rs +++ b/src/models/ids.rs @@ -84,12 +84,27 @@ macro_rules! from_base62id { }; } -from_base62id! { - ModId, ModId; - UserId, UserId; - VersionId, VersionId; - TeamId, TeamId; +macro_rules! impl_base62_display { + ($struct:ty) => { + impl std::fmt::Display for $struct { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&base62_impl::to_base62(self.0)) + } + } + }; } +impl_base62_display!(Base62Id); + +macro_rules! base62_id_impl { + ($struct:ty, $cons:expr) => { + from_base62id!($struct, $cons;); + impl_base62_display!($struct); + } +} +base62_id_impl!(ModId, ModId); +base62_id_impl!(UserId, UserId); +base62_id_impl!(VersionId, VersionId); +base62_id_impl!(TeamId, TeamId); pub mod base62_impl { use serde::de::{self, Deserializer, Visitor}; diff --git a/src/models/mods.rs b/src/models/mods.rs index 2419f59cb..735f767c1 100644 --- a/src/models/mods.rs +++ b/src/models/mods.rs @@ -24,11 +24,12 @@ pub struct Mod { // unnecessary info /// The team of people that has ownership of this mod. pub team: Team, - /// The title or name of the mod. pub title: String, /// A short description of the mod. pub description: String, + /// The link to the long description of the mod. + pub body_url: String, /// The date at which the mod was first published. pub published: DateTime, @@ -38,10 +39,8 @@ pub struct Mod { pub categories: Vec, /// A list of ids for versions of the mod. pub versions: Vec, - - /// The latest version of the mod. - pub latest_version: Version, - + ///The URL of the icon of the mod + pub icon_url: Option, /// An optional link to where to submit bugs or issues with the mod. pub issues_url: Option, /// An optional link to the source code for the mod. @@ -60,6 +59,8 @@ pub struct Version { /// The name of this version pub name: String, + /// The version number. Ideally will follow semantic versioning + pub version_number: String, /// A link to the changelog for this version of the mod. pub changelog_url: Option, /// The date that this version was published. @@ -72,9 +73,11 @@ pub struct Version { /// A list of files available for download for this version. pub files: Vec, /// A list of mods that this version depends on. - pub dependencies: Vec, + pub dependencies: Vec, /// A list of versions of Minecraft that this version of the mod supports. pub game_versions: Vec, + /// The loaders that this version works on + pub loaders: Vec, } /// A single mod file, with a url for the file and the file's hash @@ -96,18 +99,34 @@ pub struct FileHash { pub hash: String, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone)] pub enum VersionType { Release, Beta, Alpha, } +impl ToString for VersionType { + fn to_string(&self) -> String { + match self { + VersionType::Release => "release", + VersionType::Beta => "beta", + VersionType::Alpha => "alpha", + } + .to_string() + } +} + /// A specific version of Minecraft -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone)] #[serde(transparent)] pub struct GameVersion(pub String); +/// A mod loader +#[derive(Serialize, Deserialize, Clone)] +#[serde(transparent)] +pub struct ModLoader(pub String); + #[derive(Serialize, Deserialize)] pub struct SearchRequest { pub query: Option, diff --git a/src/models/teams.rs b/src/models/teams.rs index 4a7da249b..80dcb83f4 100644 --- a/src/models/teams.rs +++ b/src/models/teams.rs @@ -1,6 +1,7 @@ use super::ids::Base62Id; use serde::{Deserialize, Serialize}; +//TODO Implement Item for teams /// The ID of a specific user, encoded as base62 for usage in the API #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(from = "Base62Id")] @@ -24,10 +25,12 @@ pub struct Team { } /// A member of a team -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone)] pub struct TeamMember { /// The ID of the user associated with the member pub user_id: UserId, /// The name of the user pub name: String, + ///The role of the use in the team + pub role: String, } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 4fc5bd280..1b2985b92 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,4 +1,5 @@ mod index; +mod mod_creation; mod mods; mod not_found; diff --git a/src/routes/mod_creation.rs b/src/routes/mod_creation.rs new file mode 100644 index 000000000..816260d96 --- /dev/null +++ b/src/routes/mod_creation.rs @@ -0,0 +1,416 @@ +use crate::database::models::{FileHash, Mod, Team, Version, VersionFile}; +use crate::file_hosting::{upload_file, FileHostingError, UploadUrlData}; +use crate::models::error::ApiError; +use crate::models::ids::random_base62; +use crate::models::mods::{GameVersion, ModId, VersionId, VersionType}; +use crate::models::teams::TeamMember; +use actix_multipart::{Field, Multipart}; +use actix_web::http::StatusCode; +use actix_web::web::Data; +use actix_web::{post, HttpResponse}; +use bson::doc; +use bson::Bson; +use chrono::Utc; +use futures::stream::StreamExt; +use mongodb::Client; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum CreateError { + #[error("Environment Error")] + EnvError(#[from] dotenv::Error), + #[error("Error while adding project to database")] + DatabaseError(#[from] mongodb::error::Error), + #[error("Error while parsing multipart payload")] + MultipartError(actix_multipart::MultipartError), + #[error("Error while parsing JSON")] + SerDeError(#[from] serde_json::Error), + #[error("Error while uploading file")] + FileHostingError(#[from] FileHostingError), + #[error("Error while parsing string as UTF-8")] + InvalidUtf8Input(#[source] std::string::FromUtf8Error), + #[error("{}", .0)] + MissingValueError(String), + #[error("Error while trying to generate random ID")] + RandomIdError, +} + +impl actix_web::ResponseError for CreateError { + fn status_code(&self) -> StatusCode { + match self { + CreateError::EnvError(..) => StatusCode::INTERNAL_SERVER_ERROR, + CreateError::DatabaseError(..) => StatusCode::INTERNAL_SERVER_ERROR, + CreateError::FileHostingError(..) => StatusCode::INTERNAL_SERVER_ERROR, + CreateError::SerDeError(..) => StatusCode::BAD_REQUEST, + CreateError::MultipartError(..) => StatusCode::BAD_REQUEST, + CreateError::InvalidUtf8Input(..) => StatusCode::BAD_REQUEST, + CreateError::MissingValueError(..) => StatusCode::BAD_REQUEST, + CreateError::RandomIdError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(ApiError { + error: match self { + CreateError::EnvError(..) => "environment_error", + CreateError::DatabaseError(..) => "database_error", + CreateError::FileHostingError(..) => "file_hosting_error", + CreateError::SerDeError(..) => "invalid_input", + CreateError::MultipartError(..) => "invalid_input", + CreateError::InvalidUtf8Input(..) => "invalid_input", + CreateError::MissingValueError(..) => "invalid_input", + CreateError::RandomIdError => "id_generation_error", + }, + description: &self.to_string(), + }) + } +} + +#[derive(Serialize, Deserialize, Clone)] +struct InitialVersionData { + pub file_indexes: Vec, + pub version_number: String, + pub version_title: String, + pub version_body: String, + pub dependencies: Vec, + pub game_versions: Vec, + pub version_type: VersionType, + pub loaders: Vec, +} + +#[derive(Serialize, Deserialize, Clone)] +struct ModCreateData { + /// The title or name of the mod. + pub mod_name: String, + /// The namespace of the mod + pub mod_namespace: String, + /// A short description of the mod. + pub mod_description: String, + /// A long description of the mod, in markdown. + pub mod_body: String, + /// A list of initial versions to upload with the created mod + pub initial_versions: Vec, + /// The team of people that has ownership of this mod. + pub team_members: Vec, + /// A list of the categories that the mod is in. + pub categories: Vec, + /// An optional link to where to submit bugs or issues with the mod. + pub issues_url: Option, + /// An optional link to the source code for the mod. + pub source_url: Option, + /// An optional link to the mod's wiki page or other relevant information. + pub wiki_url: Option, +} + +#[post("api/v1/mod")] +pub async fn mod_create( + mut payload: Multipart, + client: Data, + upload_url: Data, +) -> Result { + //TODO Switch to transactions for safer database and file upload calls (once it is implemented in the APIs) + let cdn_url = dotenv::var("CDN_URL")?; + + let db = client.database("modrinth"); + + let mods = db.collection("mods"); + let versions = db.collection("versions"); + + let mut mod_id = ModId(random_base62(8)); + let mut retry_count = 0; + + //Check if ID is unique + loop { + let filter = doc! { "_id": mod_id.0 }; + + if mods.find(filter, None).await?.next().await.is_some() { + mod_id = ModId(random_base62(8)); + } else { + break; + } + + retry_count += 1; + if retry_count > 20 { + return Err(CreateError::RandomIdError); + } + } + + let mut created_versions: Vec = vec![]; + + let mut mod_create_data: Option = None; + let mut icon_url = "".to_string(); + + let mut current_file_index = 0; + while let Some(item) = payload.next().await { + let mut field: Field = item.map_err(CreateError::MultipartError)?; + let content_disposition = field.content_disposition().ok_or_else(|| { + CreateError::MissingValueError("Missing content disposition!".to_string()) + })?; + let name = content_disposition + .get_name() + .ok_or_else(|| CreateError::MissingValueError("Missing content name!".to_string()))?; + + while let Some(chunk) = field.next().await { + let data = &chunk.map_err(CreateError::MultipartError)?; + + if name == "data" { + mod_create_data = Some(serde_json::from_slice(&data)?); + } else { + let file_name = content_disposition.get_filename().ok_or_else(|| { + CreateError::MissingValueError("Missing content file name!".to_string()) + })?; + let file_extension = String::from_utf8( + content_disposition + .get_filename_ext() + .ok_or_else(|| { + CreateError::MissingValueError("Missing file extension!".to_string()) + })? + .clone() + .value, + ) + .map_err(CreateError::InvalidUtf8Input)?; + + if let Some(create_data) = &mod_create_data { + if name == "icon" { + if let Some(ext) = get_image_content_type(file_extension) { + let upload_data = upload_file( + upload_url.get_ref().clone(), + ext, + format!("mods/icons/{}/{}", mod_id.0, file_name), + data.to_vec(), + ) + .await?; + + icon_url = format!("{}/{}", cdn_url, upload_data.file_name); + } else { + panic!("Invalid Icon Format!"); + } + } else if &*file_extension == "jar" { + let initial_version_data = create_data + .initial_versions + .iter() + .position(|x| x.file_indexes.contains(¤t_file_index)); + + if let Some(version_data_index) = initial_version_data { + let version_data = create_data + .initial_versions + .get(version_data_index) + .ok_or_else(|| { + CreateError::MissingValueError( + "Missing file extension!".to_string(), + ) + })? + .clone(); + + let mut created_version_filter = created_versions + .iter_mut() + .filter(|x| x.number == version_data.version_number); + + match created_version_filter.next() { + Some(created_version) => { + let upload_data = upload_file( + upload_url.get_ref().clone(), + "application/java-archive".to_string(), + format!( + "{}/{}/{}", + create_data.mod_namespace.replace(".", "/"), + version_data.version_number, + file_name + ), + data.to_vec(), + ) + .await?; + + created_version.files.push(VersionFile { + game_versions: version_data + .game_versions + .into_iter() + .map(|x| x.0) + .collect(), + hashes: vec![FileHash { + algorithm: "sha1".to_string(), + hash: upload_data.content_sha1, + }], + url: format!("{}/{}", cdn_url, upload_data.file_name), + }); + } + None => { + //Check if ID is unique + let mut version_id = VersionId(random_base62(8)); + retry_count = 0; + + loop { + let filter = doc! { "_id": version_id.0 }; + + if versions.find(filter, None).await?.next().await.is_some() + { + version_id = VersionId(random_base62(8)); + } else { + break; + } + + retry_count += 1; + if retry_count > 20 { + return Err(CreateError::RandomIdError); + } + } + + let body_url = format!( + "data/{}/changelogs/{}/body.md", + mod_id.0, version_id.0 + ); + + upload_file( + upload_url.get_ref().clone(), + "text/plain".to_string(), + body_url.clone(), + version_data.version_body.into_bytes(), + ) + .await?; + + let upload_data = upload_file( + upload_url.get_ref().clone(), + "application/java-archive".to_string(), + format!( + "{}/{}/{}", + create_data.mod_namespace.replace(".", "/"), + version_data.version_number, + file_name + ), + data.to_vec(), + ) + .await?; + + let version = Version { + version_id: version_id.0 as i32, + mod_id: mod_id.0 as i32, + name: version_data.version_title, + number: version_data.version_number.clone(), + changelog_url: Some(format!("{}/{}", cdn_url, body_url)), + date_published: Utc::now().to_rfc2822(), + downloads: 0, + version_type: version_data.version_type.to_string(), + files: vec![VersionFile { + game_versions: version_data + .game_versions + .into_iter() + .map(|x| x.0) + .collect::>(), + hashes: vec![FileHash { + algorithm: "sha1".to_string(), + hash: upload_data.content_sha1, + }], + url: format!("{}/{}", cdn_url, upload_data.file_name), + }], + dependencies: version_data + .dependencies + .into_iter() + .map(|x| x.0 as i32) + .collect::>(), + game_versions: vec![], + loaders: vec![], + }; + //TODO: Malware scan + file validation + + created_versions.push(version); + } + } + } + } + } + } + } + + current_file_index += 1; + } + + for version in &created_versions { + let serialized_version = serde_json::to_string(&version)?; + let document = Bson::from(serialized_version) + .as_document() + .ok_or_else(|| { + CreateError::MissingValueError( + "No document present for database entry!".to_string(), + ) + })? + .clone(); + + versions.insert_one(document, None).await?; + } + + if let Some(create_data) = mod_create_data { + let body_url = format!("data/{}/body.md", mod_id.0); + + upload_file( + upload_url.get_ref().clone(), + "text/plain".to_string(), + body_url.clone(), + create_data.mod_body.into_bytes(), + ) + .await?; + + let created_mod: Mod = Mod { + id: mod_id.0 as i32, + team: Team { + id: random_base62(8) as i32, + members: create_data + .team_members + .into_iter() + .map(|x| crate::database::models::TeamMember { + user_id: x.user_id.0 as i32, + name: x.name, + role: x.role, + }) + .collect(), + }, + title: create_data.mod_name, + icon_url: Some(icon_url), + description: create_data.mod_description, + body_url: format!("{}/{}", cdn_url, body_url), + published: Utc::now().to_rfc2822(), + downloads: 0, + categories: create_data.categories, + version_ids: created_versions + .into_iter() + .map(|x| x.version_id as i32) + .collect::>(), + issues_url: create_data.issues_url, + source_url: create_data.source_url, + wiki_url: create_data.wiki_url, + }; + + let serialized_mod = serde_json::to_string(&created_mod)?; + let document = Bson::from(serialized_mod) + .as_document() + .ok_or_else(|| { + CreateError::MissingValueError( + "No document present for database entry!".to_string(), + ) + })? + .clone(); + + mods.insert_one(document, None).await?; + } + + Ok(HttpResponse::Ok().into()) +} + +fn get_image_content_type(extension: String) -> Option { + let content_type = match &*extension { + "bmp" => "image/bmp", + "gif" => "image/gif", + "jpeg" | "jpg" | "jpe" => "image/jpeg", + "png" => "image/png", + "svg" | "svgz" => "image/svg+xml", + "webp" => "image/webp", + "rgb" => "image/x-rgb", + _ => "", + }; + + if content_type != "" { + Some(content_type.to_string()) + } else { + None + } +} diff --git a/src/routes/mods.rs b/src/routes/mods.rs index c56b63fdf..3620f45c0 100644 --- a/src/routes/mods.rs +++ b/src/routes/mods.rs @@ -2,7 +2,7 @@ use crate::models::mods::SearchRequest; use crate::search::{search_for_mod, SearchError}; use actix_web::{get, web, HttpResponse}; -#[get("api/v1/mods")] +#[get("api/v1/mod")] pub async fn mod_search( web::Query(info): web::Query, ) -> Result { diff --git a/src/search/indexing/local_import.rs b/src/search/indexing/local_import.rs index 4e9f4846d..01c85487d 100644 --- a/src/search/indexing/local_import.rs +++ b/src/search/indexing/local_import.rs @@ -41,16 +41,22 @@ pub async fn index_local(client: mongodb::Client) -> Result, Inde mod_game_versions.append(&mut version.game_versions); } + let mut icon_url = "".to_string(); + + if let Some(url) = result.icon_url { + icon_url = url; + } + docs_to_add.push(SearchMod { mod_id: result.id, - author: result.author, + author: "".to_string(), title: result.title, description: result.description, keywords: result.categories, versions: mod_game_versions, downloads: result.downloads, page_url: "".to_string(), - icon_url: result.icon_path, + icon_url, author_url: "".to_string(), date_created: "".to_string(), created: 0, diff --git a/src/search/indexing/mod.rs b/src/search/indexing/mod.rs index 8bfdce4a1..f06d6392f 100644 --- a/src/search/indexing/mod.rs +++ b/src/search/indexing/mod.rs @@ -43,7 +43,7 @@ pub async fn index_mods(db: mongodb::Client) -> Result<(), IndexingError> { .parse() .expect("`INDEX_CURSEFORGE` is not a boolean.") { - docs_to_add.append(&mut index_curseforge(1, 400000).await?); + docs_to_add.append(&mut index_curseforge(1, 400_000).await?); } //Write Indexes //Relevance Index