Project Types, Code Cleanup, and Rename Mods -> Projects (#192)

* Initial work for modpacks and project types

* Code cleanup, fix some issues

* Username route getting, remove pointless tests

* Base validator types + fixes

* Fix strange IML generation

* Multiple hash requests for version files

* Fix docker build (hopefully)

* Legacy routes

* Finish validator architecture

* Update rust version in dockerfile

* Added caching and fixed typo (#203)

* Added caching and fixed typo

* Fixed clippy error

* Removed log for cache

* Add final validators, fix how loaders are handled and add icons to tags

* Fix search module

* Fix parts of legacy API not working

Co-authored-by: Redblueflame <contact@redblueflame.com>
This commit is contained in:
Geometrically
2021-05-30 15:02:07 -07:00
committed by GitHub
parent 712424c339
commit 16db28060c
55 changed files with 6656 additions and 3908 deletions

View File

@@ -11,4 +11,4 @@ pub async fn index_get() -> HttpResponse {
});
HttpResponse::Ok().json(data)
}
}

View File

@@ -1,6 +1,6 @@
use crate::auth::get_user_from_headers;
use crate::database;
use crate::models::mods::ModId;
use crate::models::projects::ProjectId;
use crate::routes::ApiError;
use actix_web::{get, web, HttpRequest, HttpResponse};
use sqlx::PgPool;
@@ -55,22 +55,13 @@ pub async fn maven_metadata(
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 project_data =
database::models::Project::get_full_from_slug_or_project_id(string, &**pool).await?;
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
let data = if let Some(data) = mod_data {
let data = if let Some(data) = project_data {
data
} else {
return Ok(HttpResponse::NotFound().body(""));
@@ -85,17 +76,16 @@ pub async fn maven_metadata(
} else {
let user_id: database::models::ids::UserId = user.id.into();
let mod_exists = sqlx::query!(
let project_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()))?
.await?
.exists;
authorized = mod_exists.unwrap_or(false);
authorized = project_exists.unwrap_or(false);
}
}
}
@@ -110,15 +100,16 @@ pub async fn maven_metadata(
LEFT JOIN release_channels ON release_channels.id = versions.release_channel
WHERE mod_id = $1
",
data.inner.id as database::models::ids::ModId
data.inner.id as database::models::ids::ProjectId
)
.fetch_all(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
let project_id: ProjectId = data.inner.id.into();
let respdata = Metadata {
group_id: "maven.modrinth".to_string(),
artifact_id: string,
artifact_id: format!("{}", project_id),
versioning: Versioning {
latest: version_names
.last()
@@ -141,7 +132,7 @@ pub async fn maven_metadata(
Ok(HttpResponse::Ok()
.content_type("text/xml")
.body(yaserde::ser::to_string(&respdata).map_err(|e| ApiError::XmlError(e))?))
.body(yaserde::ser::to_string(&respdata).map_err(ApiError::XmlError)?))
}
#[get("maven/modrinth/{id}/{versionnum}/{file}")]
@@ -150,22 +141,21 @@ pub async fn version_file(
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 id_option: Option<ProjectId> = 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 {
let project_data = if let Some(id) = id_option {
match database::models::Project::get_full(id.into(), &**pool).await {
Ok(Some(data)) => Ok(Some(data)),
Ok(None) => database::models::Mod::get_full_from_slug(&string, &**pool).await,
Ok(None) => database::models::Project::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()))?;
database::models::Project::get_full_from_slug(&string, &**pool).await
}?;
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
let data = if let Some(data) = mod_data {
let data = if let Some(data) = project_data {
data
} else {
return Ok(HttpResponse::NotFound().body(""));
@@ -180,17 +170,16 @@ pub async fn version_file(
} else {
let user_id: database::models::ids::UserId = user.id.into();
let mod_exists = sqlx::query!(
let project_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()))?
.await?
.exists;
authorized = mod_exists.unwrap_or(false);
authorized = project_exists.unwrap_or(false);
}
}
}
@@ -201,12 +190,11 @@ pub async fn version_file(
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,
data.inner.id as database::models::ids::ProjectId,
vnum
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.await?
{
vid
} else {
@@ -215,8 +203,7 @@ pub async fn version_file(
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()))?
.await?
{
version
} else {
@@ -238,19 +225,17 @@ pub async fn version_file(
};
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) {
.body(yaserde::ser::to_string(&respdata).map_err(ApiError::XmlError)?));
} 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(""));
} 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

