More mod info (#104)

* More mod info

* Downloading mods

* Run prepare

* User editing + icon editing

* Finish

* Some fixes

* Fix clippy errors
This commit is contained in:
Geometrically
2020-11-27 10:57:04 -07:00
committed by GitHub
parent 92e1847c59
commit 1da5357df6
19 changed files with 3287 additions and 604 deletions

View File

@@ -26,9 +26,11 @@ pub fn mods_config(cfg: &mut web::ServiceConfig) {
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(web::scope("{mod_id}").service(versions::version_list)),
);
}
@@ -46,7 +48,8 @@ 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::get_version_from_hash)
.service(versions::download_version),
);
}
@@ -56,9 +59,12 @@ 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::user_delete)
.service(users::user_edit)
.service(users::user_icon_edit)
.service(users::teams),
);
}
@@ -84,6 +90,8 @@ pub fn moderation_config(cfg: &mut web::ServiceConfig) {
#[derive(thiserror::Error, Debug)]
pub enum ApiError {
#[error("Environment Error")]
EnvError(#[from] dotenv::Error),
#[error("Error while uploading file")]
FileHostingError(#[from] FileHostingError),
#[error("Internal server error")]
@@ -103,6 +111,7 @@ pub enum ApiError {
impl actix_web::ResponseError for ApiError {
fn status_code(&self) -> actix_web::http::StatusCode {
match self {
ApiError::EnvError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::DatabaseError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::AuthenticationError(..) => actix_web::http::StatusCode::UNAUTHORIZED,
ApiError::CustomAuthenticationError(..) => actix_web::http::StatusCode::UNAUTHORIZED,
@@ -117,6 +126,7 @@ impl actix_web::ResponseError for ApiError {
actix_web::web::HttpResponse::build(self.status_code()).json(
crate::models::error::ApiError {
error: match self {
ApiError::EnvError(..) => "environment_error",
ApiError::DatabaseError(..) => "database_error",
ApiError::AuthenticationError(..) => "unauthorized",
ApiError::CustomAuthenticationError(..) => "unauthorized",

View File

@@ -2,7 +2,7 @@ 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::{ModId, ModStatus, VersionId};
use crate::models::mods::{DonationLink, License, ModId, ModStatus, SideType, VersionId};
use crate::models::users::UserId;
use crate::routes::version_creation::InitialVersionData;
use crate::search::indexing::{queue::CreationQueue, IndexingError};
@@ -99,6 +99,8 @@ impl actix_web::ResponseError for CreateError {
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: Option<String>,
/// A short description of the mod.
pub mod_description: String,
/// A long description of the mod, in markdown.
@@ -113,8 +115,20 @@ struct ModCreateData {
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
pub client_side: SideType,
/// The support range for the server mod
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
pub donation_urls: Option<Vec<DonationLink>>,
}
pub struct UploadedFile {
@@ -461,7 +475,53 @@ async fn mod_create_inner(
let status_id = models::StatusId::get_id(&status, &mut *transaction)
.await?
.expect("No database entry found for status");
.ok_or_else(|| {
CreateError::InvalidInput(format!("Status {} does not exist.", status.clone()))
})?;
let client_side_id =
models::SideTypeId::get_id(&mod_create_data.client_side, &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::InvalidInput(
"Client side type specified does not exist.".to_string(),
)
})?;
let server_side_id =
models::SideTypeId::get_id(&mod_create_data.server_side, &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::InvalidInput(
"Server side type specified does not exist.".to_string(),
)
})?;
let license_id =
models::categories::License::get_id(&mod_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 {
for url in urls {
let platform_id = models::DonationPlatformId::get_id(&url.id, &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::InvalidInput(format!(
"Donation platform {} does not exist.",
url.id.clone()
))
})?;
donation_urls.push(models::mod_item::DonationUrl {
mod_id: mod_id.into(),
platform_id,
url: url.url.clone(),
})
}
}
let mod_builder = models::mod_item::ModBuilder {
mod_id: mod_id.into(),
@@ -474,15 +534,23 @@ async fn mod_create_inner(
source_url: mod_create_data.source_url,
wiki_url: mod_create_data.wiki_url,
license_url: mod_create_data.license_url,
discord_url: mod_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: mod_create_data.mod_slug,
donation_urls,
};
let now = chrono::Utc::now();
let response = crate::models::mods::Mod {
id: mod_id,
slug: mod_builder.slug.clone(),
team: team_id.into(),
title: mod_builder.title.clone(),
description: mod_builder.description.clone(),
@@ -490,6 +558,13 @@ async fn mod_create_inner(
published: now,
updated: now,
status,
license: License {
id: mod_create_data.license_id.clone(),
name: "".to_string(),
url: mod_builder.license_url.clone(),
},
client_side: mod_create_data.client_side,
server_side: mod_create_data.server_side,
downloads: 0,
categories: mod_create_data.categories,
versions: mod_builder
@@ -501,6 +576,8 @@ async fn mod_create_inner(
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(),
};
let _mod_id = mod_builder.insert(&mut *transaction).await?;
@@ -598,6 +675,7 @@ async fn create_initial_version(
game_versions,
loaders,
release_channel,
featured: version_data.featured,
};
Ok(version)
@@ -642,7 +720,7 @@ async fn process_icon_upload(
}
}
fn get_image_content_type(extension: &str) -> Option<&'static str> {
pub fn get_image_content_type(extension: &str) -> Option<&'static str> {
let content_type = match &*extension {
"bmp" => "image/bmp",
"gif" => "image/gif",

View File

@@ -2,10 +2,12 @@ use super::ApiError;
use crate::auth::check_is_moderator_from_headers;
use crate::database;
use crate::models;
use crate::models::mods::{ModStatus, VersionType};
use crate::models::mods::{ModStatus, VersionType, ModId};
use actix_web::{get, web, HttpRequest, HttpResponse};
use serde::Deserialize;
use serde::{Serialize, Deserialize};
use sqlx::PgPool;
use sqlx::types::chrono::{DateTime, Utc};
use crate::models::teams::TeamId;
#[derive(Deserialize)]
pub struct ResultCount {
@@ -17,6 +19,42 @@ fn default_count() -> i16 {
100
}
/// A mod returned from the API moderation routes
#[derive(Serialize)]
pub struct ModerationMod {
/// The ID of the mod, encoded as a base62 string.
pub id: ModId,
/// The slug of a mod, used for vanity URLs
pub slug: Option<String>,
/// The team of people that has ownership of this mod.
pub team: TeamId,
/// The title or name of the mod.
pub title: String,
/// A short description of the mod.
pub description: String,
/// The link to the long description of the mod.
pub body_url: String,
/// The date at which the mod was first published.
pub published: DateTime<Utc>,
/// The date at which the mod was first published.
pub updated: DateTime<Utc>,
/// The status of the mod
pub status: ModStatus,
/// The total number of downloads the mod has had.
pub downloads: u32,
/// The URL of the icon of the mod
pub icon_url: Option<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 discord
pub discord_url: Option<String>,
}
#[get("mods")]
pub async fn mods(
req: HttpRequest,
@@ -41,15 +79,14 @@ pub async fn mods(
)
.fetch_many(&**pool)
.try_filter_map(|e| async {
Ok(e.right().map(|m| models::mods::Mod {
Ok(e.right().map(|m| ModerationMod {
id: database::models::ids::ModId(m.id).into(),
slug: m.slug,
team: database::models::ids::TeamId(m.team_id).into(),
title: m.title,
description: m.description,
body_url: m.body_url,
published: m.published,
categories: vec![],
versions: vec![],
icon_url: m.icon_url,
issues_url: m.issues_url,
source_url: m.source_url,
@@ -57,9 +94,10 @@ pub async fn mods(
updated: m.updated,
downloads: m.downloads as u32,
wiki_url: m.wiki_url,
discord_url: m.discord_url,
}))
})
.try_collect::<Vec<models::mods::Mod>>()
.try_collect::<Vec<ModerationMod>>()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
@@ -92,6 +130,7 @@ pub async fn versions(
id: database::models::ids::VersionId(m.id).into(),
mod_id: database::models::ids::ModId(m.mod_id).into(),
author_id: database::models::ids::UserId(m.author_id).into(),
featured: m.featured,
name: m.name,
version_number: m.version_number,
changelog_url: m.changelog_url,

View File

@@ -3,10 +3,11 @@ use crate::auth::get_user_from_headers;
use crate::database;
use crate::file_hosting::FileHost;
use crate::models;
use crate::models::mods::{ModStatus, SearchRequest};
use crate::models::mods::{DonationLink, License, ModStatus, SearchRequest, SideType};
use crate::models::teams::Permissions;
use crate::search::{search_for_mod, SearchConfig, SearchError};
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
@@ -80,6 +81,53 @@ pub async fn mods_get(
Ok(HttpResponse::Ok().json(mods))
}
#[get("@{id}")]
pub async fn mod_slug_get(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let mod_data = database::models::Mod::get_full_from_slug(id, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
if let Some(data) = mod_data {
let mut authorized = !data.status.is_hidden();
if let Some(user) = user_option {
if !authorized {
if user.role.is_mod() {
authorized = true;
} else {
let user_id: database::models::ids::UserId = user.id.into();
let mod_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM team_members WHERE id = $1 AND user_id = $2)",
data.inner.team_id as database::models::ids::TeamId,
user_id as database::models::ids::UserId,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.exists;
authorized = mod_exists.unwrap_or(false);
}
}
}
if authorized {
return Ok(HttpResponse::Ok().json(convert_mod(data)));
}
Ok(HttpResponse::NotFound().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[get("{id}")]
pub async fn mod_get(
req: HttpRequest,
@@ -132,6 +180,7 @@ fn convert_mod(data: database::models::mod_item::QueryMod) -> models::mods::Mod
models::mods::Mod {
id: m.id.into(),
slug: m.slug,
team: m.team_id.into(),
title: m.title,
description: m.description,
@@ -139,6 +188,13 @@ fn convert_mod(data: database::models::mod_item::QueryMod) -> models::mods::Mod
published: m.published,
updated: m.updated,
status: data.status,
license: License {
id: data.license_id,
name: data.license_name,
url: m.license_url,
},
client_side: data.client_side,
server_side: data.server_side,
downloads: m.downloads as u32,
categories: data.categories,
versions: data.versions.into_iter().map(|v| v.into()).collect(),
@@ -146,6 +202,8 @@ fn convert_mod(data: database::models::mod_item::QueryMod) -> models::mods::Mod
issues_url: m.issues_url,
source_url: m.source_url,
wiki_url: m.wiki_url,
discord_url: m.discord_url,
donation_urls: None,
}
}
@@ -175,6 +233,28 @@ pub struct EditMod {
with = "::serde_with::rust::double_option"
)]
pub wiki_url: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub license_url: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub discord_url: Option<Option<String>>,
pub donation_urls: Option<Vec<DonationLink>>,
pub license_id: Option<String>,
pub client_side: Option<SideType>,
pub server_side: Option<SideType>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub slug: Option<Option<String>>,
}
#[patch("{id}")]
@@ -270,12 +350,10 @@ pub async fn mod_edit(
));
}
if status == &ModStatus::Rejected || status == &ModStatus::Approved {
if !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to set this status".to_string(),
));
}
if (status == &ModStatus::Rejected || status == &ModStatus::Approved) && !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to set this status".to_string(),
));
}
let status_id = database::models::StatusId::get_id(&status, &mut *transaction)
@@ -421,6 +499,199 @@ pub async fn mod_edit(
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(license_url) = &new_mod.license_url {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the license URL of this mod!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE mods
SET license_url = $1
WHERE (id = $2)
",
license_url.as_deref(),
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(discord_url) = &new_mod.discord_url {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the discord URL of this mod!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE mods
SET discord_url = $1
WHERE (id = $2)
",
discord_url.as_deref(),
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(slug) = &new_mod.slug {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the slug of this mod!".to_string(),
));
}
sqlx::query!(
"
UPDATE mods
SET slug = $1
WHERE (id = $2)
",
slug.as_deref(),
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(new_side) = &new_mod.client_side {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the side type of this mod!"
.to_string(),
));
}
let side_type_id =
database::models::SideTypeId::get_id(new_side, &mut *transaction)
.await?
.expect("No database entry found for side type");
sqlx::query!(
"
UPDATE mods
SET client_side = $1
WHERE (id = $2)
",
side_type_id as database::models::SideTypeId,
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(new_side) = &new_mod.server_side {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the side type of this mod!"
.to_string(),
));
}
let side_type_id =
database::models::SideTypeId::get_id(new_side, &mut *transaction)
.await?
.expect("No database entry found for side type");
sqlx::query!(
"
UPDATE mods
SET server_side = $1
WHERE (id = $2)
",
side_type_id as database::models::SideTypeId,
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(license) = &new_mod.license_id {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the license of this mod!"
.to_string(),
));
}
let license_id =
database::models::categories::License::get_id(license, &mut *transaction)
.await?
.expect("No database entry found for license");
sqlx::query!(
"
UPDATE mods
SET license = $1
WHERE (id = $2)
",
license_id as database::models::LicenseId,
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(donations) = &new_mod.donation_urls {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the donation links of this mod!"
.to_string(),
));
}
sqlx::query!(
"
DELETE FROM mods_donations
WHERE joining_mod_id = $1
",
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
for donation in donations {
let platform_id = database::models::DonationPlatformId::get_id(
&donation.id,
&mut *transaction,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError(format!(
"Platform {} does not exist.",
donation.id.clone()
))
})?;
sqlx::query!(
"
INSERT INTO mods_donations (joining_mod_id, joining_platform_id, url)
VALUES ($1, $2, $3)
",
id as database::models::ids::ModId,
platform_id as database::models::ids::DonationPlatformId,
donation.url
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
}
if let Some(body) = &new_mod.body {
if !perms.contains(Permissions::EDIT_BODY) {
return Err(ApiError::CustomAuthenticationError(
@@ -452,6 +723,99 @@ pub async fn mod_edit(
}
}
#[derive(Serialize, Deserialize)]
pub struct Extension {
pub ext: String,
}
#[patch("{id}/icon")]
pub async fn mod_icon_edit(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
info: web::Path<(models::ids::ModId,)>,
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) {
let cdn_url = dotenv::var("CDN_URL")?;
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
let mod_item = database::models::Mod::get(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.ok_or_else(|| ApiError::InvalidInputError("Invalid Mod ID specified!".to_string()))?;
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id(
mod_item.team_id,
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::DatabaseError)?
.ok_or_else(|| ApiError::InvalidInputError("Invalid Mod ID specified!".to_string()))?;
if !team_member.permissions.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to edit this mod's icon.".to_string(),
));
}
}
if let Some(icon) = mod_item.icon_url {
let name = icon.split('/').next();
if let Some(icon_path) = name {
file_host.delete_file_version("", icon_path).await?;
}
}
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!("data/{}/icon.{}", id, ext.ext),
bytes.to_vec(),
)
.await?;
let mod_id: database::models::ids::ModId = id.into();
sqlx::query!(
"
UPDATE mods
SET icon_url = $1
WHERE (id = $2)
",
format!("{}/{}", cdn_url, upload_data.file_name),
mod_id as database::models::ids::ModId,
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().body(""))
} else {
Err(ApiError::InvalidInputError(format!(
"Invalid format for mod icon: {}",
ext.ext
)))
}
}
#[delete("{id}")]
pub async fn mod_delete(
req: HttpRequest,

View File

@@ -1,6 +1,7 @@
use super::ApiError;
use crate::auth::check_is_admin_from_headers;
use crate::database::models;
use crate::database::models::categories::{DonationPlatform, License};
use actix_web::{delete, get, put, web, HttpRequest, HttpResponse};
use models::categories::{Category, GameVersion, Loader};
use sqlx::PgPool;
@@ -16,7 +17,13 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(loader_delete)
.service(game_version_list)
.service(game_version_create)
.service(game_version_delete),
.service(game_version_delete)
.service(license_create)
.service(license_delete)
.service(license_list)
.service(donation_platform_create)
.service(donation_platform_list)
.service(donation_platform_delete),
);
}
@@ -34,14 +41,7 @@ pub async fn category_create(
pool: web::Data<PgPool>,
category: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(
req.headers(),
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?;
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = category.into_inner().0;
@@ -56,14 +56,7 @@ pub async fn category_delete(
pool: web::Data<PgPool>,
category: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(
req.headers(),
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?;
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = category.into_inner().0;
let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?;
@@ -94,14 +87,7 @@ pub async fn loader_create(
pool: web::Data<PgPool>,
loader: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(
req.headers(),
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?;
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = loader.into_inner().0;
@@ -116,14 +102,7 @@ pub async fn loader_delete(
pool: web::Data<PgPool>,
loader: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(
req.headers(),
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?;
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = loader.into_inner().0;
let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?;
@@ -176,14 +155,7 @@ pub async fn game_version_create(
game_version: web::Path<(String,)>,
version_data: web::Json<GameVersionData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(
req.headers(),
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?;
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = game_version.into_inner().0;
@@ -209,14 +181,7 @@ pub async fn game_version_delete(
pool: web::Data<PgPool>,
game_version: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(
req.headers(),
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?;
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = game_version.into_inner().0;
let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?;
@@ -234,3 +199,141 @@ pub async fn game_version_delete(
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(serde::Serialize)]
pub struct LicenseQueryData {
short: String,
name: String,
}
#[get("license")]
pub async fn license_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results: Vec<LicenseQueryData> = License::list(&**pool)
.await?
.into_iter()
.map(|x| LicenseQueryData {
short: x.short,
name: x.name,
})
.collect();
Ok(HttpResponse::Ok().json(results))
}
#[derive(serde::Deserialize)]
pub struct LicenseData {
name: String,
}
#[put("license/{name}")]
pub async fn license_create(
req: HttpRequest,
pool: web::Data<PgPool>,
license: web::Path<(String,)>,
license_data: web::Json<LicenseData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let short = license.into_inner().0;
let _id = License::builder()
.short(&short)?
.name(&license_data.name)?
.insert(&**pool)
.await?;
Ok(HttpResponse::Ok().body(""))
}
#[delete("license/{name}")]
pub async fn license_delete(
req: HttpRequest,
pool: web::Data<PgPool>,
license: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = license.into_inner().0;
let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?;
let result = License::remove(&name, &mut transaction).await?;
transaction
.commit()
.await
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(serde::Serialize)]
pub struct DonationPlatformQueryData {
short: String,
name: String,
}
#[get("donation_platform")]
pub async fn donation_platform_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results: Vec<DonationPlatformQueryData> = DonationPlatform::list(&**pool)
.await?
.into_iter()
.map(|x| DonationPlatformQueryData {
short: x.short,
name: x.name,
})
.collect();
Ok(HttpResponse::Ok().json(results))
}
#[derive(serde::Deserialize)]
pub struct DonationPlatformData {
name: String,
}
#[put("donation_platform/{name}")]
pub async fn donation_platform_create(
req: HttpRequest,
pool: web::Data<PgPool>,
license: web::Path<(String,)>,
license_data: web::Json<DonationPlatformData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let short = license.into_inner().0;
let _id = DonationPlatform::builder()
.short(&short)?
.name(&license_data.name)?
.insert(&**pool)
.await?;
Ok(HttpResponse::Ok().body(""))
}
#[delete("donation_platform/{name}")]
pub async fn donation_platform_delete(
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.map_err(models::DatabaseError::from)?;
let result = DonationPlatform::remove(&name, &mut transaction).await?;
transaction
.commit()
.await
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

View File

@@ -1,10 +1,13 @@
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::database::models::{TeamMember, User};
use crate::file_hosting::FileHost;
use crate::models::users::{Role, UserId};
use crate::routes::ApiError;
use actix_web::{delete, get, web, HttpRequest, HttpResponse};
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
#[get("user")]
pub async fn user_auth_get(
@@ -44,22 +47,30 @@ pub async fn users_get(
let users: Vec<crate::models::users::User> = users_data
.into_iter()
.map(|data| crate::models::users::User {
id: data.id.into(),
github_id: data.github_id.map(|i| i as u64),
username: data.username,
name: data.name,
email: None,
avatar_url: data.avatar_url,
bio: data.bio,
created: data.created,
role: Role::from_string(&*data.role),
})
.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<(UserId,)>,
@@ -71,23 +82,27 @@ pub async fn user_get(
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(data) = user_data {
let response = crate::models::users::User {
id: data.id.into(),
github_id: data.github_id.map(|i| i as u64),
username: data.username,
name: data.name,
email: None,
avatar_url: data.avatar_url,
bio: data.bio,
created: data.created,
role: Role::from_string(&*data.role),
};
let response = convert_user(data);
Ok(HttpResponse::Ok().json(response))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
fn convert_user(data: crate::database::models::user_item::User) -> crate::models::users::User {
crate::models::users::User {
id: data.id.into(),
github_id: data.github_id.map(|i| i as u64),
username: data.username,
name: data.name,
email: None,
avatar_url: data.avatar_url,
bio: data.bio,
created: data.created,
role: Role::from_string(&*data.role),
}
}
#[get("{user_id}/mods")]
pub async fn mods_list(
info: web::Path<(UserId,)>,
@@ -161,6 +176,236 @@ pub async fn teams(
Ok(HttpResponse::Ok().json(team_members))
}
#[derive(Serialize, Deserialize)]
pub struct EditUser {
pub username: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub name: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub email: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub bio: Option<Option<String>>,
pub role: Option<String>,
}
#[patch("{id}")]
pub async fn user_edit(
req: HttpRequest,
info: web::Path<(UserId,)>,
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();
if user.id == user_id || user.role.is_mod() {
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
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()))?;
}
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()))?;
}
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()))?;
}
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(),
));
}
let role = Role::from_string(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()))?;
}
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().body(""))
} else {
Err(ApiError::CustomAuthenticationError(
"You do not have permission to edit this user!".to_string(),
))
}
}
#[derive(Serialize, Deserialize)]
pub struct Extension {
pub ext: String,
}
#[patch("{id}/icon")]
pub async fn user_icon_edit(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
info: web::Path<(UserId,)>,
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) {
let cdn_url = dotenv::var("CDN_URL")?;
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
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(icon) = icon_url {
if icon.starts_with(&cdn_url) {
let name = icon.split('/').next();
if let Some(icon_path) = name {
file_host.delete_file_version("", icon_path).await?;
}
}
}
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.{}", id, ext.ext),
bytes.to_vec(),
)
.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(""))
} else {
Err(ApiError::InvalidInputError(format!(
"Invalid format for user icon: {}",
ext.ext
)))
}
}
// TODO: Make this actually do stuff
#[delete("{id}")]
pub async fn user_delete(

View File

@@ -24,6 +24,7 @@ pub struct InitialVersionData {
pub game_versions: Vec<GameVersion>,
pub release_channel: VersionType,
pub loaders: Vec<ModLoader>,
pub featured: bool,
}
#[derive(Serialize, Deserialize, Clone)]
@@ -265,6 +266,7 @@ async fn version_create_inner(
game_versions,
loaders,
release_channel,
featured: version_create_data.featured,
});
continue;
@@ -298,6 +300,7 @@ async fn version_create_inner(
id: builder.version_id.into(),
mod_id: builder.mod_id.into(),
author_id: user.id,
featured: builder.featured,
name: builder.name.clone(),
version_number: builder.version_number.clone(),
changelog_url: builder.changelog_url.clone(),
@@ -324,6 +327,7 @@ async fn version_create_inner(
.collect(),
url: file.url.clone(),
filename: file.filename.clone(),
primary: file.primary,
})
.collect::<Vec<_>>(),
dependencies: version_data.dependencies,
@@ -528,6 +532,7 @@ pub async fn upload_file(
// bytes, but this is the string version.
hash: upload_data.content_sha1.into_bytes(),
}],
primary: uploaded_files.len() == 1,
})
}

View File

@@ -1,6 +1,6 @@
use super::ApiError;
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::database;
use crate::{database, Pepper};
use crate::file_hosting::FileHost;
use crate::models;
use crate::models::teams::Permissions;
@@ -151,6 +151,7 @@ fn convert_version(data: database::models::version_item::QueryVersion) -> models
mod_id: data.mod_id.into(),
author_id: data.author_id.into(),
featured: data.featured,
name: data.name,
version_number: data.version_number,
changelog_url: data.changelog_url,
@@ -178,6 +179,7 @@ fn convert_version(data: database::models::version_item::QueryVersion) -> models
.map(|(k, v)| Some((k, String::from_utf8(v).ok()?)))
.collect::<Option<_>>()
.unwrap_or_else(Default::default),
primary: f.primary,
}
})
.collect(),
@@ -204,6 +206,8 @@ pub struct EditVersion {
pub game_versions: Option<Vec<models::mods::GameVersion>>,
pub loaders: Option<Vec<models::mods::ModLoader>>,
pub accepted: Option<bool>,
pub featured: Option<bool>,
pub primary_file: Option<(String, String)>,
}
#[patch("{id}")]
@@ -388,6 +392,65 @@ pub async fn version_edit(
}
}
if let Some(featured) = &new_version.featured {
sqlx::query!(
"
UPDATE versions
SET featured = $1
WHERE (id = $2)
",
featured,
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(primary_file) = &new_version.primary_file {
let result = sqlx::query!(
"
SELECT id FROM files
INNER JOIN hashes ON hash = $1 AND algorithm = $2
",
primary_file.1.as_bytes(),
primary_file.0
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.ok_or_else(|| {
ApiError::InvalidInputError(format!(
"Specified file with hash {} does not exist.",
primary_file.1.clone()
))
})?;
sqlx::query!(
"
UPDATE files
SET is_primary = FALSE
WHERE (version_id = $1)
",
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
UPDATE files
SET is_primary = TRUE
WHERE (id = $1)
",
result.id,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(body) = &new_version.changelog {
let mod_id: models::mods::ModId = version_item.mod_id.into();
let body_path = format!(
@@ -518,6 +581,102 @@ pub async fn get_version_from_hash(
}
}
#[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;
let result = sqlx::query!(
"
SELECT f.url url, f.id id, f.version_id version_id, v.mod_id mod_id FROM files f
INNER JOIN versions v ON v.id = f.version_id
INNER JOIN hashes ON hash = $1 AND algorithm = $2
",
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.realip_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(