You've already forked AstralRinth
forked from didirus/AstralRinth
Perses finale (#558)
* Move v2 routes to v2 module * Remove v1 routes and make it run * Make config declaration consistent, add v3 module * Readd API v1 msgs * Fix imports
This commit is contained in:
401
src/routes/v2/admin.rs
Normal file
401
src/routes/v2/admin.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::guards::admin_key_guard;
|
||||
use crate::DownloadQueue;
|
||||
use actix_web::{patch, post, web, HttpResponse};
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("admin")
|
||||
.service(count_download)
|
||||
.service(process_payout),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DownloadBody {
|
||||
pub url: String,
|
||||
pub project_id: ProjectId,
|
||||
pub version_name: String,
|
||||
|
||||
pub ip: String,
|
||||
pub headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
// This is an internal route, cannot be used without key
|
||||
#[patch("/_count-download", guard = "admin_key_guard")]
|
||||
pub async fn count_download(
|
||||
pool: web::Data<PgPool>,
|
||||
download_body: web::Json<DownloadBody>,
|
||||
download_queue: web::Data<Arc<DownloadQueue>>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let project_id: crate::database::models::ids::ProjectId =
|
||||
download_body.project_id.into();
|
||||
|
||||
let id_option = crate::models::ids::base62_impl::parse_base62(
|
||||
&download_body.version_name,
|
||||
)
|
||||
.ok()
|
||||
.map(|x| x as i64);
|
||||
|
||||
let (version_id, project_id, file_type) = if let Some(version) =
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT v.id id, v.mod_id mod_id, file_type FROM files f
|
||||
INNER JOIN versions v ON v.id = f.version_id
|
||||
WHERE f.url = $1
|
||||
",
|
||||
download_body.url,
|
||||
)
|
||||
.fetch_optional(pool.as_ref())
|
||||
.await?
|
||||
{
|
||||
(version.id, version.mod_id, version.file_type)
|
||||
} else if let Some(version) = sqlx::query!(
|
||||
"
|
||||
SELECT id, mod_id FROM versions
|
||||
WHERE ((version_number = $1 OR id = $3) AND mod_id = $2)
|
||||
",
|
||||
download_body.version_name,
|
||||
project_id as crate::database::models::ids::ProjectId,
|
||||
id_option
|
||||
)
|
||||
.fetch_optional(pool.as_ref())
|
||||
.await?
|
||||
{
|
||||
(version.id, version.mod_id, None)
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Specified version does not exist!".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
if file_type.is_none() {
|
||||
download_queue
|
||||
.add(
|
||||
crate::database::models::ProjectId(project_id),
|
||||
crate::database::models::VersionId(version_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
client
|
||||
.post(format!("{}download", dotenvy::var("ARIADNE_URL")?))
|
||||
.header("Modrinth-Admin", dotenvy::var("ARIADNE_ADMIN_KEY")?)
|
||||
.json(&json!({
|
||||
"ip": download_body.ip,
|
||||
"url": download_body.url,
|
||||
"project_id": download_body.project_id,
|
||||
"version_id": crate::models::projects::VersionId(version_id as u64).to_string(),
|
||||
"headers": download_body.headers
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.ok();
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PayoutData {
|
||||
amount: Decimal,
|
||||
date: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[post("/_process_payout", guard = "admin_key_guard")]
|
||||
pub async fn process_payout(
|
||||
pool: web::Data<PgPool>,
|
||||
data: web::Json<PayoutData>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let start: DateTime<Utc> = DateTime::from_utc(
|
||||
data.date
|
||||
.date_naive()
|
||||
.and_hms_nano_opt(0, 0, 0, 0)
|
||||
.unwrap_or_default(),
|
||||
Utc,
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutMultipliers {
|
||||
sum: u64,
|
||||
values: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
let multipliers: PayoutMultipliers = client
|
||||
.get(format!("{}multipliers", dotenvy::var("ARIADNE_URL")?,))
|
||||
.header("Modrinth-Admin", dotenvy::var("ARIADNE_ADMIN_KEY")?)
|
||||
.query(&[(
|
||||
"start_date",
|
||||
start.to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ApiError::Analytics(
|
||||
"Error while fetching payout multipliers!".to_string(),
|
||||
)
|
||||
})?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ApiError::Analytics(
|
||||
"Error while deserializing payout multipliers!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
struct Project {
|
||||
project_type: String,
|
||||
// user_id, payouts_split
|
||||
team_members: Vec<(i64, Decimal)>,
|
||||
// user_id, payouts_split, actual_project_id
|
||||
split_team_members: Vec<(i64, Decimal, i64)>,
|
||||
}
|
||||
|
||||
let mut projects_map: HashMap<i64, Project> = HashMap::new();
|
||||
|
||||
use futures::TryStreamExt;
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT m.id id, tm.user_id user_id, tm.payouts_split payouts_split, pt.name project_type
|
||||
FROM mods m
|
||||
INNER JOIN team_members tm on m.team_id = tm.team_id AND tm.accepted = TRUE
|
||||
INNER JOIN project_types pt ON pt.id = m.project_type
|
||||
WHERE m.id = ANY($1)
|
||||
",
|
||||
&multipliers.values.keys().flat_map(|x| x.parse::<i64>().ok()).collect::<Vec<i64>>()
|
||||
)
|
||||
.fetch_many(&mut *transaction)
|
||||
.try_for_each(|e| {
|
||||
if let Some(row) = e.right() {
|
||||
if let Some(project) = projects_map.get_mut(&row.id) {
|
||||
project.team_members.push((row.user_id, row.payouts_split));
|
||||
} else {
|
||||
projects_map.insert(row.id, Project {
|
||||
project_type: row.project_type,
|
||||
team_members: vec![(row.user_id, row.payouts_split)],
|
||||
split_team_members: Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
futures::future::ready(Ok(()))
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Specific Payout Conditions (ex: modpack payout split)
|
||||
let mut projects_split_dependencies = Vec::new();
|
||||
|
||||
for (id, project) in &projects_map {
|
||||
if project.project_type == "modpack" {
|
||||
projects_split_dependencies.push(*id);
|
||||
}
|
||||
}
|
||||
|
||||
if !projects_split_dependencies.is_empty() {
|
||||
// (dependent_id, (dependency_id, times_depended))
|
||||
let mut project_dependencies: HashMap<i64, Vec<(i64, i64)>> =
|
||||
HashMap::new();
|
||||
// dependency_ids to fetch team members from
|
||||
let mut fetch_team_members: Vec<i64> = Vec::new();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT mv.mod_id, m.id, COUNT(m.id) times_depended FROM versions mv
|
||||
INNER JOIN dependencies d ON d.dependent_id = mv.id
|
||||
INNER JOIN versions v ON d.dependency_id = v.id
|
||||
INNER JOIN mods m ON v.mod_id = m.id OR d.mod_dependency_id = m.id
|
||||
WHERE mv.mod_id = ANY($1)
|
||||
group by mv.mod_id, m.id;
|
||||
",
|
||||
&projects_split_dependencies
|
||||
)
|
||||
.fetch_many(&mut *transaction)
|
||||
.try_for_each(|e| {
|
||||
if let Some(row) = e.right() {
|
||||
fetch_team_members.push(row.id);
|
||||
|
||||
if let Some(project) = project_dependencies.get_mut(&row.mod_id)
|
||||
{
|
||||
project.push((row.id, row.times_depended.unwrap_or(0)));
|
||||
} else {
|
||||
project_dependencies.insert(
|
||||
row.mod_id,
|
||||
vec![(row.id, row.times_depended.unwrap_or(0))],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
futures::future::ready(Ok(()))
|
||||
})
|
||||
.await?;
|
||||
|
||||
// (project_id, (user_id, payouts_split))
|
||||
let mut team_members: HashMap<i64, Vec<(i64, Decimal)>> =
|
||||
HashMap::new();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT m.id id, tm.user_id user_id, tm.payouts_split payouts_split
|
||||
FROM mods m
|
||||
INNER JOIN team_members tm on m.team_id = tm.team_id AND tm.accepted = TRUE
|
||||
WHERE m.id = ANY($1)
|
||||
",
|
||||
&*fetch_team_members
|
||||
)
|
||||
.fetch_many(&mut *transaction)
|
||||
.try_for_each(|e| {
|
||||
if let Some(row) = e.right() {
|
||||
if let Some(project) = team_members.get_mut(&row.id) {
|
||||
project.push((row.user_id, row.payouts_split));
|
||||
} else {
|
||||
team_members
|
||||
.insert(row.id, vec![(row.user_id, row.payouts_split)]);
|
||||
}
|
||||
}
|
||||
|
||||
futures::future::ready(Ok(()))
|
||||
})
|
||||
.await?;
|
||||
|
||||
for (project_id, dependencies) in project_dependencies {
|
||||
let dep_sum: i64 = dependencies.iter().map(|x| x.1).sum();
|
||||
|
||||
let project = projects_map.get_mut(&project_id);
|
||||
|
||||
if let Some(project) = project {
|
||||
if dep_sum > 0 {
|
||||
for dependency in dependencies {
|
||||
let project_multiplier: Decimal =
|
||||
Decimal::from(dependency.1)
|
||||
/ Decimal::from(dep_sum);
|
||||
|
||||
if let Some(members) = team_members.get(&dependency.0) {
|
||||
let members_sum: Decimal =
|
||||
members.iter().map(|x| x.1).sum();
|
||||
|
||||
if members_sum > Decimal::ZERO {
|
||||
for member in members {
|
||||
let member_multiplier: Decimal =
|
||||
member.1 / members_sum;
|
||||
project.split_team_members.push((
|
||||
member.0,
|
||||
member_multiplier * project_multiplier,
|
||||
project_id,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (id, project) in projects_map {
|
||||
if let Some(value) = &multipliers.values.get(&id.to_string()) {
|
||||
let project_multiplier: Decimal =
|
||||
Decimal::from(**value) / Decimal::from(multipliers.sum);
|
||||
|
||||
let default_split_given = Decimal::ONE;
|
||||
let split_given = Decimal::ONE / Decimal::from(5);
|
||||
let split_retention = Decimal::from(4) / Decimal::from(5);
|
||||
|
||||
let sum_splits: Decimal =
|
||||
project.team_members.iter().map(|x| x.1).sum();
|
||||
let sum_tm_splits: Decimal =
|
||||
project.split_team_members.iter().map(|x| x.1).sum();
|
||||
|
||||
if sum_splits > Decimal::ZERO {
|
||||
for (user_id, split) in project.team_members {
|
||||
let payout: Decimal = data.amount
|
||||
* project_multiplier
|
||||
* (split / sum_splits)
|
||||
* (if !project.split_team_members.is_empty() {
|
||||
&split_given
|
||||
} else {
|
||||
&default_split_given
|
||||
});
|
||||
|
||||
if payout > Decimal::ZERO {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO payouts_values (user_id, mod_id, amount, created)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
",
|
||||
user_id,
|
||||
id,
|
||||
payout,
|
||||
start
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET balance = balance + $1
|
||||
WHERE id = $2
|
||||
",
|
||||
payout,
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sum_tm_splits > Decimal::ZERO {
|
||||
for (user_id, split, project_id) in project.split_team_members {
|
||||
let payout: Decimal = data.amount
|
||||
* project_multiplier
|
||||
* (split / sum_tm_splits)
|
||||
* split_retention;
|
||||
|
||||
if payout > Decimal::ZERO {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO payouts_values (user_id, mod_id, amount, created)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
",
|
||||
user_id,
|
||||
project_id,
|
||||
payout,
|
||||
start
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET balance = balance + $1
|
||||
WHERE id = $2
|
||||
",
|
||||
payout,
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
303
src/routes/v2/auth.rs
Normal file
303
src/routes/v2/auth.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
/*!
|
||||
This auth module is primarily for use within the main website. Applications interacting with the
|
||||
authenticated API (a very small portion - notifications, private projects, editing/creating projects
|
||||
and versions) should either retrieve the Modrinth GitHub token through the site, or create a personal
|
||||
app token for use with Modrinth.
|
||||
|
||||
JUst as a summary: Don't implement this flow in your application! Instead, use a personal access token
|
||||
or create your own GitHub OAuth2 application.
|
||||
|
||||
This system will be revisited and allow easier interaction with the authenticated API once we roll
|
||||
out our own authentication system.
|
||||
*/
|
||||
|
||||
use crate::database::models::{generate_state_id, User};
|
||||
use crate::models::error::ApiError;
|
||||
use crate::models::ids::base62_impl::{parse_base62, to_base62};
|
||||
use crate::models::ids::DecodingError;
|
||||
use crate::models::users::{Badges, Role};
|
||||
use crate::parse_strings_from_var;
|
||||
use crate::util::auth::get_github_user_from_token;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::{scope, Data, Query, ServiceConfig};
|
||||
use actix_web::{get, HttpResponse};
|
||||
use chrono::Utc;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPool;
|
||||
use thiserror::Error;
|
||||
|
||||
pub fn config(cfg: &mut ServiceConfig) {
|
||||
cfg.service(scope("auth").service(auth_callback).service(init));
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AuthorizationError {
|
||||
#[error("Environment Error")]
|
||||
Env(#[from] dotenvy::Error),
|
||||
#[error("An unknown database error occured: {0}")]
|
||||
SqlxDatabase(#[from] sqlx::Error),
|
||||
#[error("Database Error: {0}")]
|
||||
Database(#[from] crate::database::models::DatabaseError),
|
||||
#[error("Error while parsing JSON: {0}")]
|
||||
SerDe(#[from] serde_json::Error),
|
||||
#[error("Error while communicating to GitHub OAuth2")]
|
||||
Github(#[from] reqwest::Error),
|
||||
#[error("Invalid Authentication credentials")]
|
||||
InvalidCredentials,
|
||||
#[error("Authentication Error: {0}")]
|
||||
Authentication(#[from] crate::util::auth::AuthenticationError),
|
||||
#[error("Error while decoding Base62")]
|
||||
Decoding(#[from] DecodingError),
|
||||
#[error("Invalid callback URL specified")]
|
||||
Url,
|
||||
#[error("User is not allowed to access Modrinth services")]
|
||||
Banned,
|
||||
}
|
||||
impl actix_web::ResponseError for AuthorizationError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
AuthorizationError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AuthorizationError::SqlxDatabase(..) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
AuthorizationError::Database(..) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
AuthorizationError::SerDe(..) => StatusCode::BAD_REQUEST,
|
||||
AuthorizationError::Github(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
AuthorizationError::InvalidCredentials => StatusCode::UNAUTHORIZED,
|
||||
AuthorizationError::Decoding(..) => StatusCode::BAD_REQUEST,
|
||||
AuthorizationError::Authentication(..) => StatusCode::UNAUTHORIZED,
|
||||
AuthorizationError::Url => StatusCode::BAD_REQUEST,
|
||||
AuthorizationError::Banned => StatusCode::FORBIDDEN,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
HttpResponse::build(self.status_code()).json(ApiError {
|
||||
error: match self {
|
||||
AuthorizationError::Env(..) => "environment_error",
|
||||
AuthorizationError::SqlxDatabase(..) => "database_error",
|
||||
AuthorizationError::Database(..) => "database_error",
|
||||
AuthorizationError::SerDe(..) => "invalid_input",
|
||||
AuthorizationError::Github(..) => "github_error",
|
||||
AuthorizationError::InvalidCredentials => "invalid_credentials",
|
||||
AuthorizationError::Decoding(..) => "decoding_error",
|
||||
AuthorizationError::Authentication(..) => {
|
||||
"authentication_error"
|
||||
}
|
||||
AuthorizationError::Url => "url_error",
|
||||
AuthorizationError::Banned => "user_banned",
|
||||
},
|
||||
description: &self.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct AuthorizationInit {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Authorization {
|
||||
pub code: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct AccessToken {
|
||||
pub access_token: String,
|
||||
pub scope: String,
|
||||
pub token_type: String,
|
||||
}
|
||||
|
||||
//http://localhost:8000/api/v1/auth/init?url=https%3A%2F%2Fmodrinth.com%2Fmods
|
||||
#[get("init")]
|
||||
pub async fn init(
|
||||
Query(info): Query<AuthorizationInit>,
|
||||
client: Data<PgPool>,
|
||||
) -> Result<HttpResponse, AuthorizationError> {
|
||||
let url =
|
||||
url::Url::parse(&info.url).map_err(|_| AuthorizationError::Url)?;
|
||||
|
||||
let allowed_callback_urls =
|
||||
parse_strings_from_var("ALLOWED_CALLBACK_URLS").unwrap_or_default();
|
||||
|
||||
let domain = url.domain().ok_or(AuthorizationError::Url)?;
|
||||
if !allowed_callback_urls.iter().any(|x| domain.ends_with(x))
|
||||
&& domain != "modrinth.com"
|
||||
{
|
||||
return Err(AuthorizationError::Url);
|
||||
}
|
||||
|
||||
let mut transaction = client.begin().await?;
|
||||
|
||||
let state = generate_state_id(&mut transaction).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO states (id, url)
|
||||
VALUES ($1, $2)
|
||||
",
|
||||
state.0,
|
||||
info.url
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
let client_id = dotenvy::var("GITHUB_CLIENT_ID")?;
|
||||
let url = format!(
|
||||
"https://github.com/login/oauth/authorize?client_id={}&state={}&scope={}",
|
||||
client_id,
|
||||
to_base62(state.0 as u64),
|
||||
"read%3Auser"
|
||||
);
|
||||
|
||||
Ok(HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", &*url))
|
||||
.json(AuthorizationInit { url }))
|
||||
}
|
||||
|
||||
#[get("callback")]
|
||||
pub async fn auth_callback(
|
||||
Query(info): Query<Authorization>,
|
||||
client: Data<PgPool>,
|
||||
) -> Result<HttpResponse, AuthorizationError> {
|
||||
let mut transaction = client.begin().await?;
|
||||
let state_id = parse_base62(&info.state)?;
|
||||
|
||||
let result_option = sqlx::query!(
|
||||
"
|
||||
SELECT url, expires FROM states
|
||||
WHERE id = $1
|
||||
",
|
||||
state_id as i64
|
||||
)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if let Some(result) = result_option {
|
||||
let duration: chrono::Duration = result.expires - Utc::now();
|
||||
|
||||
if duration.num_seconds() < 0 {
|
||||
return Err(AuthorizationError::InvalidCredentials);
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM states
|
||||
WHERE id = $1
|
||||
",
|
||||
state_id as i64
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let client_id = dotenvy::var("GITHUB_CLIENT_ID")?;
|
||||
let client_secret = dotenvy::var("GITHUB_CLIENT_SECRET")?;
|
||||
|
||||
let url = format!(
|
||||
"https://github.com/login/oauth/access_token?client_id={}&client_secret={}&code={}",
|
||||
client_id, client_secret, info.code
|
||||
);
|
||||
|
||||
let token: AccessToken = reqwest::Client::new()
|
||||
.post(&url)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
let user = get_github_user_from_token(&token.access_token).await?;
|
||||
|
||||
let user_result =
|
||||
User::get_from_github_id(user.id, &mut *transaction).await?;
|
||||
match user_result {
|
||||
Some(_) => {}
|
||||
None => {
|
||||
let banned_user = sqlx::query!(
|
||||
"SELECT user FROM banned_users WHERE github_id = $1",
|
||||
user.id as i64
|
||||
)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if banned_user.is_some() {
|
||||
return Err(AuthorizationError::Banned);
|
||||
}
|
||||
|
||||
let user_id =
|
||||
crate::database::models::generate_user_id(&mut transaction)
|
||||
.await?;
|
||||
|
||||
let mut username_increment: i32 = 0;
|
||||
let mut username = None;
|
||||
|
||||
while username.is_none() {
|
||||
let test_username = format!(
|
||||
"{}{}",
|
||||
&user.login,
|
||||
if username_increment > 0 {
|
||||
username_increment.to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
);
|
||||
|
||||
let new_id = crate::database::models::User::get_id_from_username_or_id(
|
||||
&test_username,
|
||||
&**client,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if new_id.is_none() {
|
||||
username = Some(test_username);
|
||||
} else {
|
||||
username_increment += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(username) = username {
|
||||
User {
|
||||
id: user_id,
|
||||
github_id: Some(user.id as i64),
|
||||
username,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatar_url: Some(user.avatar_url),
|
||||
bio: user.bio,
|
||||
created: Utc::now(),
|
||||
role: Role::Developer.to_string(),
|
||||
badges: Badges::default(),
|
||||
balance: Decimal::ZERO,
|
||||
payout_wallet: None,
|
||||
payout_wallet_type: None,
|
||||
payout_address: None,
|
||||
flame_anvil_key: None,
|
||||
}
|
||||
.insert(&mut transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
let redirect_url = if result.url.contains('?') {
|
||||
format!("{}&code={}", result.url, token.access_token)
|
||||
} else {
|
||||
format!("{}?code={}", result.url, token.access_token)
|
||||
};
|
||||
|
||||
Ok(HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", &*redirect_url))
|
||||
.json(AuthorizationInit { url: redirect_url }))
|
||||
} else {
|
||||
Err(AuthorizationError::InvalidCredentials)
|
||||
}
|
||||
}
|
||||
346
src/routes/v2/midas.rs
Normal file
346
src/routes/v2/midas.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
use crate::models::users::UserId;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::auth::get_user_from_headers;
|
||||
use actix_web::{post, web, HttpRequest, HttpResponse};
|
||||
use chrono::{DateTime, Duration, NaiveDateTime, Utc};
|
||||
use hmac::{Hmac, Mac, NewMac};
|
||||
use itertools::Itertools;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("midas")
|
||||
.service(init_checkout)
|
||||
.service(init_customer_portal)
|
||||
.service(handle_stripe_webhook),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CheckoutData {
|
||||
pub price_id: String,
|
||||
}
|
||||
|
||||
#[post("/_stripe-init-checkout")]
|
||||
pub async fn init_checkout(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
data: web::Json<CheckoutData>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Session {
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
let session = client
|
||||
.post("https://api.stripe.com/v1/checkout/sessions")
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", dotenvy::var("STRIPE_TOKEN")?),
|
||||
)
|
||||
.form(&[
|
||||
("mode", "subscription"),
|
||||
("line_items[0][price]", &*data.price_id),
|
||||
("line_items[0][quantity]", "1"),
|
||||
("success_url", "https://modrinth.com/welcome-to-midas"),
|
||||
("cancel_url", "https://modrinth.com/midas"),
|
||||
("metadata[user_id]", &user.id.to_string()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ApiError::Payments(
|
||||
"Error while creating checkout session!".to_string(),
|
||||
)
|
||||
})?
|
||||
.json::<Session>()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ApiError::Payments(
|
||||
"Error while deserializing checkout response!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(json!(
|
||||
{
|
||||
"url": session.url
|
||||
}
|
||||
)))
|
||||
}
|
||||
|
||||
#[post("/_stripe-init-portal")]
|
||||
pub async fn init_customer_portal(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
let customer_id = sqlx::query!(
|
||||
"
|
||||
SELECT u.stripe_customer_id
|
||||
FROM users u
|
||||
WHERE u.id = $1
|
||||
",
|
||||
user.id.0 as i64,
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await?
|
||||
.and_then(|x| x.stripe_customer_id)
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"User is not linked to stripe account!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Session {
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
let session = client
|
||||
.post("https://api.stripe.com/v1/billing_portal/sessions")
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", dotenvy::var("STRIPE_TOKEN")?),
|
||||
)
|
||||
.form(&[
|
||||
("customer", &*customer_id),
|
||||
("return_url", "https://modrinth.com/settings/billing"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ApiError::Payments(
|
||||
"Error while creating billing session!".to_string(),
|
||||
)
|
||||
})?
|
||||
.json::<Session>()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ApiError::Payments(
|
||||
"Error while deserializing billing response!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(json!(
|
||||
{
|
||||
"url": session.url
|
||||
}
|
||||
)))
|
||||
}
|
||||
|
||||
#[post("/_stripe-webook")]
|
||||
pub async fn handle_stripe_webhook(
|
||||
body: String,
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
if let Some(signature_raw) = req
|
||||
.headers()
|
||||
.get("Stripe-Signature")
|
||||
.and_then(|x| x.to_str().ok())
|
||||
{
|
||||
let mut timestamp = None;
|
||||
let mut signature = None;
|
||||
for val in signature_raw.split(',') {
|
||||
let key_val = val.split('=').collect_vec();
|
||||
|
||||
if key_val.len() == 2 {
|
||||
if key_val[0] == "v1" {
|
||||
signature = hex::decode(key_val[1]).ok()
|
||||
} else if key_val[0] == "t" {
|
||||
timestamp = key_val[1].parse::<i64>().ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(timestamp) = timestamp {
|
||||
if let Some(signature) = signature {
|
||||
type HmacSha256 = Hmac<sha2::Sha256>;
|
||||
|
||||
let mut key = HmacSha256::new_from_slice(dotenvy::var("STRIPE_WEBHOOK_SECRET")?.as_bytes()).map_err(|_| {
|
||||
ApiError::Crypto(
|
||||
"Unable to initialize HMAC instance due to invalid key length!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
key.update(format!("{timestamp}.{body}").as_bytes());
|
||||
|
||||
key.verify(&signature).map_err(|_| {
|
||||
ApiError::Crypto(
|
||||
"Unable to verify webhook signature!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if timestamp < (Utc::now() - Duration::minutes(5)).timestamp()
|
||||
|| timestamp
|
||||
> (Utc::now() + Duration::minutes(5)).timestamp()
|
||||
{
|
||||
return Err(ApiError::Crypto(
|
||||
"Webhook signature expired!".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::Crypto("Missing signature!".to_string()));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::Crypto("Missing timestamp!".to_string()));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::Crypto("Missing signature header!".to_string()));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StripeWebhookBody {
|
||||
#[serde(rename = "type")]
|
||||
type_: String,
|
||||
data: StripeWebhookObject,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StripeWebhookObject {
|
||||
object: Value,
|
||||
}
|
||||
|
||||
let webhook: StripeWebhookBody = serde_json::from_str(&body)?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CheckoutSession {
|
||||
customer: String,
|
||||
metadata: SessionMetadata,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SessionMetadata {
|
||||
user_id: UserId,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Invoice {
|
||||
customer: String,
|
||||
// paid: bool,
|
||||
lines: InvoiceLineItems,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InvoiceLineItems {
|
||||
pub data: Vec<InvoiceLineItem>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InvoiceLineItem {
|
||||
period: Period,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Period {
|
||||
// start: i64,
|
||||
end: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Subscription {
|
||||
customer: String,
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
// TODO: Currently hardcoded to midas-only. When we add more stuff should include price IDs
|
||||
match &*webhook.type_ {
|
||||
"checkout.session.completed" => {
|
||||
let session: CheckoutSession =
|
||||
serde_json::from_value(webhook.data.object)?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET stripe_customer_id = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
session.customer,
|
||||
session.metadata.user_id.0 as i64,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
"invoice.paid" => {
|
||||
let invoice: Invoice = serde_json::from_value(webhook.data.object)?;
|
||||
|
||||
if let Some(item) = invoice.lines.data.first() {
|
||||
let expires: DateTime<Utc> = DateTime::from_utc(
|
||||
NaiveDateTime::from_timestamp_opt(item.period.end, 0)
|
||||
.unwrap_or_default(),
|
||||
Utc,
|
||||
) + Duration::days(1);
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET midas_expires = $1, is_overdue = FALSE
|
||||
WHERE (stripe_customer_id = $2)
|
||||
",
|
||||
expires,
|
||||
invoice.customer,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
"invoice.payment_failed" => {
|
||||
let invoice: Invoice = serde_json::from_value(webhook.data.object)?;
|
||||
|
||||
let customer_id = sqlx::query!(
|
||||
"
|
||||
SELECT u.id
|
||||
FROM users u
|
||||
WHERE u.stripe_customer_id = $1
|
||||
",
|
||||
invoice.customer,
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await?
|
||||
.map(|x| x.id);
|
||||
|
||||
if let Some(user_id) = customer_id {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET is_overdue = TRUE
|
||||
WHERE (id = $1)
|
||||
",
|
||||
user_id,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
"customer.subscription.deleted" => {
|
||||
let session: Subscription =
|
||||
serde_json::from_value(webhook.data.object)?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET stripe_customer_id = NULL, midas_expires = NULL, is_overdue = NULL
|
||||
WHERE (stripe_customer_id = $1)
|
||||
",
|
||||
session.customer,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
38
src/routes/v2/mod.rs
Normal file
38
src/routes/v2/mod.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
mod admin;
|
||||
mod auth;
|
||||
mod midas;
|
||||
mod moderation;
|
||||
mod notifications;
|
||||
pub(crate) mod project_creation;
|
||||
mod projects;
|
||||
mod reports;
|
||||
mod statistics;
|
||||
mod tags;
|
||||
mod teams;
|
||||
mod users;
|
||||
mod version_creation;
|
||||
mod version_file;
|
||||
mod versions;
|
||||
|
||||
pub use super::ApiError;
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(
|
||||
actix_web::web::scope("v2")
|
||||
.configure(admin::config)
|
||||
.configure(auth::config)
|
||||
.configure(midas::config)
|
||||
.configure(moderation::config)
|
||||
.configure(notifications::config)
|
||||
.configure(project_creation::config)
|
||||
.configure(projects::config)
|
||||
.configure(reports::config)
|
||||
.configure(statistics::config)
|
||||
.configure(tags::config)
|
||||
.configure(teams::config)
|
||||
.configure(users::config)
|
||||
.configure(version_creation::config)
|
||||
.configure(version_file::config)
|
||||
.configure(versions::config),
|
||||
);
|
||||
}
|
||||
98
src/routes/v2/moderation.rs
Normal file
98
src/routes/v2/moderation.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use super::ApiError;
|
||||
use crate::database;
|
||||
use crate::models::projects::ProjectStatus;
|
||||
use crate::util::auth::check_is_moderator_from_headers;
|
||||
use actix_web::{delete, get, web, HttpRequest, HttpResponse};
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("moderation")
|
||||
.service(get_projects)
|
||||
.service(ban_user)
|
||||
.service(unban_user),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResultCount {
|
||||
#[serde(default = "default_count")]
|
||||
pub count: i16,
|
||||
}
|
||||
|
||||
fn default_count() -> i16 {
|
||||
100
|
||||
}
|
||||
|
||||
#[get("projects")]
|
||||
pub async fn get_projects(
|
||||
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 = $1
|
||||
ORDER BY queued 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<_> =
|
||||
database::Project::get_many_full(&project_ids, &**pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(crate::models::projects::Project::from)
|
||||
.collect();
|
||||
|
||||
Ok(HttpResponse::Ok().json(projects))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BanUser {
|
||||
pub id: i64,
|
||||
}
|
||||
|
||||
#[get("ban")]
|
||||
pub async fn ban_user(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
id: web::Query<BanUser>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
check_is_moderator_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
sqlx::query!("INSERT INTO banned_users (github_id) VALUES ($1);", id.id)
|
||||
.execute(&**pool)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[delete("ban")]
|
||||
pub async fn unban_user(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
id: web::Query<BanUser>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
check_is_moderator_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
sqlx::query!("DELETE FROM banned_users WHERE github_id = $1;", id.id)
|
||||
.execute(&**pool)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
170
src/routes/v2/notifications.rs
Normal file
170
src/routes/v2/notifications.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use crate::database;
|
||||
use crate::models::ids::NotificationId;
|
||||
use crate::models::notifications::Notification;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::auth::get_user_from_headers;
|
||||
use actix_web::{delete, get, web, HttpRequest, HttpResponse};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(notifications_get);
|
||||
cfg.service(notifications_delete);
|
||||
|
||||
cfg.service(
|
||||
web::scope("notification")
|
||||
.service(notification_get)
|
||||
.service(notification_delete),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct NotificationIds {
|
||||
pub ids: String,
|
||||
}
|
||||
|
||||
#[get("notifications")]
|
||||
pub async fn notifications_get(
|
||||
req: HttpRequest,
|
||||
web::Query(ids): web::Query<NotificationIds>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
// TODO: this is really confusingly named.
|
||||
use database::models::notification_item::Notification as DBNotification;
|
||||
use database::models::NotificationId as DBNotificationId;
|
||||
|
||||
let notification_ids: Vec<DBNotificationId> =
|
||||
serde_json::from_str::<Vec<NotificationId>>(ids.ids.as_str())?
|
||||
.into_iter()
|
||||
.map(DBNotificationId::from)
|
||||
.collect();
|
||||
|
||||
let notifications_data: Vec<DBNotification> =
|
||||
database::models::notification_item::Notification::get_many(
|
||||
¬ification_ids,
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let notifications: Vec<Notification> = notifications_data
|
||||
.into_iter()
|
||||
.filter(|n| n.user_id == user.id.into() || user.role.is_admin())
|
||||
.map(Notification::from)
|
||||
.collect();
|
||||
|
||||
Ok(HttpResponse::Ok().json(notifications))
|
||||
}
|
||||
|
||||
#[get("{id}")]
|
||||
pub async fn notification_get(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(NotificationId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
let id = info.into_inner().0;
|
||||
|
||||
let notification_data =
|
||||
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_admin() {
|
||||
Ok(HttpResponse::Ok().json(Notification::from(data)))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("{id}")]
|
||||
pub async fn notification_delete(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(NotificationId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
let id = info.into_inner().0;
|
||||
|
||||
let notification_data =
|
||||
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_admin() {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
database::models::notification_item::Notification::remove(
|
||||
id.into(),
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Err(ApiError::CustomAuthentication(
|
||||
"You are not authorized to delete this notification!"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("notifications")]
|
||||
pub async fn notifications_delete(
|
||||
req: HttpRequest,
|
||||
web::Query(ids): web::Query<NotificationIds>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
let notification_ids =
|
||||
serde_json::from_str::<Vec<NotificationId>>(&ids.ids)?
|
||||
.into_iter()
|
||||
.map(|x| x.into())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let notifications_data =
|
||||
database::models::notification_item::Notification::get_many(
|
||||
¬ification_ids,
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut notifications: Vec<database::models::ids::NotificationId> =
|
||||
Vec::new();
|
||||
|
||||
for notification in notifications_data {
|
||||
if notification.user_id == user.id.into() || user.role.is_admin() {
|
||||
notifications.push(notification.id);
|
||||
}
|
||||
}
|
||||
|
||||
database::models::notification_item::Notification::remove_many(
|
||||
¬ifications,
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
983
src/routes/v2/project_creation.rs
Normal file
983
src/routes/v2/project_creation.rs
Normal file
@@ -0,0 +1,983 @@
|
||||
use super::version_creation::InitialVersionData;
|
||||
use crate::database::models;
|
||||
use crate::file_hosting::{FileHost, FileHostingError};
|
||||
use crate::models::error::ApiError;
|
||||
use crate::models::projects::{
|
||||
DonationLink, License, ProjectId, ProjectStatus, SideType, VersionId,
|
||||
VersionStatus,
|
||||
};
|
||||
use crate::models::users::UserId;
|
||||
use crate::search::indexing::IndexingError;
|
||||
use crate::util::auth::{get_user_from_headers, AuthenticationError};
|
||||
use crate::util::routes::read_from_field;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use actix_multipart::{Field, Multipart};
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::Data;
|
||||
use actix_web::{post, HttpRequest, HttpResponse};
|
||||
use chrono::Utc;
|
||||
use futures::stream::StreamExt;
|
||||
use image::ImageError;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPool;
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(project_create);
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CreateError {
|
||||
#[error("Environment Error")]
|
||||
EnvError(#[from] dotenvy::Error),
|
||||
#[error("An unknown database error occurred")]
|
||||
SqlxDatabaseError(#[from] sqlx::Error),
|
||||
#[error("Database Error: {0}")]
|
||||
DatabaseError(#[from] models::DatabaseError),
|
||||
#[error("Indexing Error: {0}")]
|
||||
IndexingError(#[from] IndexingError),
|
||||
#[error("Error while parsing multipart payload: {0}")]
|
||||
MultipartError(#[from] actix_multipart::MultipartError),
|
||||
#[error("Error while parsing JSON: {0}")]
|
||||
SerDeError(#[from] serde_json::Error),
|
||||
#[error("Error while validating input: {0}")]
|
||||
ValidationError(String),
|
||||
#[error("Error while uploading file: {0}")]
|
||||
FileHostingError(#[from] FileHostingError),
|
||||
#[error("Error while validating uploaded file: {0}")]
|
||||
FileValidationError(#[from] crate::validate::ValidationError),
|
||||
#[error("{}", .0)]
|
||||
MissingValueError(String),
|
||||
#[error("Invalid format for image: {0}")]
|
||||
InvalidIconFormat(String),
|
||||
#[error("Error with multipart data: {0}")]
|
||||
InvalidInput(String),
|
||||
#[error("Invalid game version: {0}")]
|
||||
InvalidGameVersion(String),
|
||||
#[error("Invalid loader: {0}")]
|
||||
InvalidLoader(String),
|
||||
#[error("Invalid category: {0}")]
|
||||
InvalidCategory(String),
|
||||
#[error("Invalid file type for version file: {0}")]
|
||||
InvalidFileType(String),
|
||||
#[error("Slug collides with other project's id!")]
|
||||
SlugCollision,
|
||||
#[error("Authentication Error: {0}")]
|
||||
Unauthorized(#[from] AuthenticationError),
|
||||
#[error("Authentication Error: {0}")]
|
||||
CustomAuthenticationError(String),
|
||||
#[error("Image Parsing Error: {0}")]
|
||||
ImageError(#[from] ImageError),
|
||||
}
|
||||
|
||||
impl actix_web::ResponseError for CreateError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
CreateError::EnvError(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
CreateError::SqlxDatabaseError(..) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
CreateError::DatabaseError(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
CreateError::IndexingError(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
CreateError::FileHostingError(..) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
CreateError::SerDeError(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::MultipartError(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::MissingValueError(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::InvalidIconFormat(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::InvalidInput(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::InvalidGameVersion(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::InvalidLoader(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::InvalidCategory(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::InvalidFileType(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::Unauthorized(..) => StatusCode::UNAUTHORIZED,
|
||||
CreateError::CustomAuthenticationError(..) => {
|
||||
StatusCode::UNAUTHORIZED
|
||||
}
|
||||
CreateError::SlugCollision => StatusCode::BAD_REQUEST,
|
||||
CreateError::ValidationError(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST,
|
||||
CreateError::ImageError(..) => StatusCode::BAD_REQUEST,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
HttpResponse::build(self.status_code()).json(ApiError {
|
||||
error: match self {
|
||||
CreateError::EnvError(..) => "environment_error",
|
||||
CreateError::SqlxDatabaseError(..) => "database_error",
|
||||
CreateError::DatabaseError(..) => "database_error",
|
||||
CreateError::IndexingError(..) => "indexing_error",
|
||||
CreateError::FileHostingError(..) => "file_hosting_error",
|
||||
CreateError::SerDeError(..) => "invalid_input",
|
||||
CreateError::MultipartError(..) => "invalid_input",
|
||||
CreateError::MissingValueError(..) => "invalid_input",
|
||||
CreateError::InvalidIconFormat(..) => "invalid_input",
|
||||
CreateError::InvalidInput(..) => "invalid_input",
|
||||
CreateError::InvalidGameVersion(..) => "invalid_input",
|
||||
CreateError::InvalidLoader(..) => "invalid_input",
|
||||
CreateError::InvalidCategory(..) => "invalid_input",
|
||||
CreateError::InvalidFileType(..) => "invalid_input",
|
||||
CreateError::Unauthorized(..) => "unauthorized",
|
||||
CreateError::CustomAuthenticationError(..) => "unauthorized",
|
||||
CreateError::SlugCollision => "invalid_input",
|
||||
CreateError::ValidationError(..) => "invalid_input",
|
||||
CreateError::FileValidationError(..) => "invalid_input",
|
||||
CreateError::ImageError(..) => "invalid_image",
|
||||
},
|
||||
description: &self.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn default_project_type() -> String {
|
||||
"mod".to_string()
|
||||
}
|
||||
|
||||
fn default_requested_status() -> ProjectStatus {
|
||||
ProjectStatus::Approved
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Validate, Clone)]
|
||||
struct ProjectCreateData {
|
||||
#[validate(
|
||||
length(min = 3, max = 64),
|
||||
custom(function = "crate::util::validate::validate_name")
|
||||
)]
|
||||
#[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 = "crate::util::validate::RE_URL_SAFE"
|
||||
)]
|
||||
#[serde(alias = "mod_slug")]
|
||||
/// The slug of a project, used for vanity URLs
|
||||
pub slug: String,
|
||||
#[validate(length(min = 3, max = 255))]
|
||||
#[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 project
|
||||
pub server_side: SideType,
|
||||
|
||||
#[validate(length(max = 32))]
|
||||
#[validate]
|
||||
/// 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(length(max = 256))]
|
||||
#[serde(default = "Vec::new")]
|
||||
/// A list of the categories that the project is in.
|
||||
pub additional_categories: Vec<String>,
|
||||
|
||||
#[validate(
|
||||
custom(function = "crate::util::validate::validate_url"),
|
||||
length(max = 2048)
|
||||
)]
|
||||
/// An optional link to where to submit bugs or issues with the project.
|
||||
pub issues_url: Option<String>,
|
||||
#[validate(
|
||||
custom(function = "crate::util::validate::validate_url"),
|
||||
length(max = 2048)
|
||||
)]
|
||||
/// An optional link to the source code for the project.
|
||||
pub source_url: Option<String>,
|
||||
#[validate(
|
||||
custom(function = "crate::util::validate::validate_url"),
|
||||
length(max = 2048)
|
||||
)]
|
||||
/// An optional link to the project's wiki page or other relevant information.
|
||||
pub wiki_url: Option<String>,
|
||||
#[validate(
|
||||
custom(function = "crate::util::validate::validate_url"),
|
||||
length(max = 2048)
|
||||
)]
|
||||
/// An optional link to the project's license page
|
||||
pub license_url: Option<String>,
|
||||
#[validate(
|
||||
custom(function = "crate::util::validate::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,
|
||||
|
||||
#[validate(length(max = 64))]
|
||||
#[validate]
|
||||
/// The multipart names of the gallery items to upload
|
||||
pub gallery_items: Option<Vec<NewGalleryItem>>,
|
||||
#[serde(default = "default_requested_status")]
|
||||
/// The status of the mod to be set once it is approved
|
||||
pub requested_status: ProjectStatus,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Validate, Clone)]
|
||||
pub struct NewGalleryItem {
|
||||
/// The name of the multipart item where the gallery media is located
|
||||
pub item: String,
|
||||
/// Whether the gallery item should show in search or not
|
||||
pub featured: bool,
|
||||
#[validate(length(min = 1, max = 2048))]
|
||||
/// The title of the gallery item
|
||||
pub title: Option<String>,
|
||||
#[validate(length(min = 1, max = 2048))]
|
||||
/// The description of the gallery item
|
||||
pub description: Option<String>,
|
||||
pub ordering: i64,
|
||||
}
|
||||
|
||||
pub struct UploadedFile {
|
||||
pub file_id: String,
|
||||
pub file_name: String,
|
||||
}
|
||||
|
||||
pub async fn undo_uploads(
|
||||
file_host: &dyn FileHost,
|
||||
uploaded_files: &[UploadedFile],
|
||||
) -> Result<(), CreateError> {
|
||||
for file in uploaded_files {
|
||||
file_host
|
||||
.delete_file_version(&file.file_id, &file.file_name)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("project")]
|
||||
pub async fn project_create(
|
||||
req: HttpRequest,
|
||||
mut payload: Multipart,
|
||||
client: Data<PgPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
) -> Result<HttpResponse, CreateError> {
|
||||
let mut transaction = client.begin().await?;
|
||||
let mut uploaded_files = Vec::new();
|
||||
|
||||
let result = project_create_inner(
|
||||
req,
|
||||
&mut payload,
|
||||
&mut transaction,
|
||||
&***file_host,
|
||||
&mut uploaded_files,
|
||||
&client,
|
||||
)
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let undo_result = undo_uploads(&***file_host, &uploaded_files).await;
|
||||
let rollback_result = transaction.rollback().await;
|
||||
|
||||
undo_result?;
|
||||
if let Err(e) = rollback_result {
|
||||
return Err(e.into());
|
||||
}
|
||||
} else {
|
||||
transaction.commit().await?;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
/*
|
||||
|
||||
Project Creation Steps:
|
||||
Get logged in user
|
||||
Must match the author in the version creation
|
||||
|
||||
1. Data
|
||||
- Gets "data" field from multipart form; must be first
|
||||
- Verification: string lengths
|
||||
- Create versions
|
||||
- Some shared logic with version creation
|
||||
- Create list of VersionBuilders
|
||||
- Create ProjectBuilder
|
||||
|
||||
2. Upload
|
||||
- Icon: check file format & size
|
||||
- Upload to backblaze & record URL
|
||||
- Project files
|
||||
- Check for matching version
|
||||
- File size limits?
|
||||
- Check file type
|
||||
- Eventually, malware scan
|
||||
- Upload to backblaze & create VersionFileBuilder
|
||||
-
|
||||
|
||||
3. Creation
|
||||
- Database stuff
|
||||
- Add project data to indexing queue
|
||||
*/
|
||||
|
||||
async fn project_create_inner(
|
||||
req: HttpRequest,
|
||||
payload: &mut Multipart,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
file_host: &dyn FileHost,
|
||||
uploaded_files: &mut Vec<UploadedFile>,
|
||||
pool: &PgPool,
|
||||
) -> Result<HttpResponse, CreateError> {
|
||||
// The base URL for files uploaded to backblaze
|
||||
let cdn_url = dotenvy::var("CDN_URL")?;
|
||||
|
||||
// The currently logged in user
|
||||
let current_user =
|
||||
get_user_from_headers(req.headers(), &mut *transaction).await?;
|
||||
|
||||
let project_id: ProjectId =
|
||||
models::generate_project_id(transaction).await?.into();
|
||||
|
||||
let project_create_data;
|
||||
let mut versions;
|
||||
let mut versions_map = std::collections::HashMap::new();
|
||||
let mut gallery_urls = Vec::new();
|
||||
|
||||
let all_game_versions =
|
||||
models::categories::GameVersion::list(&mut *transaction).await?;
|
||||
let all_loaders =
|
||||
models::categories::Loader::list(&mut *transaction).await?;
|
||||
|
||||
{
|
||||
// The first multipart field must be named "data" and contain a
|
||||
// JSON `ProjectCreateData` object.
|
||||
|
||||
let mut field = payload
|
||||
.next()
|
||||
.await
|
||||
.map(|m| m.map_err(CreateError::MultipartError))
|
||||
.unwrap_or_else(|| {
|
||||
Err(CreateError::MissingValueError(String::from(
|
||||
"No `data` field in multipart upload",
|
||||
)))
|
||||
})?;
|
||||
|
||||
let content_disposition = field.content_disposition();
|
||||
let name = content_disposition.get_name().ok_or_else(|| {
|
||||
CreateError::MissingValueError(String::from("Missing content name"))
|
||||
})?;
|
||||
|
||||
if name != "data" {
|
||||
return Err(CreateError::InvalidInput(String::from(
|
||||
"`data` field must come before file fields",
|
||||
)));
|
||||
}
|
||||
|
||||
let mut data = Vec::new();
|
||||
while let Some(chunk) = field.next().await {
|
||||
data.extend_from_slice(
|
||||
&chunk.map_err(CreateError::MultipartError)?,
|
||||
);
|
||||
}
|
||||
let create_data: ProjectCreateData = serde_json::from_slice(&data)?;
|
||||
|
||||
create_data.validate().map_err(|err| {
|
||||
CreateError::InvalidInput(validation_errors_to_string(err, None))
|
||||
})?;
|
||||
|
||||
let slug_project_id_option: Option<ProjectId> =
|
||||
serde_json::from_str(&format!("\"{}\"", create_data.slug)).ok();
|
||||
|
||||
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_project_id as models::ids::ProjectId
|
||||
)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await
|
||||
.map_err(|e| CreateError::DatabaseError(e.into()))?;
|
||||
|
||||
if results.exists.unwrap_or(false) {
|
||||
return Err(CreateError::SlugCollision);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let results = sqlx::query!(
|
||||
"
|
||||
SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1))
|
||||
",
|
||||
create_data.slug
|
||||
)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await
|
||||
.map_err(|e| CreateError::DatabaseError(e.into()))?;
|
||||
|
||||
if results.exists.unwrap_or(false) {
|
||||
return Err(CreateError::SlugCollision);
|
||||
}
|
||||
}
|
||||
|
||||
// Create VersionBuilders for the versions specified in `initial_versions`
|
||||
versions = Vec::with_capacity(create_data.initial_versions.len());
|
||||
for (i, data) in create_data.initial_versions.iter().enumerate() {
|
||||
// Create a map of multipart field names to version indices
|
||||
for name in &data.file_parts {
|
||||
if versions_map.insert(name.to_owned(), i).is_some() {
|
||||
// If the name is already used
|
||||
return Err(CreateError::InvalidInput(String::from(
|
||||
"Duplicate multipart field name",
|
||||
)));
|
||||
}
|
||||
}
|
||||
versions.push(
|
||||
create_initial_version(
|
||||
data,
|
||||
project_id,
|
||||
current_user.id,
|
||||
&all_game_versions,
|
||||
&all_loaders,
|
||||
&create_data.project_type,
|
||||
transaction,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
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_data = None;
|
||||
|
||||
let mut error = None;
|
||||
while let Some(item) = payload.next().await {
|
||||
let mut field: Field = item?;
|
||||
|
||||
if error.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = async {
|
||||
let content_disposition = field.content_disposition().clone();
|
||||
|
||||
let name = content_disposition.get_name().ok_or_else(|| {
|
||||
CreateError::MissingValueError(
|
||||
"Missing content name".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let (file_name, file_extension) =
|
||||
super::version_creation::get_name_ext(&content_disposition)?;
|
||||
|
||||
if name == "icon" {
|
||||
if icon_data.is_some() {
|
||||
return Err(CreateError::InvalidInput(String::from(
|
||||
"Projects can only have one icon",
|
||||
)));
|
||||
}
|
||||
// Upload the icon to the cdn
|
||||
icon_data = Some(
|
||||
process_icon_upload(
|
||||
uploaded_files,
|
||||
project_id,
|
||||
file_extension,
|
||||
file_host,
|
||||
field,
|
||||
&cdn_url,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(gallery_items) = &project_create_data.gallery_items {
|
||||
if gallery_items.iter().filter(|a| a.featured).count() > 1 {
|
||||
return Err(CreateError::InvalidInput(String::from(
|
||||
"Only one gallery image can be featured.",
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(item) =
|
||||
gallery_items.iter().find(|x| x.item == name)
|
||||
{
|
||||
let data = read_from_field(
|
||||
&mut field,
|
||||
5 * (1 << 20),
|
||||
"Gallery image exceeds the maximum of 5MiB.",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let hash = sha1::Sha1::from(&data).hexdigest();
|
||||
let (_, file_extension) =
|
||||
super::version_creation::get_name_ext(
|
||||
&content_disposition,
|
||||
)?;
|
||||
let content_type =
|
||||
crate::util::ext::get_image_content_type(
|
||||
file_extension,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
CreateError::InvalidIconFormat(
|
||||
file_extension.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let url = format!(
|
||||
"data/{project_id}/images/{hash}.{file_extension}"
|
||||
);
|
||||
let upload_data = file_host
|
||||
.upload_file(content_type, &url, data.freeze())
|
||||
.await?;
|
||||
|
||||
uploaded_files.push(UploadedFile {
|
||||
file_id: upload_data.file_id,
|
||||
file_name: upload_data.file_name,
|
||||
});
|
||||
|
||||
gallery_urls.push(crate::models::projects::GalleryItem {
|
||||
url: format!("{cdn_url}/{url}"),
|
||||
featured: item.featured,
|
||||
title: item.title.clone(),
|
||||
description: item.description.clone(),
|
||||
created: Utc::now(),
|
||||
ordering: item.ordering,
|
||||
});
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let index = if let Some(i) = versions_map.get(name) {
|
||||
*i
|
||||
} else {
|
||||
return Err(CreateError::InvalidInput(format!(
|
||||
"File `{file_name}` (field {name}) isn't specified in the versions data"
|
||||
)));
|
||||
};
|
||||
|
||||
// `index` is always valid for these lists
|
||||
let created_version = versions.get_mut(index).unwrap();
|
||||
let version_data =
|
||||
project_create_data.initial_versions.get(index).unwrap();
|
||||
|
||||
// Upload the new jar file
|
||||
super::version_creation::upload_file(
|
||||
&mut field,
|
||||
file_host,
|
||||
version_data.file_parts.len(),
|
||||
uploaded_files,
|
||||
&mut created_version.files,
|
||||
&mut created_version.dependencies,
|
||||
&cdn_url,
|
||||
&content_disposition,
|
||||
project_id,
|
||||
created_version.version_id.into(),
|
||||
&project_create_data.project_type,
|
||||
version_data.loaders.clone(),
|
||||
version_data.game_versions.clone(),
|
||||
all_game_versions.clone(),
|
||||
version_data.primary_file.is_some(),
|
||||
version_data.primary_file.as_deref() == Some(name),
|
||||
None,
|
||||
transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
error = result.err();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(error) = error {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
{
|
||||
// Check to make sure that all specified files were uploaded
|
||||
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(
|
||||
"Some files were specified in initial_versions but not uploaded",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the list of category names to actual categories
|
||||
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);
|
||||
}
|
||||
|
||||
let mut additional_categories =
|
||||
Vec::with_capacity(project_create_data.additional_categories.len());
|
||||
for category in &project_create_data.additional_categories {
|
||||
let id = models::categories::Category::get_id_project(
|
||||
category,
|
||||
project_type_id,
|
||||
&mut *transaction,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| CreateError::InvalidCategory(category.clone()))?;
|
||||
additional_categories.push(id);
|
||||
}
|
||||
|
||||
let team = models::team_item::TeamBuilder {
|
||||
members: vec![models::team_item::TeamMemberBuilder {
|
||||
user_id: current_user.id.into(),
|
||||
role: crate::models::teams::OWNER_ROLE.to_owned(),
|
||||
permissions: crate::models::teams::Permissions::ALL,
|
||||
accepted: true,
|
||||
payouts_split: Decimal::ONE_HUNDRED,
|
||||
ordering: 0,
|
||||
}],
|
||||
};
|
||||
|
||||
let team_id = team.insert(&mut *transaction).await?;
|
||||
|
||||
let status;
|
||||
if project_create_data.is_draft.unwrap_or(false) {
|
||||
status = ProjectStatus::Draft;
|
||||
} else {
|
||||
status = ProjectStatus::Processing;
|
||||
|
||||
if project_create_data.initial_versions.is_empty() {
|
||||
return Err(CreateError::InvalidInput(String::from(
|
||||
"Project submitted for review with no initial versions",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if !project_create_data.requested_status.can_be_requested() {
|
||||
return Err(CreateError::InvalidInput(String::from(
|
||||
"Specified requested status is not allowed to be requested",
|
||||
)));
|
||||
}
|
||||
|
||||
let client_side_id = models::SideTypeId::get_id(
|
||||
&project_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(
|
||||
&project_create_data.server_side,
|
||||
&mut *transaction,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
CreateError::InvalidInput(
|
||||
"Server side type specified does not exist.".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let license_id = spdx::Expression::parse(
|
||||
&project_create_data.license_id,
|
||||
)
|
||||
.map_err(|err| {
|
||||
CreateError::InvalidInput(format!(
|
||||
"Invalid SPDX license identifier: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut donation_urls = vec![];
|
||||
|
||||
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?
|
||||
.ok_or_else(|| {
|
||||
CreateError::InvalidInput(format!(
|
||||
"Donation platform {} does not exist.",
|
||||
url.id.clone()
|
||||
))
|
||||
})?;
|
||||
|
||||
donation_urls.push(models::project_item::DonationUrl {
|
||||
platform_id,
|
||||
platform_short: "".to_string(),
|
||||
platform_name: "".to_string(),
|
||||
url: url.url.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let project_builder = models::project_item::ProjectBuilder {
|
||||
project_id: project_id.into(),
|
||||
project_type_id,
|
||||
team_id,
|
||||
title: project_create_data.title,
|
||||
description: project_create_data.description,
|
||||
body: project_create_data.body,
|
||||
icon_url: icon_data.clone().map(|x| x.0),
|
||||
issues_url: project_create_data.issues_url,
|
||||
source_url: project_create_data.source_url,
|
||||
wiki_url: project_create_data.wiki_url,
|
||||
|
||||
license_url: project_create_data.license_url,
|
||||
discord_url: project_create_data.discord_url,
|
||||
categories,
|
||||
additional_categories,
|
||||
initial_versions: versions,
|
||||
status,
|
||||
requested_status: Some(project_create_data.requested_status),
|
||||
client_side: client_side_id,
|
||||
server_side: server_side_id,
|
||||
license: license_id.to_string(),
|
||||
slug: Some(project_create_data.slug),
|
||||
donation_urls,
|
||||
gallery_items: gallery_urls
|
||||
.iter()
|
||||
.map(|x| models::project_item::GalleryItem {
|
||||
image_url: x.url.clone(),
|
||||
featured: x.featured,
|
||||
title: x.title.clone(),
|
||||
description: x.description.clone(),
|
||||
created: x.created,
|
||||
ordering: x.ordering,
|
||||
})
|
||||
.collect(),
|
||||
color: icon_data.and_then(|x| x.1),
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
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: project_builder.title.clone(),
|
||||
description: project_builder.description.clone(),
|
||||
body: project_builder.body.clone(),
|
||||
body_url: None,
|
||||
published: now,
|
||||
updated: now,
|
||||
approved: None,
|
||||
queued: None,
|
||||
status,
|
||||
requested_status: project_builder.requested_status,
|
||||
moderator_message: None,
|
||||
license: License {
|
||||
id: project_create_data.license_id.clone(),
|
||||
name: "".to_string(),
|
||||
url: project_builder.license_url.clone(),
|
||||
},
|
||||
client_side: project_create_data.client_side,
|
||||
server_side: project_create_data.server_side,
|
||||
downloads: 0,
|
||||
followers: 0,
|
||||
categories: project_create_data.categories,
|
||||
additional_categories: project_create_data.additional_categories,
|
||||
game_versions: vec![],
|
||||
loaders: vec![],
|
||||
versions: project_builder
|
||||
.initial_versions
|
||||
.iter()
|
||||
.map(|v| v.version_id.into())
|
||||
.collect::<Vec<_>>(),
|
||||
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(),
|
||||
gallery: gallery_urls,
|
||||
flame_anvil_project: None,
|
||||
flame_anvil_user: None,
|
||||
color: project_builder.color,
|
||||
};
|
||||
|
||||
let _project_id = project_builder.insert(&mut *transaction).await?;
|
||||
|
||||
if status == ProjectStatus::Processing {
|
||||
if let Ok(webhook_url) = dotenvy::var("MODERATION_DISCORD_WEBHOOK")
|
||||
{
|
||||
crate::util::webhook::send_discord_webhook(
|
||||
response.id,
|
||||
pool,
|
||||
webhook_url,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_initial_version(
|
||||
version_data: &InitialVersionData,
|
||||
project_id: ProjectId,
|
||||
author: UserId,
|
||||
all_game_versions: &[models::categories::GameVersion],
|
||||
all_loaders: &[models::categories::Loader],
|
||||
project_type: &str,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<models::version_item::VersionBuilder, CreateError> {
|
||||
if version_data.project_id.is_some() {
|
||||
return Err(CreateError::InvalidInput(String::from(
|
||||
"Found project id in initial version for new project",
|
||||
)));
|
||||
}
|
||||
|
||||
version_data.validate().map_err(|err| {
|
||||
CreateError::ValidationError(validation_errors_to_string(err, None))
|
||||
})?;
|
||||
|
||||
// Randomly generate a new id to be used for the version
|
||||
let version_id: VersionId =
|
||||
models::generate_version_id(transaction).await?.into();
|
||||
|
||||
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 loaders = version_data
|
||||
.loaders
|
||||
.iter()
|
||||
.map(|x| {
|
||||
all_loaders
|
||||
.iter()
|
||||
.find(|y| {
|
||||
y.loader == x.0
|
||||
&& y.supported_project_types
|
||||
.contains(&project_type.to_string())
|
||||
})
|
||||
.ok_or_else(|| CreateError::InvalidLoader(x.0.clone()))
|
||||
.map(|y| y.id)
|
||||
})
|
||||
.collect::<Result<Vec<models::LoaderId>, CreateError>>()?;
|
||||
|
||||
let dependencies = version_data
|
||||
.dependencies
|
||||
.iter()
|
||||
.map(|d| models::version_item::DependencyBuilder {
|
||||
version_id: d.version_id.map(|x| x.into()),
|
||||
project_id: d.project_id.map(|x| x.into()),
|
||||
dependency_type: d.dependency_type.to_string(),
|
||||
file_name: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let version = models::version_item::VersionBuilder {
|
||||
version_id: version_id.into(),
|
||||
project_id: project_id.into(),
|
||||
author_id: author.into(),
|
||||
name: version_data.version_title.clone(),
|
||||
version_number: version_data.version_number.clone(),
|
||||
changelog: version_data.version_body.clone().unwrap_or_default(),
|
||||
files: Vec::new(),
|
||||
dependencies,
|
||||
game_versions,
|
||||
loaders,
|
||||
featured: version_data.featured,
|
||||
status: VersionStatus::Listed,
|
||||
version_type: version_data.release_channel.to_string(),
|
||||
requested_status: None,
|
||||
};
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
async fn process_icon_upload(
|
||||
uploaded_files: &mut Vec<UploadedFile>,
|
||||
project_id: ProjectId,
|
||||
file_extension: &str,
|
||||
file_host: &dyn FileHost,
|
||||
mut field: Field,
|
||||
cdn_url: &str,
|
||||
) -> Result<(String, Option<u32>), CreateError> {
|
||||
if let Some(content_type) =
|
||||
crate::util::ext::get_image_content_type(file_extension)
|
||||
{
|
||||
let data = read_from_field(
|
||||
&mut field,
|
||||
262144,
|
||||
"Icons must be smaller than 256KiB",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let color = crate::util::img::get_color_from_img(&data)?;
|
||||
|
||||
let hash = sha1::Sha1::from(&data).hexdigest();
|
||||
let upload_data = file_host
|
||||
.upload_file(
|
||||
content_type,
|
||||
&format!("data/{project_id}/{hash}.{file_extension}"),
|
||||
data.freeze(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
uploaded_files.push(UploadedFile {
|
||||
file_id: upload_data.file_id,
|
||||
file_name: upload_data.file_name.clone(),
|
||||
});
|
||||
|
||||
Ok((format!("{}/{}", cdn_url, upload_data.file_name), color))
|
||||
} else {
|
||||
Err(CreateError::InvalidIconFormat(file_extension.to_string()))
|
||||
}
|
||||
}
|
||||
2401
src/routes/v2/projects.rs
Normal file
2401
src/routes/v2/projects.rs
Normal file
File diff suppressed because it is too large
Load Diff
248
src/routes/v2/reports.rs
Normal file
248
src/routes/v2/reports.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
use crate::models::ids::{
|
||||
base62_impl::parse_base62, ProjectId, UserId, VersionId,
|
||||
};
|
||||
use crate::models::reports::{ItemType, Report};
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::auth::{
|
||||
check_is_moderator_from_headers, get_user_from_headers,
|
||||
};
|
||||
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
|
||||
use chrono::Utc;
|
||||
use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(reports);
|
||||
cfg.service(report_create);
|
||||
cfg.service(delete_report);
|
||||
}
|
||||
|
||||
#[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::InvalidInput(
|
||||
"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::InvalidInput(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: Utc::now(),
|
||||
};
|
||||
|
||||
match new_report.item_type {
|
||||
ItemType::Project => {
|
||||
let project_id =
|
||||
ProjectId(parse_base62(new_report.item_id.as_str())?);
|
||||
|
||||
let result = sqlx::query!(
|
||||
"SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1)",
|
||||
project_id.0 as i64
|
||||
)
|
||||
.fetch_one(&mut transaction)
|
||||
.await?;
|
||||
|
||||
if !result.exists.unwrap_or(false) {
|
||||
return Err(ApiError::InvalidInput(format!(
|
||||
"Project could not be found: {}",
|
||||
new_report.item_id
|
||||
)));
|
||||
}
|
||||
|
||||
report.project_id = Some(project_id.into())
|
||||
}
|
||||
ItemType::Version => {
|
||||
let version_id =
|
||||
VersionId(parse_base62(new_report.item_id.as_str())?);
|
||||
|
||||
let result = sqlx::query!(
|
||||
"SELECT EXISTS(SELECT 1 FROM versions WHERE id = $1)",
|
||||
version_id.0 as i64
|
||||
)
|
||||
.fetch_one(&mut transaction)
|
||||
.await?;
|
||||
|
||||
if !result.exists.unwrap_or(false) {
|
||||
return Err(ApiError::InvalidInput(format!(
|
||||
"Version could not be found: {}",
|
||||
new_report.item_id
|
||||
)));
|
||||
}
|
||||
|
||||
report.version_id = Some(version_id.into())
|
||||
}
|
||||
ItemType::User => {
|
||||
let user_id = UserId(parse_base62(new_report.item_id.as_str())?);
|
||||
|
||||
let result = sqlx::query!(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)",
|
||||
user_id.0 as i64
|
||||
)
|
||||
.fetch_one(&mut transaction)
|
||||
.await?;
|
||||
|
||||
if !result.exists.unwrap_or(false) {
|
||||
return Err(ApiError::InvalidInput(format!(
|
||||
"User could not be found: {}",
|
||||
new_report.item_id
|
||||
)));
|
||||
}
|
||||
|
||||
report.user_id = Some(user_id.into())
|
||||
}
|
||||
ItemType::Unknown => {
|
||||
return Err(ApiError::InvalidInput(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: 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::Project;
|
||||
} 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))
|
||||
}
|
||||
|
||||
#[delete("report/{id}")]
|
||||
pub async fn delete_report(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
info: web::Path<(crate::models::reports::ReportId,)>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
check_is_moderator_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
let result = crate::database::models::report_item::Report::remove_full(
|
||||
info.into_inner().0.into(),
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if result.is_some() {
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
88
src/routes/v2/statistics.rs
Normal file
88
src/routes/v2/statistics.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use crate::routes::ApiError;
|
||||
use actix_web::{get, web, HttpResponse};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(get_stats);
|
||||
}
|
||||
|
||||
#[get("statistics")]
|
||||
pub async fn get_stats(
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let projects = sqlx::query!(
|
||||
"
|
||||
SELECT COUNT(id)
|
||||
FROM mods
|
||||
WHERE status = ANY($1)
|
||||
",
|
||||
&*crate::models::projects::ProjectStatus::iterator()
|
||||
.filter(|x| x.is_searchable())
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
)
|
||||
.fetch_one(&**pool)
|
||||
.await?;
|
||||
|
||||
let versions = sqlx::query!(
|
||||
"
|
||||
SELECT COUNT(v.id)
|
||||
FROM versions v
|
||||
INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1)
|
||||
WHERE v.status = ANY($2)
|
||||
",
|
||||
&*crate::models::projects::ProjectStatus::iterator()
|
||||
.filter(|x| x.is_searchable())
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
&*crate::models::projects::VersionStatus::iterator()
|
||||
.filter(|x| x.is_listed())
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
)
|
||||
.fetch_one(&**pool)
|
||||
.await?;
|
||||
|
||||
let authors = sqlx::query!(
|
||||
"
|
||||
SELECT COUNT(DISTINCT u.id)
|
||||
FROM users u
|
||||
INNER JOIN team_members tm on u.id = tm.user_id AND tm.accepted = TRUE
|
||||
INNER JOIN mods m on tm.team_id = m.team_id AND m.status = ANY($1)
|
||||
",
|
||||
&*crate::models::projects::ProjectStatus::iterator()
|
||||
.filter(|x| x.is_searchable())
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
)
|
||||
.fetch_one(&**pool)
|
||||
.await?;
|
||||
|
||||
let files = sqlx::query!(
|
||||
"
|
||||
SELECT COUNT(f.id) FROM files f
|
||||
INNER JOIN versions v on f.version_id = v.id AND v.status = ANY($2)
|
||||
INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1)
|
||||
",
|
||||
&*crate::models::projects::ProjectStatus::iterator()
|
||||
.filter(|x| x.is_searchable())
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
&*crate::models::projects::VersionStatus::iterator()
|
||||
.filter(|x| x.is_listed())
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
)
|
||||
.fetch_one(&**pool)
|
||||
.await?;
|
||||
|
||||
let json = json!({
|
||||
"projects": projects.count,
|
||||
"versions": versions.count,
|
||||
"authors": authors.count,
|
||||
"files": files.count,
|
||||
});
|
||||
|
||||
Ok(HttpResponse::Ok().json(json))
|
||||
}
|
||||
473
src/routes/v2/tags.rs
Normal file
473
src/routes/v2/tags.rs
Normal file
@@ -0,0 +1,473 @@
|
||||
use super::ApiError;
|
||||
use crate::database::models;
|
||||
use crate::database::models::categories::{
|
||||
DonationPlatform, ProjectType, ReportType,
|
||||
};
|
||||
use crate::util::auth::check_is_admin_from_headers;
|
||||
use actix_web::{delete, get, put, web, HttpRequest, HttpResponse};
|
||||
use chrono::{DateTime, Utc};
|
||||
use models::categories::{Category, GameVersion, Loader};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("tag")
|
||||
.service(category_list)
|
||||
.service(category_create)
|
||||
.service(category_delete)
|
||||
.service(loader_list)
|
||||
.service(loader_create)
|
||||
.service(loader_delete)
|
||||
.service(game_version_list)
|
||||
.service(game_version_create)
|
||||
.service(game_version_delete)
|
||||
.service(license_list)
|
||||
.service(license_text)
|
||||
.service(donation_platform_create)
|
||||
.service(donation_platform_list)
|
||||
.service(donation_platform_delete)
|
||||
.service(report_type_create)
|
||||
.service(report_type_delete)
|
||||
.service(report_type_list),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct CategoryData {
|
||||
icon: String,
|
||||
name: String,
|
||||
project_type: String,
|
||||
header: 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?
|
||||
.into_iter()
|
||||
.map(|x| CategoryData {
|
||||
icon: x.icon,
|
||||
name: x.category,
|
||||
project_type: x.project_type,
|
||||
header: x.header,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(HttpResponse::Ok().json(results))
|
||||
}
|
||||
|
||||
#[put("category")]
|
||||
pub async fn category_create(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
new_category: web::Json<CategoryData>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
check_is_admin_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
let project_type = crate::database::models::ProjectTypeId::get_id(
|
||||
new_category.project_type.clone(),
|
||||
&**pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Specified project type does not exist!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let _id = Category::builder()
|
||||
.name(&new_category.name)?
|
||||
.project_type(&project_type)?
|
||||
.icon(&new_category.icon)?
|
||||
.header(&new_category.header)?
|
||||
.insert(&**pool)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[delete("category/{name}")]
|
||||
pub async fn category_delete(
|
||||
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 mut transaction =
|
||||
pool.begin().await.map_err(models::DatabaseError::from)?;
|
||||
|
||||
let result = Category::remove(&name, &mut transaction).await?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(models::DatabaseError::from)?;
|
||||
|
||||
if result.is_some() {
|
||||
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 mut 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<_>>();
|
||||
|
||||
results.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
|
||||
Ok(HttpResponse::Ok().json(results))
|
||||
}
|
||||
|
||||
#[put("loader")]
|
||||
pub async fn loader_create(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
new_loader: web::Json<LoaderData>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
check_is_admin_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let project_types = ProjectType::get_many_id(
|
||||
&new_loader.supported_project_types,
|
||||
&mut *transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
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}")]
|
||||
pub async fn loader_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 = Loader::remove(&name, &mut transaction).await?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(models::DatabaseError::from)?;
|
||||
|
||||
if result.is_some() {
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct GameVersionQueryData {
|
||||
pub version: String,
|
||||
pub version_type: String,
|
||||
pub date: DateTime<Utc>,
|
||||
pub major: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct GameVersionQuery {
|
||||
#[serde(rename = "type")]
|
||||
type_: Option<String>,
|
||||
major: Option<bool>,
|
||||
}
|
||||
|
||||
#[get("game_version")]
|
||||
pub async fn game_version_list(
|
||||
pool: web::Data<PgPool>,
|
||||
query: web::Query<GameVersionQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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 {
|
||||
GameVersion::list(&**pool).await?
|
||||
}
|
||||
.into_iter()
|
||||
.map(|x| GameVersionQueryData {
|
||||
version: x.version,
|
||||
version_type: x.type_,
|
||||
date: x.created,
|
||||
major: x.major,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(HttpResponse::Ok().json(results))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct GameVersionData {
|
||||
#[serde(rename = "type")]
|
||||
type_: String,
|
||||
date: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[put("game_version/{name}")]
|
||||
pub async fn game_version_create(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
game_version: web::Path<(String,)>,
|
||||
version_data: web::Json<GameVersionData>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
check_is_admin_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
let name = game_version.into_inner().0;
|
||||
|
||||
// The version type currently isn't limited, but it should be one of:
|
||||
// "release", "snapshot", "alpha", "beta", "other"
|
||||
|
||||
let mut builder = GameVersion::builder()
|
||||
.version(&name)?
|
||||
.version_type(&version_data.type_)?;
|
||||
|
||||
if let Some(date) = &version_data.date {
|
||||
builder = builder.created(date);
|
||||
}
|
||||
|
||||
let _id = builder.insert(&**pool).await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[delete("game_version/{name}")]
|
||||
pub async fn game_version_delete(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
game_version: web::Path<(String,)>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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)?;
|
||||
|
||||
let result = GameVersion::remove(&name, &mut transaction).await?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(models::DatabaseError::from)?;
|
||||
|
||||
if result.is_some() {
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct License {
|
||||
short: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[get("license")]
|
||||
pub async fn license_list() -> HttpResponse {
|
||||
let licenses = spdx::identifiers::LICENSES;
|
||||
let mut results: Vec<License> = Vec::with_capacity(licenses.len());
|
||||
|
||||
for (short, name, _) in licenses {
|
||||
results.push(License {
|
||||
short: short.to_string(),
|
||||
name: name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
HttpResponse::Ok().json(results)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct LicenseText {
|
||||
title: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[get("license/{id}")]
|
||||
pub async fn license_text(
|
||||
params: web::Path<(String,)>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let license_id = params.into_inner().0;
|
||||
|
||||
if license_id == *crate::models::projects::DEFAULT_LICENSE_ID {
|
||||
return Ok(HttpResponse::Ok().json(LicenseText {
|
||||
title: "All Rights Reserved".to_string(),
|
||||
body: "All rights reserved unless explicitly stated.".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(license) = spdx::license_id(&license_id) {
|
||||
return Ok(HttpResponse::Ok().json(LicenseText {
|
||||
title: license.full_name.to_string(),
|
||||
body: license.text().to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
Err(ApiError::InvalidInput(
|
||||
"Invalid SPDX identifier specified".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[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::NoContent().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::NoContent().body(""))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[get("report_type")]
|
||||
pub async fn report_type_list(
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let results = ReportType::list(&**pool).await?;
|
||||
Ok(HttpResponse::Ok().json(results))
|
||||
}
|
||||
|
||||
#[put("report_type/{name}")]
|
||||
pub async fn report_type_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 _id = ReportType::builder().name(&name)?.insert(&**pool).await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[delete("report_type/{name}")]
|
||||
pub async fn report_type_delete(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
report_type: web::Path<(String,)>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
check_is_admin_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
let name = report_type.into_inner().0;
|
||||
let mut transaction =
|
||||
pool.begin().await.map_err(models::DatabaseError::from)?;
|
||||
|
||||
let result = ReportType::remove(&name, &mut transaction).await?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(models::DatabaseError::from)?;
|
||||
|
||||
if result.is_some() {
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
635
src/routes/v2/teams.rs
Normal file
635
src/routes/v2/teams.rs
Normal file
@@ -0,0 +1,635 @@
|
||||
use crate::database::models::notification_item::{
|
||||
NotificationActionBuilder, NotificationBuilder,
|
||||
};
|
||||
use crate::database::models::TeamMember;
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::models::teams::{Permissions, TeamId};
|
||||
use crate::models::users::UserId;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::auth::get_user_from_headers;
|
||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(teams_get);
|
||||
|
||||
cfg.service(
|
||||
web::scope("team")
|
||||
.service(team_members_get)
|
||||
.service(edit_team_member)
|
||||
.service(transfer_ownership)
|
||||
.service(add_team_member)
|
||||
.service(join_team)
|
||||
.service(remove_team_member),
|
||||
);
|
||||
|
||||
cfg.service(web::scope("project").service(team_members_get_project));
|
||||
}
|
||||
|
||||
#[get("{id}/members")]
|
||||
pub async fn team_members_get_project(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let string = info.into_inner().0;
|
||||
let project_data =
|
||||
crate::database::models::Project::get_from_slug_or_project_id(
|
||||
&string, &**pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(project) = project_data {
|
||||
let members_data =
|
||||
TeamMember::get_from_team_full(project.team_id, &**pool).await?;
|
||||
|
||||
let current_user =
|
||||
get_user_from_headers(req.headers(), &**pool).await.ok();
|
||||
|
||||
if let Some(user) = current_user {
|
||||
let team_member = TeamMember::get_from_user_id(
|
||||
project.team_id,
|
||||
user.id.into(),
|
||||
&**pool,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::Database)?;
|
||||
|
||||
if team_member.is_some() {
|
||||
let team_members: Vec<_> = members_data
|
||||
.into_iter()
|
||||
.map(|data| {
|
||||
crate::models::teams::TeamMember::from(data, false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Ok(HttpResponse::Ok().json(team_members));
|
||||
}
|
||||
}
|
||||
|
||||
let team_members: Vec<_> = members_data
|
||||
.into_iter()
|
||||
.filter(|x| x.accepted)
|
||||
.map(|data| crate::models::teams::TeamMember::from(data, true))
|
||||
.collect();
|
||||
|
||||
Ok(HttpResponse::Ok().json(team_members))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[get("{id}/members")]
|
||||
pub async fn team_members_get(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(TeamId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let id = info.into_inner().0;
|
||||
let members_data =
|
||||
TeamMember::get_from_team_full(id.into(), &**pool).await?;
|
||||
|
||||
let current_user = get_user_from_headers(req.headers(), &**pool).await.ok();
|
||||
|
||||
if let Some(user) = current_user {
|
||||
let team_member =
|
||||
TeamMember::get_from_user_id(id.into(), user.id.into(), &**pool)
|
||||
.await
|
||||
.map_err(ApiError::Database)?;
|
||||
|
||||
if team_member.is_some() {
|
||||
let team_members: Vec<_> = members_data
|
||||
.into_iter()
|
||||
.map(|data| crate::models::teams::TeamMember::from(data, false))
|
||||
.collect();
|
||||
|
||||
return Ok(HttpResponse::Ok().json(team_members));
|
||||
}
|
||||
}
|
||||
|
||||
let team_members: Vec<_> = members_data
|
||||
.into_iter()
|
||||
.filter(|x| x.accepted)
|
||||
.map(|data| crate::models::teams::TeamMember::from(data, true))
|
||||
.collect();
|
||||
|
||||
Ok(HttpResponse::Ok().json(team_members))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TeamIds {
|
||||
pub ids: String,
|
||||
}
|
||||
|
||||
#[get("teams")]
|
||||
pub async fn teams_get(
|
||||
req: HttpRequest,
|
||||
web::Query(ids): web::Query<TeamIds>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
use itertools::Itertools;
|
||||
|
||||
let team_ids = serde_json::from_str::<Vec<TeamId>>(&ids.ids)?
|
||||
.into_iter()
|
||||
.map(|x| x.into())
|
||||
.collect::<Vec<crate::database::models::ids::TeamId>>();
|
||||
|
||||
let teams_data =
|
||||
TeamMember::get_from_team_full_many(&team_ids, &**pool).await?;
|
||||
|
||||
let current_user = get_user_from_headers(req.headers(), &**pool).await.ok();
|
||||
let accepted = if let Some(user) = current_user {
|
||||
TeamMember::get_from_user_id_many(&team_ids, user.id.into(), &**pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|m| m.team_id.0)
|
||||
.collect()
|
||||
} else {
|
||||
std::collections::HashSet::new()
|
||||
};
|
||||
|
||||
let teams_groups = teams_data.into_iter().group_by(|data| data.team_id.0);
|
||||
|
||||
let mut teams: Vec<Vec<crate::models::teams::TeamMember>> = vec![];
|
||||
|
||||
for (id, member_data) in &teams_groups {
|
||||
if accepted.contains(&id) {
|
||||
let team_members = member_data.map(|data| {
|
||||
crate::models::teams::TeamMember::from(data, false)
|
||||
});
|
||||
|
||||
teams.push(team_members.collect());
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
let team_members = member_data
|
||||
.filter(|x| x.accepted)
|
||||
.map(|data| crate::models::teams::TeamMember::from(data, true));
|
||||
|
||||
teams.push(team_members.collect());
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().json(teams))
|
||||
}
|
||||
|
||||
#[post("{id}/join")]
|
||||
pub async fn join_team(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(TeamId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let team_id = info.into_inner().0.into();
|
||||
let current_user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
let member = TeamMember::get_from_user_id_pending(
|
||||
team_id,
|
||||
current_user.id.into(),
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(member) = member {
|
||||
if member.accepted {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You are already a member of this team".to_string(),
|
||||
));
|
||||
}
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
// Edit Team Member to set Accepted to True
|
||||
TeamMember::edit_team_member(
|
||||
team_id,
|
||||
current_user.id.into(),
|
||||
None,
|
||||
None,
|
||||
Some(true),
|
||||
None,
|
||||
None,
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"There is no pending request from this team".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
fn default_role() -> String {
|
||||
"Member".to_string()
|
||||
}
|
||||
|
||||
fn default_ordering() -> i64 {
|
||||
0
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct NewTeamMember {
|
||||
pub user_id: UserId,
|
||||
#[serde(default = "default_role")]
|
||||
pub role: String,
|
||||
#[serde(default = "Permissions::default")]
|
||||
pub permissions: Permissions,
|
||||
#[serde(default)]
|
||||
pub payouts_split: Decimal,
|
||||
#[serde(default = "default_ordering")]
|
||||
pub ordering: i64,
|
||||
}
|
||||
|
||||
#[post("{id}/members")]
|
||||
pub async fn add_team_member(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(TeamId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
new_member: web::Json<NewTeamMember>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let team_id = info.into_inner().0.into();
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let current_user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
let member =
|
||||
TeamMember::get_from_user_id(team_id, current_user.id.into(), &**pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::CustomAuthentication(
|
||||
"You don't have permission to edit members of this team"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !member.permissions.contains(Permissions::MANAGE_INVITES) {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You don't have permission to invite users to this team"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if !member.permissions.contains(new_member.permissions) {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"The new member has permissions that you don't have".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if new_member.role == crate::models::teams::OWNER_ROLE {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"The `Owner` role is restricted to one person".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if new_member.payouts_split < Decimal::ZERO
|
||||
|| new_member.payouts_split > Decimal::from(5000)
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Payouts split must be between 0 and 5000!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let request = crate::database::models::team_item::TeamMember::get_from_user_id_pending(
|
||||
team_id,
|
||||
new_member.user_id.into(),
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(req) = request {
|
||||
if req.accepted {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"The user is already a member of that team".to_string(),
|
||||
));
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"There is already a pending member request for this user"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
crate::database::models::User::get(member.user_id, &**pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput("An invalid User ID specified".to_string())
|
||||
})?;
|
||||
|
||||
let new_id =
|
||||
crate::database::models::ids::generate_team_member_id(&mut transaction)
|
||||
.await?;
|
||||
TeamMember {
|
||||
id: new_id,
|
||||
team_id,
|
||||
user_id: new_member.user_id.into(),
|
||||
role: new_member.role.clone(),
|
||||
permissions: new_member.permissions,
|
||||
accepted: false,
|
||||
payouts_split: new_member.payouts_split,
|
||||
ordering: new_member.ordering,
|
||||
}
|
||||
.insert(&mut transaction)
|
||||
.await?;
|
||||
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
SELECT m.title title, m.id id, pt.name project_type
|
||||
FROM mods m
|
||||
INNER JOIN project_types pt ON pt.id = m.project_type
|
||||
WHERE m.team_id = $1
|
||||
",
|
||||
team_id as crate::database::models::ids::TeamId
|
||||
)
|
||||
.fetch_one(&**pool)
|
||||
.await?;
|
||||
|
||||
let team: TeamId = team_id.into();
|
||||
NotificationBuilder {
|
||||
notification_type: Some("team_invite".to_string()),
|
||||
title: "You have been invited to join a team!".to_string(),
|
||||
text: format!(
|
||||
"Team invite from {} to join the team for project {}",
|
||||
current_user.username, result.title
|
||||
),
|
||||
link: format!(
|
||||
"/{}/{}",
|
||||
result.project_type,
|
||||
ProjectId(result.id as u64)
|
||||
),
|
||||
actions: vec![
|
||||
NotificationActionBuilder {
|
||||
title: "Accept".to_string(),
|
||||
action_route: ("POST".to_string(), format!("team/{team}/join")),
|
||||
},
|
||||
NotificationActionBuilder {
|
||||
title: "Deny".to_string(),
|
||||
action_route: (
|
||||
"DELETE".to_string(),
|
||||
format!("team/{team}/members/{}", new_member.user_id),
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
.insert(new_member.user_id.into(), &mut transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct EditTeamMember {
|
||||
pub permissions: Option<Permissions>,
|
||||
pub role: Option<String>,
|
||||
pub payouts_split: Option<Decimal>,
|
||||
pub ordering: Option<i64>,
|
||||
}
|
||||
|
||||
#[patch("{id}/members/{user_id}")]
|
||||
pub async fn edit_team_member(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(TeamId, UserId)>,
|
||||
pool: web::Data<PgPool>,
|
||||
edit_member: web::Json<EditTeamMember>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let ids = info.into_inner();
|
||||
let id = ids.0.into();
|
||||
let user_id = ids.1.into();
|
||||
|
||||
let current_user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
let member =
|
||||
TeamMember::get_from_user_id(id, current_user.id.into(), &**pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::CustomAuthentication(
|
||||
"You don't have permission to edit members of this team"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let edit_member_db =
|
||||
TeamMember::get_from_user_id_pending(id, user_id, &**pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::CustomAuthentication(
|
||||
"You don't have permission to edit members of this team"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
if &*edit_member_db.role == crate::models::teams::OWNER_ROLE
|
||||
&& (edit_member.role.is_some() || edit_member.permissions.is_some())
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"The owner's permission and role of a team cannot be edited"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !member.permissions.contains(Permissions::EDIT_MEMBER) {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You don't have permission to edit members of this team"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(new_permissions) = edit_member.permissions {
|
||||
if !member.permissions.contains(new_permissions) {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"The new permissions have permissions that you don't have"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(payouts_split) = edit_member.payouts_split {
|
||||
if payouts_split < Decimal::ZERO || payouts_split > Decimal::from(5000)
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Payouts split must be between 0 and 5000!".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if edit_member.role.as_deref() == Some(crate::models::teams::OWNER_ROLE) {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"The `Owner` role is restricted to one person".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
TeamMember::edit_team_member(
|
||||
id,
|
||||
user_id,
|
||||
edit_member.permissions,
|
||||
edit_member.role.clone(),
|
||||
None,
|
||||
edit_member.payouts_split,
|
||||
edit_member.ordering,
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TransferOwnership {
|
||||
pub user_id: UserId,
|
||||
}
|
||||
|
||||
#[patch("{id}/owner")]
|
||||
pub async fn transfer_ownership(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(TeamId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
new_owner: web::Json<TransferOwnership>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let id = info.into_inner().0;
|
||||
|
||||
let current_user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
if !current_user.role.is_admin() {
|
||||
let member = TeamMember::get_from_user_id(
|
||||
id.into(),
|
||||
current_user.id.into(),
|
||||
&**pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::CustomAuthentication(
|
||||
"You don't have permission to edit members of this team"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if member.role != crate::models::teams::OWNER_ROLE {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You don't have permission to edit the ownership of this team"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let new_member = TeamMember::get_from_user_id(
|
||||
id.into(),
|
||||
new_owner.user_id.into(),
|
||||
&**pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"The new owner specified does not exist".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !new_member.accepted {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You can only transfer ownership to members who are currently in your team".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
TeamMember::edit_team_member(
|
||||
id.into(),
|
||||
current_user.id.into(),
|
||||
None,
|
||||
Some(crate::models::teams::DEFAULT_ROLE.to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
TeamMember::edit_team_member(
|
||||
id.into(),
|
||||
new_owner.user_id.into(),
|
||||
Some(Permissions::ALL),
|
||||
Some(crate::models::teams::OWNER_ROLE.to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[delete("{id}/members/{user_id}")]
|
||||
pub async fn remove_team_member(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(TeamId, UserId)>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let ids = info.into_inner();
|
||||
let id = ids.0.into();
|
||||
let user_id = ids.1.into();
|
||||
|
||||
let current_user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
let member =
|
||||
TeamMember::get_from_user_id(id, current_user.id.into(), &**pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::CustomAuthentication(
|
||||
"You don't have permission to edit members of this team"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let delete_member =
|
||||
TeamMember::get_from_user_id_pending(id, user_id, &**pool).await?;
|
||||
|
||||
if let Some(delete_member) = delete_member {
|
||||
if delete_member.role == crate::models::teams::OWNER_ROLE {
|
||||
// The owner cannot be removed from a team
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"The owner can't be removed from a team".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
if delete_member.accepted {
|
||||
// Members other than the owner can either leave the team, or be
|
||||
// removed by a member with the REMOVE_MEMBER permission.
|
||||
if delete_member.user_id == member.user_id
|
||||
|| (member.permissions.contains(Permissions::REMOVE_MEMBER)
|
||||
&& member.accepted)
|
||||
{
|
||||
TeamMember::delete(id, user_id, &mut transaction).await?;
|
||||
} else {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to remove a member from this team".to_string(),
|
||||
));
|
||||
}
|
||||
} else if delete_member.user_id == member.user_id
|
||||
|| (member.permissions.contains(Permissions::MANAGE_INVITES)
|
||||
&& member.accepted)
|
||||
{
|
||||
// This is a pending invite rather than a member, so the
|
||||
// user being invited or team members with the MANAGE_INVITES
|
||||
// permission can remove it.
|
||||
TeamMember::delete(id, user_id, &mut transaction).await?;
|
||||
} else {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to cancel a team invite"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
838
src/routes/v2/users.rs
Normal file
838
src/routes/v2/users.rs
Normal file
@@ -0,0 +1,838 @@
|
||||
use crate::database::models::User;
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::notifications::Notification;
|
||||
use crate::models::projects::Project;
|
||||
use crate::models::users::{
|
||||
Badges, RecipientType, RecipientWallet, Role, UserId,
|
||||
};
|
||||
use crate::queue::payouts::{PayoutAmount, PayoutItem, PayoutsQueue};
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::auth::get_user_from_headers;
|
||||
use crate::util::routes::read_from_payload;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
||||
use chrono::{DateTime, Utc};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(user_auth_get);
|
||||
cfg.service(users_get);
|
||||
|
||||
cfg.service(
|
||||
web::scope("user")
|
||||
.service(user_get)
|
||||
.service(projects_list)
|
||||
.service(user_delete)
|
||||
.service(user_edit)
|
||||
.service(user_icon_edit)
|
||||
.service(user_notifications)
|
||||
.service(user_follows)
|
||||
.service(user_payouts)
|
||||
.service(user_payouts_request),
|
||||
);
|
||||
}
|
||||
|
||||
#[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(), &**pool).await?))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UserIds {
|
||||
pub ids: String,
|
||||
}
|
||||
|
||||
#[get("users")]
|
||||
pub async fn users_get(
|
||||
web::Query(ids): web::Query<UserIds>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user_ids = serde_json::from_str::<Vec<UserId>>(&ids.ids)?
|
||||
.into_iter()
|
||||
.map(|x| x.into())
|
||||
.collect::<Vec<crate::database::models::UserId>>();
|
||||
|
||||
let users_data = User::get_many(&user_ids, &**pool).await?;
|
||||
|
||||
let users: Vec<crate::models::users::User> =
|
||||
users_data.into_iter().map(From::from).collect();
|
||||
|
||||
Ok(HttpResponse::Ok().json(users))
|
||||
}
|
||||
|
||||
#[get("{id}")]
|
||||
pub async fn user_get(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let string = info.into_inner().0;
|
||||
let id_option: Option<UserId> =
|
||||
serde_json::from_str(&format!("\"{string}\"")).ok();
|
||||
|
||||
let mut user_data;
|
||||
|
||||
if let Some(id) = id_option {
|
||||
user_data = User::get(id.into(), &**pool).await?;
|
||||
|
||||
if user_data.is_none() {
|
||||
user_data = User::get_from_username(string, &**pool).await?;
|
||||
}
|
||||
} else {
|
||||
user_data = User::get_from_username(string, &**pool).await?;
|
||||
}
|
||||
|
||||
if let Some(data) = user_data {
|
||||
let response: crate::models::users::User = data.into();
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[get("{user_id}/projects")]
|
||||
pub async fn projects_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 =
|
||||
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 can_view_private = user
|
||||
.map(|y| y.role.is_mod() || y.id == user_id)
|
||||
.unwrap_or(false);
|
||||
|
||||
let project_data = User::get_projects(id, &**pool).await?;
|
||||
|
||||
let response: Vec<_> =
|
||||
crate::database::Project::get_many_full(&project_data, &**pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|x| can_view_private || x.inner.status.is_searchable())
|
||||
.map(Project::from)
|
||||
.collect();
|
||||
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
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 = 39), 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 = 64), 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, length(max = 2048))]
|
||||
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<Role>,
|
||||
pub badges: Option<Badges>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "::serde_with::rust::double_option"
|
||||
)]
|
||||
#[validate]
|
||||
pub payout_data: Option<Option<EditPayoutData>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "::serde_with::rust::double_option"
|
||||
)]
|
||||
#[validate(length(min = 1, max = 40), regex = "RE_URL_SAFE")]
|
||||
pub flame_anvil_key: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Validate)]
|
||||
pub struct EditPayoutData {
|
||||
pub payout_wallet: RecipientWallet,
|
||||
pub payout_wallet_type: RecipientType,
|
||||
#[validate(length(max = 128))]
|
||||
pub payout_address: String,
|
||||
}
|
||||
|
||||
#[patch("{id}")]
|
||||
pub async fn user_edit(
|
||||
req: HttpRequest,
|
||||
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?;
|
||||
|
||||
new_user.validate().map_err(|err| {
|
||||
ApiError::Validation(validation_errors_to_string(err, None))
|
||||
})?;
|
||||
|
||||
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();
|
||||
|
||||
if user.id == user_id || user.role.is_mod() {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
if let Some(username) = &new_user.username {
|
||||
let existing_user_id_option =
|
||||
crate::database::models::User::get_id_from_username_or_id(
|
||||
username, &**pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if existing_user_id_option
|
||||
.map(UserId::from)
|
||||
.map(|id| id == user.id)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET username = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
username,
|
||||
id as crate::database::models::ids::UserId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(format!(
|
||||
"Username {username} is taken!"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
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?;
|
||||
}
|
||||
|
||||
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?;
|
||||
}
|
||||
|
||||
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?;
|
||||
}
|
||||
|
||||
if let Some(role) = &new_user.role {
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have the permissions to edit the role of this user!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
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?;
|
||||
}
|
||||
|
||||
if let Some(badges) = &new_user.badges {
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have the permissions to edit the badges of this user!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET badges = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
badges.bits() as i64,
|
||||
id as crate::database::models::ids::UserId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(payout_data) = &new_user.payout_data {
|
||||
if let Some(payout_data) = payout_data {
|
||||
if payout_data.payout_wallet_type
|
||||
== RecipientType::UserHandle
|
||||
&& payout_data.payout_wallet == RecipientWallet::Paypal
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You cannot use a paypal wallet with a user handle!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !match payout_data.payout_wallet_type {
|
||||
RecipientType::Email => validator::validate_email(
|
||||
&payout_data.payout_address,
|
||||
),
|
||||
RecipientType::Phone => validator::validate_phone(
|
||||
&payout_data.payout_address,
|
||||
),
|
||||
RecipientType::UserHandle => true,
|
||||
} {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Invalid wallet specified!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let results = sqlx::query!(
|
||||
"
|
||||
SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND email IS NULL)
|
||||
",
|
||||
id as crate::database::models::ids::UserId,
|
||||
)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if results.exists.unwrap_or(false) {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You must have an email set on your Modrinth account to enroll in the monetization program!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET payout_wallet = $1, payout_wallet_type = $2, payout_address = $3
|
||||
WHERE (id = $4)
|
||||
",
|
||||
payout_data.payout_wallet.as_str(),
|
||||
payout_data.payout_wallet_type.as_str(),
|
||||
payout_data.payout_address,
|
||||
id as crate::database::models::ids::UserId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET payout_wallet = NULL, payout_wallet_type = NULL, payout_address = NULL
|
||||
WHERE (id = $1)
|
||||
",
|
||||
id as crate::database::models::ids::UserId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(flame_anvil_key) = &new_user.flame_anvil_key {
|
||||
if flame_anvil_key.is_none() {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE mods
|
||||
SET flame_anvil_user = NULL
|
||||
WHERE (flame_anvil_user = $1)
|
||||
",
|
||||
id as crate::database::models::ids::UserId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET flame_anvil_key = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
flame_anvil_key.as_deref(),
|
||||
id as crate::database::models::ids::UserId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to edit this user!".to_string(),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[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<(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) =
|
||||
crate::util::ext::get_image_content_type(&ext.ext)
|
||||
{
|
||||
let cdn_url = dotenvy::var("CDN_URL")?;
|
||||
let user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
let id_option =
|
||||
User::get_id_from_username_or_id(&info.into_inner().0, &**pool)
|
||||
.await?;
|
||||
|
||||
if let Some(id) = id_option {
|
||||
if user.id != id.into() && !user.role.is_mod() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You don't have permission to edit this user's icon."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut icon_url = user.avatar_url;
|
||||
|
||||
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(""));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(icon) = icon_url {
|
||||
let name = icon.split(&format!("{cdn_url}/")).nth(1);
|
||||
|
||||
if let Some(icon_path) = name {
|
||||
file_host.delete_file_version("", icon_path).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = read_from_payload(
|
||||
&mut payload,
|
||||
2097152,
|
||||
"Icons must be smaller than 2MiB",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let hash = sha1::Sha1::from(&bytes).hexdigest();
|
||||
let upload_data = file_host
|
||||
.upload_file(
|
||||
content_type,
|
||||
&format!("user/{}/{}.{}", user_id, hash, ext.ext),
|
||||
bytes.freeze(),
|
||||
)
|
||||
.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?;
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
} else {
|
||||
Err(ApiError::InvalidInput(format!(
|
||||
"Invalid format for user icon: {}",
|
||||
ext.ext
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RemovalType {
|
||||
#[serde(default = "default_removal")]
|
||||
removal_type: String,
|
||||
}
|
||||
|
||||
fn default_removal() -> String {
|
||||
"partial".into()
|
||||
}
|
||||
|
||||
#[delete("{id}")]
|
||||
pub async fn user_delete(
|
||||
req: HttpRequest,
|
||||
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_option =
|
||||
User::get_id_from_username_or_id(&info.into_inner().0, &**pool).await?;
|
||||
|
||||
if let Some(id) = id_option {
|
||||
if !user.role.is_admin() && user.id != id.into() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to delete this user!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let result = if &*removal_type.removal_type == "full" {
|
||||
User::remove_full(id, &mut transaction).await?
|
||||
} else {
|
||||
User::remove(id, &mut transaction).await?
|
||||
};
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
if result.is_some() {
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[get("{id}/follows")]
|
||||
pub async fn user_follows(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
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 {
|
||||
if !user.role.is_admin() && user.id != id.into() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to see the projects this user follows!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
use futures::TryStreamExt;
|
||||
|
||||
let project_ids = 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| crate::database::models::ProjectId(m.mod_id)))
|
||||
})
|
||||
.try_collect::<Vec<crate::database::models::ProjectId>>()
|
||||
.await?;
|
||||
|
||||
let projects: Vec<_> =
|
||||
crate::database::Project::get_many_full(&project_ids, &**pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Project::from)
|
||||
.collect();
|
||||
|
||||
Ok(HttpResponse::Ok().json(projects))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[get("{id}/notifications")]
|
||||
pub async fn user_notifications(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
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 {
|
||||
if !user.role.is_admin() && user.id != id.into() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to see the notifications of this user!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut notifications: Vec<Notification> =
|
||||
crate::database::models::notification_item::Notification::get_many_user(id, &**pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
|
||||
notifications.sort_by(|a, b| b.created.cmp(&a.created));
|
||||
|
||||
Ok(HttpResponse::Ok().json(notifications))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Payout {
|
||||
pub created: DateTime<Utc>,
|
||||
pub amount: Decimal,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[get("{id}/payouts")]
|
||||
pub async fn user_payouts(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
let id_option =
|
||||
User::get_id_from_username_or_id(&info.into_inner().0, &**pool).await?;
|
||||
|
||||
if let Some(id) = id_option {
|
||||
if !user.role.is_admin() && user.id != id.into() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to see the payouts of this user!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (all_time, last_month, payouts) = futures::future::try_join3(
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT SUM(pv.amount) amount
|
||||
FROM payouts_values pv
|
||||
WHERE pv.user_id = $1
|
||||
",
|
||||
id as crate::database::models::UserId
|
||||
)
|
||||
.fetch_one(&**pool),
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT SUM(pv.amount) amount
|
||||
FROM payouts_values pv
|
||||
WHERE pv.user_id = $1 AND created > NOW() - '1 month'::interval
|
||||
",
|
||||
id as crate::database::models::UserId
|
||||
)
|
||||
.fetch_one(&**pool),
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT hp.created, hp.amount, hp.status
|
||||
FROM historical_payouts hp
|
||||
WHERE hp.user_id = $1
|
||||
ORDER BY hp.created DESC
|
||||
",
|
||||
id as crate::database::models::UserId
|
||||
)
|
||||
.fetch_many(&**pool)
|
||||
.try_filter_map(|e| async {
|
||||
Ok(e.right().map(|row| Payout {
|
||||
created: row.created,
|
||||
amount: row.amount,
|
||||
status: row.status,
|
||||
}))
|
||||
})
|
||||
.try_collect::<Vec<Payout>>(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
use futures::TryStreamExt;
|
||||
|
||||
Ok(HttpResponse::Ok().json(json!({
|
||||
"all_time": all_time.amount,
|
||||
"last_month": last_month.amount,
|
||||
"payouts": payouts,
|
||||
})))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PayoutData {
|
||||
amount: Decimal,
|
||||
}
|
||||
|
||||
#[post("{id}/payouts")]
|
||||
pub async fn user_payouts_request(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
data: web::Json<PayoutData>,
|
||||
payouts_queue: web::Data<Arc<Mutex<PayoutsQueue>>>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
let id_option =
|
||||
User::get_id_from_username_or_id(&info.into_inner().0, &**pool).await?;
|
||||
|
||||
if let Some(id) = id_option {
|
||||
if !user.role.is_admin() && user.id != id.into() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to request payouts of this user!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(payouts_data) = user.payout_data {
|
||||
if let Some(payout_address) = payouts_data.payout_address {
|
||||
if let Some(payout_wallet_type) =
|
||||
payouts_data.payout_wallet_type
|
||||
{
|
||||
if let Some(payout_wallet) = payouts_data.payout_wallet {
|
||||
return if data.amount < payouts_data.balance {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let mut payouts_queue = payouts_queue.lock().await;
|
||||
|
||||
let leftover = payouts_queue
|
||||
.send_payout(PayoutItem {
|
||||
amount: PayoutAmount {
|
||||
currency: "USD".to_string(),
|
||||
value: data.amount,
|
||||
},
|
||||
receiver: payout_address,
|
||||
note: "Payment from Modrinth creator monetization program".to_string(),
|
||||
recipient_type: payout_wallet_type.to_string().to_uppercase(),
|
||||
recipient_wallet: payout_wallet.as_str_api().to_string(),
|
||||
sender_item_id: format!("{}-{}", UserId::from(id), Utc::now().timestamp()),
|
||||
})
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO historical_payouts (user_id, amount, status)
|
||||
VALUES ($1, $2, $3)
|
||||
",
|
||||
id as crate::database::models::ids::UserId,
|
||||
data.amount,
|
||||
"success"
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET balance = balance - $1
|
||||
WHERE id = $2
|
||||
",
|
||||
data.amount - leftover,
|
||||
id as crate::database::models::ids::UserId
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Err(ApiError::InvalidInput(
|
||||
"You do not have enough funds to make this payout!"
|
||||
.to_string(),
|
||||
))
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(ApiError::InvalidInput(
|
||||
"You are not enrolled in the payouts program yet!".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
921
src/routes/v2/version_creation.rs
Normal file
921
src/routes/v2/version_creation.rs
Normal file
@@ -0,0 +1,921 @@
|
||||
use super::project_creation::{CreateError, UploadedFile};
|
||||
use crate::database::models;
|
||||
use crate::database::models::notification_item::NotificationBuilder;
|
||||
use crate::database::models::version_item::{
|
||||
DependencyBuilder, VersionBuilder, VersionFileBuilder,
|
||||
};
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::pack::PackFileHash;
|
||||
use crate::models::projects::{
|
||||
Dependency, DependencyType, FileType, GameVersion, Loader, ProjectId,
|
||||
Version, VersionFile, VersionId, VersionStatus, VersionType,
|
||||
};
|
||||
use crate::models::teams::Permissions;
|
||||
use crate::util::auth::get_user_from_headers;
|
||||
use crate::util::routes::read_from_field;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use crate::validate::{validate_file, ValidationResult};
|
||||
use actix_multipart::{Field, Multipart};
|
||||
use actix_web::web::Data;
|
||||
use actix_web::{post, web, HttpRequest, HttpResponse};
|
||||
use chrono::Utc;
|
||||
use futures::stream::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(version_create);
|
||||
|
||||
cfg.service(web::scope("version").service(upload_file_to_version));
|
||||
}
|
||||
|
||||
fn default_requested_status() -> VersionStatus {
|
||||
VersionStatus::Listed
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Validate, Clone)]
|
||||
pub struct InitialVersionData {
|
||||
#[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 = 32),
|
||||
regex = "crate::util::validate::RE_URL_SAFE"
|
||||
)]
|
||||
pub version_number: String,
|
||||
#[validate(
|
||||
length(min = 1, max = 64),
|
||||
custom(function = "crate::util::validate::validate_name")
|
||||
)]
|
||||
#[serde(alias = "name")]
|
||||
pub version_title: String,
|
||||
#[validate(length(max = 65536))]
|
||||
#[serde(alias = "changelog")]
|
||||
pub version_body: Option<String>,
|
||||
#[validate(
|
||||
length(min = 0, max = 4096),
|
||||
custom(function = "crate::util::validate::validate_deps")
|
||||
)]
|
||||
pub dependencies: Vec<Dependency>,
|
||||
#[validate(length(min = 1))]
|
||||
pub game_versions: Vec<GameVersion>,
|
||||
#[serde(alias = "version_type")]
|
||||
pub release_channel: VersionType,
|
||||
#[validate(length(min = 1))]
|
||||
pub loaders: Vec<Loader>,
|
||||
pub featured: bool,
|
||||
pub primary_file: Option<String>,
|
||||
#[serde(default = "default_requested_status")]
|
||||
pub status: VersionStatus,
|
||||
#[serde(default = "HashMap::new")]
|
||||
pub file_types: HashMap<String, Option<FileType>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct InitialFileData {
|
||||
#[serde(default = "HashMap::new")]
|
||||
pub file_types: HashMap<String, Option<FileType>>,
|
||||
}
|
||||
|
||||
// under `/api/v1/version`
|
||||
#[post("version")]
|
||||
pub async fn version_create(
|
||||
req: HttpRequest,
|
||||
mut payload: Multipart,
|
||||
client: Data<PgPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
) -> Result<HttpResponse, CreateError> {
|
||||
let mut transaction = client.begin().await?;
|
||||
let mut uploaded_files = Vec::new();
|
||||
|
||||
let result = version_create_inner(
|
||||
req,
|
||||
&mut payload,
|
||||
&mut transaction,
|
||||
&***file_host,
|
||||
&mut uploaded_files,
|
||||
)
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let undo_result = super::project_creation::undo_uploads(
|
||||
&***file_host,
|
||||
&uploaded_files,
|
||||
)
|
||||
.await;
|
||||
let rollback_result = transaction.rollback().await;
|
||||
|
||||
undo_result?;
|
||||
if let Err(e) = rollback_result {
|
||||
return Err(e.into());
|
||||
}
|
||||
} else {
|
||||
transaction.commit().await?;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn version_create_inner(
|
||||
req: HttpRequest,
|
||||
payload: &mut Multipart,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
file_host: &dyn FileHost,
|
||||
uploaded_files: &mut Vec<UploadedFile>,
|
||||
) -> Result<HttpResponse, CreateError> {
|
||||
let cdn_url = dotenvy::var("CDN_URL")?;
|
||||
|
||||
let mut initial_version_data = None;
|
||||
let mut version_builder = None;
|
||||
|
||||
let all_game_versions =
|
||||
models::categories::GameVersion::list(&mut *transaction).await?;
|
||||
let all_loaders =
|
||||
models::categories::Loader::list(&mut *transaction).await?;
|
||||
|
||||
let user = get_user_from_headers(req.headers(), &mut *transaction).await?;
|
||||
|
||||
let mut error = None;
|
||||
while let Some(item) = payload.next().await {
|
||||
let mut field: Field = item?;
|
||||
|
||||
if error.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = async {
|
||||
let content_disposition = field.content_disposition().clone();
|
||||
let name = content_disposition.get_name().ok_or_else(|| {
|
||||
CreateError::MissingValueError(
|
||||
"Missing content name".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if name == "data" {
|
||||
let mut data = Vec::new();
|
||||
while let Some(chunk) = field.next().await {
|
||||
data.extend_from_slice(&chunk?);
|
||||
}
|
||||
|
||||
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.project_id.is_none() {
|
||||
return Err(CreateError::MissingValueError(
|
||||
"Missing project id".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
version_create_data.validate().map_err(|err| {
|
||||
CreateError::ValidationError(validation_errors_to_string(
|
||||
err, None,
|
||||
))
|
||||
})?;
|
||||
|
||||
if !version_create_data.status.can_be_requested() {
|
||||
return Err(CreateError::InvalidInput(
|
||||
"Status specified cannot be requested".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let project_id: models::ProjectId =
|
||||
version_create_data.project_id.unwrap().into();
|
||||
|
||||
// Ensure that the project this version is being added to exists
|
||||
let results = sqlx::query!(
|
||||
"SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)",
|
||||
project_id as models::ProjectId
|
||||
)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if !results.exists.unwrap_or(false) {
|
||||
return Err(CreateError::InvalidInput(
|
||||
"An invalid project id was supplied".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check that the user creating this version is a team member
|
||||
// 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
|
||||
.contains(Permissions::UPLOAD_VERSION)
|
||||
{
|
||||
return Err(CreateError::CustomAuthenticationError(
|
||||
"You don't have permission to upload this version!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let version_id: VersionId =
|
||||
models::generate_version_id(transaction).await?.into();
|
||||
|
||||
let project_type = sqlx::query!(
|
||||
"
|
||||
SELECT name FROM project_types pt
|
||||
INNER JOIN mods ON mods.project_type = pt.id
|
||||
WHERE mods.id = $1
|
||||
",
|
||||
project_id as models::ProjectId,
|
||||
)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?
|
||||
.name;
|
||||
|
||||
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 loaders = version_create_data
|
||||
.loaders
|
||||
.iter()
|
||||
.map(|x| {
|
||||
all_loaders
|
||||
.iter()
|
||||
.find(|y| {
|
||||
y.loader == x.0
|
||||
&& y.supported_project_types
|
||||
.contains(&project_type)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
CreateError::InvalidLoader(x.0.clone())
|
||||
})
|
||||
.map(|y| y.id)
|
||||
})
|
||||
.collect::<Result<Vec<models::LoaderId>, CreateError>>()?;
|
||||
|
||||
let dependencies = version_create_data
|
||||
.dependencies
|
||||
.iter()
|
||||
.map(|d| models::version_item::DependencyBuilder {
|
||||
version_id: d.version_id.map(|x| x.into()),
|
||||
project_id: d.project_id.map(|x| x.into()),
|
||||
dependency_type: d.dependency_type.to_string(),
|
||||
file_name: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
version_builder = Some(VersionBuilder {
|
||||
version_id: version_id.into(),
|
||||
project_id,
|
||||
author_id: user.id.into(),
|
||||
name: version_create_data.version_title.clone(),
|
||||
version_number: version_create_data.version_number.clone(),
|
||||
changelog: version_create_data
|
||||
.version_body
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
files: Vec::new(),
|
||||
dependencies,
|
||||
game_versions,
|
||||
loaders,
|
||||
version_type: version_create_data
|
||||
.release_channel
|
||||
.to_string(),
|
||||
featured: version_create_data.featured,
|
||||
status: version_create_data.status,
|
||||
requested_status: None,
|
||||
});
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let version = version_builder.as_mut().ok_or_else(|| {
|
||||
CreateError::InvalidInput(String::from(
|
||||
"`data` field must come before file fields",
|
||||
))
|
||||
})?;
|
||||
|
||||
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,
|
||||
version_data.file_parts.len(),
|
||||
uploaded_files,
|
||||
&mut version.files,
|
||||
&mut version.dependencies,
|
||||
&cdn_url,
|
||||
&content_disposition,
|
||||
version.project_id.into(),
|
||||
version.version_id.into(),
|
||||
&project_type,
|
||||
version_data.loaders,
|
||||
version_data.game_versions,
|
||||
all_game_versions.clone(),
|
||||
version_data.primary_file.is_some(),
|
||||
version_data.primary_file.as_deref() == Some(name),
|
||||
version_data.file_types.get(name).copied().flatten(),
|
||||
transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
error = result.err();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(error) = error {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let version_data = initial_version_data.ok_or_else(|| {
|
||||
CreateError::InvalidInput("`data` field is required".to_string())
|
||||
})?;
|
||||
let builder = version_builder.ok_or_else(|| {
|
||||
CreateError::InvalidInput("`data` field is required".to_string())
|
||||
})?;
|
||||
|
||||
if builder.files.is_empty() {
|
||||
return Err(CreateError::InvalidInput(
|
||||
"Versions must have at least one file uploaded to them".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
SELECT m.title title, pt.name project_type
|
||||
FROM mods m
|
||||
INNER JOIN project_types pt ON pt.id = m.project_type
|
||||
WHERE m.id = $1
|
||||
",
|
||||
builder.project_id as crate::database::models::ids::ProjectId
|
||||
)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
use futures::stream::TryStreamExt;
|
||||
|
||||
let users = sqlx::query!(
|
||||
"
|
||||
SELECT follower_id FROM mod_follows
|
||||
WHERE mod_id = $1
|
||||
",
|
||||
builder.project_id as crate::database::models::ids::ProjectId
|
||||
)
|
||||
.fetch_many(&mut *transaction)
|
||||
.try_filter_map(|e| async {
|
||||
Ok(e.right().map(|m| models::ids::UserId(m.follower_id)))
|
||||
})
|
||||
.try_collect::<Vec<models::ids::UserId>>()
|
||||
.await?;
|
||||
|
||||
let project_id: ProjectId = builder.project_id.into();
|
||||
let version_id: VersionId = builder.version_id.into();
|
||||
|
||||
NotificationBuilder {
|
||||
notification_type: Some("project_update".to_string()),
|
||||
title: format!("**{}** has been updated!", result.title),
|
||||
text: format!(
|
||||
"The project {} has released a new version: {}",
|
||||
result.title,
|
||||
version_data.version_number.clone()
|
||||
),
|
||||
link: format!(
|
||||
"/{}/{}/version/{}",
|
||||
result.project_type, project_id, version_id
|
||||
),
|
||||
actions: vec![],
|
||||
}
|
||||
.insert_many(users, &mut *transaction)
|
||||
.await?;
|
||||
|
||||
let response = Version {
|
||||
id: builder.version_id.into(),
|
||||
project_id: builder.project_id.into(),
|
||||
author_id: user.id,
|
||||
featured: builder.featured,
|
||||
name: builder.name.clone(),
|
||||
version_number: builder.version_number.clone(),
|
||||
changelog: builder.changelog.clone(),
|
||||
changelog_url: None,
|
||||
date_published: Utc::now(),
|
||||
downloads: 0,
|
||||
version_type: version_data.release_channel,
|
||||
status: builder.status,
|
||||
requested_status: builder.requested_status,
|
||||
files: builder
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| VersionFile {
|
||||
hashes: file
|
||||
.hashes
|
||||
.iter()
|
||||
.map(|hash| {
|
||||
(
|
||||
hash.algorithm.clone(),
|
||||
// This is a hack since the hashes are currently stored as ASCII
|
||||
// in the database, but represented here as a Vec<u8>. At some
|
||||
// point we need to change the hash to be the real bytes in the
|
||||
// database and add more processing here.
|
||||
String::from_utf8(hash.hash.clone()).unwrap(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
url: file.url.clone(),
|
||||
filename: file.filename.clone(),
|
||||
primary: file.primary,
|
||||
size: file.size,
|
||||
file_type: file.file_type,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
dependencies: version_data.dependencies,
|
||||
game_versions: version_data.game_versions,
|
||||
loaders: version_data.loaders,
|
||||
};
|
||||
|
||||
let project_id = builder.project_id;
|
||||
builder.insert(transaction).await?;
|
||||
|
||||
models::Project::update_game_versions(project_id, &mut *transaction)
|
||||
.await?;
|
||||
models::Project::update_loaders(project_id, &mut *transaction).await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
}
|
||||
|
||||
// under /api/v1/version/{version_id}
|
||||
#[post("{version_id}/file")]
|
||||
pub async fn upload_file_to_version(
|
||||
req: HttpRequest,
|
||||
url_data: web::Path<(VersionId,)>,
|
||||
mut payload: Multipart,
|
||||
client: Data<PgPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
) -> Result<HttpResponse, CreateError> {
|
||||
let mut transaction = client.begin().await?;
|
||||
let mut uploaded_files = Vec::new();
|
||||
|
||||
let version_id = models::VersionId::from(url_data.into_inner().0);
|
||||
|
||||
let result = upload_file_to_version_inner(
|
||||
req,
|
||||
&mut payload,
|
||||
client,
|
||||
&mut transaction,
|
||||
&***file_host,
|
||||
&mut uploaded_files,
|
||||
version_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let undo_result = super::project_creation::undo_uploads(
|
||||
&***file_host,
|
||||
&uploaded_files,
|
||||
)
|
||||
.await;
|
||||
let rollback_result = transaction.rollback().await;
|
||||
|
||||
undo_result?;
|
||||
if let Err(e) = rollback_result {
|
||||
return Err(e.into());
|
||||
}
|
||||
} else {
|
||||
transaction.commit().await?;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn upload_file_to_version_inner(
|
||||
req: HttpRequest,
|
||||
payload: &mut Multipart,
|
||||
client: Data<PgPool>,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
file_host: &dyn FileHost,
|
||||
uploaded_files: &mut Vec<UploadedFile>,
|
||||
version_id: models::VersionId,
|
||||
) -> Result<HttpResponse, CreateError> {
|
||||
let cdn_url = dotenvy::var("CDN_URL")?;
|
||||
|
||||
let mut initial_file_data: Option<InitialFileData> = None;
|
||||
let mut file_builders: Vec<VersionFileBuilder> = Vec::new();
|
||||
|
||||
let user = get_user_from_headers(req.headers(), &mut *transaction).await?;
|
||||
|
||||
let result = models::Version::get_full(version_id, &**client).await?;
|
||||
|
||||
let version = match result {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return Err(CreateError::InvalidInput(
|
||||
"An invalid version id was supplied".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if !user.role.is_admin() {
|
||||
let team_member = models::TeamMember::get_from_user_id_version(
|
||||
version_id,
|
||||
user.id.into(),
|
||||
&mut *transaction,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
CreateError::CustomAuthenticationError(
|
||||
"You don't have permission to upload files to this version!"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !team_member
|
||||
.permissions
|
||||
.contains(Permissions::UPLOAD_VERSION)
|
||||
{
|
||||
return Err(CreateError::CustomAuthenticationError(
|
||||
"You don't have permission to upload files to this version!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let project_id = ProjectId(version.inner.project_id.0 as u64);
|
||||
|
||||
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.inner.project_id as models::ProjectId,
|
||||
)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?
|
||||
.name;
|
||||
|
||||
let all_game_versions =
|
||||
models::categories::GameVersion::list(&mut *transaction).await?;
|
||||
|
||||
let mut error = None;
|
||||
while let Some(item) = payload.next().await {
|
||||
let mut field: Field = item?;
|
||||
|
||||
if error.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = async {
|
||||
let content_disposition = field.content_disposition().clone();
|
||||
let name = content_disposition.get_name().ok_or_else(|| {
|
||||
CreateError::MissingValueError(
|
||||
"Missing content name".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if name == "data" {
|
||||
let mut data = Vec::new();
|
||||
while let Some(chunk) = field.next().await {
|
||||
data.extend_from_slice(&chunk?);
|
||||
}
|
||||
let file_data: InitialFileData = serde_json::from_slice(&data)?;
|
||||
|
||||
initial_file_data = Some(file_data);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let file_data = initial_file_data.as_ref().ok_or_else(|| {
|
||||
CreateError::InvalidInput(String::from(
|
||||
"`data` field must come before file fields",
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut dependencies = version
|
||||
.dependencies
|
||||
.iter()
|
||||
.map(|x| DependencyBuilder {
|
||||
project_id: x.project_id,
|
||||
version_id: x.version_id,
|
||||
file_name: x.file_name.clone(),
|
||||
dependency_type: x.dependency_type.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
upload_file(
|
||||
&mut field,
|
||||
file_host,
|
||||
0,
|
||||
uploaded_files,
|
||||
&mut file_builders,
|
||||
&mut dependencies,
|
||||
&cdn_url,
|
||||
&content_disposition,
|
||||
project_id,
|
||||
version_id.into(),
|
||||
&project_type,
|
||||
version.loaders.clone().into_iter().map(Loader).collect(),
|
||||
version
|
||||
.game_versions
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(GameVersion)
|
||||
.collect(),
|
||||
all_game_versions.clone(),
|
||||
true,
|
||||
false,
|
||||
file_data.file_types.get(name).copied().flatten(),
|
||||
transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
error = result.err();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(error) = error {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
if file_builders.is_empty() {
|
||||
return Err(CreateError::InvalidInput(
|
||||
"At least one file must be specified".to_string(),
|
||||
));
|
||||
} else {
|
||||
for file_builder in file_builders {
|
||||
file_builder.insert(version_id, &mut *transaction).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
// 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 project
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn upload_file(
|
||||
field: &mut Field,
|
||||
file_host: &dyn FileHost,
|
||||
total_files_len: usize,
|
||||
uploaded_files: &mut Vec<UploadedFile>,
|
||||
version_files: &mut Vec<VersionFileBuilder>,
|
||||
dependencies: &mut Vec<DependencyBuilder>,
|
||||
cdn_url: &str,
|
||||
content_disposition: &actix_web::http::header::ContentDisposition,
|
||||
project_id: ProjectId,
|
||||
version_id: VersionId,
|
||||
project_type: &str,
|
||||
loaders: Vec<Loader>,
|
||||
game_versions: Vec<GameVersion>,
|
||||
all_game_versions: Vec<models::categories::GameVersion>,
|
||||
ignore_primary: bool,
|
||||
force_primary: bool,
|
||||
file_type: Option<FileType>,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), CreateError> {
|
||||
let (file_name, file_extension) = get_name_ext(content_disposition)?;
|
||||
|
||||
if file_name.contains('/') {
|
||||
return Err(CreateError::InvalidInput(
|
||||
"File names must not contain slashes!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let content_type = crate::util::ext::project_file_type(file_extension)
|
||||
.ok_or_else(|| {
|
||||
CreateError::InvalidFileType(file_extension.to_string())
|
||||
})?;
|
||||
|
||||
let data = read_from_field(
|
||||
field, 500 * (1 << 20),
|
||||
"Project file exceeds the maximum of 500MiB. Contact a moderator or admin to request permission to upload larger files."
|
||||
).await?;
|
||||
|
||||
let hash = sha1::Sha1::from(&data).hexdigest();
|
||||
let exists = sqlx::query!(
|
||||
"
|
||||
SELECT EXISTS(SELECT 1 FROM hashes h
|
||||
INNER JOIN files f ON f.id = h.file_id
|
||||
INNER JOIN versions v ON v.id = f.version_id
|
||||
WHERE h.algorithm = $2 AND h.hash = $1 AND v.mod_id != $3)
|
||||
",
|
||||
hash.as_bytes(),
|
||||
"sha1",
|
||||
project_id.0 as i64
|
||||
)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?
|
||||
.exists
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Err(CreateError::InvalidInput(
|
||||
"Duplicate files are not allowed to be uploaded to Modrinth!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let validation_result = validate_file(
|
||||
data.clone().into(),
|
||||
file_extension.to_string(),
|
||||
project_type.to_string(),
|
||||
loaders.clone(),
|
||||
game_versions.clone(),
|
||||
all_game_versions.clone(),
|
||||
file_type,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let ValidationResult::PassWithPackDataAndFiles {
|
||||
ref format,
|
||||
ref files,
|
||||
} = validation_result
|
||||
{
|
||||
if dependencies.is_empty() {
|
||||
let hashes: Vec<Vec<u8>> = format
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|x| x.hashes.get(&PackFileHash::Sha1))
|
||||
.map(|x| x.as_bytes().to_vec())
|
||||
.collect();
|
||||
|
||||
let res = sqlx::query!(
|
||||
"
|
||||
SELECT v.id version_id, v.mod_id project_id, h.hash hash FROM hashes h
|
||||
INNER JOIN files f on h.file_id = f.id
|
||||
INNER JOIN versions v on f.version_id = v.id
|
||||
WHERE h.algorithm = 'sha1' AND h.hash = ANY($1)
|
||||
",
|
||||
&*hashes
|
||||
)
|
||||
.fetch_all(&mut *transaction).await?;
|
||||
|
||||
for file in &format.files {
|
||||
if let Some(dep) = res.iter().find(|x| {
|
||||
Some(&*x.hash)
|
||||
== file
|
||||
.hashes
|
||||
.get(&PackFileHash::Sha1)
|
||||
.map(|x| x.as_bytes())
|
||||
}) {
|
||||
dependencies.push(DependencyBuilder {
|
||||
project_id: Some(models::ProjectId(dep.project_id)),
|
||||
version_id: Some(models::VersionId(dep.version_id)),
|
||||
file_name: None,
|
||||
dependency_type: DependencyType::Embedded.to_string(),
|
||||
});
|
||||
} else if let Some(first_download) = file.downloads.first() {
|
||||
dependencies.push(DependencyBuilder {
|
||||
project_id: None,
|
||||
version_id: None,
|
||||
file_name: Some(
|
||||
first_download
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(first_download)
|
||||
.to_string(),
|
||||
),
|
||||
dependency_type: DependencyType::Embedded.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for file in files {
|
||||
if !file.is_empty() {
|
||||
dependencies.push(DependencyBuilder {
|
||||
project_id: None,
|
||||
version_id: None,
|
||||
file_name: Some(file.to_string()),
|
||||
dependency_type: DependencyType::Embedded.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data = data.freeze();
|
||||
|
||||
let primary = (validation_result.is_passed()
|
||||
&& version_files.iter().all(|x| !x.primary)
|
||||
&& !ignore_primary)
|
||||
|| force_primary
|
||||
|| total_files_len == 1;
|
||||
|
||||
let file_path_encode = format!(
|
||||
"data/{}/versions/{}/{}",
|
||||
project_id,
|
||||
version_id,
|
||||
urlencoding::encode(file_name)
|
||||
);
|
||||
let file_path =
|
||||
format!("data/{}/versions/{}/{}", project_id, version_id, &file_name);
|
||||
|
||||
let upload_data = file_host
|
||||
.upload_file(content_type, &file_path, data)
|
||||
.await?;
|
||||
|
||||
uploaded_files.push(UploadedFile {
|
||||
file_id: upload_data.file_id,
|
||||
file_name: file_path,
|
||||
});
|
||||
|
||||
let sha1_bytes = upload_data.content_sha1.into_bytes();
|
||||
let sha512_bytes = upload_data.content_sha512.into_bytes();
|
||||
|
||||
if version_files.iter().any(|x| {
|
||||
x.hashes
|
||||
.iter()
|
||||
.any(|y| y.hash == sha1_bytes || y.hash == sha512_bytes)
|
||||
}) {
|
||||
return Err(CreateError::InvalidInput(
|
||||
"Duplicate files are not allowed to be uploaded to Modrinth!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
version_files.push(VersionFileBuilder {
|
||||
filename: file_name.to_string(),
|
||||
url: format!("{cdn_url}/{file_path_encode}"),
|
||||
hashes: vec![
|
||||
models::version_item::HashBuilder {
|
||||
algorithm: "sha1".to_string(),
|
||||
// This is an invalid cast - the database expects the hash's
|
||||
// bytes, but this is the string version.
|
||||
hash: sha1_bytes,
|
||||
},
|
||||
models::version_item::HashBuilder {
|
||||
algorithm: "sha512".to_string(),
|
||||
// This is an invalid cast - the database expects the hash's
|
||||
// bytes, but this is the string version.
|
||||
hash: sha512_bytes,
|
||||
},
|
||||
],
|
||||
primary,
|
||||
size: upload_data.content_length,
|
||||
file_type,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_name_ext(
|
||||
content_disposition: &actix_web::http::header::ContentDisposition,
|
||||
) -> Result<(&str, &str), CreateError> {
|
||||
let file_name = content_disposition.get_filename().ok_or_else(|| {
|
||||
CreateError::MissingValueError("Missing content file name".to_string())
|
||||
})?;
|
||||
let file_extension = if let Some(last_period) = file_name.rfind('.') {
|
||||
file_name.get((last_period + 1)..).unwrap_or("")
|
||||
} else {
|
||||
return Err(CreateError::MissingValueError(
|
||||
"Missing content file extension".to_string(),
|
||||
));
|
||||
};
|
||||
Ok((file_name, file_extension))
|
||||
}
|
||||
561
src/routes/v2/version_file.rs
Normal file
561
src/routes/v2/version_file.rs
Normal file
@@ -0,0 +1,561 @@
|
||||
use super::ApiError;
|
||||
use crate::database::models::{version_item::QueryVersion, DatabaseError};
|
||||
use crate::models::ids::VersionId;
|
||||
use crate::models::projects::{GameVersion, Loader, Version};
|
||||
use crate::models::teams::Permissions;
|
||||
use crate::util::auth::get_user_from_headers;
|
||||
use crate::util::routes::ok_or_not_found;
|
||||
use crate::{database, models};
|
||||
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
|
||||
use futures::TryStreamExt;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("version_file")
|
||||
.service(delete_file)
|
||||
.service(get_version_from_hash)
|
||||
.service(download_version)
|
||||
.service(get_update_from_hash),
|
||||
);
|
||||
|
||||
cfg.service(
|
||||
web::scope("version_files")
|
||||
.service(get_versions_from_hashes)
|
||||
.service(download_files)
|
||||
.service(update_files),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HashQuery {
|
||||
#[serde(default = "default_algorithm")]
|
||||
pub algorithm: String,
|
||||
#[serde(default = "default_multiple")]
|
||||
pub multiple: bool,
|
||||
pub version_id: Option<VersionId>,
|
||||
}
|
||||
|
||||
fn default_algorithm() -> String {
|
||||
"sha1".into()
|
||||
}
|
||||
|
||||
fn default_multiple() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// under /api/v1/version_file/{hash}
|
||||
#[get("{version_id}")]
|
||||
pub async fn get_version_from_hash(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
hash_query: web::Query<HashQuery>,
|
||||
) -> 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
|
||||
INNER JOIN versions v on f.version_id = v.id AND v.status != ANY($1)
|
||||
INNER JOIN mods m on v.mod_id = m.id
|
||||
WHERE h.algorithm = $3 AND h.hash = $2 AND m.status != ANY($4)
|
||||
ORDER BY v.date_published ASC
|
||||
",
|
||||
&*crate::models::projects::VersionStatus::iterator()
|
||||
.filter(|x| x.is_hidden())
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
hash.as_bytes(),
|
||||
hash_query.algorithm,
|
||||
&*crate::models::projects::ProjectStatus::iterator()
|
||||
.filter(|x| x.is_hidden())
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
)
|
||||
.fetch_all(&**pool)
|
||||
.await?;
|
||||
|
||||
let version_ids = result
|
||||
.iter()
|
||||
.map(|x| database::models::VersionId(x.version_id))
|
||||
.collect::<Vec<_>>();
|
||||
let versions_data =
|
||||
database::models::Version::get_many_full(&version_ids, &**pool).await?;
|
||||
|
||||
if let Some(first) = versions_data.first() {
|
||||
if hash_query.multiple {
|
||||
Ok(HttpResponse::Ok().json(
|
||||
versions_data
|
||||
.into_iter()
|
||||
.map(models::projects::Version::from)
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
} else {
|
||||
Ok(HttpResponse::Ok()
|
||||
.json(models::projects::Version::from(first.clone())))
|
||||
}
|
||||
} 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(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
hash_query: web::Query<HashQuery>,
|
||||
) -> 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 AND v.status != ANY($1)
|
||||
INNER JOIN mods m on v.mod_id = m.id
|
||||
WHERE h.algorithm = $3 AND h.hash = $2 AND m.status != ANY($4)
|
||||
ORDER BY v.date_published ASC
|
||||
",
|
||||
&*crate::models::projects::VersionStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::<Vec<String>>(),
|
||||
hash.as_bytes(),
|
||||
hash_query.algorithm,
|
||||
&*crate::models::projects::ProjectStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::<Vec<String>>(),
|
||||
)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if let Some(id) = result {
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::TemporaryRedirect()
|
||||
.append_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>,
|
||||
hash_query: web::Query<HashQuery>,
|
||||
) -> 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
|
||||
ORDER BY v.date_published ASC
|
||||
",
|
||||
hash.as_bytes(),
|
||||
hash_query.algorithm
|
||||
)
|
||||
.fetch_all(&**pool)
|
||||
.await?;
|
||||
|
||||
if let Some(row) = result.iter().find_or_first(|x| {
|
||||
hash_query.version_id.is_none()
|
||||
|| Some(x.version_id) == hash_query.version_id.map(|x| x.0 as i64)
|
||||
}) {
|
||||
if !user.role.is_admin() {
|
||||
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::Database)?
|
||||
.ok_or_else(|| {
|
||||
ApiError::CustomAuthentication(
|
||||
"You don't have permission to delete this file!"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !team_member
|
||||
.permissions
|
||||
.contains(Permissions::DELETE_VERSION)
|
||||
{
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You don't have permission to delete this file!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
use futures::stream::TryStreamExt;
|
||||
|
||||
let files = sqlx::query!(
|
||||
"
|
||||
SELECT f.id id FROM files f
|
||||
WHERE f.version_id = $1
|
||||
",
|
||||
row.version_id
|
||||
)
|
||||
.fetch_many(&**pool)
|
||||
.try_filter_map(|e| async { Ok(e.right().map(|_| ())) })
|
||||
.try_collect::<Vec<()>>()
|
||||
.await?;
|
||||
|
||||
if files.len() < 2 {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Versions must have at least one file uploaded to them"
|
||||
.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?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateData {
|
||||
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>,
|
||||
hash_query: web::Query<HashQuery>,
|
||||
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 AND v.status != ANY($1)
|
||||
INNER JOIN mods m on v.mod_id = m.id
|
||||
WHERE h.algorithm = $3 AND h.hash = $2 AND m.status != ANY($4)
|
||||
ORDER BY v.date_published ASC
|
||||
",
|
||||
&*crate::models::projects::VersionStatus::iterator()
|
||||
.filter(|x| x.is_hidden())
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
hash.as_bytes(),
|
||||
hash_query.algorithm,
|
||||
&*crate::models::projects::ProjectStatus::iterator()
|
||||
.filter(|x| x.is_hidden())
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
)
|
||||
.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(),
|
||||
),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(version_id) = version_ids.first() {
|
||||
let version_data =
|
||||
database::models::Version::get_full(*version_id, &**pool)
|
||||
.await?;
|
||||
|
||||
ok_or_not_found::<QueryVersion, Version>(version_data)
|
||||
} 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.to_lowercase().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
|
||||
INNER JOIN versions v ON v.id = f.version_id AND v.status != ANY($1)
|
||||
INNER JOIN mods m on v.mod_id = m.id
|
||||
WHERE h.algorithm = $3 AND h.hash = ANY($2::bytea[]) AND m.status != ANY($4)
|
||||
",
|
||||
&*crate::models::projects::VersionStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::<Vec<String>>(),
|
||||
hashes_parsed.as_slice(),
|
||||
file_data.algorithm,
|
||||
&*crate::models::projects::ProjectStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::<Vec<String>>(),
|
||||
)
|
||||
.fetch_all(&**pool)
|
||||
.await?;
|
||||
|
||||
let version_ids = result
|
||||
.iter()
|
||||
.map(|x| database::models::VersionId(x.version_id))
|
||||
.collect::<Vec<_>>();
|
||||
let versions_data =
|
||||
database::models::Version::get_many_full(&version_ids, &**pool).await?;
|
||||
|
||||
let response: Result<HashMap<String, Version>, ApiError> = result
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
versions_data
|
||||
.clone()
|
||||
.into_iter()
|
||||
.find(|x| x.inner.id.0 == row.version_id)
|
||||
.map(|v| {
|
||||
if let Ok(parsed_hash) = String::from_utf8(row.hash) {
|
||||
Ok((
|
||||
parsed_hash,
|
||||
crate::models::projects::Version::from(v),
|
||||
))
|
||||
} else {
|
||||
Err(ApiError::Database(DatabaseError::Other(format!(
|
||||
"Could not parse hash for version {}",
|
||||
row.version_id
|
||||
))))
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(HttpResponse::Ok().json(response?))
|
||||
}
|
||||
|
||||
#[post("download")]
|
||||
pub async fn download_files(
|
||||
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.to_lowercase().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 AND v.status != ANY($1)
|
||||
INNER JOIN mods m on v.mod_id = m.id
|
||||
WHERE h.algorithm = $3 AND h.hash = ANY($2::bytea[]) AND m.status != ANY($4)
|
||||
",
|
||||
&*crate::models::projects::VersionStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::<Vec<String>>(),
|
||||
hashes_parsed.as_slice(),
|
||||
file_data.algorithm,
|
||||
&*crate::models::projects::ProjectStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::<Vec<String>>(),
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let response = result
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
if let Ok(parsed_hash) = String::from_utf8(row.hash) {
|
||||
Ok((parsed_hash, row.url))
|
||||
} else {
|
||||
Err(ApiError::Database(DatabaseError::Other(format!(
|
||||
"Could not parse hash for version {}",
|
||||
row.version_id
|
||||
))))
|
||||
}
|
||||
})
|
||||
.collect::<Result<HashMap<String, String>, ApiError>>();
|
||||
|
||||
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.to_lowercase().as_bytes().to_vec())
|
||||
.collect();
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
SELECT h.hash, v.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 AND v.status != ANY($1)
|
||||
INNER JOIN mods m on v.mod_id = m.id
|
||||
WHERE h.algorithm = $3 AND h.hash = ANY($2::bytea[]) AND m.status != ANY($4)
|
||||
",
|
||||
&*crate::models::projects::VersionStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::<Vec<String>>(),
|
||||
hashes_parsed.as_slice(),
|
||||
update_data.algorithm,
|
||||
&*crate::models::projects::ProjectStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::<Vec<String>>(),
|
||||
)
|
||||
.fetch_many(&mut *transaction)
|
||||
.try_filter_map(|e| async {
|
||||
Ok(e.right().map(|m| (m.hash, database::models::ids::ProjectId(m.mod_id))))
|
||||
})
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
|
||||
let mut version_ids: HashMap<database::models::VersionId, Vec<u8>> =
|
||||
HashMap::new();
|
||||
|
||||
let updated_versions = database::models::Version::get_projects_versions(
|
||||
result
|
||||
.iter()
|
||||
.map(|x| x.1)
|
||||
.collect::<Vec<database::models::ProjectId>>()
|
||||
.clone(),
|
||||
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(),
|
||||
),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for (hash, id) in result {
|
||||
if let Some(latest_version) =
|
||||
updated_versions.get(&id).and_then(|x| x.last())
|
||||
{
|
||||
version_ids.insert(*latest_version, hash);
|
||||
}
|
||||
}
|
||||
|
||||
let query_version_ids = version_ids.keys().copied().collect::<Vec<_>>();
|
||||
let versions =
|
||||
database::models::Version::get_many_full(&query_version_ids, &**pool)
|
||||
.await?;
|
||||
|
||||
let mut response = HashMap::new();
|
||||
|
||||
for version in versions {
|
||||
let hash = version_ids.get(&version.inner.id);
|
||||
|
||||
if let Some(hash) = hash {
|
||||
if let Ok(parsed_hash) = String::from_utf8(hash.clone()) {
|
||||
response.insert(
|
||||
parsed_hash,
|
||||
models::projects::Version::from(version),
|
||||
);
|
||||
} else {
|
||||
let version_id: VersionId = version.inner.id.into();
|
||||
|
||||
return Err(ApiError::Database(DatabaseError::Other(format!(
|
||||
"Could not parse hash for version {version_id}"
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
}
|
||||
774
src/routes/v2/versions.rs
Normal file
774
src/routes/v2/versions.rs
Normal file
@@ -0,0 +1,774 @@
|
||||
use super::ApiError;
|
||||
use crate::database;
|
||||
use crate::models;
|
||||
use crate::models::projects::{
|
||||
Dependency, FileType, VersionStatus, VersionType,
|
||||
};
|
||||
use crate::models::teams::Permissions;
|
||||
use crate::util::auth::{
|
||||
filter_authorized_versions, get_user_from_headers, is_authorized,
|
||||
is_authorized_version,
|
||||
};
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(versions_get);
|
||||
|
||||
cfg.service(
|
||||
web::scope("version")
|
||||
.service(version_get)
|
||||
.service(version_delete)
|
||||
.service(version_edit)
|
||||
.service(version_schedule),
|
||||
);
|
||||
|
||||
cfg.service(
|
||||
web::scope("project/{project_id}")
|
||||
.service(version_list)
|
||||
.service(version_project_get),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct VersionListFilters {
|
||||
pub game_versions: Option<String>,
|
||||
pub loaders: Option<String>,
|
||||
pub featured: Option<bool>,
|
||||
pub version_type: Option<VersionType>,
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[get("version")]
|
||||
pub async fn version_list(
|
||||
req: HttpRequest,
|
||||
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?;
|
||||
|
||||
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
|
||||
|
||||
if let Some(project) = result {
|
||||
if !is_authorized(&project, &user_option, &pool).await? {
|
||||
return Ok(HttpResponse::NotFound().body(""));
|
||||
}
|
||||
|
||||
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()),
|
||||
filters.version_type,
|
||||
filters.limit,
|
||||
filters.offset,
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut versions =
|
||||
database::models::Version::get_many_full(&version_ids, &**pool)
|
||||
.await?;
|
||||
|
||||
let mut response = versions
|
||||
.iter()
|
||||
.filter(|version| {
|
||||
filters
|
||||
.featured
|
||||
.map(|featured| featured == version.inner.featured)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
versions.sort_by(|a, b| {
|
||||
b.inner.date_published.cmp(&a.inner.date_published)
|
||||
});
|
||||
|
||||
// Attempt to populate versions with "auto featured" versions
|
||||
if response.is_empty()
|
||||
&& !versions.is_empty()
|
||||
&& filters.featured.unwrap_or(false)
|
||||
{
|
||||
let (loaders, game_versions) = futures::future::try_join(
|
||||
database::models::categories::Loader::list(&**pool),
|
||||
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(version.clone()))
|
||||
.unwrap_or(());
|
||||
});
|
||||
|
||||
if response.is_empty() {
|
||||
versions
|
||||
.into_iter()
|
||||
.for_each(|version| response.push(version));
|
||||
}
|
||||
}
|
||||
|
||||
response.sort_by(|a, b| {
|
||||
b.inner.date_published.cmp(&a.inner.date_published)
|
||||
});
|
||||
response.dedup_by(|a, b| a.inner.id == b.inner.id);
|
||||
|
||||
let response =
|
||||
filter_authorized_versions(response, &user_option, &pool).await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
// Given a project ID/slug and a version slug
|
||||
#[get("version/{slug}")]
|
||||
pub async fn version_project_get(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String, String)>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let id = info.into_inner();
|
||||
let version_data =
|
||||
database::models::Version::get_full_from_id_slug(&id.0, &id.1, &**pool)
|
||||
.await?;
|
||||
|
||||
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
|
||||
|
||||
if let Some(data) = version_data {
|
||||
if is_authorized_version(&data.inner, &user_option, &pool).await? {
|
||||
return Ok(
|
||||
HttpResponse::Ok().json(models::projects::Version::from(data))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct VersionIds {
|
||||
pub ids: String,
|
||||
}
|
||||
|
||||
#[get("versions")]
|
||||
pub async fn versions_get(
|
||||
req: HttpRequest,
|
||||
web::Query(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::<Vec<database::models::VersionId>>();
|
||||
let versions_data =
|
||||
database::models::Version::get_many_full(&version_ids, &**pool).await?;
|
||||
|
||||
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
|
||||
|
||||
let versions =
|
||||
filter_authorized_versions(versions_data, &user_option, &pool).await?;
|
||||
|
||||
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> {
|
||||
let id = info.into_inner().0;
|
||||
let version_data =
|
||||
database::models::Version::get_full(id.into(), &**pool).await?;
|
||||
|
||||
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
|
||||
|
||||
if let Some(data) = version_data {
|
||||
if is_authorized_version(&data.inner, &user_option, &pool).await? {
|
||||
return Ok(
|
||||
HttpResponse::Ok().json(models::projects::Version::from(data))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Validate)]
|
||||
pub struct EditVersion {
|
||||
#[validate(
|
||||
length(min = 1, max = 64),
|
||||
custom(function = "crate::util::validate::validate_name")
|
||||
)]
|
||||
pub name: Option<String>,
|
||||
#[validate(
|
||||
length(min = 1, max = 32),
|
||||
regex = "crate::util::validate::RE_URL_SAFE"
|
||||
)]
|
||||
pub version_number: Option<String>,
|
||||
#[validate(length(max = 65536))]
|
||||
pub changelog: Option<String>,
|
||||
pub version_type: Option<models::projects::VersionType>,
|
||||
#[validate(
|
||||
length(min = 0, max = 4096),
|
||||
custom(function = "crate::util::validate::validate_deps")
|
||||
)]
|
||||
pub dependencies: Option<Vec<Dependency>>,
|
||||
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)>,
|
||||
pub downloads: Option<u32>,
|
||||
pub status: Option<VersionStatus>,
|
||||
pub file_types: Option<Vec<EditVersionFileType>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct EditVersionFileType {
|
||||
pub algorithm: String,
|
||||
pub hash: String,
|
||||
pub file_type: Option<FileType>,
|
||||
}
|
||||
|
||||
#[patch("{id}")]
|
||||
pub async fn version_edit(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(models::ids::VersionId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
new_version: web::Json<EditVersion>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
new_version.validate().map_err(|err| {
|
||||
ApiError::Validation(validation_errors_to_string(err, None))
|
||||
})?;
|
||||
|
||||
let version_id = info.into_inner().0;
|
||||
let id = version_id.into();
|
||||
|
||||
let result = database::models::Version::get_full(id, &**pool).await?;
|
||||
|
||||
if let Some(version_item) = result {
|
||||
let project_item = database::models::Project::get_full(
|
||||
version_item.inner.project_id,
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let team_member =
|
||||
database::models::TeamMember::get_from_user_id_version(
|
||||
version_item.inner.id,
|
||||
user.id.into(),
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
let permissions;
|
||||
|
||||
if user.role.is_admin() {
|
||||
permissions = Some(Permissions::ALL)
|
||||
} else if let Some(member) = team_member {
|
||||
permissions = Some(member.permissions)
|
||||
} else if user.role.is_mod() {
|
||||
permissions =
|
||||
Some(Permissions::EDIT_DETAILS | Permissions::EDIT_BODY)
|
||||
} else {
|
||||
permissions = None
|
||||
}
|
||||
|
||||
if let Some(perms) = permissions {
|
||||
if !perms.contains(Permissions::UPLOAD_VERSION) {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have the permissions to edit this version!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
if let Some(name) = &new_version.name {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE versions
|
||||
SET name = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
name.trim(),
|
||||
id as database::models::ids::VersionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(number) = &new_version.version_number {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE versions
|
||||
SET version_number = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
number,
|
||||
id as database::models::ids::VersionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(version_type) = &new_version.version_type {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE versions
|
||||
SET version_type = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
version_type.as_str(),
|
||||
id as database::models::ids::VersionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(dependencies) = &new_version.dependencies {
|
||||
if let Some(project) = project_item {
|
||||
if project.project_type != "modpack" {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM dependencies WHERE dependent_id = $1
|
||||
",
|
||||
id as database::models::ids::VersionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let builders = dependencies
|
||||
.iter()
|
||||
.map(|x| database::models::version_item::DependencyBuilder {
|
||||
project_id: x.project_id.map(|x| x.into()),
|
||||
version_id: x.version_id.map(|x| x.into()),
|
||||
file_name: x.file_name.clone(),
|
||||
dependency_type: x.dependency_type.to_string(),
|
||||
})
|
||||
.collect::<Vec<database::models::version_item::DependencyBuilder>>();
|
||||
|
||||
for dependency in builders {
|
||||
dependency
|
||||
.insert(version_item.inner.id, &mut transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(game_versions) = &new_version.game_versions {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM game_versions_versions WHERE joining_version_id = $1
|
||||
",
|
||||
id as database::models::ids::VersionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
for game_version in game_versions {
|
||||
let game_version_id =
|
||||
database::models::categories::GameVersion::get_id(
|
||||
&game_version.0,
|
||||
&mut *transaction,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"No database entry for game version provided."
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO game_versions_versions (game_version_id, joining_version_id)
|
||||
VALUES ($1, $2)
|
||||
",
|
||||
game_version_id as database::models::ids::GameVersionId,
|
||||
id as database::models::ids::VersionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
database::models::Project::update_game_versions(
|
||||
version_item.inner.project_id,
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
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?;
|
||||
|
||||
for loader in loaders {
|
||||
let loader_id =
|
||||
database::models::categories::Loader::get_id(
|
||||
&loader.0,
|
||||
&mut *transaction,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"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?;
|
||||
}
|
||||
|
||||
database::models::Project::update_loaders(
|
||||
version_item.inner.project_id,
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
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?;
|
||||
}
|
||||
|
||||
if let Some(primary_file) = &new_version.primary_file {
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
SELECT f.id id FROM hashes h
|
||||
INNER JOIN files f ON h.file_id = f.id
|
||||
WHERE h.algorithm = $2 AND h.hash = $1
|
||||
",
|
||||
primary_file.1.as_bytes(),
|
||||
primary_file.0
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(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?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE files
|
||||
SET is_primary = TRUE
|
||||
WHERE (id = $1)
|
||||
",
|
||||
result.id,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(body) = &new_version.changelog {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE versions
|
||||
SET changelog = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
body,
|
||||
id as database::models::ids::VersionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(downloads) = &new_version.downloads {
|
||||
if !user.role.is_mod() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You don't have permission to set the downloads of this mod"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE versions
|
||||
SET downloads = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
*downloads as i32,
|
||||
id as database::models::ids::VersionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let diff = *downloads - (version_item.inner.downloads as u32);
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE mods
|
||||
SET downloads = downloads + $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
diff as i32,
|
||||
version_item.inner.project_id
|
||||
as database::models::ids::ProjectId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(status) = &new_version.status {
|
||||
if !status.can_be_requested() {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"The requested status cannot be set!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE versions
|
||||
SET status = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
status.as_str(),
|
||||
id as database::models::ids::VersionId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(file_types) = &new_version.file_types {
|
||||
for file_type in file_types {
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
SELECT f.id id FROM hashes h
|
||||
INNER JOIN files f ON h.file_id = f.id
|
||||
WHERE h.algorithm = $2 AND h.hash = $1
|
||||
",
|
||||
file_type.hash.as_bytes(),
|
||||
file_type.algorithm
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(format!(
|
||||
"Specified file with hash {} does not exist.",
|
||||
file_type.algorithm.clone()
|
||||
))
|
||||
})?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE files
|
||||
SET file_type = $2
|
||||
WHERE (id = $1)
|
||||
",
|
||||
result.id,
|
||||
file_type.file_type.as_ref().map(|x| x.as_str()),
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to edit this version!".to_string(),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SchedulingData {
|
||||
pub time: DateTime<Utc>,
|
||||
pub requested_status: VersionStatus,
|
||||
}
|
||||
|
||||
#[post("{id}/schedule")]
|
||||
pub async fn version_schedule(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(models::ids::VersionId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
scheduling_data: web::Json<SchedulingData>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
if scheduling_data.time < Utc::now() {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You cannot schedule a version to be released in the past!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !scheduling_data.requested_status.can_be_requested() {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Specified requested status cannot be requested!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let string = info.into_inner().0;
|
||||
let result =
|
||||
database::models::Version::get_full(string.into(), &**pool).await?;
|
||||
|
||||
if let Some(version_item) = result {
|
||||
let team_member =
|
||||
database::models::TeamMember::get_from_user_id_version(
|
||||
version_item.inner.id,
|
||||
user.id.into(),
|
||||
&**pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if user.role.is_mod()
|
||||
|| team_member
|
||||
.map(|x| x.permissions.contains(Permissions::EDIT_DETAILS))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to edit this version's scheduling data!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE versions
|
||||
SET status = $1, date_published = $2
|
||||
WHERE (id = $3)
|
||||
",
|
||||
VersionStatus::Scheduled.as_str(),
|
||||
scheduling_data.time,
|
||||
version_item.inner.id as database::models::ids::VersionId,
|
||||
)
|
||||
.execute(&**pool)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("{version_id}")]
|
||||
pub async fn version_delete(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(models::ids::VersionId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
let id = info.into_inner().0;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
let team_member = database::models::TeamMember::get_from_user_id_version(
|
||||
id.into(),
|
||||
user.id.into(),
|
||||
&**pool,
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::Database)?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"You do not have permission to delete versions in this team".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !team_member
|
||||
.permissions
|
||||
.contains(Permissions::DELETE_VERSION)
|
||||
{
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to delete versions in this team"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let result =
|
||||
database::models::Version::remove_full(id.into(), &mut transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
if result.is_some() {
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user