@@ -1,18 +1,22 @@
use actix_web::web;
mod v1;
pub use v1::v1_config;
mod auth;
mod index;
mod maven;
mod mod_creation;
mod moderation;
mod mods;
mod not_found;
mod notifications;
mod project_creation;
mod projects;
mod reports;
mod tags;
mod teams;
mod users;
mod version_creation;
mod version_file;
mod versions;
pub use auth::config as auth_config;
@@ -22,21 +26,35 @@ pub use self::index::index_get;
pub use self::not_found::not_found;
use crate::file_hosting::FileHostingError;
pub fn mods_config(cfg: &mut web::ServiceConfig) {
cfg.service(mods::mod_search);
cfg.service(mods::mods_get);
cfg.service(mod_creation::mod_create);
pub fn v2_config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/v2/")
.configure(auth_config)
.configure(tags_config)
.configure(projects_config)
.configure(versions_config)
.configure(teams_config)
.configure(users_config)
.configure(moderation_config)
.configure(reports_config)
.configure(notifications_config),
);
}
pub fn projects_config(cfg: &mut web::ServiceConfig) {
cfg.service(projects::project_search);
cfg.service(projects::projects_get);
cfg.service(project_creation::project_create);
cfg.service(
web::scope("mod")
.service(mods::mod_slug_get)
.service(mods::mod_get)
.service(mods::mod_delete)
.service(mods::mod_edit)
.service(mods::mod_icon_edit)
.service(mods::mod_follow)
.service(mods::mod_unfollow)
.service(web::scope("{mod_id}").service(versions::version_list)),
web::scope("project")
.service(projects::project_get)
.service(projects::project_delete)
.service(projects::project_edit)
.service(projects::project_icon_edit)
.service(projects::project_follow)
.service(projects::project_unfollow)
.service(web::scope("{project_id}").service(versions::version_list)),
);
}
@@ -57,9 +75,17 @@ pub fn versions_config(cfg: &mut web::ServiceConfig) {
);
cfg.service(
web::scope("version_file")
.service(versions::delete_file)
.service(versions::get_version_from_hash)
.service(versions::download_version),
.service(version_file::delete_file)
.service(version_file::get_version_from_hash)
.service(version_file::download_version)
.service(version_file::get_update_from_hash),
);
cfg.service(
web::scope("version_files")
.service(version_file::get_versions_from_hashes)
.service(version_file::download_files)
.service(version_file::update_files),
);
}
@@ -69,9 +95,8 @@ pub fn users_config(cfg: &mut web::ServiceConfig) {
cfg.service(users::users_get);
cfg.service(
web::scope("user")
.service(users::user_username_get)
.service(users::user_get)
.service(users::mods_list)
.service(users::projects_list)
.service(users::user_delete)
.service(users::user_edit)
.service(users::user_icon_edit)
@@ -102,7 +127,7 @@ pub fn notifications_config(cfg: &mut web::ServiceConfig) {
}
pub fn moderation_config(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("moderation").service(moderation::mods));
cfg.service(web::scope("moderation").service(moderation::get_projects));
}
pub fn reports_config(cfg: &mut web::ServiceConfig) {
@@ -117,8 +142,10 @@ pub enum ApiError {
EnvError(#[from] dotenv::Error),
#[error("Error while uploading file")]
FileHostingError(#[from] FileHostingError),
#[error("Internal server error: {0}")]
#[error("Database Error: {0}")]
DatabaseError(#[from] crate::database::models::DatabaseError),
#[error("Database Error: {0}")]
SqlxDatabaseError(#[from] sqlx::Error),
#[error("Internal server error: {0}")]
XmlError(String),
#[error("Deserialization error: {0}")]
@@ -129,6 +156,8 @@ pub enum ApiError {
CustomAuthenticationError(String),
#[error("Invalid Input: {0}")]
InvalidInputError(String),
#[error("Error while validating input: {0}")]
ValidationError(#[from] validator::ValidationErrors),
#[error("Search Error: {0}")]
SearchError(#[from] meilisearch_sdk::errors::Error),
#[error("Indexing Error: {0}")]
@@ -140,6 +169,7 @@ impl actix_web::ResponseError for ApiError {
match self {
ApiError::EnvError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::DatabaseError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::SqlxDatabaseError(..) => 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,
@@ -148,6 +178,7 @@ impl actix_web::ResponseError for ApiError {
ApiError::IndexingError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::FileHostingError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::InvalidInputError(..) => actix_web::http::StatusCode::BAD_REQUEST,
ApiError::ValidationError(..) => actix_web::http::StatusCode::BAD_REQUEST,
}
}
@@ -156,6 +187,7 @@ impl actix_web::ResponseError for ApiError {
crate::models::error::ApiError {
error: match self {
ApiError::EnvError(..) => "environment_error",
ApiError::SqlxDatabaseError(..) => "database_error",
ApiError::DatabaseError(..) => "database_error",
ApiError::AuthenticationError(..) => "unauthorized",
ApiError::CustomAuthenticationError(..) => "unauthorized",
@@ -165,6 +197,7 @@ impl actix_web::ResponseError for ApiError {
ApiError::IndexingError(..) => "indexing_error",
ApiError::FileHostingError(..) => "file_hosting_error",
ApiError::InvalidInputError(..) => "invalid_input",
ApiError::ValidationError(..) => "invalid_input",
},
description: &self.to_string(),
},

View File

@@ -1,7 +1,7 @@
use super::ApiError;
use crate::auth::check_is_moderator_from_headers;
use crate::database;
use crate::models::mods::{Mod, ModStatus};
use crate::models::projects::{Project, ProjectStatus};
use actix_web::{get, web, HttpRequest, HttpResponse};
use serde::Deserialize;
use sqlx::PgPool;
@@ -9,15 +9,15 @@ use sqlx::PgPool;
#[derive(Deserialize)]
pub struct ResultCount {
#[serde(default = "default_count")]
count: i16,
pub count: i16,
}
fn default_count() -> i16 {
100
}
#[get("mods")]
pub async fn mods(
#[get("projects")]
pub async fn get_projects(
req: HttpRequest,
pool: web::Data<PgPool>,
count: web::Query<ResultCount>,
@@ -26,7 +26,7 @@ pub async fn mods(
use futures::stream::TryStreamExt;
let mod_ids = sqlx::query!(
let project_ids = sqlx::query!(
"
SELECT id FROM mods
WHERE status = (
@@ -35,21 +35,19 @@ pub async fn mods(
ORDER BY updated ASC
LIMIT $2;
",
ModStatus::Processing.as_str(),
ProjectStatus::Processing.as_str(),
count.count as i64
)
.fetch_many(&**pool)
.try_filter_map(|e| async { Ok(e.right().map(|m| database::models::ids::ModId(m.id))) })
.try_collect::<Vec<database::models::ModId>>()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.try_filter_map(|e| async { Ok(e.right().map(|m| database::models::ProjectId(m.id))) })
.try_collect::<Vec<database::models::ProjectId>>()
.await?;
let mods: Vec<Mod> = database::models::mod_item::Mod::get_many_full(mod_ids, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
let projects: Vec<Project> = database::Project::get_many_full(project_ids, &**pool)
.await?
.into_iter()
.map(super::mods::convert_mod)
.map(super::projects::convert_project)
.collect();
Ok(HttpResponse::Ok().json(mods))
Ok(HttpResponse::Ok().json(projects))
}

View File

@@ -27,8 +27,7 @@ pub async fn notifications_get(
let notifications_data =
database::models::notification_item::Notification::get_many(notification_ids, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
let mut notifications: Vec<Notification> = Vec::new();
@@ -52,9 +51,7 @@ pub async fn notification_get(
let id = info.into_inner().0;
let notification_data =
database::models::notification_item::Notification::get(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
database::models::notification_item::Notification::get(id.into(), &**pool).await?;
if let Some(data) = notification_data {
if user.id == data.user_id.into() || user.role.is_mod() {
@@ -100,17 +97,13 @@ pub async fn notification_delete(
let id = info.into_inner().0;
let notification_data =
database::models::notification_item::Notification::get(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
database::models::notification_item::Notification::get(id.into(), &**pool).await?;
if let Some(data) = notification_data {
if data.user_id == user.id.into() || user.role.is_mod() {
database::models::notification_item::Notification::remove(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
database::models::notification_item::Notification::remove(id.into(), &**pool).await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::CustomAuthenticationError(
"You are not authorized to delete this notification!".to_string(),

View File

@@ -2,7 +2,9 @@ use crate::auth::{get_user_from_headers, AuthenticationError};
use crate::database::models;
use crate::file_hosting::{FileHost, FileHostingError};
use crate::models::error::ApiError;
use crate::models::mods::{DonationLink, License, ModId, ModStatus, SideType, VersionId};
use crate::models::projects::{
DonationLink, License, ProjectId, ProjectStatus, SideType, VersionId,
};
use crate::models::users::UserId;
use crate::routes::version_creation::InitialVersionData;
use crate::search::indexing::{queue::CreationQueue, IndexingError};
@@ -11,16 +13,19 @@ use actix_web::http::StatusCode;
use actix_web::web::Data;
use actix_web::{post, HttpRequest, HttpResponse};
use futures::stream::StreamExt;
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool;
use std::sync::Arc;
use thiserror::Error;
use validator::Validate;
#[derive(Error, Debug)]
pub enum CreateError {
#[error("Environment Error")]
EnvError(#[from] dotenv::Error),
#[error("An unknown database error occured")]
#[error("An unknown database error occurred")]
SqlxDatabaseError(#[from] sqlx::Error),
#[error("Database Error: {0}")]
DatabaseError(#[from] models::DatabaseError),
@@ -30,11 +35,15 @@ pub enum CreateError {
MultipartError(actix_multipart::MultipartError),
#[error("Error while parsing JSON: {0}")]
SerDeError(#[from] serde_json::Error),
#[error("Error while validating input: {0}")]
ValidationError(#[from] validator::ValidationErrors),
#[error("Error while uploading file")]
FileHostingError(#[from] FileHostingError),
#[error("Error while validating uploaded file: {0}")]
FileValidationError(#[from] crate::validate::ValidationError),
#[error("{}", .0)]
MissingValueError(String),
#[error("Invalid format for mod icon: {0}")]
#[error("Invalid format for project icon: {0}")]
InvalidIconFormat(String),
#[error("Error with multipart data: {0}")]
InvalidInput(String),
@@ -46,7 +55,7 @@ pub enum CreateError {
InvalidCategory(String),
#[error("Invalid file type for version file: {0}")]
InvalidFileType(String),
#[error("Slug collides with other mod's id!")]
#[error("Slug collides with other project's id!")]
SlugCollision,
#[error("Authentication Error: {0}")]
Unauthorized(#[from] AuthenticationError),
@@ -74,6 +83,8 @@ impl actix_web::ResponseError for CreateError {
CreateError::Unauthorized(..) => StatusCode::UNAUTHORIZED,
CreateError::CustomAuthenticationError(..) => StatusCode::UNAUTHORIZED,
CreateError::SlugCollision => StatusCode::BAD_REQUEST,
CreateError::ValidationError(..) => StatusCode::BAD_REQUEST,
CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST,
}
}
@@ -97,46 +108,81 @@ impl actix_web::ResponseError for CreateError {
CreateError::Unauthorized(..) => "unauthorized",
CreateError::CustomAuthenticationError(..) => "unauthorized",
CreateError::SlugCollision => "invalid_input",
CreateError::ValidationError(..) => "invalid_input",
CreateError::FileValidationError(..) => "invalid_input",
},
description: &self.to_string(),
})
}
}
#[derive(Serialize, Deserialize, Clone)]
struct ModCreateData {
/// The title or name of the mod.
pub mod_name: String,
/// The slug of a mod, used for vanity URLs
pub mod_slug: 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<InitialVersionData>,
/// A list of the categories that the mod is in.
pub categories: Vec<String>,
/// An optional link to where to submit bugs or issues with the mod.
pub issues_url: Option<String>,
/// An optional link to the source code for the mod.
pub source_url: Option<String>,
/// An optional link to the mod's wiki page or other relevant information.
pub wiki_url: Option<String>,
/// An optional link to the mod's license page
pub license_url: Option<String>,
/// An optional link to the mod's discord.
pub discord_url: Option<String>,
/// An optional boolean. If true, the mod will be created as a draft.
pub is_draft: Option<bool>,
/// The support range for the client mod
lazy_static! {
static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_-]*$").unwrap();
}
fn default_project_type() -> String {
"mod".to_string()
}
#[derive(Serialize, Deserialize, Validate, Clone)]
struct ProjectCreateData {
#[validate(length(min = 3, max = 256))]
#[serde(alias = "mod_name")]
/// The title or name of the project.
pub title: String,
#[validate(length(min = 1, max = 64))]
#[serde(default = "default_project_type")]
/// The project type of this mod
pub project_type: String,
#[validate(length(min = 3, max = 64), regex = "RE_URL_SAFE")]
#[serde(alias = "mod_slug")]
/// The slug of a project, used for vanity URLs
pub slug: String,
#[validate(length(min = 3, max = 2048))]
#[serde(alias = "mod_description")]
/// A short description of the project.
pub description: String,
#[validate(length(max = 65536))]
#[serde(alias = "mod_body")]
/// A long description of the project, in markdown.
pub body: String,
/// The support range for the client project
pub client_side: SideType,
/// The support range for the server mod
/// The support range for the server project
pub server_side: SideType,
/// The license id that the mod follows
pub license_id: String,
/// An optional list of all donation links the mod has
#[validate(length(max = 64))]
/// A list of initial versions to upload with the created project
pub initial_versions: Vec<InitialVersionData>,
#[validate(length(max = 3))]
/// A list of the categories that the project is in.
pub categories: Vec<String>,
#[validate(url, length(max = 2048))]
/// An optional link to where to submit bugs or issues with the project.
pub issues_url: Option<String>,
#[validate(url, length(max = 2048))]
/// An optional link to the source code for the project.
pub source_url: Option<String>,
#[validate(url, length(max = 2048))]
/// An optional link to the project's wiki page or other relevant information.
pub wiki_url: Option<String>,
#[validate(url, length(max = 2048))]
/// An optional link to the project's license page
pub license_url: Option<String>,
#[validate(url, length(max = 2048))]
/// An optional link to the project's discord.
pub discord_url: Option<String>,
/// An optional list of all donation links the project has\
#[validate]
pub donation_urls: Option<Vec<DonationLink>>,
/// An optional boolean. If true, the project will be created as a draft.
pub is_draft: Option<bool>,
/// The license id that the project follows
pub license_id: String,
}
pub struct UploadedFile {
@@ -156,8 +202,8 @@ pub async fn undo_uploads(
Ok(())
}
#[post("mod")]
pub async fn mod_create(
#[post("project")]
pub async fn project_create(
req: HttpRequest,
payload: Multipart,
client: Data<PgPool>,
@@ -167,7 +213,7 @@ pub async fn mod_create(
let mut transaction = client.begin().await?;
let mut uploaded_files = Vec::new();
let result = mod_create_inner(
let result = project_create_inner(
req,
payload,
&mut transaction,
@@ -196,7 +242,7 @@ pub async fn mod_create(
/*
Mod Creation Steps:
Project Creation Steps:
Get logged in user
Must match the author in the version creation
@@ -206,12 +252,12 @@ Get logged in user
- Create versions
- Some shared logic with version creation
- Create list of VersionBuilders
- Create ModBuilder
- Create ProjectBuilder
2. Upload
- Icon: check file format & size
- Upload to backblaze & record URL
- Mod files
- Project files
- Check for matching version
- File size limits?
- Check file type
@@ -221,10 +267,10 @@ Get logged in user
3. Creation
- Database stuff
- Add mod data to indexing queue
- Add project data to indexing queue
*/
async fn mod_create_inner(
pub async fn project_create_inner(
req: HttpRequest,
mut payload: Multipart,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
@@ -238,15 +284,17 @@ async fn mod_create_inner(
// The currently logged in user
let current_user = get_user_from_headers(req.headers(), &mut *transaction).await?;
let mod_id: ModId = models::generate_mod_id(transaction).await?.into();
let project_id: ProjectId = models::generate_project_id(transaction).await?.into();
let mod_create_data;
let project_create_data;
let mut versions;
let mut versions_map = std::collections::HashMap::new();
let all_game_versions = models::categories::GameVersion::list(&mut *transaction).await?;
{
// The first multipart field must be named "data" and contain a
// JSON `ModCreateData` object.
// JSON `ProjectCreateData` object.
let mut field = payload
.next()
@@ -275,75 +323,20 @@ async fn mod_create_inner(
while let Some(chunk) = field.next().await {
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
}
let create_data: ModCreateData = serde_json::from_slice(&data)?;
let create_data: ProjectCreateData = serde_json::from_slice(&data)?;
{
// Verify the lengths of various fields in the mod create data
/*
# ModCreateData
mod_name: 3..=256
mod_description: 3..=2048,
mod_body: max of 64KiB?,
categories: Vec<String>, 1..=256
issues_url: 0..=2048, (Validate url?)
source_url: 0..=2048,
wiki_url: 0..=2048,
create_data.validate()?;
initial_versions: Vec<InitialVersionData>,
team_members: Vec<TeamMember>,
let slug_project_id_option: Option<ProjectId> =
serde_json::from_str(&*format!("\"{}\"", create_data.slug)).ok();
# TeamMember:
name: 3..=64
role: 3..=64
*/
check_length(3..=256, "mod name", &create_data.mod_name)?;
check_length(3..=2048, "mod description", &create_data.mod_description)?;
check_length(3..=64, "mod slug", &create_data.mod_slug)?;
check_length(..65536, "mod body", &create_data.mod_body)?;
if create_data.categories.len() > 3 {
return Err(CreateError::InvalidInput(
"The maximum number of categories for a mod is four.".to_string(),
));
}
create_data
.categories
.iter()
.try_for_each(|f| check_length(1..=256, "category", f))?;
if let Some(url) = &create_data.issues_url {
check_length(..=2048, "url", url)?;
}
if let Some(url) = &create_data.wiki_url {
check_length(..=2048, "url", url)?;
}
if let Some(url) = &create_data.source_url {
check_length(..=2048, "url", url)?;
}
if let Some(url) = &create_data.discord_url {
check_length(..=2048, "url", url)?;
}
if let Some(url) = &create_data.license_url {
check_length(..=2048, "url", url)?;
}
create_data
.initial_versions
.iter()
.try_for_each(|v| super::version_creation::check_version(v))?;
}
let slug_modid_option: Option<ModId> =
serde_json::from_str(&*format!("\"{}\"", create_data.mod_slug)).ok();
if let Some(slug_modid) = slug_modid_option {
let slug_modid: models::ids::ModId = slug_modid.into();
if let Some(slug_project_id) = slug_project_id_option {
let slug_project_id: models::ids::ProjectId = slug_project_id.into();
let results = sqlx::query!(
"
SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)
",
slug_modid as models::ids::ModId
slug_project_id as models::ids::ProjectId
)
.fetch_one(&mut *transaction)
.await
@@ -366,13 +359,31 @@ async fn mod_create_inner(
)));
}
}
versions
.push(create_initial_version(data, mod_id, current_user.id, transaction).await?);
versions.push(
create_initial_version(
data,
project_id,
current_user.id,
&all_game_versions,
transaction,
)
.await?,
);
}
mod_create_data = create_data;
project_create_data = create_data;
}
let project_type_id =
models::ProjectTypeId::get_id(project_create_data.project_type.clone(), &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::InvalidInput(format!(
"Project Type {} does not exist.",
project_create_data.project_type.clone()
))
})?;
let mut icon_url = None;
while let Some(item) = payload.next().await {
@@ -391,14 +402,14 @@ async fn mod_create_inner(
if name == "icon" {
if icon_url.is_some() {
return Err(CreateError::InvalidInput(String::from(
"Mods can only have one icon",
"Projects can only have one icon",
)));
}
// Upload the icon to the cdn
icon_url = Some(
process_icon_upload(
uploaded_files,
mod_id,
project_id,
file_extension,
file_host,
field,
@@ -420,27 +431,33 @@ async fn mod_create_inner(
// `index` is always valid for these lists
let created_version = versions.get_mut(index).unwrap();
let version_data = mod_create_data.initial_versions.get(index).unwrap();
let version_data = project_create_data.initial_versions.get(index).unwrap();
// Upload the new jar file
let file_builder = super::version_creation::upload_file(
super::version_creation::upload_file(
&mut field,
file_host,
uploaded_files,
&mut created_version.files,
&cdn_url,
&content_disposition,
mod_id,
project_id,
&version_data.version_number,
&*project_create_data.project_type,
version_data.loaders.clone(),
version_data.game_versions.clone(),
&all_game_versions,
false,
)
.await?;
// Add the newly uploaded file to the existing or new version
created_version.files.push(file_builder);
}
{
// Check to make sure that all specified files were uploaded
for (version_data, builder) in mod_create_data.initial_versions.iter().zip(versions.iter())
for (version_data, builder) in project_create_data
.initial_versions
.iter()
.zip(versions.iter())
{
if version_data.file_parts.len() != builder.files.len() {
return Err(CreateError::InvalidInput(String::from(
@@ -450,11 +467,15 @@ async fn mod_create_inner(
}
// Convert the list of category names to actual categories
let mut categories = Vec::with_capacity(mod_create_data.categories.len());
for category in &mod_create_data.categories {
let id = models::categories::Category::get_id(&category, &mut *transaction)
.await?
.ok_or_else(|| CreateError::InvalidCategory(category.clone()))?;
let mut categories = Vec::with_capacity(project_create_data.categories.len());
for category in &project_create_data.categories {
let id = models::categories::Category::get_id_project(
&category,
project_type_id,
&mut *transaction,
)
.await?
.ok_or_else(|| CreateError::InvalidCategory(category.clone()))?;
categories.push(id);
}
@@ -470,10 +491,10 @@ async fn mod_create_inner(
let team_id = team.insert(&mut *transaction).await?;
let status;
if mod_create_data.is_draft.unwrap_or(false) {
status = ModStatus::Draft;
if project_create_data.is_draft.unwrap_or(false) {
status = ProjectStatus::Draft;
} else {
status = ModStatus::Processing;
status = ProjectStatus::Processing;
}
let status_id = models::StatusId::get_id(&status, &mut *transaction)
@@ -482,7 +503,7 @@ async fn mod_create_inner(
CreateError::InvalidInput(format!("Status {} does not exist.", status.clone()))
})?;
let client_side_id =
models::SideTypeId::get_id(&mod_create_data.client_side, &mut *transaction)
models::SideTypeId::get_id(&project_create_data.client_side, &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::InvalidInput(
@@ -491,7 +512,7 @@ async fn mod_create_inner(
})?;
let server_side_id =
models::SideTypeId::get_id(&mod_create_data.server_side, &mut *transaction)
models::SideTypeId::get_id(&project_create_data.server_side, &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::InvalidInput(
@@ -500,14 +521,14 @@ async fn mod_create_inner(
})?;
let license_id =
models::categories::License::get_id(&mod_create_data.license_id, &mut *transaction)
models::categories::License::get_id(&project_create_data.license_id, &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::InvalidInput("License specified does not exist.".to_string())
})?;
let mut donation_urls = vec![];
if let Some(urls) = &mod_create_data.donation_urls {
if let Some(urls) = &project_create_data.donation_urls {
for url in urls {
let platform_id = models::DonationPlatformId::get_id(&url.id, &mut *transaction)
.await?
@@ -518,8 +539,8 @@ async fn mod_create_inner(
))
})?;
donation_urls.push(models::mod_item::DonationUrl {
mod_id: mod_id.into(),
donation_urls.push(models::project_item::DonationUrl {
project_id: project_id.into(),
platform_id,
platform_short: "".to_string(),
platform_name: "".to_string(),
@@ -528,72 +549,76 @@ async fn mod_create_inner(
}
}
let mod_builder = models::mod_item::ModBuilder {
mod_id: mod_id.into(),
let project_builder = models::project_item::ProjectBuilder {
project_id: project_id.into(),
project_type_id,
team_id,
title: mod_create_data.mod_name,
description: mod_create_data.mod_description,
body: mod_create_data.mod_body,
title: project_create_data.title,
description: project_create_data.description,
body: project_create_data.body,
icon_url,
issues_url: mod_create_data.issues_url,
source_url: mod_create_data.source_url,
wiki_url: mod_create_data.wiki_url,
issues_url: project_create_data.issues_url,
source_url: project_create_data.source_url,
wiki_url: project_create_data.wiki_url,
license_url: mod_create_data.license_url,
discord_url: mod_create_data.discord_url,
license_url: project_create_data.license_url,
discord_url: project_create_data.discord_url,
categories,
initial_versions: versions,
status: status_id,
client_side: client_side_id,
server_side: server_side_id,
license: license_id,
slug: Some(mod_create_data.mod_slug),
slug: Some(project_create_data.slug),
donation_urls,
};
let now = chrono::Utc::now();
let response = crate::models::mods::Mod {
id: mod_id,
slug: mod_builder.slug.clone(),
let response = crate::models::projects::Project {
id: project_id,
slug: project_builder.slug.clone(),
project_type: project_create_data.project_type.clone(),
team: team_id.into(),
title: mod_builder.title.clone(),
description: mod_builder.description.clone(),
body: mod_builder.body.clone(),
title: project_builder.title.clone(),
description: project_builder.description.clone(),
body: project_builder.body.clone(),
body_url: None,
published: now,
updated: now,
status: status.clone(),
license: License {
id: mod_create_data.license_id.clone(),
id: project_create_data.license_id.clone(),
name: "".to_string(),
url: mod_builder.license_url.clone(),
url: project_builder.license_url.clone(),
},
client_side: mod_create_data.client_side,
server_side: mod_create_data.server_side,
client_side: project_create_data.client_side,
server_side: project_create_data.server_side,
downloads: 0,
followers: 0,
categories: mod_create_data.categories,
versions: mod_builder
categories: project_create_data.categories,
versions: project_builder
.initial_versions
.iter()
.map(|v| v.version_id.into())
.collect::<Vec<_>>(),
icon_url: mod_builder.icon_url.clone(),
issues_url: mod_builder.issues_url.clone(),
source_url: mod_builder.source_url.clone(),
wiki_url: mod_builder.wiki_url.clone(),
discord_url: mod_builder.discord_url.clone(),
donation_urls: mod_create_data.donation_urls.clone(),
icon_url: project_builder.icon_url.clone(),
issues_url: project_builder.issues_url.clone(),
source_url: project_builder.source_url.clone(),
wiki_url: project_builder.wiki_url.clone(),
discord_url: project_builder.discord_url.clone(),
donation_urls: project_create_data.donation_urls.clone(),
};
let _mod_id = mod_builder.insert(&mut *transaction).await?;
let _project_id = project_builder.insert(&mut *transaction).await?;
if status.is_searchable() {
let index_mod =
crate::search::indexing::local_import::query_one(mod_id.into(), &mut *transaction)
.await?;
indexing_queue.add(index_mod);
let index_project = crate::search::indexing::local_import::query_one(
project_id.into(),
&mut *transaction,
)
.await?;
indexing_queue.add(index_project);
}
Ok(HttpResponse::Ok().json(response))
@@ -602,18 +627,18 @@ async fn mod_create_inner(
async fn create_initial_version(
version_data: &InitialVersionData,
mod_id: ModId,
project_id: ProjectId,
author: UserId,
all_game_versions: &[models::categories::GameVersion],
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<models::version_item::VersionBuilder, CreateError> {
if version_data.mod_id.is_some() {
if version_data.project_id.is_some() {
return Err(CreateError::InvalidInput(String::from(
"Found mod id in initial version for new mod",
"Found project id in initial version for new project",
)));
}
check_length(3..=256, "version name", &version_data.version_title)?;
check_length(1..=32, "version number", &version_data.version_number)?;
version_data.validate()?;
// Randomly generate a new id to be used for the version
let version_id: VersionId = models::generate_version_id(transaction).await?.into();
@@ -623,13 +648,17 @@ async fn create_initial_version(
.await?
.expect("Release Channel not found in database");
let mut game_versions = Vec::with_capacity(version_data.game_versions.len());
for v in &version_data.game_versions {
let id = models::categories::GameVersion::get_id(&v.0, &mut *transaction)
.await?
.ok_or_else(|| CreateError::InvalidGameVersion(v.0.clone()))?;
game_versions.push(id);
}
let game_versions = version_data
.game_versions
.iter()
.map(|x| {
all_game_versions
.iter()
.find(|y| y.version == x.0)
.ok_or_else(|| CreateError::InvalidGameVersion(x.0.clone()))
.map(|y| y.id)
})
.collect::<Result<Vec<models::GameVersionId>, CreateError>>()?;
let mut loaders = Vec::with_capacity(version_data.loaders.len());
for l in &version_data.loaders {
@@ -647,7 +676,7 @@ async fn create_initial_version(
let version = models::version_item::VersionBuilder {
version_id: version_id.into(),
mod_id: mod_id.into(),
project_id: project_id.into(),
author_id: author.into(),
name: version_data.version_title.clone(),
version_number: version_data.version_number.clone(),
@@ -668,7 +697,7 @@ async fn create_initial_version(
async fn process_icon_upload(
uploaded_files: &mut Vec<UploadedFile>,
mod_id: ModId,
project_id: ProjectId,
file_extension: &str,
file_host: &dyn FileHost,
mut field: actix_multipart::Field,
@@ -689,7 +718,7 @@ async fn process_icon_upload(
let upload_data = file_host
.upload_file(
content_type,
&format!("data/{}/icon.{}", mod_id, file_extension),
&format!("data/{}/icon.{}", project_id, file_extension),
data,
)
.await?;
@@ -723,31 +752,3 @@ pub fn get_image_content_type(extension: &str) -> Option<&'static str> {
None
}
}
pub fn check_length(
range: impl std::ops::RangeBounds<usize> + std::fmt::Debug,
field_name: &str,
field: &str,
) -> Result<(), CreateError> {
use std::ops::Bound;
let length = field.len();
if !range.contains(&length) {
let bounds = match (range.start_bound(), range.end_bound()) {
(Bound::Included(a), Bound::Included(b)) => format!("between {} and {} bytes", a, b),
(Bound::Included(a), Bound::Excluded(b)) => {
format!("between {} and {} bytes", a, b - 1)
}
(Bound::Included(a), Bound::Unbounded) => format!("more than {} bytes", a),
(Bound::Unbounded, Bound::Included(b)) => format!("less than or equal to {} bytes", b),
(Bound::Unbounded, Bound::Excluded(b)) => format!("less than {} bytes", b),
_ => format!("{:?}", range),
};
Err(CreateError::InvalidInput(format!(
"The {} must be {}; got {}.",
field_name, bounds, length
)))
} else {
Ok(())
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::models::ids::{ModId, UserId, VersionId};
use crate::models::ids::{ProjectId, UserId, VersionId};
use crate::models::reports::{ItemType, Report};
use crate::routes::ApiError;
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
@@ -21,10 +21,7 @@ pub async fn report_create(
pool: web::Data<PgPool>,
mut body: web::Payload,
) -> Result<HttpResponse, ApiError> {
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mut transaction = pool.begin().await?;
let current_user = get_user_from_headers(req.headers(), &mut *transaction).await?;
@@ -48,7 +45,7 @@ pub async fn report_create(
let mut report = crate::database::models::report_item::Report {
id,
report_type_id: report_type,
mod_id: None,
project_id: None,
version_id: None,
user_id: None,
body: new_report.body.clone(),
@@ -57,9 +54,10 @@ pub async fn report_create(
};
match new_report.item_type {
ItemType::Mod => {
report.mod_id =
Some(serde_json::from_str::<ModId>(&*format!("\"{}\"", new_report.item_id))?.into())
ItemType::Project => {
report.project_id = Some(
serde_json::from_str::<ProjectId>(&*format!("\"{}\"", new_report.item_id))?.into(),
)
}
ItemType::Version => {
report.version_id = Some(
@@ -79,14 +77,8 @@ pub async fn report_create(
}
}
report
.insert(&mut transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
report.insert(&mut transaction).await?;
transaction.commit().await?;
Ok(HttpResponse::Ok().json(Report {
id: id.into(),
@@ -133,12 +125,10 @@ pub async fn reports(
.map(|m| crate::database::models::ids::ReportId(m.id)))
})
.try_collect::<Vec<crate::database::models::ids::ReportId>>()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
let query_reports = crate::database::models::report_item::Report::get_many(report_ids, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let query_reports =
crate::database::models::report_item::Report::get_many(report_ids, &**pool).await?;
let mut reports = Vec::new();
@@ -146,9 +136,9 @@ pub async fn reports(
let mut item_id = "".to_string();
let mut item_type = ItemType::Unknown;
if let Some(mod_id) = x.mod_id {
item_id = serde_json::to_string::<ModId>(&mod_id.into())?;
item_type = ItemType::Mod;
if let Some(project_id) = x.project_id {
item_id = serde_json::to_string::<ProjectId>(&project_id.into())?;
item_type = ItemType::Project;
} else if let Some(version_id) = x.version_id {
item_id = serde_json::to_string::<VersionId>(&version_id.into())?;
item_type = ItemType::Version;
@@ -183,11 +173,10 @@ pub async fn delete_report(
info.into_inner().0.into(),
&**pool,
)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}

View File

@@ -1,7 +1,7 @@
use super::ApiError;
use crate::auth::check_is_admin_from_headers;
use crate::database::models;
use crate::database::models::categories::{DonationPlatform, License, ReportType};
use crate::database::models::categories::{DonationPlatform, License, ProjectType, ReportType};
use actix_web::{delete, get, put, web, HttpRequest, HttpResponse};
use models::categories::{Category, GameVersion, Loader};
use sqlx::PgPool;
@@ -30,27 +30,55 @@ pub fn config(cfg: &mut web::ServiceConfig) {
);
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct CategoryData {
icon: String,
name: String,
project_type: String,
}
// TODO: searching / filtering? Could be used to implement a live
// searching category list
#[get("category")]
pub async fn category_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results = Category::list(&**pool).await?;
let results = Category::list(&**pool)
.await?
.into_iter()
.map(|x| CategoryData {
icon: x.icon,
name: x.category,
project_type: x.project_type,
})
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(results))
}
#[put("category/{name}")]
#[put("category")]
pub async fn category_create(
req: HttpRequest,
pool: web::Data<PgPool>,
category: web::Path<(String,)>,
new_category: web::Json<CategoryData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = category.into_inner().0;
let project_type = crate::database::models::ProjectTypeId::get_id(
(&new_category).project_type.clone(),
&**pool,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError("Specified project type does not exist!".to_string())
})?;
let _id = Category::builder().name(&name)?.insert(&**pool).await?;
let _id = Category::builder()
.name(&new_category.name)?
.project_type(&project_type)?
.icon(&new_category.icon)?
.insert(&**pool)
.await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
#[delete("category/{name}")]
@@ -72,31 +100,56 @@ pub async fn category_delete(
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct LoaderData {
icon: String,
name: String,
supported_project_types: Vec<String>,
}
#[get("loader")]
pub async fn loader_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results = Loader::list(&**pool).await?;
let results = Loader::list(&**pool)
.await?
.into_iter()
.map(|x| LoaderData {
icon: x.icon,
name: x.loader,
supported_project_types: x.supported_project_types,
})
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(results))
}
#[put("loader/{name}")]
#[put("loader")]
pub async fn loader_create(
req: HttpRequest,
pool: web::Data<PgPool>,
loader: web::Path<(String,)>,
new_loader: web::Json<LoaderData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = loader.into_inner().0;
let mut transaction = pool.begin().await?;
let _id = Loader::builder().name(&name)?.insert(&**pool).await?;
let project_types =
ProjectType::get_many_id(&new_loader.supported_project_types, &mut *transaction).await?;
Ok(HttpResponse::Ok().body(""))
let _id = Loader::builder()
.name(&new_loader.name)?
.icon(&new_loader.icon)?
.supported_project_types(&*project_types.into_iter().map(|x| x.id).collect::<Vec<_>>())?
.insert(&mut transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}
#[delete("loader/{name}")]
@@ -118,14 +171,21 @@ pub async fn loader_delete(
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(serde::Deserialize)]
#[derive(serde::Serialize)]
pub struct GameVersionQueryData {
pub version: String,
pub version_type: String,
pub date: chrono::DateTime<chrono::Utc>,
}
#[derive(serde::Deserialize)]
pub struct GameVersionQuery {
#[serde(rename = "type")]
type_: Option<String>,
major: Option<bool>,
@@ -134,16 +194,22 @@ pub struct GameVersionQueryData {
#[get("game_version")]
pub async fn game_version_list(
pool: web::Data<PgPool>,
query: web::Query<GameVersionQueryData>,
query: web::Query<GameVersionQuery>,
) -> Result<HttpResponse, ApiError> {
if query.type_.is_some() || query.major.is_some() {
let results =
GameVersion::list_filter(query.type_.as_deref(), query.major, &**pool).await?;
Ok(HttpResponse::Ok().json(results))
let results: Vec<GameVersionQueryData> = if query.type_.is_some() || query.major.is_some() {
GameVersion::list_filter(query.type_.as_deref(), query.major, &**pool).await?
} else {
let results = GameVersion::list(&**pool).await?;
Ok(HttpResponse::Ok().json(results))
GameVersion::list(&**pool).await?
}
.into_iter()
.map(|x| GameVersionQueryData {
version: x.version,
version_type: x.version_type,
date: x.date,
})
.collect();
Ok(HttpResponse::Ok().json(results))
}
#[derive(serde::Deserialize)]
@@ -177,7 +243,7 @@ pub async fn game_version_create(
let _id = builder.insert(&**pool).await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
#[delete("game_version/{name}")]
@@ -199,7 +265,7 @@ pub async fn game_version_delete(
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
@@ -246,7 +312,7 @@ pub async fn license_create(
.insert(&**pool)
.await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
#[delete("license/{name}")]
@@ -268,7 +334,7 @@ pub async fn license_delete(
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
@@ -315,7 +381,7 @@ pub async fn donation_platform_create(
.insert(&**pool)
.await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
#[delete("donation_platform/{name}")]
@@ -337,7 +403,7 @@ pub async fn donation_platform_delete(
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
@@ -361,7 +427,7 @@ pub async fn report_type_create(
let _id = ReportType::builder().name(&name)?.insert(&**pool).await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
#[delete("report_type/{name}")]
@@ -383,7 +449,7 @@ pub async fn report_type_delete(
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}

View File

@@ -1,7 +1,7 @@
use crate::auth::get_user_from_headers;
use crate::database::models::notification_item::{NotificationActionBuilder, NotificationBuilder};
use crate::database::models::TeamMember;
use crate::models::ids::ModId;
use crate::models::ids::ProjectId;
use crate::models::teams::{Permissions, TeamId};
use crate::models::users::UserId;
use crate::routes::ApiError;
@@ -76,10 +76,7 @@ pub async fn join_team(
"You are already a member of this team".to_string(),
));
}
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mut transaction = pool.begin().await?;
// Edit Team Member to set Accepted to True
TeamMember::edit_team_member(
@@ -92,17 +89,14 @@ pub async fn join_team(
)
.await?;
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
transaction.commit().await?;
} else {
return Err(ApiError::InvalidInputError(
"There is no pending request from this team".to_string(),
));
}
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
fn default_role() -> String {
@@ -127,10 +121,7 @@ pub async fn add_team_member(
) -> Result<HttpResponse, ApiError> {
let team_id = info.into_inner().0.into();
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mut transaction = pool.begin().await?;
let current_user = get_user_from_headers(req.headers(), &**pool).await?;
let team_member =
@@ -181,8 +172,7 @@ pub async fn add_team_member(
}
crate::database::models::User::get(member.user_id, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.await?
.ok_or_else(|| ApiError::InvalidInputError("An invalid User ID specified".to_string()))?;
let new_id = crate::database::models::ids::generate_team_member_id(&mut transaction).await?;
@@ -195,8 +185,7 @@ pub async fn add_team_member(
accepted: false,
}
.insert(&mut transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
let result = sqlx::query!(
"
@@ -206,17 +195,16 @@ pub async fn add_team_member(
team_id as crate::database::models::ids::TeamId
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
let team: TeamId = team_id.into();
NotificationBuilder {
title: "You have been invited to join a team!".to_string(),
text: format!(
"Team invite from {} to join the team for mod {}",
"Team invite from {} to join the team for project {}",
current_user.username, result.title
),
link: format!("mod/{}", ModId(result.id as u64)),
link: format!("project/{}", ProjectId(result.id as u64)),
actions: vec![
NotificationActionBuilder {
title: "Accept".to_string(),
@@ -234,12 +222,9 @@ pub async fn add_team_member(
.insert(new_member.user_id.into(), &mut transaction)
.await?;
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
transaction.commit().await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
#[derive(Serialize, Deserialize, Clone)]
@@ -262,10 +247,7 @@ pub async fn edit_team_member(
let current_user = get_user_from_headers(req.headers(), &**pool).await?;
let team_member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?;
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mut transaction = pool.begin().await?;
let member = match team_member {
Some(m) => m,
@@ -306,12 +288,9 @@ pub async fn edit_team_member(
)
.await?;
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
transaction.commit().await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
#[delete("{id}/members/{user_id}")]
@@ -371,7 +350,7 @@ pub async fn remove_team_member(
"You do not have permission to cancel a team invite".to_string(),
));
}
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}

View File

@@ -1,33 +1,28 @@
use crate::auth::get_user_from_headers;
use crate::database::models::User;
use crate::file_hosting::FileHost;
use crate::models::ids::ModId;
use crate::models::mods::ModStatus;
use crate::models::ids::ProjectId;
use crate::models::notifications::Notification;
use crate::models::projects::ProjectStatus;
use crate::models::users::{Role, UserId};
use crate::routes::notifications::convert_notification;
use crate::routes::ApiError;
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
use futures::StreamExt;
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
use validator::Validate;
#[get("user")]
pub async fn user_auth_get(
req: HttpRequest,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
Ok(HttpResponse::Ok().json(
get_user_from_headers(
req.headers(),
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?,
))
Ok(HttpResponse::Ok()
.json(get_user_from_headers(req.headers(), &mut *pool.acquire().await?).await?))
}
#[derive(Serialize, Deserialize)]
@@ -45,33 +40,13 @@ pub async fn users_get(
.map(|x| x.into())
.collect();
let users_data = User::get_many(user_ids, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let users_data = User::get_many(user_ids, &**pool).await?;
let users: Vec<crate::models::users::User> = users_data.into_iter().map(convert_user).collect();
Ok(HttpResponse::Ok().json(users))
}
#[get("@{id}")]
pub async fn user_username_get(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let user_data = User::get_from_username(id, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(data) = user_data {
let response = convert_user(data);
Ok(HttpResponse::Ok().json(response))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[get("{id}")]
pub async fn user_get(
info: web::Path<(String,)>,
@@ -83,19 +58,13 @@ pub async fn user_get(
let mut user_data;
if let Some(id) = id_option {
user_data = User::get(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
user_data = User::get(id.into(), &**pool).await?;
if user_data.is_none() {
user_data = User::get_from_username(string, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
user_data = User::get_from_username(string, &**pool).await?;
}
} else {
user_data = User::get_from_username(string, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
user_data = User::get_from_username(string, &**pool).await?;
}
if let Some(data) = user_data {
@@ -120,48 +89,35 @@ fn convert_user(data: crate::database::models::user_item::User) -> crate::models
}
}
#[get("{user_id}/mods")]
pub async fn mods_list(
#[get("{user_id}/projects")]
pub async fn projects_list(
req: HttpRequest,
info: web::Path<(UserId,)>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await.ok();
let id: crate::database::models::UserId = info.into_inner().0.into();
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
let user_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)",
id as crate::database::models::UserId,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.exists;
if user_exists.unwrap_or(false) {
if let Some(id) = id_option {
let user_id: UserId = id.into();
let mod_data = if let Some(current_user) = user {
let project_data = if let Some(current_user) = user {
if current_user.role.is_mod() || current_user.id == user_id {
User::get_mods_private(id, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
User::get_projects_private(id, &**pool).await?
} else {
User::get_mods(id, ModStatus::Approved.as_str(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
User::get_projects(id, ProjectStatus::Approved.as_str(), &**pool).await?
}
} else {
User::get_mods(id, ModStatus::Approved.as_str(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
User::get_projects(id, ProjectStatus::Approved.as_str(), &**pool).await?
};
let response = mod_data
let response = project_data
.into_iter()
.map(|v| v.into())
.collect::<Vec<crate::models::ids::ModId>>();
.collect::<Vec<crate::models::ids::ProjectId>>();
Ok(HttpResponse::Ok().json(response))
} else {
@@ -169,140 +125,147 @@ pub async fn mods_list(
}
}
#[derive(Serialize, Deserialize)]
lazy_static! {
static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_-]*$").unwrap();
}
#[derive(Serialize, Deserialize, Validate)]
pub struct EditUser {
#[validate(length(min = 1, max = 255), regex = "RE_URL_SAFE")]
pub username: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
#[validate(length(min = 1, max = 255), regex = "RE_URL_SAFE")]
pub name: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
#[validate(email)]
pub email: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
#[validate(length(max = 160))]
pub bio: Option<Option<String>>,
pub role: Option<String>,
pub role: Option<Role>,
}
#[patch("{id}")]
pub async fn user_edit(
req: HttpRequest,
info: web::Path<(UserId,)>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
new_user: web::Json<EditUser>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let user_id = info.into_inner().0;
let id: crate::database::models::ids::UserId = user_id.into();
new_user.validate()?;
if user.id == user_id || user.role.is_mod() {
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
if let Some(username) = &new_user.username {
sqlx::query!(
"
if let Some(id) = id_option {
let user_id: UserId = id.into();
if user.id == user_id || user.role.is_mod() {
let mut transaction = pool.begin().await?;
if let Some(username) = &new_user.username {
sqlx::query!(
"
UPDATE users
SET username = $1
WHERE (id = $2)
",
username,
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
username,
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(name) = &new_user.name {
sqlx::query!(
"
if let Some(name) = &new_user.name {
sqlx::query!(
"
UPDATE users
SET name = $1
WHERE (id = $2)
",
name.as_deref(),
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
name.as_deref(),
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(bio) = &new_user.bio {
sqlx::query!(
"
if let Some(bio) = &new_user.bio {
sqlx::query!(
"
UPDATE users
SET bio = $1
WHERE (id = $2)
",
bio.as_deref(),
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
bio.as_deref(),
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(email) = &new_user.email {
sqlx::query!(
"
if let Some(email) = &new_user.email {
sqlx::query!(
"
UPDATE users
SET email = $1
WHERE (id = $2)
",
email.as_deref(),
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(role) = &new_user.role {
if !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the role of this user!".to_string(),
));
email.as_deref(),
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
}
let role = Role::from_string(role).to_string();
if let Some(role) = &new_user.role {
if !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the role of this user!"
.to_string(),
));
}
sqlx::query!(
"
let role = role.to_string();
sqlx::query!(
"
UPDATE users
SET role = $1
WHERE (id = $2)
",
role,
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
role,
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
}
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().body(""))
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::CustomAuthenticationError(
"You do not have permission to edit this user!".to_string(),
))
}
} else {
Err(ApiError::CustomAuthenticationError(
"You do not have permission to edit this user!".to_string(),
))
Ok(HttpResponse::NotFound().body(""))
}
}
@@ -315,81 +278,87 @@ pub struct Extension {
pub async fn user_icon_edit(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
info: web::Path<(UserId,)>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
mut payload: web::Payload,
) -> Result<HttpResponse, ApiError> {
if let Some(content_type) = super::mod_creation::get_image_content_type(&*ext.ext) {
if let Some(content_type) = super::project_creation::get_image_content_type(&*ext.ext) {
let cdn_url = dotenv::var("CDN_URL")?;
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
if user.id != id && !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to edit this user's icon.".to_string(),
));
}
let mut icon_url = user.avatar_url;
if user.id != id {
let new_user = User::get(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(new) = new_user {
icon_url = new.avatar_url;
} else {
return Ok(HttpResponse::NotFound().body(""));
if let Some(id) = id_option {
if user.id != id.into() && !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to edit this user's icon.".to_string(),
));
}
}
if let Some(icon) = icon_url {
if icon.starts_with(&cdn_url) {
let name = icon.split('/').next();
let mut icon_url = user.avatar_url;
if let Some(icon_path) = name {
file_host.delete_file_version("", icon_path).await?;
let user_id: UserId = id.into();
if user.id != user_id {
let new_user = User::get(id, &**pool).await?;
if let Some(new) = new_user {
icon_url = new.avatar_url;
} else {
return Ok(HttpResponse::NotFound().body(""));
}
}
}
let mut bytes = web::BytesMut::new();
while let Some(item) = payload.next().await {
bytes.extend_from_slice(&item.map_err(|_| {
ApiError::InvalidInputError("Unable to parse bytes in payload sent!".to_string())
})?);
}
if let Some(icon) = icon_url {
if icon.starts_with(&cdn_url) {
let name = icon.split('/').next();
if bytes.len() >= 262144 {
return Err(ApiError::InvalidInputError(String::from(
"Icons must be smaller than 256KiB",
)));
}
if let Some(icon_path) = name {
file_host.delete_file_version("", icon_path).await?;
}
}
}
let upload_data = file_host
.upload_file(
content_type,
&format!("user/{}/icon.{}", id, ext.ext),
bytes.to_vec(),
let mut bytes = web::BytesMut::new();
while let Some(item) = payload.next().await {
bytes.extend_from_slice(&item.map_err(|_| {
ApiError::InvalidInputError(
"Unable to parse bytes in payload sent!".to_string(),
)
})?);
}
if bytes.len() >= 262144 {
return Err(ApiError::InvalidInputError(String::from(
"Icons must be smaller than 256KiB",
)));
}
let upload_data = file_host
.upload_file(
content_type,
&format!("user/{}/icon.{}", user_id, ext.ext),
bytes.to_vec(),
)
.await?;
sqlx::query!(
"
UPDATE users
SET avatar_url = $1
WHERE (id = $2)
",
format!("{}/{}", cdn_url, upload_data.file_name),
id as crate::database::models::ids::UserId,
)
.execute(&**pool)
.await?;
let mod_id: crate::database::models::ids::UserId = id.into();
sqlx::query!(
"
UPDATE users
SET avatar_url = $1
WHERE (id = $2)
",
format!("{}/{}", cdn_url, upload_data.file_name),
mod_id as crate::database::models::ids::UserId,
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Err(ApiError::InvalidInputError(format!(
"Invalid format for user icon: {}",
@@ -411,32 +380,34 @@ fn default_removal() -> String {
#[delete("{id}")]
pub async fn user_delete(
req: HttpRequest,
info: web::Path<(UserId,)>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
removal_type: web::Query<RemovalType>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
if !user.role.is_mod() && user.id != id {
return Err(ApiError::CustomAuthenticationError(
"You do not have permission to delete this user!".to_string(),
));
}
if let Some(id) = id_option {
if !user.role.is_mod() && user.id != id.into() {
return Err(ApiError::CustomAuthenticationError(
"You do not have permission to delete this user!".to_string(),
));
}
let result;
if &*removal_type.removal_type == "full" {
result = crate::database::models::User::remove_full(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
} else {
result = crate::database::models::User::remove(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
};
let result;
if &*removal_type.removal_type == "full" {
result = crate::database::models::User::remove_full(id, &**pool).await?;
} else {
result = crate::database::models::User::remove(id, &**pool).await?;
};
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
@@ -445,59 +416,68 @@ pub async fn user_delete(
#[get("{id}/follows")]
pub async fn user_follows(
req: HttpRequest,
info: web::Path<(UserId,)>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
if !user.role.is_mod() && user.id != id {
return Err(ApiError::CustomAuthenticationError(
"You do not have permission to see the mods this user follows!".to_string(),
));
if let Some(id) = id_option {
if !user.role.is_mod() && user.id != id.into() {
return Err(ApiError::CustomAuthenticationError(
"You do not have permission to see the projects this user follows!".to_string(),
));
}
use futures::TryStreamExt;
let projects: Vec<ProjectId> = sqlx::query!(
"
SELECT mf.mod_id FROM mod_follows mf
WHERE mf.follower_id = $1
",
id as crate::database::models::ids::UserId,
)
.fetch_many(&**pool)
.try_filter_map(|e| async { Ok(e.right().map(|m| ProjectId(m.mod_id as u64))) })
.try_collect::<Vec<ProjectId>>()
.await?;
Ok(HttpResponse::Ok().json(projects))
} else {
Ok(HttpResponse::NotFound().body(""))
}
use futures::TryStreamExt;
let user_id: crate::database::models::UserId = id.into();
let mods: Vec<ModId> = sqlx::query!(
"
SELECT mf.mod_id FROM mod_follows mf
WHERE mf.follower_id = $1
",
user_id as crate::database::models::ids::UserId,
)
.fetch_many(&**pool)
.try_filter_map(|e| async { Ok(e.right().map(|m| ModId(m.mod_id as u64))) })
.try_collect::<Vec<ModId>>()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().json(mods))
}
#[get("{id}/notifications")]
pub async fn user_notifications(
req: HttpRequest,
info: web::Path<(UserId,)>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
if !user.role.is_mod() && user.id != id {
return Err(ApiError::CustomAuthenticationError(
"You do not have permission to see the mods this user follows!".to_string(),
));
if let Some(id) = id_option {
if !user.role.is_mod() && user.id != id.into() {
return Err(ApiError::CustomAuthenticationError(
"You do not have permission to see the notifications of this user!".to_string(),
));
}
let notifications: Vec<Notification> =
crate::database::models::notification_item::Notification::get_many_user(id, &**pool)
.await?
.into_iter()
.map(convert_notification)
.collect();
Ok(HttpResponse::Ok().json(notifications))
} else {
Ok(HttpResponse::NotFound().body(""))
}
let notifications: Vec<Notification> =
crate::database::models::notification_item::Notification::get_many_user(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.into_iter()
.map(convert_notification)
.collect();
Ok(HttpResponse::Ok().json(notifications))
}

129
src/routes/v1/mod.rs Normal file
View File

@@ -0,0 +1,129 @@
use actix_web::web;
mod moderation;
mod mods;
mod reports;
mod tags;
mod users;
mod versions;
pub fn v1_config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/api/v1/")
.configure(super::auth_config)
.configure(tags_config)
.configure(mods_config)
.configure(versions_config)
.configure(teams_config)
.configure(users_config)
.configure(moderation_config)
.configure(reports_config)
.configure(notifications_config),
);
}
pub fn tags_config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/tag/")
.service(tags::category_list)
.service(tags::category_create)
.service(super::tags::category_delete)
.service(tags::loader_list)
.service(tags::loader_create)
.service(super::tags::loader_delete)
.service(super::tags::game_version_list)
.service(super::tags::game_version_create)
.service(super::tags::game_version_delete)
.service(super::tags::license_create)
.service(super::tags::license_delete)
.service(super::tags::license_list)
.service(super::tags::donation_platform_create)
.service(super::tags::donation_platform_list)
.service(super::tags::donation_platform_delete)
.service(super::tags::report_type_create)
.service(super::tags::report_type_delete)
.service(super::tags::report_type_list),
);
}
pub fn mods_config(cfg: &mut web::ServiceConfig) {
cfg.service(mods::mod_search);
cfg.service(mods::mods_get);
cfg.service(mods::mod_create);
cfg.service(
web::scope("mod")
.service(super::projects::project_get)
.service(super::projects::project_delete)
.service(super::projects::project_edit)
.service(super::projects::project_icon_edit)
.service(super::projects::project_follow)
.service(super::projects::project_unfollow)
.service(web::scope("{mod_id}").service(versions::version_list)),
);
}
pub fn versions_config(cfg: &mut web::ServiceConfig) {
cfg.service(versions::versions_get);
cfg.service(super::version_creation::version_create);
cfg.service(
web::scope("version")
.service(versions::version_get)
.service(super::versions::version_delete)
.service(super::version_creation::upload_file_to_version)
.service(super::versions::version_edit),
);
cfg.service(
web::scope("version_file")
.service(versions::delete_file)
.service(versions::get_version_from_hash)
.service(versions::download_version),
);
}
pub fn users_config(cfg: &mut web::ServiceConfig) {
cfg.service(super::users::user_auth_get);
cfg.service(super::users::users_get);
cfg.service(
web::scope("user")
.service(super::users::user_get)
.service(users::mods_list)
.service(super::users::user_delete)
.service(super::users::user_edit)
.service(super::users::user_icon_edit)
.service(super::users::user_notifications)
.service(super::users::user_follows),
);
}
pub fn teams_config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("team")
.service(super::teams::team_members_get)
.service(super::teams::edit_team_member)
.service(super::teams::add_team_member)
.service(super::teams::join_team)
.service(super::teams::remove_team_member),
);
}
pub fn notifications_config(cfg: &mut web::ServiceConfig) {
cfg.service(super::notifications::notifications_get);
cfg.service(
web::scope("notification")
.service(super::notifications::notification_get)
.service(super::notifications::notification_delete),
);
}
pub fn moderation_config(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("moderation").service(moderation::get_mods));
}
pub fn reports_config(cfg: &mut web::ServiceConfig) {
cfg.service(reports::reports);
cfg.service(reports::report_create);
cfg.service(super::reports::delete_report);
}

View File

@@ -0,0 +1,44 @@
use crate::auth::check_is_moderator_from_headers;
use crate::database;
use crate::models::projects::{Project, ProjectStatus};
use crate::routes::moderation::ResultCount;
use crate::routes::ApiError;
use actix_web::web;
use actix_web::{get, HttpRequest, HttpResponse};
use sqlx::PgPool;
#[get("mods")]
pub async fn get_mods(
req: HttpRequest,
pool: web::Data<PgPool>,
count: web::Query<ResultCount>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(req.headers(), &**pool).await?;
use futures::stream::TryStreamExt;
let project_ids = sqlx::query!(
"
SELECT id FROM mods
WHERE status = (
SELECT id FROM statuses WHERE status = $1
)
ORDER BY updated ASC
LIMIT $2;
",
ProjectStatus::Processing.as_str(),
count.count as i64
)
.fetch_many(&**pool)
.try_filter_map(|e| async { Ok(e.right().map(|m| database::models::ProjectId(m.id))) })
.try_collect::<Vec<database::models::ProjectId>>()
.await?;
let projects: Vec<Project> = database::Project::get_many_full(project_ids, &**pool)
.await?
.into_iter()
.map(crate::routes::projects::convert_project)
.collect();
Ok(HttpResponse::Ok().json(projects))
}

172
src/routes/v1/mods.rs Normal file
View File

@@ -0,0 +1,172 @@
use crate::auth::get_user_from_headers;
use crate::file_hosting::FileHost;
use crate::models::projects::SearchRequest;
use crate::routes::project_creation::{project_create_inner, undo_uploads, CreateError};
use crate::routes::projects::{convert_project, ProjectIds};
use crate::routes::ApiError;
use crate::search::indexing::queue::CreationQueue;
use crate::search::{search_for_project, SearchConfig, SearchError};
use crate::{database, models};
use actix_multipart::Multipart;
use actix_web::web;
use actix_web::web::Data;
use actix_web::{get, post, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ResultSearchMod {
pub mod_id: String,
pub slug: Option<String>,
pub author: String,
pub title: String,
pub description: String,
pub categories: Vec<String>,
pub versions: Vec<String>,
pub downloads: i32,
pub follows: i32,
pub page_url: String,
pub icon_url: String,
pub author_url: String,
pub date_created: String,
pub date_modified: String,
pub latest_version: String,
pub license: String,
pub client_side: String,
pub server_side: String,
pub host: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct SearchResults {
pub hits: Vec<ResultSearchMod>,
pub offset: usize,
pub limit: usize,
pub total_hits: usize,
}
#[get("mod")]
pub async fn mod_search(
web::Query(info): web::Query<SearchRequest>,
config: web::Data<SearchConfig>,
) -> Result<HttpResponse, SearchError> {
let results = search_for_project(&info, &**config).await?;
Ok(HttpResponse::Ok().json(SearchResults {
hits: results
.hits
.into_iter()
.map(|x| ResultSearchMod {
mod_id: x.project_id.clone(),
slug: x.slug,
author: x.author.clone(),
title: x.title,
description: x.description,
categories: x.categories,
versions: x.versions,
downloads: x.downloads,
follows: x.follows,
page_url: format!("https://modrinth.com/mod/{}", x.project_id),
icon_url: x.icon_url,
author_url: format!("https://modrinth.com/user/{}", x.author),
date_created: x.date_created,
date_modified: x.date_modified,
latest_version: x.latest_version,
license: x.license,
client_side: x.client_side,
server_side: x.server_side,
host: "modrinth".to_string(),
})
.collect(),
offset: results.offset,
limit: results.limit,
total_hits: results.total_hits,
}))
}
#[get("mods")]
pub async fn mods_get(
req: HttpRequest,
ids: web::Query<ProjectIds>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let project_ids = serde_json::from_str::<Vec<models::ids::ProjectId>>(&*ids.ids)?
.into_iter()
.map(|x| x.into())
.collect();
let projects_data = database::models::Project::get_many_full(project_ids, &**pool).await?;
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
let mut projects = Vec::new();
for project_data in projects_data {
let mut authorized = !project_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 project_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)",
project_data.inner.team_id as database::models::ids::TeamId,
user_id as database::models::ids::UserId,
)
.fetch_one(&**pool)
.await?
.exists;
authorized = project_exists.unwrap_or(false);
}
}
}
if authorized {
projects.push(convert_project(project_data));
}
}
Ok(HttpResponse::Ok().json(projects))
}
#[post("mod")]
pub async fn mod_create(
req: HttpRequest,
payload: Multipart,
client: Data<PgPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
indexing_queue: Data<Arc<CreationQueue>>,
) -> Result<HttpResponse, CreateError> {
let mut transaction = client.begin().await?;
let mut uploaded_files = Vec::new();
let result = project_create_inner(
req,
payload,
&mut transaction,
&***file_host,
&mut uploaded_files,
&***indexing_queue,
)
.await;
if result.is_err() {
let undo_result = undo_uploads(&***file_host, &uploaded_files).await;
let rollback_result = transaction.rollback().await;
if let Err(e) = undo_result {
return Err(e);
}
if let Err(e) = rollback_result {
return Err(e.into());
}
} else {
transaction.commit().await?;
}
result
}

195
src/routes/v1/reports.rs Normal file
View File

@@ -0,0 +1,195 @@
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::models::ids::ReportId;
use crate::models::projects::{ProjectId, VersionId};
use crate::models::users::UserId;
use crate::routes::ApiError;
use actix_web::web;
use actix_web::{get, post, HttpRequest, HttpResponse};
use chrono::{DateTime, Utc};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
#[derive(Serialize, Deserialize)]
pub struct Report {
pub id: ReportId,
pub report_type: String,
pub item_id: String,
pub item_type: ItemType,
pub reporter: UserId,
pub body: String,
pub created: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub enum ItemType {
Mod,
Version,
User,
Unknown,
}
impl ItemType {
pub fn as_str(&self) -> &'static str {
match self {
ItemType::Mod => "mod",
ItemType::Version => "version",
ItemType::User => "user",
ItemType::Unknown => "unknown",
}
}
}
#[derive(Deserialize)]
pub struct CreateReport {
pub report_type: String,
pub item_id: String,
pub item_type: ItemType,
pub body: String,
}
#[post("report")]
pub async fn report_create(
req: HttpRequest,
pool: web::Data<PgPool>,
mut body: web::Payload,
) -> Result<HttpResponse, ApiError> {
let mut transaction = pool.begin().await?;
let current_user = get_user_from_headers(req.headers(), &mut *transaction).await?;
let mut bytes = web::BytesMut::new();
while let Some(item) = body.next().await {
bytes.extend_from_slice(&item.map_err(|_| {
ApiError::InvalidInputError("Error while parsing request payload!".to_string())
})?);
}
let new_report: CreateReport = serde_json::from_slice(bytes.as_ref())?;
let id = crate::database::models::generate_report_id(&mut transaction).await?;
let report_type = crate::database::models::categories::ReportType::get_id(
&*new_report.report_type,
&mut *transaction,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError(format!("Invalid report type: {}", new_report.report_type))
})?;
let mut report = crate::database::models::report_item::Report {
id,
report_type_id: report_type,
project_id: None,
version_id: None,
user_id: None,
body: new_report.body.clone(),
reporter: current_user.id.into(),
created: chrono::Utc::now(),
};
match new_report.item_type {
ItemType::Mod => {
report.project_id = Some(
serde_json::from_str::<ProjectId>(&*format!("\"{}\"", new_report.item_id))?.into(),
)
}
ItemType::Version => {
report.version_id = Some(
serde_json::from_str::<VersionId>(&*format!("\"{}\"", new_report.item_id))?.into(),
)
}
ItemType::User => {
report.user_id = Some(
serde_json::from_str::<UserId>(&*format!("\"{}\"", new_report.item_id))?.into(),
)
}
ItemType::Unknown => {
return Err(ApiError::InvalidInputError(format!(
"Invalid report item type: {}",
new_report.item_type.as_str()
)))
}
}
report.insert(&mut transaction).await?;
transaction.commit().await?;
Ok(HttpResponse::Ok().json(Report {
id: id.into(),
report_type: new_report.report_type.clone(),
item_id: new_report.item_id.clone(),
item_type: new_report.item_type.clone(),
reporter: current_user.id,
body: new_report.body.clone(),
created: chrono::Utc::now(),
}))
}
#[derive(Deserialize)]
pub struct ResultCount {
#[serde(default = "default_count")]
count: i16,
}
fn default_count() -> i16 {
100
}
#[get("report")]
pub async fn reports(
req: HttpRequest,
pool: web::Data<PgPool>,
count: web::Query<ResultCount>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(req.headers(), &**pool).await?;
use futures::stream::TryStreamExt;
let report_ids = sqlx::query!(
"
SELECT id FROM reports
ORDER BY created ASC
LIMIT $1;
",
count.count as i64
)
.fetch_many(&**pool)
.try_filter_map(|e| async {
Ok(e.right()
.map(|m| crate::database::models::ids::ReportId(m.id)))
})
.try_collect::<Vec<crate::database::models::ids::ReportId>>()
.await?;
let query_reports =
crate::database::models::report_item::Report::get_many(report_ids, &**pool).await?;
let mut reports = Vec::new();
for x in query_reports {
let mut item_id = "".to_string();
let mut item_type = ItemType::Unknown;
if let Some(project_id) = x.project_id {
item_id = serde_json::to_string::<ProjectId>(&project_id.into())?;
item_type = ItemType::Mod;
} else if let Some(version_id) = x.version_id {
item_id = serde_json::to_string::<VersionId>(&version_id.into())?;
item_type = ItemType::Version;
} else if let Some(user_id) = x.user_id {
item_id = serde_json::to_string::<UserId>(&user_id.into())?;
item_type = ItemType::User;
}
reports.push(Report {
id: x.id.into(),
report_type: x.report_type,
item_id,
item_type,
reporter: x.reporter.into(),
body: x.body,
created: x.created,
})
}
Ok(HttpResponse::Ok().json(reports))
}

81
src/routes/v1/tags.rs Normal file
View File

@@ -0,0 +1,81 @@
use crate::auth::check_is_admin_from_headers;
use crate::database::models::categories::{Category, Loader, ProjectType};
use crate::routes::ApiError;
use actix_web::{get, put, web};
use actix_web::{HttpRequest, HttpResponse};
use sqlx::PgPool;
const DEFAULT_ICON: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>"#;
#[get("category")]
pub async fn category_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results = Category::list(&**pool)
.await?
.into_iter()
.filter(|x| &*x.project_type == "mod")
.map(|x| x.project_type)
.collect::<Vec<String>>();
Ok(HttpResponse::Ok().json(results))
}
#[put("category/{name}")]
pub async fn category_create(
req: HttpRequest,
pool: web::Data<PgPool>,
category: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = category.into_inner().0;
let project_type = crate::database::models::ProjectTypeId::get_id("mod".to_string(), &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError("Specified project type does not exist!".to_string())
})?;
let _id = Category::builder()
.name(&name)?
.icon(DEFAULT_ICON)?
.project_type(&project_type)?
.insert(&**pool)
.await?;
Ok(HttpResponse::NoContent().body(""))
}
#[get("loader")]
pub async fn loader_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results = Loader::list(&**pool)
.await?
.into_iter()
.filter(|x| x.supported_project_types.contains(&"mod".to_string()))
.map(|x| x.loader)
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(results))
}
#[put("loader/{name}")]
pub async fn loader_create(
req: HttpRequest,
pool: web::Data<PgPool>,
loader: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = loader.into_inner().0;
let mut transaction = pool.begin().await?;
let project_types =
ProjectType::get_many_id(&vec!["mod".to_string()], &mut *transaction).await?;
let _id = Loader::builder()
.name(&name)?
.icon(DEFAULT_ICON)?
.supported_project_types(&*project_types.into_iter().map(|x| x.id).collect::<Vec<_>>())?
.insert(&mut transaction)
.await?;
Ok(HttpResponse::NoContent().body(""))
}

44
src/routes/v1/users.rs Normal file
View File

@@ -0,0 +1,44 @@
use crate::auth::get_user_from_headers;
use crate::database::models::User;
use crate::models::ids::UserId;
use crate::models::projects::ProjectStatus;
use crate::routes::ApiError;
use actix_web::web;
use actix_web::{get, HttpRequest, HttpResponse};
use sqlx::PgPool;
#[get("{user_id}/mods")]
pub async fn mods_list(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await.ok();
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
if let Some(id) = id_option {
let user_id: UserId = id.into();
let project_data = if let Some(current_user) = user {
if current_user.role.is_mod() || current_user.id == user_id {
User::get_projects_private(id, &**pool).await?
} else {
User::get_projects(id, ProjectStatus::Approved.as_str(), &**pool).await?
}
} else {
User::get_projects(id, ProjectStatus::Approved.as_str(), &**pool).await?
};
let response = project_data
.into_iter()
.map(|v| v.into())
.collect::<Vec<crate::models::ids::ProjectId>>();
Ok(HttpResponse::Ok().json(response))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

416
src/routes/v1/versions.rs Normal file
View File

@@ -0,0 +1,416 @@
use crate::auth::get_user_from_headers;
use crate::file_hosting::FileHost;
use crate::models::ids::{ProjectId, UserId, VersionId};
use crate::models::projects::{Dependency, GameVersion, Loader, Version, VersionFile, VersionType};
use crate::models::teams::Permissions;
use crate::routes::versions::{convert_version, VersionIds, VersionListFilters};
use crate::routes::ApiError;
use crate::{database, models, Pepper};
use actix_web::{delete, get, web, HttpRequest, HttpResponse};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::borrow::Borrow;
use std::sync::Arc;
/// A specific version of a mod
#[derive(Serialize, Deserialize)]
pub struct LegacyVersion {
pub id: VersionId,
pub mod_id: ProjectId,
pub author_id: UserId,
pub featured: bool,
pub name: String,
pub version_number: String,
pub changelog: String,
pub changelog_url: Option<String>,
pub date_published: DateTime<Utc>,
pub downloads: u32,
pub version_type: VersionType,
pub files: Vec<VersionFile>,
pub dependencies: Vec<Dependency>,
pub game_versions: Vec<GameVersion>,
pub loaders: Vec<Loader>,
}
fn convert_to_legacy(version: Version) -> LegacyVersion {
LegacyVersion {
id: version.id,
mod_id: version.project_id,
author_id: version.author_id,
featured: version.featured,
name: version.name,
version_number: version.version_number,
changelog: version.changelog,
changelog_url: version.changelog_url,
date_published: version.date_published,
downloads: version.downloads,
version_type: version.version_type,
files: version.files,
dependencies: version.dependencies,
game_versions: version.game_versions,
loaders: version.loaders,
}
}
#[get("version")]
pub async fn version_list(
info: web::Path<(String,)>,
web::Query(filters): web::Query<VersionListFilters>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
let result = database::models::Project::get_from_slug_or_project_id(string, &**pool).await?;
if let Some(project) = result {
let id = project.id;
let version_ids = database::models::Version::get_project_versions(
id,
filters
.game_versions
.as_ref()
.map(|x| serde_json::from_str(x).unwrap_or_default()),
filters
.loaders
.as_ref()
.map(|x| serde_json::from_str(x).unwrap_or_default()),
&**pool,
)
.await?;
let mut versions = database::models::Version::get_many_full(version_ids, &**pool).await?;
let mut response = versions
.iter()
.cloned()
.filter(|version| {
filters
.featured
.map(|featured| featured == version.featured)
.unwrap_or(true)
})
.map(convert_version)
.map(convert_to_legacy)
.collect::<Vec<_>>();
versions.sort_by(|a, b| b.date_published.cmp(&a.date_published));
// Attempt to populate versions with "auto featured" versions
if response.is_empty() && !versions.is_empty() && filters.featured.unwrap_or(false) {
let loaders = database::models::categories::Loader::list(&**pool).await?;
let game_versions =
database::models::categories::GameVersion::list_filter(None, Some(true), &**pool)
.await?;
let mut joined_filters = Vec::new();
for game_version in &game_versions {
for loader in &loaders {
joined_filters.push((game_version, loader))
}
}
joined_filters.into_iter().for_each(|filter| {
versions
.iter()
.find(|version| {
version.game_versions.contains(&filter.0.version)
&& version.loaders.contains(&filter.1.loader)
})
.map(|version| {
response.push(convert_to_legacy(convert_version(version.clone())))
})
.unwrap_or(());
});
if response.is_empty() {
versions
.into_iter()
.for_each(|version| response.push(convert_to_legacy(convert_version(version))));
}
}
response.sort_by(|a, b| b.date_published.cmp(&a.date_published));
response.dedup_by(|a, b| a.id == b.id);
Ok(HttpResponse::Ok().json(response))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[get("versions")]
pub async fn versions_get(
ids: web::Query<VersionIds>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let version_ids = serde_json::from_str::<Vec<models::ids::VersionId>>(&*ids.ids)?
.into_iter()
.map(|x| x.into())
.collect();
let versions_data = database::models::Version::get_many_full(version_ids, &**pool).await?;
let mut versions = Vec::new();
for version_data in versions_data {
versions.push(convert_to_legacy(convert_version(version_data)));
}
Ok(HttpResponse::Ok().json(versions))
}
#[get("{version_id}")]
pub async fn version_get(
info: web::Path<(models::ids::VersionId,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let version_data = database::models::Version::get_full(id.into(), &**pool).await?;
if let Some(data) = version_data {
Ok(HttpResponse::Ok().json(convert_to_legacy(convert_version(data))))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Deserialize)]
pub struct Algorithm {
#[serde(default = "default_algorithm")]
algorithm: String,
}
fn default_algorithm() -> String {
"sha1".into()
}
// under /api/v1/version_file/{hash}
#[get("{version_id}")]
pub async fn get_version_from_hash(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
algorithm: web::Query<Algorithm>,
) -> Result<HttpResponse, ApiError> {
let hash = info.into_inner().0.to_lowercase();
let result = sqlx::query!(
"
SELECT f.version_id version_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await?;
if let Some(id) = result {
let version_data = database::models::Version::get_full(
database::models::VersionId(id.version_id),
&**pool,
)
.await?;
if let Some(data) = version_data {
Ok(HttpResponse::Ok().json(super::versions::convert_version(data)))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Serialize, Deserialize)]
pub struct DownloadRedirect {
pub url: String,
}
// under /api/v1/version_file/{hash}/download
#[allow(clippy::await_holding_refcell_ref)]
#[get("{version_id}/download")]
pub async fn download_version(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
algorithm: web::Query<Algorithm>,
pepper: web::Data<Pepper>,
) -> Result<HttpResponse, ApiError> {
let hash = info.into_inner().0;
let result = sqlx::query!(
"
SELECT f.url url, f.id id, f.version_id version_id, v.mod_id mod_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(id) = result {
let real_ip = req.connection_info();
let ip_option = real_ip.borrow().remote_addr();
if let Some(ip) = ip_option {
let hash = sha1::Sha1::from(format!("{}{}", ip, pepper.pepper)).hexdigest();
let download_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM downloads WHERE version_id = $1 AND date > (CURRENT_DATE - INTERVAL '30 minutes ago') AND identifier = $2)",
id.version_id,
hash,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.exists.unwrap_or(false);
if !download_exists {
sqlx::query!(
"
INSERT INTO downloads (
version_id, identifier
)
VALUES (
$1, $2
)
",
id.version_id,
hash
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
UPDATE versions
SET downloads = downloads + 1
WHERE id = $1
",
id.version_id,
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
UPDATE mods
SET downloads = downloads + 1
WHERE id = $1
",
id.mod_id,
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
}
Ok(HttpResponse::TemporaryRedirect()
.header("Location", &*id.url)
.json(DownloadRedirect { url: id.url }))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
// under /api/v1/version_file/{hash}
#[delete("{version_id}")]
pub async fn delete_file(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
algorithm: web::Query<Algorithm>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let hash = info.into_inner().0.to_lowercase();
let result = sqlx::query!(
"
SELECT f.id id, f.version_id version_id, f.filename filename, v.version_number version_number, v.mod_id project_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await
?;
if let Some(row) = result {
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id_version(
database::models::ids::VersionId(row.version_id),
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::DatabaseError)?
.ok_or_else(|| {
ApiError::CustomAuthenticationError(
"You don't have permission to delete this file!".to_string(),
)
})?;
if !team_member
.permissions
.contains(Permissions::DELETE_VERSION)
{
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to delete this file!".to_string(),
));
}
}
let mut transaction = pool.begin().await?;
sqlx::query!(
"
DELETE FROM hashes
WHERE file_id = $1
",
row.id
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
DELETE FROM files
WHERE files.id = $1
",
row.id,
)
.execute(&mut *transaction)
.await?;
let project_id: models::projects::ProjectId =
database::models::ids::ProjectId(row.project_id).into();
file_host
.delete_file_version(
"",
&format!(
"data/{}/versions/{}/{}",
project_id, row.version_number, row.filename
),
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

View File

@@ -3,29 +3,43 @@ use crate::database::models;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::version_item::{VersionBuilder, VersionFileBuilder};
use crate::file_hosting::FileHost;
use crate::models::mods::{
Dependency, GameVersion, ModId, ModLoader, Version, VersionFile, VersionId, VersionType,
use crate::models::projects::{
Dependency, GameVersion, Loader, ProjectId, Version, VersionFile, VersionId, VersionType,
};
use crate::models::teams::Permissions;
use crate::routes::mod_creation::{CreateError, UploadedFile};
use crate::routes::project_creation::{CreateError, UploadedFile};
use crate::validate::{validate_file, ValidationResult};
use actix_multipart::{Field, Multipart};
use actix_web::web::Data;
use actix_web::{post, HttpRequest, HttpResponse};
use futures::stream::StreamExt;
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool;
use validator::Validate;
#[derive(Serialize, Deserialize, Clone)]
lazy_static! {
static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_\-.]*$").unwrap();
}
#[derive(Serialize, Deserialize, Validate, Clone)]
pub struct InitialVersionData {
pub mod_id: Option<ModId>,
#[serde(alias = "mod_id")]
pub project_id: Option<ProjectId>,
#[validate(length(min = 1, max = 256))]
pub file_parts: Vec<String>,
#[validate(length(min = 1, max = 64), regex = "RE_URL_SAFE")]
pub version_number: String,
#[validate(length(min = 3, max = 256))]
pub version_title: String,
#[validate(length(max = 65536))]
pub version_body: Option<String>,
#[validate(length(min = 0, max = 256))]
pub dependencies: Vec<Dependency>,
pub game_versions: Vec<GameVersion>,
pub release_channel: VersionType,
pub loaders: Vec<ModLoader>,
pub loaders: Vec<Loader>,
pub featured: bool,
}
@@ -34,42 +48,6 @@ struct InitialFileData {
// TODO: hashes?
}
pub fn check_version(version: &InitialVersionData) -> Result<(), CreateError> {
/*
# InitialVersionData
file_parts: Vec<String>, 1..=256
version_number: 1..=64,
version_title: 3..=256,
version_body: max of 64KiB,
game_versions: Vec<GameVersion>, 1..=256
release_channel: VersionType,
loaders: Vec<ModLoader>, 1..=256
*/
use super::mod_creation::check_length;
version
.file_parts
.iter()
.try_for_each(|f| check_length(1..=256, "file part name", f))?;
check_length(1..=64, "version number", &version.version_number)?;
check_length(3..=256, "version title", &version.version_title)?;
if let Some(body) = &version.version_body {
check_length(..65536, "version body", body)?;
}
version
.game_versions
.iter()
.try_for_each(|v| check_length(1..=256, "game version", &v.0))?;
version
.loaders
.iter()
.try_for_each(|l| check_length(1..=256, "loader name", &l.0))?;
Ok(())
}
// under `/api/v1/version`
#[post("version")]
pub async fn version_create(
@@ -91,7 +69,8 @@ pub async fn version_create(
.await;
if result.is_err() {
let undo_result = super::mod_creation::undo_uploads(&***file_host, &uploaded_files).await;
let undo_result =
super::project_creation::undo_uploads(&***file_host, &uploaded_files).await;
let rollback_result = transaction.rollback().await;
if let Err(e) = undo_result {
@@ -119,6 +98,8 @@ async fn version_create_inner(
let mut initial_version_data = None;
let mut version_builder = None;
let all_game_versions = models::categories::GameVersion::list(&mut *transaction).await?;
let user = get_user_from_headers(req.headers(), &mut *transaction).await?;
while let Some(item) = payload.next().await {
@@ -139,34 +120,36 @@ async fn version_create_inner(
let version_create_data: InitialVersionData = serde_json::from_slice(&data)?;
initial_version_data = Some(version_create_data);
let version_create_data = initial_version_data.as_ref().unwrap();
if version_create_data.mod_id.is_none() {
return Err(CreateError::MissingValueError("Missing mod id".to_string()));
if version_create_data.project_id.is_none() {
return Err(CreateError::MissingValueError(
"Missing project id".to_string(),
));
}
check_version(version_create_data)?;
version_create_data.validate()?;
let mod_id: models::ModId = version_create_data.mod_id.unwrap().into();
let project_id: models::ProjectId = version_create_data.project_id.unwrap().into();
// Ensure that the mod this version is being added to exists
// Ensure that the project this version is being added to exists
let results = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)",
mod_id as models::ModId
project_id as models::ProjectId
)
.fetch_one(&mut *transaction)
.await?;
if !results.exists.unwrap_or(false) {
return Err(CreateError::InvalidInput(
"An invalid mod id was supplied".to_string(),
"An invalid project id was supplied".to_string(),
));
}
// Check whether there is already a version of this mod with the
// Check whether there is already a version of this project with the
// same version number
let results = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM versions WHERE (version_number=$1) AND (mod_id=$2))",
version_create_data.version_number,
mod_id as models::ModId,
project_id as models::ProjectId,
)
.fetch_one(&mut *transaction)
.await?;
@@ -178,15 +161,18 @@ async fn version_create_inner(
}
// Check that the user creating this version is a team member
// of the mod the version is being added to.
let team_member =
models::TeamMember::get_from_user_id_mod(mod_id, user.id.into(), &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::CustomAuthenticationError(
"You don't have permission to upload this version!".to_string(),
)
})?;
// of the project the version is being added to.
let team_member = models::TeamMember::get_from_user_id_project(
project_id,
user.id.into(),
&mut *transaction,
)
.await?
.ok_or_else(|| {
CreateError::CustomAuthenticationError(
"You don't have permission to upload this version!".to_string(),
)
})?;
if !team_member
.permissions
@@ -206,13 +192,17 @@ async fn version_create_inner(
.await?
.expect("Release channel not found in database");
let mut game_versions = Vec::with_capacity(version_create_data.game_versions.len());
for v in &version_create_data.game_versions {
let id = models::categories::GameVersion::get_id(&v.0, &mut *transaction)
.await?
.ok_or_else(|| CreateError::InvalidGameVersion(v.0.clone()))?;
game_versions.push(id);
}
let game_versions = version_create_data
.game_versions
.iter()
.map(|x| {
all_game_versions
.iter()
.find(|y| y.version == x.0)
.ok_or_else(|| CreateError::InvalidGameVersion(x.0.clone()))
.map(|y| y.id)
})
.collect::<Result<Vec<models::GameVersionId>, CreateError>>()?;
let mut loaders = Vec::with_capacity(version_create_data.loaders.len());
for l in &version_create_data.loaders {
@@ -230,7 +220,7 @@ async fn version_create_inner(
version_builder = Some(VersionBuilder {
version_id: version_id.into(),
mod_id: version_create_data.mod_id.unwrap().into(),
project_id,
author_id: user.id.into(),
name: version_create_data.version_title.clone(),
version_number: version_create_data.version_number.clone(),
@@ -253,19 +243,38 @@ async fn version_create_inner(
CreateError::InvalidInput(String::from("`data` field must come before file fields"))
})?;
let file_builder = upload_file(
let project_type = sqlx::query!(
"
SELECT name FROM project_types pt
INNER JOIN mods ON mods.project_type = pt.id
WHERE mods.id = $1
",
version.project_id as models::ProjectId,
)
.fetch_one(&mut *transaction)
.await?
.name;
let version_data = initial_version_data
.clone()
.ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?;
upload_file(
&mut field,
file_host,
uploaded_files,
&mut version.files,
&cdn_url,
&content_disposition,
version.mod_id.into(),
version.project_id.into(),
&version.version_number,
&*project_type,
version_data.loaders,
version_data.game_versions,
&all_game_versions,
false,
)
.await?;
// Add the newly uploaded file to the existing or new version
version.files.push(file_builder);
}
let version_data = initial_version_data
@@ -278,7 +287,7 @@ async fn version_create_inner(
SELECT m.title FROM mods m
WHERE id = $1
",
builder.mod_id as crate::database::models::ids::ModId
builder.project_id as crate::database::models::ids::ProjectId
)
.fetch_one(&mut *transaction)
.await?;
@@ -290,7 +299,7 @@ async fn version_create_inner(
SELECT follower_id FROM mod_follows
WHERE mod_id = $1
",
builder.mod_id as crate::database::models::ids::ModId
builder.project_id as crate::database::models::ids::ProjectId
)
.fetch_many(&mut *transaction)
.try_filter_map(|e| async {
@@ -300,17 +309,17 @@ async fn version_create_inner(
.try_collect::<Vec<crate::database::models::ids::UserId>>()
.await?;
let mod_id: ModId = builder.mod_id.into();
let project_id: ProjectId = builder.project_id.into();
let version_id: VersionId = builder.version_id.into();
NotificationBuilder {
title: "A mod you followed has been updated!".to_string(),
title: "A project you followed has been updated!".to_string(),
text: format!(
"Mod {} has been updated to version {}",
"Project {} has been updated to version {}",
result.title,
version_data.version_number.clone()
),
link: format!("mod/{}/version/{}", mod_id, version_id),
link: format!("project/{}/version/{}", project_id, version_id),
actions: vec![],
}
.insert_many(users, &mut *transaction)
@@ -318,7 +327,7 @@ async fn version_create_inner(
let response = Version {
id: builder.version_id.into(),
mod_id: builder.mod_id.into(),
project_id: builder.project_id.into(),
author_id: user.id,
featured: builder.featured,
name: builder.name.clone(),
@@ -388,7 +397,8 @@ pub async fn upload_file_to_version(
.await;
if result.is_err() {
let undo_result = super::mod_creation::undo_uploads(&***file_host, &uploaded_files).await;
let undo_result =
super::project_creation::undo_uploads(&***file_host, &uploaded_files).await;
let rollback_result = transaction.rollback().await;
if let Err(e) = undo_result {
@@ -419,16 +429,7 @@ async fn upload_file_to_version_inner(
let user = get_user_from_headers(req.headers(), &mut *transaction).await?;
let result = sqlx::query!(
"
SELECT mod_id, version_number, author_id
FROM versions
WHERE id = $1
",
version_id as models::VersionId,
)
.fetch_optional(&mut *transaction)
.await?;
let result = models::Version::get_full(version_id, &mut *transaction).await?;
let version = match result {
Some(v) => v,
@@ -457,9 +458,23 @@ async fn upload_file_to_version_inner(
));
}
let mod_id = ModId(version.mod_id as u64);
let project_id = ProjectId(version.project_id.0 as u64);
let version_number = version.version_number;
let project_type = sqlx::query!(
"
SELECT name FROM project_types pt
INNER JOIN mods ON mods.project_type = pt.id
WHERE mods.id = $1
",
version.project_id as models::ProjectId,
)
.fetch_one(&mut *transaction)
.await?
.name;
let all_game_versions = models::categories::GameVersion::list(&mut *transaction).await?;
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(|| {
@@ -485,19 +500,27 @@ async fn upload_file_to_version_inner(
CreateError::InvalidInput(String::from("`data` field must come before file fields"))
})?;
let file_builder = upload_file(
upload_file(
&mut field,
file_host,
uploaded_files,
&mut file_builders,
&cdn_url,
&content_disposition,
mod_id,
project_id,
&version_number,
&*project_type,
version.loaders.clone().into_iter().map(Loader).collect(),
version
.game_versions
.clone()
.into_iter()
.map(GameVersion)
.collect(),
&all_game_versions,
true,
)
.await?;
// TODO: Malware scan + file validation
file_builders.push(file_builder);
}
if file_builders.is_empty() {
@@ -514,19 +537,26 @@ async fn upload_file_to_version_inner(
}
// This function is used for adding a file to a version, uploading the initial
// files for a version, and for uploading the initial version files for a mod
// files for a version, and for uploading the initial version files for a project
#[allow(clippy::too_many_arguments)]
pub async fn upload_file(
field: &mut Field,
file_host: &dyn FileHost,
uploaded_files: &mut Vec<UploadedFile>,
version_files: &mut Vec<models::version_item::VersionFileBuilder>,
cdn_url: &str,
content_disposition: &actix_web::http::header::ContentDisposition,
mod_id: crate::models::ids::ModId,
project_id: crate::models::ids::ProjectId,
version_number: &str,
) -> Result<models::version_item::VersionFileBuilder, CreateError> {
project_type: &str,
loaders: Vec<Loader>,
game_versions: Vec<GameVersion>,
all_game_versions: &[models::categories::GameVersion],
ignore_primary: bool,
) -> Result<(), CreateError> {
let (file_name, file_extension) = get_name_ext(content_disposition)?;
let content_type = mod_file_type(file_extension)
let content_type = project_file_type(file_extension)
.ok_or_else(|| CreateError::InvalidFileType(file_extension.to_string()))?;
let mut data = Vec::new();
@@ -534,20 +564,32 @@ pub async fn upload_file(
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
}
// Mod file size limit of 25MiB
// Project file size limit of 25MiB
const FILE_SIZE_CAP: usize = 25 * (2 << 30);
// TODO: override file size cap for authorized users or mods
// TODO: override file size cap for authorized users or projects
if data.len() >= FILE_SIZE_CAP {
return Err(CreateError::InvalidInput(
String::from("Mod file exceeds the maximum of 25MiB. Contact a moderator or admin to request permission to upload larger files.")
String::from("Project file exceeds the maximum of 25MiB. Contact a moderator or admin to request permission to upload larger files.")
));
}
let validation_result = validate_file(
data.as_slice(),
file_extension,
project_type,
loaders,
game_versions,
all_game_versions,
)?;
let upload_data = file_host
.upload_file(
content_type,
&format!("data/{}/versions/{}/{}", mod_id, version_number, file_name),
&format!(
"data/{}/versions/{}/{}",
project_id, version_number, file_name
),
data.to_vec(),
)
.await?;
@@ -558,7 +600,7 @@ pub async fn upload_file(
});
// TODO: Malware scan + file validation
Ok(models::version_item::VersionFileBuilder {
version_files.push(models::version_item::VersionFileBuilder {
filename: file_name.to_string(),
url: format!("{}/{}", cdn_url, upload_data.file_name),
hashes: vec![
@@ -575,12 +617,16 @@ pub async fn upload_file(
hash: upload_data.content_sha512.into_bytes(),
},
],
primary: uploaded_files.len() == 1,
})
primary: validation_result == ValidationResult::Pass
&& version_files.iter().all(|x| !x.primary)
&& !ignore_primary,
});
Ok(())
}
// Currently we only support jar mods; this may change in the future (datapacks?)
fn mod_file_type(ext: &str) -> Option<&str> {
// Currently we only support jar projects; this may change in the future (datapacks?)
fn project_file_type(ext: &str) -> Option<&str> {
match ext {
"jar" => Some("application/java-archive"),
_ => None,

519
src/routes/version_file.rs Normal file
View File

@@ -0,0 +1,519 @@
use super::ApiError;
use crate::auth::get_user_from_headers;
use crate::file_hosting::FileHost;
use crate::models;
use crate::models::projects::{GameVersion, Loader};
use crate::models::teams::Permissions;
use crate::{database, Pepper};
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::borrow::Borrow;
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Deserialize)]
pub struct Algorithm {
#[serde(default = "default_algorithm")]
algorithm: String,
}
fn default_algorithm() -> String {
"sha1".into()
}
// under /api/v1/version_file/{hash}
#[get("{version_id}")]
pub async fn get_version_from_hash(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
algorithm: web::Query<Algorithm>,
) -> Result<HttpResponse, ApiError> {
let hash = info.into_inner().0.to_lowercase();
let result = sqlx::query!(
"
SELECT f.version_id version_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await?;
if let Some(id) = result {
let version_data = database::models::Version::get_full(
database::models::VersionId(id.version_id),
&**pool,
)
.await?;
if let Some(data) = version_data {
Ok(HttpResponse::Ok().json(super::versions::convert_version(data)))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Serialize, Deserialize)]
pub struct DownloadRedirect {
pub url: String,
}
// under /api/v1/version_file/{hash}/download
#[get("{version_id}/download")]
pub async fn download_version(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
algorithm: web::Query<Algorithm>,
pepper: web::Data<Pepper>,
) -> Result<HttpResponse, ApiError> {
let hash = info.into_inner().0.to_lowercase();
let mut transaction = pool.begin().await?;
let result = sqlx::query!(
"
SELECT f.url url, f.id id, f.version_id version_id, v.mod_id project_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&mut *transaction)
.await?;
if let Some(id) = result {
download_version_inner(
database::models::VersionId(id.version_id),
database::models::ProjectId(id.project_id),
&req,
&mut transaction,
&pepper,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::TemporaryRedirect()
.header("Location", &*id.url)
.json(DownloadRedirect { url: id.url }))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
async fn download_version_inner(
version_id: database::models::VersionId,
project_id: database::models::ProjectId,
req: &HttpRequest,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
pepper: &web::Data<Pepper>,
) -> Result<(), ApiError> {
let real_ip = req.connection_info();
let ip_option = real_ip.borrow().remote_addr();
if let Some(ip) = ip_option {
let hash = sha1::Sha1::from(format!("{}{}", ip, pepper.pepper)).hexdigest();
let download_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM downloads WHERE version_id = $1 AND date > (CURRENT_DATE - INTERVAL '30 minutes ago') AND identifier = $2)",
version_id as database::models::VersionId,
hash,
)
.fetch_one(&mut *transaction)
.await
?
.exists.unwrap_or(false);
if !download_exists {
sqlx::query!(
"
INSERT INTO downloads (
version_id, identifier
)
VALUES (
$1, $2
)
",
version_id as database::models::VersionId,
hash
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
UPDATE versions
SET downloads = downloads + 1
WHERE id = $1
",
version_id as database::models::VersionId,
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
UPDATE mods
SET downloads = downloads + 1
WHERE id = $1
",
project_id as database::models::ProjectId,
)
.execute(&mut *transaction)
.await?;
}
}
Ok(())
}
// under /api/v1/version_file/{hash}
#[delete("{version_id}")]
pub async fn delete_file(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
algorithm: web::Query<Algorithm>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let hash = info.into_inner().0.to_lowercase();
let result = sqlx::query!(
"
SELECT f.id id, f.version_id version_id, f.filename filename, v.version_number version_number, v.mod_id project_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await
?;
if let Some(row) = result {
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id_version(
database::models::ids::VersionId(row.version_id),
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::DatabaseError)?
.ok_or_else(|| {
ApiError::CustomAuthenticationError(
"You don't have permission to delete this file!".to_string(),
)
})?;
if !team_member
.permissions
.contains(Permissions::DELETE_VERSION)
{
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to delete this file!".to_string(),
));
}
}
let mut transaction = pool.begin().await?;
sqlx::query!(
"
DELETE FROM hashes
WHERE file_id = $1
",
row.id
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
DELETE FROM files
WHERE files.id = $1
",
row.id,
)
.execute(&mut *transaction)
.await?;
let project_id: models::projects::ProjectId =
database::models::ids::ProjectId(row.project_id).into();
file_host
.delete_file_version(
"",
&format!(
"data/{}/versions/{}/{}",
project_id, row.version_number, row.filename
),
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Deserialize)]
pub struct UpdateData {
pub hash: (String, String),
pub loaders: Vec<Loader>,
pub game_versions: Vec<GameVersion>,
}
#[post("{version_id}/update")]
pub async fn get_update_from_hash(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
algorithm: web::Query<Algorithm>,
update_data: web::Json<UpdateData>,
) -> Result<HttpResponse, ApiError> {
let hash = info.into_inner().0.to_lowercase();
// get version_id from hash
// get mod_id from hash
// get latest version satisfying conditions - if not found
let result = sqlx::query!(
"
SELECT v.mod_id project_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await?;
if let Some(id) = result {
let version_ids = database::models::Version::get_project_versions(
database::models::ProjectId(id.project_id),
Some(
update_data
.game_versions
.clone()
.into_iter()
.map(|x| x.0)
.collect(),
),
Some(
update_data
.loaders
.clone()
.into_iter()
.map(|x| x.0)
.collect(),
),
&**pool,
)
.await?;
if let Some(version_id) = version_ids.last() {
let version_data = database::models::Version::get_full(*version_id, &**pool).await?;
if let Some(data) = version_data {
Ok(HttpResponse::Ok().json(super::versions::convert_version(data)))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
// Requests above with multiple versions below
#[derive(Deserialize)]
pub struct FileHashes {
pub algorithm: String,
pub hashes: Vec<String>,
}
// under /api/v2/version_files
#[post("/")]
pub async fn get_versions_from_hashes(
pool: web::Data<PgPool>,
file_data: web::Json<FileHashes>,
) -> Result<HttpResponse, ApiError> {
let hashes_parsed: Vec<Vec<u8>> = file_data
.hashes
.iter()
.map(|x| x.as_bytes().to_vec())
.collect();
let result = sqlx::query!(
"
SELECT h.hash hash, h.algorithm algorithm, f.version_id version_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
WHERE h.algorithm = $2 AND h.hash IN (SELECT * FROM UNNEST($1::bytea[]))
",
hashes_parsed.as_slice(),
file_data.algorithm
)
.fetch_all(&**pool)
.await?;
let versions_data = database::models::Version::get_many_full(
result
.iter()
.map(|x| database::models::VersionId(x.version_id))
.collect(),
&**pool,
)
.await?;
let mut response = HashMap::new();
for row in result {
if let Some(version) = versions_data.iter().find(|x| x.id.0 == row.version_id) {
response.insert(row.hash, super::versions::convert_version(version.clone()));
}
}
Ok(HttpResponse::Ok().json(response))
}
#[post("download")]
pub async fn download_files(
req: HttpRequest,
pool: web::Data<PgPool>,
file_data: web::Json<FileHashes>,
pepper: web::Data<Pepper>,
) -> Result<HttpResponse, ApiError> {
let hashes_parsed: Vec<Vec<u8>> = file_data
.hashes
.iter()
.map(|x| x.as_bytes().to_vec())
.collect();
let mut transaction = pool.begin().await?;
let result = sqlx::query!(
"
SELECT f.url url, h.hash hash, h.algorithm algorithm, f.version_id version_id, v.mod_id project_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash IN (SELECT * FROM UNNEST($1::bytea[]))
",
hashes_parsed.as_slice(),
file_data.algorithm
)
.fetch_all(&mut *transaction)
.await?;
let mut response = HashMap::new();
for row in result {
download_version_inner(
database::models::VersionId(row.version_id),
database::models::ProjectId(row.project_id),
&req,
&mut transaction,
&pepper,
)
.await?;
response.insert(row.hash, row.url);
}
Ok(HttpResponse::Ok().json(response))
}
#[derive(Deserialize)]
pub struct ManyUpdateData {
pub algorithm: String,
pub hashes: Vec<String>,
pub loaders: Vec<Loader>,
pub game_versions: Vec<GameVersion>,
}
#[post("update")]
pub async fn update_files(
pool: web::Data<PgPool>,
update_data: web::Json<ManyUpdateData>,
) -> Result<HttpResponse, ApiError> {
let hashes_parsed: Vec<Vec<u8>> = update_data
.hashes
.iter()
.map(|x| x.as_bytes().to_vec())
.collect();
let mut transaction = pool.begin().await?;
let result = sqlx::query!(
"
SELECT f.url url, h.hash hash, h.algorithm algorithm, f.version_id version_id, v.mod_id project_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash IN (SELECT * FROM UNNEST($1::bytea[]))
",
hashes_parsed.as_slice(),
update_data.algorithm
)
.fetch_all(&mut *transaction)
.await?;
let mut version_ids = Vec::new();
for row in &result {
let updated_versions = database::models::Version::get_project_versions(
database::models::ProjectId(row.project_id),
Some(
update_data
.game_versions
.clone()
.iter()
.map(|x| x.0.clone())
.collect(),
),
Some(
update_data
.loaders
.clone()
.iter()
.map(|x| x.0.clone())
.collect(),
),
&**pool,
)
.await?;
if let Some(latest_version) = updated_versions.last() {
version_ids.push(*latest_version);
}
}
let versions = database::models::Version::get_many_full(version_ids, &**pool).await?;
let mut response = HashMap::new();
for row in &result {
if let Some(version) = versions.iter().find(|x| x.id.0 == row.version_id) {
response.insert(
row.hash.clone(),
super::versions::convert_version(version.clone()),
);
}
}
Ok(HttpResponse::Ok().json(response))
}

View File

@@ -1,15 +1,15 @@
use super::ApiError;
use crate::auth::get_user_from_headers;
use crate::file_hosting::FileHost;
use crate::database;
use crate::models;
use crate::models::mods::{Dependency, DependencyType};
use crate::models::projects::{Dependency, DependencyType};
use crate::models::teams::Permissions;
use crate::{database, Pepper};
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::borrow::Borrow;
use std::sync::Arc;
use validator::Validate;
#[derive(Serialize, Deserialize, Clone)]
pub struct VersionListFilters {
@@ -20,23 +20,18 @@ pub struct VersionListFilters {
#[get("version")]
pub async fn version_list(
info: web::Path<(models::ids::ModId,)>,
info: web::Path<(String,)>,
web::Query(filters): web::Query<VersionListFilters>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0.into();
let string = info.into_inner().0;
let mod_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1)",
id as database::models::ModId,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.exists;
let result = database::models::Project::get_from_slug_or_project_id(string, &**pool).await?;
if mod_exists.unwrap_or(false) {
let version_ids = database::models::Version::get_mod_versions(
if let Some(project) = result {
let id = project.id;
let version_ids = database::models::Version::get_project_versions(
id,
filters
.game_versions
@@ -48,12 +43,9 @@ pub async fn version_list(
.map(|x| serde_json::from_str(x).unwrap_or_default()),
&**pool,
)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
let mut versions = database::models::Version::get_many_full(version_ids, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mut versions = database::models::Version::get_many_full(version_ids, &**pool).await?;
let mut response = versions
.iter()
@@ -87,8 +79,8 @@ pub async fn version_list(
versions
.iter()
.find(|version| {
version.game_versions.contains(&filter.0)
&& version.loaders.contains(&filter.1)
version.game_versions.contains(&filter.0.version)
&& version.loaders.contains(&filter.1.loader)
})
.map(|version| response.push(convert_version(version.clone())))
.unwrap_or(());
@@ -124,9 +116,7 @@ pub async fn versions_get(
.into_iter()
.map(|x| x.into())
.collect();
let versions_data = database::models::Version::get_many_full(version_ids, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let versions_data = database::models::Version::get_many_full(version_ids, &**pool).await?;
let mut versions = Vec::new();
@@ -143,9 +133,7 @@ pub async fn version_get(
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let version_data = database::models::Version::get_full(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let version_data = database::models::Version::get_full(id.into(), &**pool).await?;
if let Some(data) = version_data {
Ok(HttpResponse::Ok().json(convert_version(data)))
@@ -154,12 +142,14 @@ pub async fn version_get(
}
}
fn convert_version(data: database::models::version_item::QueryVersion) -> models::mods::Version {
use models::mods::VersionType;
pub fn convert_version(
data: database::models::version_item::QueryVersion,
) -> models::projects::Version {
use models::projects::VersionType;
models::mods::Version {
models::projects::Version {
id: data.id.into(),
mod_id: data.mod_id.into(),
project_id: data.project_id.into(),
author_id: data.author_id.into(),
featured: data.featured,
@@ -180,7 +170,7 @@ fn convert_version(data: database::models::version_item::QueryVersion) -> models
.files
.into_iter()
.map(|f| {
models::mods::VersionFile {
models::projects::VersionFile {
url: f.url,
filename: f.filename,
// FIXME: Hashes are currently stored as an ascii byte slice instead
@@ -206,25 +196,33 @@ fn convert_version(data: database::models::version_item::QueryVersion) -> models
game_versions: data
.game_versions
.into_iter()
.map(models::mods::GameVersion)
.map(models::projects::GameVersion)
.collect(),
loaders: data
.loaders
.into_iter()
.map(models::mods::ModLoader)
.map(models::projects::Loader)
.collect(),
}
}
#[derive(Serialize, Deserialize)]
lazy_static! {
static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_-]*$").unwrap();
}
#[derive(Serialize, Deserialize, Validate)]
pub struct EditVersion {
#[validate(length(min = 3, max = 256))]
pub name: Option<String>,
#[validate(length(min = 1, max = 64), regex = "RE_URL_SAFE")]
pub version_number: Option<String>,
#[validate(length(max = 65536))]
pub changelog: Option<String>,
pub version_type: Option<models::mods::VersionType>,
pub version_type: Option<models::projects::VersionType>,
#[validate(length(min = 1, max = 256))]
pub dependencies: Option<Vec<Dependency>>,
pub game_versions: Option<Vec<models::mods::GameVersion>>,
pub loaders: Option<Vec<models::mods::ModLoader>>,
pub game_versions: Option<Vec<models::projects::GameVersion>>,
pub loaders: Option<Vec<models::projects::Loader>>,
pub featured: Option<bool>,
pub primary_file: Option<(String, String)>,
}
@@ -238,12 +236,12 @@ pub async fn version_edit(
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
new_version.validate()?;
let version_id = info.into_inner().0;
let id = version_id.into();
let result = database::models::Version::get_full(id, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let result = database::models::Version::get_full(id, &**pool).await?;
if let Some(version_item) = result {
let team_member = database::models::TeamMember::get_from_user_id_version(
@@ -269,18 +267,9 @@ pub async fn version_edit(
));
}
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mut transaction = pool.begin().await?;
if let Some(name) = &new_version.name {
if name.len() > 256 || name.len() < 3 {
return Err(ApiError::InvalidInputError(
"The version name must be within 3-256 characters!".to_string(),
));
}
sqlx::query!(
"
UPDATE versions
@@ -291,17 +280,10 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
if let Some(number) = &new_version.version_number {
if number.len() > 64 || number.is_empty() {
return Err(ApiError::InvalidInputError(
"The version number must be within 1-64 characters!".to_string(),
));
}
sqlx::query!(
"
UPDATE versions
@@ -312,8 +294,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
if let Some(version_type) = &new_version.version_type {
@@ -338,8 +319,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
if let Some(dependencies) = &new_version.dependencies {
@@ -350,8 +330,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
for dependency in dependencies {
let dependency_id: database::models::ids::VersionId =
@@ -367,8 +346,7 @@ pub async fn version_edit(
dependency.dependency_type.as_str()
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
}
@@ -380,8 +358,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
for game_version in game_versions {
let game_version_id = database::models::categories::GameVersion::get_id(
@@ -404,8 +381,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
}
@@ -417,8 +393,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
for loader in loaders {
let loader_id =
@@ -439,8 +414,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
}
@@ -455,8 +429,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
if let Some(primary_file) = &new_version.primary_file {
@@ -470,8 +443,7 @@ pub async fn version_edit(
primary_file.0
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.await?
.ok_or_else(|| {
ApiError::InvalidInputError(format!(
"Specified file with hash {} does not exist.",
@@ -488,8 +460,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
sqlx::query!(
"
@@ -500,18 +471,10 @@ pub async fn version_edit(
result.id,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
if let Some(body) = &new_version.changelog {
if body.len() > 65536 {
return Err(ApiError::InvalidInputError(
"The version changelog must be less than 65536 characters long!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE versions
@@ -522,15 +485,11 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().body(""))
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::CustomAuthenticationError(
"You do not have permission to edit this version!".to_string(),
@@ -574,261 +533,10 @@ pub async fn version_delete(
}
}
let result = database::models::Version::remove_full(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let result = database::models::Version::remove_full(id.into(), &**pool).await?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Deserialize)]
pub struct Algorithm {
#[serde(default = "default_algorithm")]
algorithm: String,
}
fn default_algorithm() -> String {
"sha1".into()
}
// under /api/v1/version_file/{hash}
#[get("{version_id}")]
pub async fn get_version_from_hash(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
algorithm: web::Query<Algorithm>,
) -> Result<HttpResponse, ApiError> {
let hash = info.into_inner().0;
let result = sqlx::query!(
"
SELECT f.version_id version_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(id) = result {
let version_data = database::models::Version::get_full(
database::models::VersionId(id.version_id),
&**pool,
)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(data) = version_data {
Ok(HttpResponse::Ok().json(convert_version(data)))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Serialize, Deserialize)]
pub struct DownloadRedirect {
pub url: String,
}
// under /api/v1/version_file/{hash}/download
#[allow(clippy::await_holding_refcell_ref)]
#[get("{version_id}/download")]
pub async fn download_version(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
algorithm: web::Query<Algorithm>,
pepper: web::Data<Pepper>,
) -> Result<HttpResponse, ApiError> {
let hash = info.into_inner().0;
let result = sqlx::query!(
"
SELECT f.url url, f.id id, f.version_id version_id, v.mod_id mod_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(id) = result {
let real_ip = req.connection_info();
let ip_option = real_ip.borrow().remote_addr();
if let Some(ip) = ip_option {
let hash = sha1::Sha1::from(format!("{}{}", ip, pepper.pepper)).hexdigest();
let download_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM downloads WHERE version_id = $1 AND date > (CURRENT_DATE - INTERVAL '30 minutes ago') AND identifier = $2)",
id.version_id,
hash,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.exists.unwrap_or(false);
if !download_exists {
sqlx::query!(
"
INSERT INTO downloads (
version_id, identifier
)
VALUES (
$1, $2
)
",
id.version_id,
hash
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
UPDATE versions
SET downloads = downloads + 1
WHERE id = $1
",
id.version_id,
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
UPDATE mods
SET downloads = downloads + 1
WHERE id = $1
",
id.mod_id,
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
}
Ok(HttpResponse::TemporaryRedirect()
.header("Location", &*id.url)
.json(DownloadRedirect { url: id.url }))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
// under /api/v1/version_file/{hash}
#[delete("{version_id}")]
pub async fn delete_file(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
algorithm: web::Query<Algorithm>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let hash = info.into_inner().0;
let result = sqlx::query!(
"
SELECT f.id id, f.version_id version_id, f.filename filename, v.version_number version_number, v.mod_id mod_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(row) = result {
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id_version(
database::models::ids::VersionId(row.version_id),
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::DatabaseError)?
.ok_or_else(|| {
ApiError::CustomAuthenticationError(
"You don't have permission to delete this file!".to_string(),
)
})?;
if !team_member
.permissions
.contains(Permissions::DELETE_VERSION)
{
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to delete this file!".to_string(),
));
}
}
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
DELETE FROM hashes
WHERE file_id = $1
",
row.id
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
DELETE FROM files
WHERE files.id = $1
",
row.id,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mod_id: models::mods::ModId = database::models::ids::ModId(row.mod_id).into();
file_host
.delete_file_version(
"",
&format!(
"data/{}/versions/{}/{}",
mod_id, row.version_number, row.filename
),
)
.await?;
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}