Moderation + Mod Editing (#101)

* Moderation + Mod Editing WIP

* Run prepare, fix perms

* Make it compile

* Finish moderation and edit routes

* More fixes

* Use better queries

* Final Fixes
This commit is contained in:
Geometrically 2020-11-15 19:58:11 -07:00 committed by GitHub
parent da911bfeb8
commit 0500994def
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1830 additions and 332 deletions

70
Cargo.lock generated
View File

@ -950,6 +950,41 @@ dependencies = [
"subtle",
]
[[package]]
name = "darling"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d706e75d87e35569db781a9b5e2416cff1236a47ed380831f959382ccd5f858"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c960ae2da4de88a91b2d920c2a7233b400bc33cb28453a2987822d8392519b"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b5a2f4ac4969822c62224815d069952656cadc7084fdca9751e6d959189b72"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "dashmap"
version = "3.11.10"
@ -1509,6 +1544,12 @@ dependencies = [
"tokio-tls",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "0.2.0"
@ -1627,6 +1668,7 @@ dependencies = [
"rust-s3",
"serde",
"serde_json",
"serde_with",
"sha1",
"sqlx",
"sqlx-macros",
@ -2397,6 +2439,28 @@ dependencies = [
"url",
]
[[package]]
name = "serde_with"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bac272128fb3b1e98872dca27a05c18d8b78b9bd089d3edb7b5871501b50bce"
dependencies = [
"serde",
"serde_with_macros",
]
[[package]]
name = "serde_with_macros"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c747a9ab2e833b807f74f6b6141530655010bfa9c9c06d5508bce75c8f8072f"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "sha-1"
version = "0.9.1"
@ -2635,6 +2699,12 @@ dependencies = [
"unicode-normalization",
]
[[package]]
name = "strsim"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c"
[[package]]
name = "subtle"
version = "2.3.0"

View File

@ -23,6 +23,7 @@ reqwest = { version = "0.10.8", features = ["json"] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_with = "1.5.1"
chrono = { version = "0.4", features = ["serde"] }
rand = "0.7.3"
base64 = "0.13.0"

View File

@ -0,0 +1,7 @@
-- Add migration script here
DELETE FROM release_channels WHERE channel = 'release-hidden';
DELETE FROM release_channels WHERE channel = 'beta-hidden';
DELETE FROM release_channels WHERE channel = 'alpha-hidden';
ALTER TABLE versions
ADD COLUMN accepted BOOLEAN NOT NULL default FALSE;

File diff suppressed because it is too large Load Diff

View File

@ -95,9 +95,10 @@ where
{
let user = get_user_from_headers(headers, executor).await?;
match user.role {
Role::Moderator | Role::Admin => Ok(user),
_ => Err(AuthenticationError::InvalidCredentialsError),
if user.role.is_mod() {
Ok(user)
} else {
Err(AuthenticationError::InvalidCredentialsError)
}
}

View File

@ -80,6 +80,7 @@ impl VersionBuilder {
date_published: chrono::Utc::now(),
downloads: 0,
release_channel: self.release_channel,
accepted: false,
};
version.insert(&mut *transaction).await?;
@ -152,6 +153,7 @@ pub struct Version {
pub date_published: chrono::DateTime<chrono::Utc>,
pub downloads: i32,
pub release_channel: ChannelId,
pub accepted: bool,
}
impl Version {
@ -164,12 +166,13 @@ impl Version {
INSERT INTO versions (
id, mod_id, author_id, name, version_number,
changelog_url, date_published,
downloads, release_channel
downloads, release_channel, accepted
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7,
$8, $9
$8, $9,
$10
)
",
self.id as VersionId,
@ -181,6 +184,7 @@ impl Version {
self.date_published,
self.downloads,
self.release_channel as ChannelId,
self.accepted
)
.execute(&mut *transaction)
.await?;
@ -291,6 +295,15 @@ impl Version {
.execute(exec)
.await?;
sqlx::query!(
"
DELETE FROM dependencies WHERE dependent_id = $1
",
id as VersionId,
)
.execute(exec)
.await?;
Ok(Some(()))
}
@ -354,7 +367,7 @@ impl Version {
"
SELECT v.mod_id, v.author_id, v.name, v.version_number,
v.changelog_url, v.date_published, v.downloads,
v.release_channel
v.release_channel, v.accepted
FROM versions v
WHERE v.id = $1
",
@ -374,6 +387,7 @@ impl Version {
date_published: row.date_published,
downloads: row.downloads,
release_channel: ChannelId(row.release_channel),
accepted: row.accepted,
}))
} else {
Ok(None)
@ -394,7 +408,7 @@ impl Version {
"
SELECT v.id, v.mod_id, v.author_id, v.name, v.version_number,
v.changelog_url, v.date_published, v.downloads,
v.release_channel
v.release_channel, accepted
FROM versions v
WHERE v.id IN (SELECT * FROM UNNEST($1::bigint[]))
",
@ -412,6 +426,7 @@ impl Version {
date_published: v.date_published,
downloads: v.downloads,
release_channel: ChannelId(v.release_channel),
accepted: v.accepted,
}))
})
.try_collect::<Vec<Version>>()
@ -431,7 +446,7 @@ impl Version {
"
SELECT v.mod_id, v.author_id, v.name, v.version_number,
v.changelog_url, v.date_published, v.downloads,
release_channels.channel
release_channels.channel, v.accepted
FROM versions v
INNER JOIN release_channels ON v.release_channel = release_channels.id
WHERE v.id = $1
@ -519,6 +534,7 @@ impl Version {
files,
loaders,
game_versions,
accepted: row.accepted,
}))
} else {
Ok(None)
@ -570,6 +586,7 @@ pub struct QueryVersion {
pub files: Vec<QueryFile>,
pub game_versions: Vec<String>,
pub loaders: Vec<String>,
pub accepted: bool,
}
pub struct QueryFile {

View File

@ -58,7 +58,7 @@ pub struct Mod {
/// Draft - Mod is not displayed on search, and not accessible by URL
/// Unlisted - Mod is not displayed on search, but accessible by URL
/// Processing - Mod is not displayed on search, and not accessible by URL (Temporary state, mod under review)
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ModStatus {
Approved,
@ -103,6 +103,24 @@ impl ModStatus {
ModStatus::Unknown => "unknown",
}
}
pub fn is_hidden(&self) -> bool {
match self {
ModStatus::Approved => false,
ModStatus::Rejected => true,
ModStatus::Draft => true,
ModStatus::Unlisted => false,
ModStatus::Processing => true,
ModStatus::Unknown => true,
}
}
pub fn is_searchable(&self) -> bool {
match self {
ModStatus::Approved => true,
_ => false,
}
}
}
/// A specific version of a mod

View File

@ -45,4 +45,11 @@ impl Role {
_ => Role::Developer,
}
}
pub fn is_mod(&self) -> bool {
match self {
Role::Developer => false,
Role::Moderator | Role::Admin => true,
}
}
}

View File

@ -3,6 +3,7 @@ use actix_web::web;
mod auth;
mod index;
mod mod_creation;
mod moderation;
mod mods;
mod not_found;
mod tags;
@ -16,6 +17,7 @@ pub use tags::config as tags_config;
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);
@ -26,6 +28,7 @@ pub fn mods_config(cfg: &mut web::ServiceConfig) {
web::scope("mod")
.service(mods::mod_get)
.service(mods::mod_delete)
.service(mods::mod_edit)
.service(web::scope("{mod_id}").service(versions::version_list)),
);
}
@ -37,7 +40,13 @@ pub fn versions_config(cfg: &mut web::ServiceConfig) {
web::scope("version")
.service(versions::version_get)
.service(versions::version_delete)
.service(version_creation::upload_file_to_version),
.service(version_creation::upload_file_to_version)
.service(versions::version_edit),
);
cfg.service(
web::scope("version_file")
.service(versions::delete_file)
.service(versions::get_version_from_hash),
);
}
@ -67,6 +76,8 @@ pub fn teams_config(cfg: &mut web::ServiceConfig) {
#[derive(thiserror::Error, Debug)]
pub enum ApiError {
#[error("Error while uploading file")]
FileHostingError(#[from] FileHostingError),
#[error("Internal server error")]
DatabaseError(#[from] crate::database::models::DatabaseError),
#[error("Deserialization error: {0}")]
@ -89,6 +100,7 @@ impl actix_web::ResponseError for ApiError {
ApiError::CustomAuthenticationError(..) => actix_web::http::StatusCode::UNAUTHORIZED,
ApiError::JsonError(..) => actix_web::http::StatusCode::BAD_REQUEST,
ApiError::SearchError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::FileHostingError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::InvalidInputError(..) => actix_web::http::StatusCode::BAD_REQUEST,
}
}
@ -102,6 +114,7 @@ impl actix_web::ResponseError for ApiError {
ApiError::CustomAuthenticationError(..) => "unauthorized",
ApiError::JsonError(..) => "json_error",
ApiError::SearchError(..) => "search_error",
ApiError::FileHostingError(..) => "file_hosting_error",
ApiError::InvalidInputError(..) => "invalid_input",
},
description: &self.to_string(),

View File

@ -113,6 +113,8 @@ 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 boolean. If true, the mod will be created as a draft.
pub is_draft: Option<bool>,
}
pub struct UploadedFile {
@ -277,6 +279,12 @@ async fn mod_create_inner(
check_length(3..=2048, "mod description", &create_data.mod_description)?;
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()
@ -444,7 +452,13 @@ async fn mod_create_inner(
let team_id = team.insert(&mut *transaction).await?;
let status = ModStatus::Processing;
let status;
if mod_create_data.is_draft.unwrap_or(false) {
status = ModStatus::Draft;
} else {
status = ModStatus::Processing;
}
let status_id = models::StatusId::get_id(&status, &mut *transaction)
.await?
.expect("No database entry found for status");

112
src/routes/moderation.rs Normal file
View File

@ -0,0 +1,112 @@
use super::ApiError;
use crate::auth::check_is_moderator_from_headers;
use crate::database;
use crate::models;
use crate::models::mods::{ModStatus, VersionType};
use actix_web::{get, web, HttpRequest, HttpResponse};
use serde::Deserialize;
use sqlx::PgPool;
#[derive(Deserialize)]
pub struct ResultCount {
#[serde(default = "default_count")]
count: i16,
}
fn default_count() -> i16 {
100
}
#[get("mods")]
pub async fn 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 mods = sqlx::query!(
"
SELECT * FROM mods
WHERE status = (
SELECT id FROM statuses WHERE status = $1
)
ORDER BY updated ASC
LIMIT $2;
",
ModStatus::Processing.as_str(),
count.count as i64
)
.fetch_many(&**pool)
.try_filter_map(|e| async {
Ok(e.right().map(|m| models::mods::Mod {
id: database::models::ids::ModId(m.id).into(),
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,
status: ModStatus::Processing,
updated: m.updated,
downloads: m.downloads as u32,
wiki_url: m.wiki_url,
}))
})
.try_collect::<Vec<models::mods::Mod>>()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().json(mods))
}
/// Returns a list of versions that need to be approved
#[get("versions")]
pub async fn versions(
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 versions = sqlx::query!(
"
SELECT * FROM versions
WHERE accepted = FALSE
ORDER BY date_published ASC
LIMIT $1;
",
count.count as i64
)
.fetch_many(&**pool)
.try_filter_map(|e| async {
Ok(e.right().map(|m| models::mods::Version {
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(),
name: m.name,
version_number: m.version_number,
changelog_url: m.changelog_url,
date_published: m.date_published,
downloads: m.downloads as u32,
version_type: VersionType::Release,
files: vec![],
dependencies: vec![],
game_versions: vec![],
loaders: vec![],
}))
})
.try_collect::<Vec<models::mods::Version>>()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().json(versions))
}

View File

@ -1,14 +1,15 @@
use super::ApiError;
use crate::auth::get_user_from_headers;
use crate::database;
use crate::file_hosting::FileHost;
use crate::models;
use crate::models::mods::SearchRequest;
use crate::models::mods::{ModStatus, SearchRequest};
use crate::models::teams::Permissions;
use crate::models::users::Role;
use crate::search::{search_for_mod, SearchConfig, SearchError};
use actix_web::{delete, get, web, HttpRequest, HttpResponse};
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
#[get("mod")]
pub async fn mod_search(
@ -27,6 +28,7 @@ pub struct ModIds {
// TODO: Make this return the full mod struct
#[get("mods")]
pub async fn mods_get(
req: HttpRequest,
web::Query(ids): web::Query<ModIds>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
@ -39,17 +41,48 @@ pub async fn mods_get(
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mods = mods_data
.into_iter()
.filter_map(|m| m)
.map(convert_mod)
.collect::<Vec<_>>();
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
let mut mods = Vec::new();
for mod_data_option in mods_data {
if let Some(mod_data) = mod_data_option {
let mut authorized = !mod_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)",
mod_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 {
mods.push(convert_mod(mod_data));
}
}
}
Ok(HttpResponse::Ok().json(mods))
}
#[get("{id}")]
pub async fn mod_get(
req: HttpRequest,
info: web::Path<(models::ids::ModId,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
@ -57,9 +90,38 @@ pub async fn mod_get(
let mod_data = database::models::Mod::get_full(id.into(), &**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 {
Ok(HttpResponse::Ok().json(convert_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(""))
}
@ -87,6 +149,309 @@ fn convert_mod(data: database::models::mod_item::QueryMod) -> models::mods::Mod
}
}
/// A mod returned from the API
#[derive(Serialize, Deserialize)]
pub struct EditMod {
pub title: Option<String>,
pub description: Option<String>,
pub body: Option<String>,
pub status: Option<ModStatus>,
pub categories: Option<Vec<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub issues_url: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub source_url: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub wiki_url: Option<Option<String>>,
}
#[patch("{id}")]
pub async fn mod_edit(
req: HttpRequest,
info: web::Path<(models::ids::ModId,)>,
pool: web::Data<PgPool>,
config: web::Data<SearchConfig>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
new_mod: web::Json<EditMod>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let mod_id = info.into_inner().0;
let id = mod_id.into();
let result = database::models::Mod::get_full(id, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(mod_item) = result {
let team_member = database::models::TeamMember::get_from_user_id(
mod_item.inner.team_id,
user.id.into(),
&**pool,
)
.await?;
let permissions;
if let Some(member) = team_member {
permissions = Some(member.permissions)
} else if user.role.is_mod() {
permissions = Some(Permissions::ALL)
} else {
permissions = None
}
if let Some(perms) = permissions {
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(title) = &new_mod.title {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the title of this mod!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE mods
SET title = $1
WHERE (id = $2)
",
title,
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(description) = &new_mod.description {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the description of this mod!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE mods
SET description = $1
WHERE (id = $2)
",
description,
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(status) = &new_mod.status {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the status of this mod!"
.to_string(),
));
}
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(),
));
}
}
let status_id = database::models::StatusId::get_id(&status, &mut *transaction)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError(
"No database entry for status provided.".to_string(),
)
})?;
sqlx::query!(
"
UPDATE mods
SET status = $1
WHERE (id = $2)
",
status_id as database::models::ids::StatusId,
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if mod_item.status.is_searchable() && status.is_searchable() {
delete_from_index(id.into(), config).await?;
}
}
if let Some(categories) = &new_mod.categories {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the categories of this mod!"
.to_string(),
));
}
if categories.len() > 3 {
return Err(ApiError::InvalidInputError(
"The maximum number of categories for a mod is four.".to_string(),
));
}
sqlx::query!(
"
DELETE FROM mods_categories
WHERE joining_mod_id = $1
",
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
for category in categories {
let category_id = database::models::categories::Category::get_id(
&category,
&mut *transaction,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError(format!(
"Category {} does not exist.",
category.clone()
))
})?;
sqlx::query!(
"
INSERT INTO mods_categories (joining_mod_id, joining_category_id)
VALUES ($1, $2)
",
id as database::models::ids::ModId,
category_id as database::models::ids::CategoryId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
}
if let Some(issues_url) = &new_mod.issues_url {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the issues URL of this mod!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE mods
SET issues_url = $1
WHERE (id = $2)
",
issues_url.as_deref(),
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(source_url) = &new_mod.source_url {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the source URL of this mod!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE mods
SET source_url = $1
WHERE (id = $2)
",
source_url.as_deref(),
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(wiki_url) = &new_mod.wiki_url {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the wiki URL of this mod!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE mods
SET wiki_url = $1
WHERE (id = $2)
",
wiki_url.as_deref(),
id as database::models::ids::ModId,
)
.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(
"You do not have the permissions to edit the body of this mod!".to_string(),
));
}
let body_path = format!("data/{}/description.md", mod_id);
file_host.delete_file_version("", &*body_path).await?;
file_host
.upload_file("text/plain", &body_path, body.clone().into_bytes())
.await?;
}
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 mod!".to_string(),
))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[delete("{id}")]
pub async fn mod_delete(
req: HttpRequest,
@ -97,7 +462,7 @@ pub async fn mod_delete(
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
if user.role != Role::Moderator || user.role != Role::Admin {
if !user.role.is_mod() {
let mod_item = database::models::Mod::get(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
@ -122,12 +487,7 @@ pub async fn mod_delete(
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let client = meilisearch_sdk::client::Client::new(&*config.address, &*config.key);
let indexes: Vec<meilisearch_sdk::indexes::Index> = client.get_indexes().await?;
for index in indexes {
index.delete_document(format!("local-{}", id)).await?;
}
delete_from_index(id, config).await?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
@ -135,3 +495,17 @@ pub async fn mod_delete(
Ok(HttpResponse::NotFound().body(""))
}
}
pub async fn delete_from_index(
id: crate::models::mods::ModId,
config: web::Data<SearchConfig>,
) -> Result<(), meilisearch_sdk::errors::Error> {
let client = meilisearch_sdk::client::Client::new(&*config.address, &*config.key);
let indexes: Vec<meilisearch_sdk::indexes::Index> = client.get_indexes().await?;
for index in indexes {
index.delete_document(format!("local-{}", id)).await?;
}
Ok(())
}

View File

@ -1,12 +1,13 @@
use super::ApiError;
use crate::auth::get_user_from_headers;
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::database;
use crate::file_hosting::FileHost;
use crate::models;
use crate::models::teams::Permissions;
use crate::models::users::Role;
use actix_web::{delete, get, web, HttpRequest, HttpResponse};
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
// TODO: this needs filtering, and a better response type
// Currently it only gives a list of ids, which have to be
@ -52,6 +53,7 @@ pub struct VersionIds {
#[get("versions")]
pub async fn versions_get(
req: HttpRequest,
web::Query(ids): web::Query<VersionIds>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
@ -63,17 +65,48 @@ pub async fn versions_get(
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let versions: Vec<models::mods::Version> = versions_data
.into_iter()
.filter_map(|v| v)
.map(convert_version)
.collect();
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
let mut versions = Vec::new();
for version_data in versions_data {
if let Some(version) = version_data {
let mut authorized = version.accepted;
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 member_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM team_members tm INNER JOIN mods m ON m.team_id = tm.id AND m.id = $1 WHERE tm.user_id = $2)",
version.mod_id as database::models::ModId,
user_id as database::models::ids::UserId,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.exists;
authorized = member_exists.unwrap_or(false);
}
}
}
if authorized {
versions.push(convert_version(version));
}
}
}
Ok(HttpResponse::Ok().json(versions))
}
#[get("{version_id}")]
pub async fn version_get(
req: HttpRequest,
info: web::Path<(models::ids::VersionId,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
@ -81,8 +114,29 @@ pub async fn version_get(
let version_data = database::models::Version::get_full(id.into(), &**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) = version_data {
if let Some(user) = user_option {
if !data.accepted && !user.role.is_mod() {
let user_id: database::models::ids::UserId = user.id.into();
let member_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM team_members tm INNER JOIN mods m ON m.team_id = tm.id AND m.id = $1 WHERE tm.user_id = $2)",
data.mod_id as database::models::ModId,
user_id as database::models::ids::UserId,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.exists;
if !member_exists.unwrap_or(false) {
return Ok(HttpResponse::NotFound().body(""));
}
}
}
Ok(HttpResponse::Ok().json(convert_version(data)))
} else {
Ok(HttpResponse::NotFound().body(""))
@ -106,7 +160,7 @@ fn convert_version(data: database::models::version_item::QueryVersion) -> models
"release" => VersionType::Release,
"beta" => VersionType::Beta,
"alpha" => VersionType::Alpha,
_ => VersionType::Alpha,
_ => VersionType::Release,
},
files: data
@ -141,6 +195,228 @@ fn convert_version(data: database::models::version_item::QueryVersion) -> models
}
}
#[derive(Serialize, Deserialize)]
pub struct EditVersion {
pub name: Option<String>,
pub changelog: Option<String>,
pub version_type: Option<models::mods::VersionType>,
pub dependencies: Option<Vec<models::ids::VersionId>>,
pub game_versions: Option<Vec<models::mods::GameVersion>>,
pub loaders: Option<Vec<models::mods::ModLoader>>,
pub accepted: Option<bool>,
}
#[patch("{id}")]
pub async fn version_edit(
req: HttpRequest,
info: web::Path<(models::ids::VersionId,)>,
pool: web::Data<PgPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
new_version: web::Json<EditVersion>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
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()))?;
if let Some(version_item) = result {
let mod_item = database::models::Mod::get(version_item.mod_id, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.ok_or_else(|| {
ApiError::InvalidInputError(
"Attempted to edit version not attached to mod. How did this happen?"
.to_string(),
)
})?;
let team_member = database::models::TeamMember::get_from_user_id(
mod_item.team_id,
user.id.into(),
&**pool,
)
.await?;
let permissions;
if let Some(member) = team_member {
permissions = Some(member.permissions)
} else if user.role.is_mod() {
permissions = Some(Permissions::ALL)
} else {
permissions = None
}
if let Some(perms) = permissions {
if !perms.contains(Permissions::UPLOAD_VERSION) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit this version!".to_string(),
));
}
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(accepted) = &new_version.accepted {
if !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the approval of this version!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE versions
SET accepted = $1
WHERE (id = $2)
",
accepted,
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(name) = &new_version.name {
sqlx::query!(
"
UPDATE versions
SET name = $1
WHERE (id = $2)
",
name,
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(version_type) = &new_version.version_type {
let channel = database::models::ids::ChannelId::get_id(
version_type.as_str(),
&mut *transaction,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError(
"No database entry for version type provided.".to_string(),
)
})?;
sqlx::query!(
"
UPDATE versions
SET release_channel = $1
WHERE (id = $2)
",
channel as database::models::ids::ChannelId,
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(dependencies) = &new_version.dependencies {
sqlx::query!(
"
DELETE FROM dependencies WHERE dependent_id = $1
",
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
for dependency in dependencies {
let dependency_id: database::models::ids::VersionId = dependency.clone().into();
sqlx::query!(
"
INSERT INTO dependencies (dependent_id, dependency_id)
VALUES ($1, $2)
",
id as database::models::ids::VersionId,
dependency_id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
}
if let Some(loaders) = &new_version.loaders {
sqlx::query!(
"
DELETE FROM loaders_versions WHERE version_id = $1
",
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
for loader in loaders {
let loader_id =
database::models::categories::Loader::get_id(&loader.0, &mut *transaction)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError(
"No database entry for loader provided.".to_string(),
)
})?;
sqlx::query!(
"
INSERT INTO loaders_versions (loader_id, version_id)
VALUES ($1, $2)
",
loader_id as database::models::ids::LoaderId,
id as database::models::ids::VersionId,
)
.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!(
"data/{}/versions/{}/changelog.md",
mod_id, version_item.version_number
);
file_host.delete_file_version("", &*body_path).await?;
file_host
.upload_file("text/plain", &body_path, body.clone().into_bytes())
.await?;
}
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 version!".to_string(),
))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[delete("{version_id}")]
pub async fn version_delete(
req: HttpRequest,
@ -150,7 +426,7 @@ pub async fn version_delete(
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
if user.role != Role::Moderator || user.role != Role::Admin {
if user.role.is_mod() {
let version = database::models::Version::get(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
@ -192,3 +468,139 @@ pub async fn version_delete(
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 version_id FROM files
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 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(""))
}
}
// 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> {
check_is_moderator_from_headers(req.headers(), &**pool).await?;
let hash = info.into_inner().0;
let result = sqlx::query!(
"
SELECT version_id, filename FROM files
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(row) = result {
let version_data = database::models::Version::get_full(
database::models::VersionId(row.version_id),
&**pool,
)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(data) = version_data {
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
DELETE FROM hashes
WHERE hash = $1 AND algorithm = $2
",
hash.as_bytes(),
algorithm.algorithm
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
DELETE FROM files
WHERE files.version_id = $1
",
data.id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mod_id: models::mods::ModId = data.mod_id.into();
file_host
.delete_file_version(
"",
&format!(
"data/{}/versions/{}/{}",
mod_id, data.version_number, row.filename
),
)
.await?;
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

View File

@ -14,12 +14,29 @@ pub async fn index_local(pool: PgPool) -> Result<Vec<UploadSearchMod>, IndexingE
let mut mods = sqlx::query!(
"
SELECT m.id, m.title, m.description, m.downloads, m.icon_url, m.body_url, m.published, m.updated, m.team_id FROM mods m
SELECT m.id, m.title, m.description, m.downloads, m.icon_url, m.body_url, m.published, m.updated, m.team_id, m.status FROM mods m
"
).fetch(&pool);
while let Some(result) = mods.next().await {
if let Ok(mod_data) = result {
let status = crate::models::mods::ModStatus::from_str(
&sqlx::query!(
"
SELECT status FROM statuses
WHERE id = $1
",
mod_data.status,
)
.fetch_one(&pool)
.await?
.status,
);
if !status.is_searchable() {
continue;
}
let versions = sqlx::query!(
"
SELECT DISTINCT gv.version, gv.created FROM versions