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
This commit is contained in:
BasiqueEvangelist 2021-03-29 11:36:55 +03:00 committed by GitHub
parent b98ad47618
commit 15c56dfcb8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 350 additions and 4 deletions

27
Cargo.lock generated
View File

@ -1,5 +1,7 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3
[[package]] [[package]]
name = "actix" name = "actix"
version = "0.10.0" version = "0.10.0"
@ -1991,6 +1993,9 @@ dependencies = [
"sha2", "sha2",
"sqlx", "sqlx",
"thiserror", "thiserror",
"xml-rs",
"yaserde",
"yaserde_derive",
] ]
[[package]] [[package]]
@ -4313,3 +4318,25 @@ name = "xml-rs"
version = "0.8.3" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b07db065a5cf61a7e4ba64f29e67db906fb1787316516c4e6e5ff0fea1efcd8a" 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",
]

View File

@ -22,6 +22,10 @@ actix-ratelimit = "0.3.0"
meilisearch-sdk = "0.6.0" meilisearch-sdk = "0.6.0"
reqwest = { version = "0.10.8", features = ["json"] } 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_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_with = "1.5.1" serde_with = "1.5.1"

View File

@ -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": { "15b8ea323c2f6d03c2e385d9c46d7f13460764f2f106fd638226c42ae0217f75": {
"query": "\n DELETE FROM notifications\n WHERE user_id = $1\n ", "query": "\n DELETE FROM notifications\n WHERE user_id = $1\n ",
"describe": { "describe": {
@ -4283,6 +4304,32 @@
"nullable": [] "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": { "cdd7f8f95c308d9474e214d584c03be0466214da1e157f6bc577b76dbef7df86": {
"query": "\n DELETE FROM hashes\n WHERE file_id = $1\n ", "query": "\n DELETE FROM hashes\n WHERE file_id = $1\n ",
"describe": { "describe": {

View File

@ -405,7 +405,7 @@ impl Mod {
} }
pub async fn get_full_from_slug<'a, 'b, E>( pub async fn get_full_from_slug<'a, 'b, E>(
slug: String, slug: &str,
executor: E, executor: E,
) -> Result<Option<QueryMod>, sqlx::error::Error> ) -> Result<Option<QueryMod>, sqlx::error::Error>
where where

View File

@ -310,6 +310,7 @@ async fn main() -> std::io::Result<()> {
.configure(routes::reports_config) .configure(routes::reports_config)
.configure(routes::notifications_config), .configure(routes::notifications_config),
) )
.service(web::scope("/maven/").configure(routes::maven_config))
.default_service(web::get().to(routes::not_found)) .default_service(web::get().to(routes::not_found))
}) })
.bind(dotenv::var("BIND_ADDR").unwrap())? .bind(dotenv::var("BIND_ADDR").unwrap())?

257
src/routes/maven.rs Normal file
View File

@ -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<String>,
}
#[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<PgPool>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
let id_option: Option<ModId> = 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::<Vec<_>>(),
},
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<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id_option: Option<ModId> = 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(""))
}

View File

@ -2,6 +2,7 @@ use actix_web::web;
mod auth; mod auth;
mod index; mod index;
mod maven;
mod mod_creation; mod mod_creation;
mod moderation; mod moderation;
mod mods; 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) { pub fn versions_config(cfg: &mut web::ServiceConfig) {
cfg.service(versions::versions_get); cfg.service(versions::versions_get);
cfg.service(version_creation::version_create); cfg.service(version_creation::version_create);
@ -113,6 +119,8 @@ pub enum ApiError {
FileHostingError(#[from] FileHostingError), FileHostingError(#[from] FileHostingError),
#[error("Internal server error: {0}")] #[error("Internal server error: {0}")]
DatabaseError(#[from] crate::database::models::DatabaseError), DatabaseError(#[from] crate::database::models::DatabaseError),
#[error("Internal server error: {0}")]
XmlError(String),
#[error("Deserialization error: {0}")] #[error("Deserialization error: {0}")]
JsonError(#[from] serde_json::Error), JsonError(#[from] serde_json::Error),
#[error("Authentication Error: {0}")] #[error("Authentication Error: {0}")]
@ -134,6 +142,7 @@ impl actix_web::ResponseError for ApiError {
ApiError::DatabaseError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, ApiError::DatabaseError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::AuthenticationError(..) => actix_web::http::StatusCode::UNAUTHORIZED, ApiError::AuthenticationError(..) => actix_web::http::StatusCode::UNAUTHORIZED,
ApiError::CustomAuthenticationError(..) => 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::JsonError(..) => actix_web::http::StatusCode::BAD_REQUEST,
ApiError::SearchError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, ApiError::SearchError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::IndexingError(..) => 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::DatabaseError(..) => "database_error",
ApiError::AuthenticationError(..) => "unauthorized", ApiError::AuthenticationError(..) => "unauthorized",
ApiError::CustomAuthenticationError(..) => "unauthorized", ApiError::CustomAuthenticationError(..) => "unauthorized",
ApiError::XmlError(..) => "xml_error",
ApiError::JsonError(..) => "json_error", ApiError::JsonError(..) => "json_error",
ApiError::SearchError(..) => "search_error", ApiError::SearchError(..) => "search_error",
ApiError::IndexingError(..) => "indexing_error", ApiError::IndexingError(..) => "indexing_error",

View File

@ -88,7 +88,7 @@ pub async fn mod_slug_get(
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0; 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 .await
.map_err(|e| ApiError::DatabaseError(e.into()))?; .map_err(|e| ApiError::DatabaseError(e.into()))?;
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok(); 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()))?; .map_err(|e| ApiError::DatabaseError(e.into()))?;
if mod_data.is_none() { 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 .await
.map_err(|e| ApiError::DatabaseError(e.into()))?; .map_err(|e| ApiError::DatabaseError(e.into()))?;
} }
} else { } else {
mod_data = database::models::Mod::get_full_from_slug(string, &**pool) mod_data = database::models::Mod::get_full_from_slug(&string, &**pool)
.await .await
.map_err(|e| ApiError::DatabaseError(e.into()))?; .map_err(|e| ApiError::DatabaseError(e.into()))?;
} }