From 15c56dfcb8881e397b8741a629d00f98e7c645d1 Mon Sep 17 00:00:00 2001 From: BasiqueEvangelist Date: Mon, 29 Mar 2021 11:36:55 +0300 Subject: [PATCH] Maven endpoint support (#180) * Basic maven endpoint * Clean up maven endpoint * cargo sqlx prepare * Minor cleanup * Remove indentation * Borrow &str instead of &String * Refactor mod_data-getting --- Cargo.lock | 27 ++++ Cargo.toml | 4 + sqlx-data.json | 47 ++++++ src/database/models/mod_item.rs | 2 +- src/main.rs | 1 + src/routes/maven.rs | 257 ++++++++++++++++++++++++++++++++ src/routes/mod.rs | 10 ++ src/routes/mods.rs | 6 +- 8 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 src/routes/maven.rs diff --git a/Cargo.lock b/Cargo.lock index ded4d97ec..f17f90ff4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,7 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +version = 3 + [[package]] name = "actix" version = "0.10.0" @@ -1991,6 +1993,9 @@ dependencies = [ "sha2", "sqlx", "thiserror", + "xml-rs", + "yaserde", + "yaserde_derive", ] [[package]] @@ -4313,3 +4318,25 @@ name = "xml-rs" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b07db065a5cf61a7e4ba64f29e67db906fb1787316516c4e6e5ff0fea1efcd8a" + +[[package]] +name = "yaserde" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc096efbee9ec8fee0600a15bb4fd651ccc14570cb05b6d4dd66b0325e4a0b5e" +dependencies = [ + "log", + "xml-rs", +] + +[[package]] +name = "yaserde_derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af81f1d48039716dd825cf4a7d61d39583f8b12705994abb446bae749a977bb" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 5e81cbaae..9e0d8d436 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,10 @@ actix-ratelimit = "0.3.0" meilisearch-sdk = "0.6.0" reqwest = { version = "0.10.8", features = ["json"] } +yaserde = "0.6.0" +yaserde_derive = "0.6.0" +xml-rs = "0.8.3" + serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } serde_with = "1.5.1" diff --git a/sqlx-data.json b/sqlx-data.json index 0969b2c66..2e545c1bb 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -310,6 +310,27 @@ ] } }, + "153100dc632392c4d446cc768235d071bac26a0818a4a72d203d8e549f969eea": { + "query": "SELECT id FROM versions WHERE mod_id = $1 AND version_number = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false + ] + } + }, "15b8ea323c2f6d03c2e385d9c46d7f13460764f2f106fd638226c42ae0217f75": { "query": "\n DELETE FROM notifications\n WHERE user_id = $1\n ", "describe": { @@ -4283,6 +4304,32 @@ "nullable": [] } }, + "cdd51904a4617d8a2616d9ad4b4274fa2e66e87db1825496854021a26798207c": { + "query": "\n SELECT version_number, release_channels.channel channel\n FROM versions\n LEFT JOIN release_channels ON release_channels.id = versions.release_channel\n WHERE mod_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version_number", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "channel", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false + ] + } + }, "cdd7f8f95c308d9474e214d584c03be0466214da1e157f6bc577b76dbef7df86": { "query": "\n DELETE FROM hashes\n WHERE file_id = $1\n ", "describe": { diff --git a/src/database/models/mod_item.rs b/src/database/models/mod_item.rs index 93fc027b9..432258f36 100644 --- a/src/database/models/mod_item.rs +++ b/src/database/models/mod_item.rs @@ -405,7 +405,7 @@ impl Mod { } pub async fn get_full_from_slug<'a, 'b, E>( - slug: String, + slug: &str, executor: E, ) -> Result, sqlx::error::Error> where diff --git a/src/main.rs b/src/main.rs index 92981bf54..5b7491fe1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -310,6 +310,7 @@ async fn main() -> std::io::Result<()> { .configure(routes::reports_config) .configure(routes::notifications_config), ) + .service(web::scope("/maven/").configure(routes::maven_config)) .default_service(web::get().to(routes::not_found)) }) .bind(dotenv::var("BIND_ADDR").unwrap())? diff --git a/src/routes/maven.rs b/src/routes/maven.rs new file mode 100644 index 000000000..0b674ddb5 --- /dev/null +++ b/src/routes/maven.rs @@ -0,0 +1,257 @@ +use crate::auth::get_user_from_headers; +use crate::database; +use crate::models::mods::ModId; +use crate::routes::ApiError; +use actix_web::{get, web, HttpRequest, HttpResponse}; +use sqlx::PgPool; +use yaserde_derive::YaSerialize; + +#[derive(Default, Debug, Clone, YaSerialize)] +#[yaserde(root = "metadata", rename = "metadata")] +pub struct Metadata { + #[yaserde(rename = "groupId")] + group_id: String, + #[yaserde(rename = "artifactId")] + artifact_id: String, + versioning: Versioning, +} +#[derive(Default, Debug, Clone, YaSerialize)] +#[yaserde(rename = "versioning")] +pub struct Versioning { + latest: String, + release: String, + versions: Versions, + #[yaserde(rename = "lastUpdated")] + last_updated: String, +} +#[derive(Default, Debug, Clone, YaSerialize)] +#[yaserde(rename = "versions")] +pub struct Versions { + #[yaserde(rename = "version")] + versions: Vec, +} +#[derive(Default, Debug, Clone, YaSerialize)] +#[yaserde(rename = "project", namespace = "http://maven.apache.org/POM/4.0.0")] +pub struct MavenPom { + #[yaserde(rename = "xsi:schemaLocation", attribute)] + schema_location: String, + #[yaserde(rename = "xmlns:xsi", attribute)] + xsi: String, + #[yaserde(rename = "modelVersion")] + model_version: String, + #[yaserde(rename = "groupId")] + group_id: String, + #[yaserde(rename = "artifactId")] + artifact_id: String, + version: String, + name: String, + description: String, +} + +#[get("maven/modrinth/{id}/maven-metadata.xml")] +pub async fn maven_metadata( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, +) -> Result { + let string = info.into_inner().0; + let id_option: Option = serde_json::from_str(&*format!("\"{}\"", string)).ok(); + + let mod_data = if let Some(id) = id_option { + match database::models::Mod::get_full(id.into(), &**pool).await { + Ok(Some(data)) => Ok(Some(data)), + Ok(None) => database::models::Mod::get_full_from_slug(&string, &**pool).await, + Err(e) => Err(e), + } + } else { + database::models::Mod::get_full_from_slug(&string, &**pool).await + } + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + let user_option = get_user_from_headers(req.headers(), &**pool).await.ok(); + + let data = if let Some(data) = mod_data { + data + } else { + return Ok(HttpResponse::NotFound().body("")); + }; + + let mut authorized = !data.status.is_hidden(); + + if let Some(user) = user_option { + if !authorized { + if user.role.is_mod() { + authorized = true; + } else { + let user_id: database::models::ids::UserId = user.id.into(); + + let mod_exists = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)", + data.inner.team_id as database::models::ids::TeamId, + user_id as database::models::ids::UserId, + ) + .fetch_one(&**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))? + .exists; + + authorized = mod_exists.unwrap_or(false); + } + } + } + + if !authorized { + return Ok(HttpResponse::NotFound().body("")); + } + let version_names = sqlx::query!( + " + SELECT version_number, release_channels.channel channel + FROM versions + LEFT JOIN release_channels ON release_channels.id = versions.release_channel + WHERE mod_id = $1 + ", + data.inner.id as database::models::ids::ModId + ) + .fetch_all(&**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + let respdata = Metadata { + group_id: "maven.modrinth".to_string(), + artifact_id: string, + versioning: Versioning { + latest: version_names + .last() + .map_or("release", |x| &x.version_number) + .to_string(), + release: version_names + .iter() + .rfind(|x| x.channel == "release") + .map_or("", |x| &x.version_number) + .to_string(), + versions: Versions { + versions: version_names + .iter() + .map(|x| x.version_number.clone()) + .collect::>(), + }, + last_updated: data.inner.updated.format("%Y%m%d%H%M%S").to_string(), + }, + }; + + Ok(HttpResponse::Ok() + .content_type("text/xml") + .body(yaserde::ser::to_string(&respdata).map_err(|e| ApiError::XmlError(e))?)) +} + +#[get("maven/modrinth/{id}/{versionnum}/{file}")] +pub async fn version_file( + req: HttpRequest, + web::Path((string, vnum, file)): web::Path<(String, String, String)>, + pool: web::Data, +) -> Result { + let id_option: Option = serde_json::from_str(&*format!("\"{}\"", string)).ok(); + + let mod_data = if let Some(id) = id_option { + match database::models::Mod::get_full(id.into(), &**pool).await { + Ok(Some(data)) => Ok(Some(data)), + Ok(None) => database::models::Mod::get_full_from_slug(&string, &**pool).await, + Err(e) => Err(e), + } + } else { + database::models::Mod::get_full_from_slug(&string, &**pool).await + } + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + let user_option = get_user_from_headers(req.headers(), &**pool).await.ok(); + + let data = if let Some(data) = mod_data { + data + } else { + return Ok(HttpResponse::NotFound().body("")); + }; + + let mut authorized = !data.status.is_hidden(); + + if let Some(user) = user_option { + if !authorized { + if user.role.is_mod() { + authorized = true; + } else { + let user_id: database::models::ids::UserId = user.id.into(); + + let mod_exists = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)", + data.inner.team_id as database::models::ids::TeamId, + user_id as database::models::ids::UserId, + ) + .fetch_one(&**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))? + .exists; + + authorized = mod_exists.unwrap_or(false); + } + } + } + + if !authorized { + return Ok(HttpResponse::NotFound().body("")); + } + + let vid = if let Some(vid) = sqlx::query!( + "SELECT id FROM versions WHERE mod_id = $1 AND version_number = $2", + data.inner.id as database::models::ids::ModId, + vnum + ) + .fetch_optional(&**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))? + { + vid + } else { + return Ok(HttpResponse::NotFound().body("")); + }; + + let version = if let Some(version) = + database::models::Version::get_full(database::models::ids::VersionId(vid.id), &**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))? + { + version + } else { + return Ok(HttpResponse::NotFound().body("")); + }; + + if file == format!("{}-{}.pom", &string, &version.version_number) { + let respdata = MavenPom { + schema_location: + "http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" + .to_string(), + xsi: "http://www.w3.org/2001/XMLSchema-instance".to_string(), + model_version: "4.0.0".to_string(), + group_id: "maven.modrinth".to_string(), + artifact_id: string, + version: version.version_number, + name: data.inner.title, + description: data.inner.description, + }; + return Ok(HttpResponse::Ok() + .content_type("text/xml") + .body(yaserde::ser::to_string(&respdata).map_err(|e| ApiError::XmlError(e))?)); + } else { + if let Some(selected_file) = version.files.iter().find(|x| x.filename == file) { + return Ok(HttpResponse::TemporaryRedirect() + .header("Location", &*selected_file.url) + .body("")); + } else if file == format!("{}-{}.jar", &string, &version.version_number) { + if let Some(selected_file) = version.files.iter().last() { + return Ok(HttpResponse::TemporaryRedirect() + .header("Location", &*selected_file.url) + .body("")); + } + }; + } + + Ok(HttpResponse::NotFound().body("")) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index db4a26564..ad66d1f97 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -2,6 +2,7 @@ use actix_web::web; mod auth; mod index; +mod maven; mod mod_creation; mod moderation; mod mods; @@ -39,6 +40,11 @@ pub fn mods_config(cfg: &mut web::ServiceConfig) { ); } +pub fn maven_config(cfg: &mut web::ServiceConfig) { + cfg.service(maven::maven_metadata); + cfg.service(maven::version_file); +} + pub fn versions_config(cfg: &mut web::ServiceConfig) { cfg.service(versions::versions_get); cfg.service(version_creation::version_create); @@ -113,6 +119,8 @@ pub enum ApiError { FileHostingError(#[from] FileHostingError), #[error("Internal server error: {0}")] DatabaseError(#[from] crate::database::models::DatabaseError), + #[error("Internal server error: {0}")] + XmlError(String), #[error("Deserialization error: {0}")] JsonError(#[from] serde_json::Error), #[error("Authentication Error: {0}")] @@ -134,6 +142,7 @@ impl actix_web::ResponseError for ApiError { ApiError::DatabaseError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, ApiError::AuthenticationError(..) => actix_web::http::StatusCode::UNAUTHORIZED, ApiError::CustomAuthenticationError(..) => actix_web::http::StatusCode::UNAUTHORIZED, + ApiError::XmlError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, ApiError::JsonError(..) => actix_web::http::StatusCode::BAD_REQUEST, ApiError::SearchError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, ApiError::IndexingError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, @@ -150,6 +159,7 @@ impl actix_web::ResponseError for ApiError { ApiError::DatabaseError(..) => "database_error", ApiError::AuthenticationError(..) => "unauthorized", ApiError::CustomAuthenticationError(..) => "unauthorized", + ApiError::XmlError(..) => "xml_error", ApiError::JsonError(..) => "json_error", ApiError::SearchError(..) => "search_error", ApiError::IndexingError(..) => "indexing_error", diff --git a/src/routes/mods.rs b/src/routes/mods.rs index 59d12d6ba..05d3fc7f0 100644 --- a/src/routes/mods.rs +++ b/src/routes/mods.rs @@ -88,7 +88,7 @@ pub async fn mod_slug_get( pool: web::Data, ) -> Result { let id = info.into_inner().0; - let mod_data = database::models::Mod::get_full_from_slug(id, &**pool) + let mod_data = database::models::Mod::get_full_from_slug(&id, &**pool) .await .map_err(|e| ApiError::DatabaseError(e.into()))?; let user_option = get_user_from_headers(req.headers(), &**pool).await.ok(); @@ -145,12 +145,12 @@ pub async fn mod_get( .map_err(|e| ApiError::DatabaseError(e.into()))?; if mod_data.is_none() { - mod_data = database::models::Mod::get_full_from_slug(string, &**pool) + mod_data = database::models::Mod::get_full_from_slug(&string, &**pool) .await .map_err(|e| ApiError::DatabaseError(e.into()))?; } } else { - mod_data = database::models::Mod::get_full_from_slug(string, &**pool) + mod_data = database::models::Mod::get_full_from_slug(&string, &**pool) .await .map_err(|e| ApiError::DatabaseError(e.into()))?; }