Search test + v3 (#731)

* search patch for accurate loader/gv filtering

* backup

* basic search test

* finished test

* incomplete commit; backing up

* Working multipat reroute backup

* working rough draft v3

* most tests passing

* works

* search v2 conversion

* added some tags.rs v2 conversions

* Worked through warnings, unwraps, prints

* refactors

* new search test

* version files changes fixes

* redesign to revs

* removed old caches

* removed games

* fmt clippy

* merge conflicts

* fmt, prepare

* moved v2 routes over to v3

* fixes; tests passing

* project type changes

* moved files over

* fmt, clippy, prepare, etc

* loaders to loader_fields, added tests

* fmt, clippy, prepare

* fixed sorting bug

* reversed back- wrong order for consistency

* fmt; clippy; prepare

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
This commit is contained in:
Wyatt Verchere
2023-11-11 16:40:10 -08:00
committed by GitHub
parent 97ccb7df94
commit ae1c5342f2
133 changed files with 18153 additions and 11320 deletions

View File

@@ -1,4 +1,5 @@
use crate::database::models::categories::Loader;
use crate::database::models::legacy_loader_fields::MinecraftGameVersion;
use crate::database::models::loader_fields::Loader;
use crate::database::models::project_item::QueryProject;
use crate::database::models::version_item::{QueryFile, QueryVersion};
use crate::database::redis::RedisPool;
@@ -22,6 +23,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(version_file);
}
// TODO: These were modified in v3 and should be tested
#[derive(Default, Debug, Clone, YaSerialize)]
#[yaserde(root = "metadata", rename = "metadata")]
pub struct Metadata {
@@ -198,8 +201,19 @@ async fn find_version(
if !loaders.is_empty() {
bool &= x.loaders.iter().any(|y| loaders.contains(y));
}
// For maven in particular, we will hardcode it to use GameVersions rather than generic loader fields, as this is minecraft-java exclusive
if !game_versions.is_empty() {
bool &= x.game_versions.iter().any(|y| game_versions.contains(y));
let version_game_versions = x
.version_fields
.clone()
.into_iter()
.find_map(|v| MinecraftGameVersion::try_from_version_field(&v).ok());
if let Some(version_game_versions) = version_game_versions {
bool &= version_game_versions
.iter()
.any(|y| game_versions.contains(&y.version));
}
}
bool
@@ -216,7 +230,6 @@ async fn find_version(
fn find_file<'a>(
project_id: &str,
vcoords: &str,
project: &QueryProject,
version: &'a QueryVersion,
file: &str,
) -> Option<&'a QueryFile> {
@@ -224,21 +237,27 @@ fn find_file<'a>(
return Some(selected_file);
}
let fileext = match project.project_type.as_str() {
"mod" => "jar",
"modpack" => "mrpack",
_ => return None,
};
if file == format!("{}-{}.{}", &project_id, &vcoords, fileext) {
version
.files
.iter()
.find(|x| x.primary)
.or_else(|| version.files.iter().last())
} else {
None
// Minecraft mods are not going to be both a mod and a modpack, so this minecraft-specific handling is fine
// As there can be multiple project types, returns the first allowable match
let mut fileexts = vec![];
for project_type in version.project_types.iter() {
match project_type.as_str() {
"mod" => fileexts.push("jar"),
"modpack" => fileexts.push("mrpack"),
_ => (),
}
}
for fileext in fileexts {
if file == format!("{}-{}.{}", &project_id, &vcoords, fileext) {
return version
.files
.iter()
.find(|x| x.primary)
.or_else(|| version.files.iter().last());
}
}
None
}
#[route(
@@ -297,7 +316,7 @@ pub async fn version_file(
return Ok(HttpResponse::Ok()
.content_type("text/xml")
.body(yaserde::ser::to_string(&respdata).map_err(ApiError::Xml)?));
} else if let Some(selected_file) = find_file(&project_id, &vnum, &project, &version, &file) {
} else if let Some(selected_file) = find_file(&project_id, &vnum, &version, &file) {
return Ok(HttpResponse::TemporaryRedirect()
.append_header(("location", &*selected_file.url))
.body(""));
@@ -342,7 +361,7 @@ pub async fn version_file_sha1(
return Ok(HttpResponse::NotFound().body(""));
}
Ok(find_file(&project_id, &vnum, &project, &version, &file)
Ok(find_file(&project_id, &vnum, &version, &file)
.and_then(|file| file.hashes.get("sha1"))
.map(|hash_str| HttpResponse::Ok().body(hash_str.clone()))
.unwrap_or_else(|| HttpResponse::NotFound().body("")))
@@ -384,7 +403,7 @@ pub async fn version_file_sha512(
return Ok(HttpResponse::NotFound().body(""));
}
Ok(find_file(&project_id, &vnum, &project, &version, &file)
Ok(find_file(&project_id, &vnum, &version, &file)
.and_then(|file| file.hashes.get("sha512"))
.map(|hash_str| HttpResponse::Ok().body(hash_str.clone()))
.unwrap_or_else(|| HttpResponse::NotFound().body("")))

View File

@@ -11,6 +11,8 @@ use futures::FutureExt;
pub mod v2;
pub mod v3;
pub mod v2_reroute;
mod analytics;
mod index;
mod maven;
@@ -118,6 +120,8 @@ pub enum ApiError {
PasswordStrengthCheck(#[from] zxcvbn::ZxcvbnError),
#[error("{0}")]
Mail(#[from] crate::auth::email::MailError),
#[error("Error while rerouting request: {0}")]
Reroute(#[from] reqwest::Error),
}
impl actix_web::ResponseError for ApiError {
@@ -144,6 +148,7 @@ impl actix_web::ResponseError for ApiError {
ApiError::PasswordHashing(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::PasswordStrengthCheck(..) => StatusCode::BAD_REQUEST,
ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
@@ -171,6 +176,7 @@ impl actix_web::ResponseError for ApiError {
ApiError::PasswordStrengthCheck(..) => "strength_check_error",
ApiError::Mail(..) => "mail_error",
ApiError::Clickhouse(..) => "clickhouse_error",
ApiError::Reroute(..) => "reroute_error",
},
description: &self.to_string(),
})

View File

@@ -6,6 +6,7 @@ use sqlx::PgPool;
use crate::auth::{filter_authorized_versions, get_user_from_headers, is_authorized};
use crate::database;
use crate::database::models::legacy_loader_fields::MinecraftGameVersion;
use crate::database::redis::RedisPool;
use crate::models::pats::Scopes;
use crate::models::projects::VersionType;
@@ -95,19 +96,29 @@ pub async fn forge_updates(
};
for version in versions {
// For forge in particular, we will hardcode it to use GameVersions rather than generic loader fields, as this is minecraft-java exclusive
// Will have duplicates between game_versions (for non-forge loaders), but that's okay as
// before v3 this was stored to the project and not the version
let game_versions: Vec<String> = version
.fields
.iter()
.find(|(key, _)| key.as_str() == MinecraftGameVersion::FIELD_NAME)
.and_then(|(_, value)| serde_json::from_value::<Vec<String>>(value.clone()).ok())
.unwrap_or_default();
if version.version_type == VersionType::Release {
for game_version in &version.game_versions {
for game_version in &game_versions {
response
.promos
.entry(format!("{}-recommended", game_version.0))
.entry(format!("{}-recommended", game_version))
.or_insert_with(|| version.version_number.clone());
}
}
for game_version in &version.game_versions {
for game_version in &game_versions {
response
.promos
.entry(format!("{}-latest", game_version.0))
.entry(format!("{}-latest", game_version))
.or_insert_with(|| version.version_number.clone());
}
}

View File

@@ -9,6 +9,7 @@ use crate::queue::analytics::AnalyticsQueue;
use crate::queue::maxmind::MaxMindIndexer;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::search::SearchConfig;
use crate::util::date::get_current_tenths_of_ms;
use crate::util::guards::admin_key_guard;
use crate::util::routes::read_from_payload;
@@ -27,7 +28,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("admin")
.service(count_download)
.service(trolley_webhook),
.service(trolley_webhook)
.service(force_reindex),
);
}
@@ -308,3 +310,13 @@ pub async fn trolley_webhook(
Ok(HttpResponse::NoContent().finish())
}
#[post("/_force_reindex", guard = "admin_key_guard")]
pub async fn force_reindex(
pool: web::Data<PgPool>,
config: web::Data<SearchConfig>,
) -> Result<HttpResponse, ApiError> {
use crate::search::indexing::index_projects;
index_projects(pool.as_ref().clone(), &config).await?;
Ok(HttpResponse::NoContent().finish())
}

View File

@@ -1,24 +1,12 @@
use super::ApiError;
use crate::database::redis::RedisPool;
use crate::{
auth::{filter_authorized_projects, filter_authorized_versions, get_user_from_headers},
database::models::{project_item, user_item, version_item},
models::{
ids::{
base62_impl::{parse_base62, to_base62},
ProjectId, VersionId,
},
pats::Scopes,
},
queue::session::AuthQueue,
};
use crate::routes::v3;
use crate::{models::ids::VersionId, queue::session::AuthQueue};
use actix_web::{get, web, HttpRequest, HttpResponse};
use chrono::{DateTime, Duration, Utc};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::postgres::types::PgInterval;
use sqlx::PgPool;
use std::collections::HashMap;
use std::convert::TryInto;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
@@ -76,66 +64,22 @@ pub async fn playtimes_get(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
let data = data.into_inner();
v3::analytics_get::playtimes_get(
req,
clickhouse,
web::Query(v3::analytics_get::GetData {
project_ids: data.project_ids,
version_ids: data.version_ids,
start_date: data.start_date,
end_date: data.end_date,
resolution_minutes: data.resolution_minutes,
}),
session_queue,
pool,
redis,
)
.await
.map(|x| x.1)?;
let project_ids = data
.project_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
let version_ids = data
.version_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
if project_ids.is_some() && version_ids.is_some() {
return Err(ApiError::InvalidInput(
"Only one of 'project_ids' or 'version_ids' should be used.".to_string(),
));
}
let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2));
let end_date = data.end_date.unwrap_or(Utc::now());
let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24);
// Convert String list to list of ProjectIds or VersionIds
// - Filter out unauthorized projects/versions
// - If no project_ids or version_ids are provided, we default to all projects the user has access to
let (project_ids, version_ids) =
filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?;
// Get the views
let playtimes = crate::clickhouse::fetch_playtimes(
project_ids,
version_ids,
start_date,
end_date,
resolution_minutes,
clickhouse.into_inner(),
)
.await?;
let mut hm = HashMap::new();
for playtime in playtimes {
let id_string = to_base62(playtime.id);
if !hm.contains_key(&id_string) {
hm.insert(id_string.clone(), HashMap::new());
}
if let Some(hm) = hm.get_mut(&id_string) {
hm.insert(playtime.time, playtime.total_seconds);
}
}
Ok(HttpResponse::Ok().json(hm))
}
/// Get view data for a set of projects or versions
@@ -156,66 +100,22 @@ pub async fn views_get(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
let data = data.into_inner();
v3::analytics_get::views_get(
req,
clickhouse,
web::Query(v3::analytics_get::GetData {
project_ids: data.project_ids,
version_ids: data.version_ids,
start_date: data.start_date,
end_date: data.end_date,
resolution_minutes: data.resolution_minutes,
}),
session_queue,
pool,
redis,
)
.await
.map(|x| x.1)?;
let project_ids = data
.project_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
let version_ids = data
.version_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
if project_ids.is_some() && version_ids.is_some() {
return Err(ApiError::InvalidInput(
"Only one of 'project_ids' or 'version_ids' should be used.".to_string(),
));
}
let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2));
let end_date = data.end_date.unwrap_or(Utc::now());
let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24);
// Convert String list to list of ProjectIds or VersionIds
// - Filter out unauthorized projects/versions
// - If no project_ids or version_ids are provided, we default to all projects the user has access to
let (project_ids, version_ids) =
filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?;
// Get the views
let views = crate::clickhouse::fetch_views(
project_ids,
version_ids,
start_date,
end_date,
resolution_minutes,
clickhouse.into_inner(),
)
.await?;
let mut hm = HashMap::new();
for views in views {
let id_string = to_base62(views.id);
if !hm.contains_key(&id_string) {
hm.insert(id_string.clone(), HashMap::new());
}
if let Some(hm) = hm.get_mut(&id_string) {
hm.insert(views.time, views.total_views);
}
}
Ok(HttpResponse::Ok().json(hm))
}
/// Get download data for a set of projects or versions
@@ -236,66 +136,22 @@ pub async fn downloads_get(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
let data = data.into_inner();
v3::analytics_get::downloads_get(
req,
clickhouse,
web::Query(v3::analytics_get::GetData {
project_ids: data.project_ids,
version_ids: data.version_ids,
start_date: data.start_date,
end_date: data.end_date,
resolution_minutes: data.resolution_minutes,
}),
session_queue,
pool,
redis,
)
.await
.map(|x| x.1)?;
let project_ids = data
.project_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
let version_ids = data
.version_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
if project_ids.is_some() && version_ids.is_some() {
return Err(ApiError::InvalidInput(
"Only one of 'project_ids' or 'version_ids' should be used.".to_string(),
));
}
let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2));
let end_date = data.end_date.unwrap_or(Utc::now());
let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24);
// Convert String list to list of ProjectIds or VersionIds
// - Filter out unauthorized projects/versions
// - If no project_ids or version_ids are provided, we default to all projects the user has access to
let (project_ids, version_ids) =
filter_allowed_ids(project_ids, version_ids, user_option, &pool, &redis).await?;
// Get the downloads
let downloads = crate::clickhouse::fetch_downloads(
project_ids,
version_ids,
start_date,
end_date,
resolution_minutes,
clickhouse.into_inner(),
)
.await?;
let mut hm = HashMap::new();
for downloads in downloads {
let id_string = to_base62(downloads.id);
if !hm.contains_key(&id_string) {
hm.insert(id_string.clone(), HashMap::new());
}
if let Some(hm) = hm.get_mut(&id_string) {
hm.insert(downloads.time, downloads.total_downloads);
}
}
Ok(HttpResponse::Ok().json(hm))
}
/// Get payout data for a set of projects
@@ -315,77 +171,21 @@ pub async fn revenue_get(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAYOUTS_READ]),
let data = data.into_inner();
v3::analytics_get::revenue_get(
req,
web::Query(v3::analytics_get::GetData {
project_ids: data.project_ids,
version_ids: None,
start_date: data.start_date,
end_date: data.end_date,
resolution_minutes: data.resolution_minutes,
}),
session_queue,
pool,
redis,
)
.await
.map(|x| x.1)?;
let project_ids = data
.project_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2));
let end_date = data.end_date.unwrap_or(Utc::now());
let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24);
// Round up/down to nearest duration as we are using pgadmin, does not have rounding in the fetch command
// Round start_date down to nearest resolution
let diff = start_date.timestamp() % (resolution_minutes as i64 * 60);
let start_date = start_date - Duration::seconds(diff);
// Round end_date up to nearest resolution
let diff = end_date.timestamp() % (resolution_minutes as i64 * 60);
let end_date = end_date + Duration::seconds((resolution_minutes as i64 * 60) - diff);
// Convert String list to list of ProjectIds or VersionIds
// - Filter out unauthorized projects/versions
// - If no project_ids or version_ids are provided, we default to all projects the user has access to
let (project_ids, _) = filter_allowed_ids(project_ids, None, user, &pool, &redis).await?;
let duration: PgInterval = Duration::minutes(resolution_minutes as i64)
.try_into()
.unwrap();
// Get the revenue data
let payouts_values = sqlx::query!(
"
SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start
FROM payouts_values
WHERE mod_id = ANY($1) AND created BETWEEN $2 AND $3
GROUP by mod_id, interval_start ORDER BY interval_start
",
&project_ids.unwrap_or_default().into_iter().map(|x| x.0 as i64).collect::<Vec<_>>(),
start_date,
end_date,
duration,
)
.fetch_all(&**pool)
.await?;
let mut hm = HashMap::new();
for value in payouts_values {
if let Some(mod_id) = value.mod_id {
if let Some(amount) = value.amount_sum {
if let Some(interval_start) = value.interval_start {
let id_string = to_base62(mod_id as u64);
if !hm.contains_key(&id_string) {
hm.insert(id_string.clone(), HashMap::new());
}
if let Some(hm) = hm.get_mut(&id_string) {
hm.insert(interval_start.timestamp(), amount);
}
}
}
}
}
Ok(HttpResponse::Ok().json(hm))
}
/// Get country data for a set of projects or versions
@@ -409,64 +209,22 @@ pub async fn countries_downloads_get(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
let data = data.into_inner();
v3::analytics_get::countries_downloads_get(
req,
clickhouse,
web::Query(v3::analytics_get::GetData {
project_ids: data.project_ids,
version_ids: data.version_ids,
start_date: data.start_date,
end_date: data.end_date,
resolution_minutes: data.resolution_minutes,
}),
session_queue,
pool,
redis,
)
.await
.map(|x| x.1)?;
let project_ids = data
.project_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
let version_ids = data
.version_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
if project_ids.is_some() && version_ids.is_some() {
return Err(ApiError::InvalidInput(
"Only one of 'project_ids' or 'version_ids' should be used.".to_string(),
));
}
let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2));
let end_date = data.end_date.unwrap_or(Utc::now());
// Convert String list to list of ProjectIds or VersionIds
// - Filter out unauthorized projects/versions
// - If no project_ids or version_ids are provided, we default to all projects the user has access to
let (project_ids, version_ids) =
filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?;
// Get the countries
let countries = crate::clickhouse::fetch_countries(
project_ids,
version_ids,
start_date,
end_date,
clickhouse.into_inner(),
)
.await?;
let mut hm = HashMap::new();
for views in countries {
let id_string = to_base62(views.id);
if !hm.contains_key(&id_string) {
hm.insert(id_string.clone(), HashMap::new());
}
if let Some(hm) = hm.get_mut(&id_string) {
hm.insert(views.country, views.total_downloads);
}
}
Ok(HttpResponse::Ok().json(hm))
}
/// Get country data for a set of projects or versions
@@ -490,126 +248,20 @@ pub async fn countries_views_get(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
let data = data.into_inner();
v3::analytics_get::countries_views_get(
req,
clickhouse,
web::Query(v3::analytics_get::GetData {
project_ids: data.project_ids,
version_ids: data.version_ids,
start_date: data.start_date,
end_date: data.end_date,
resolution_minutes: data.resolution_minutes,
}),
session_queue,
pool,
redis,
)
.await
.map(|x| x.1)?;
let project_ids = data
.project_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
let version_ids = data
.version_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
if project_ids.is_some() && version_ids.is_some() {
return Err(ApiError::InvalidInput(
"Only one of 'project_ids' or 'version_ids' should be used.".to_string(),
));
}
let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2));
let end_date = data.end_date.unwrap_or(Utc::now());
// Convert String list to list of ProjectIds or VersionIds
// - Filter out unauthorized projects/versions
// - If no project_ids or version_ids are provided, we default to all projects the user has access to
let (project_ids, version_ids) =
filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?;
// Get the countries
let countries = crate::clickhouse::fetch_countries(
project_ids,
version_ids,
start_date,
end_date,
clickhouse.into_inner(),
)
.await?;
let mut hm = HashMap::new();
for views in countries {
let id_string = to_base62(views.id);
if !hm.contains_key(&id_string) {
hm.insert(id_string.clone(), HashMap::new());
}
if let Some(hm) = hm.get_mut(&id_string) {
hm.insert(views.country, views.total_views);
}
}
Ok(HttpResponse::Ok().json(hm))
}
async fn filter_allowed_ids(
mut project_ids: Option<Vec<String>>,
version_ids: Option<Vec<String>>,
user: crate::models::users::User,
pool: &web::Data<PgPool>,
redis: &RedisPool,
) -> Result<(Option<Vec<ProjectId>>, Option<Vec<VersionId>>), ApiError> {
if project_ids.is_some() && version_ids.is_some() {
return Err(ApiError::InvalidInput(
"Only one of 'project_ids' or 'version_ids' should be used.".to_string(),
));
}
// If no project_ids or version_ids are provided, we default to all projects the user has access to
if project_ids.is_none() && version_ids.is_none() {
project_ids = Some(
user_item::User::get_projects(user.id.into(), &***pool, redis)
.await?
.into_iter()
.map(|x| ProjectId::from(x).to_string())
.collect(),
);
}
// Convert String list to list of ProjectIds or VersionIds
// - Filter out unauthorized projects/versions
let project_ids = if let Some(project_ids) = project_ids {
// Submitted project_ids are filtered by the user's permissions
let ids = project_ids
.iter()
.map(|id| Ok(ProjectId(parse_base62(id)?).into()))
.collect::<Result<Vec<_>, ApiError>>()?;
let projects = project_item::Project::get_many_ids(&ids, &***pool, redis).await?;
let ids: Vec<ProjectId> = filter_authorized_projects(projects, &Some(user.clone()), pool)
.await?
.into_iter()
.map(|x| x.id)
.collect::<Vec<_>>();
Some(ids)
} else {
None
};
let version_ids = if let Some(version_ids) = version_ids {
// Submitted version_ids are filtered by the user's permissions
let ids = version_ids
.iter()
.map(|id| Ok(VersionId(parse_base62(id)?).into()))
.collect::<Result<Vec<_>, ApiError>>()?;
let versions = version_item::Version::get_many(&ids, &***pool, redis).await?;
let ids: Vec<VersionId> = filter_authorized_versions(versions, &Some(user), pool)
.await?
.into_iter()
.map(|x| x.id)
.collect::<Vec<_>>();
Some(ids)
} else {
None
};
// Only one of project_ids or version_ids will be Some
Ok((project_ids, version_ids))
}

View File

@@ -1,28 +1,16 @@
use crate::auth::checks::{filter_authorized_collections, is_authorized_collection};
use crate::auth::get_user_from_headers;
use crate::database::models::{collection_item, generate_collection_id, project_item};
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::collections::{Collection, CollectionStatus};
use crate::models::ids::base62_impl::parse_base62;
use crate::models::ids::{CollectionId, ProjectId};
use crate::models::pats::Scopes;
use crate::models::collections::CollectionStatus;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::util::routes::read_from_payload;
use crate::util::validate::validation_errors_to_string;
use crate::{database, models};
use crate::routes::v3::project_creation::CreateError;
use crate::routes::{v3, ApiError};
use actix_web::web::Data;
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
use chrono::Utc;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
use validator::Validate;
use super::project_creation::CreateError;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(collections_get);
cfg.service(collection_create);
@@ -62,68 +50,18 @@ pub async fn collection_create(
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, CreateError> {
let collection_create_data = collection_create_data.into_inner();
// The currently logged in user
let current_user = get_user_from_headers(
&req,
&**client,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_CREATE]),
v3::collections::collection_create(
req,
web::Json(v3::collections::CollectionCreateData {
title: collection_create_data.title,
description: collection_create_data.description,
projects: collection_create_data.projects,
}),
client,
redis,
session_queue,
)
.await?
.1;
collection_create_data
.validate()
.map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?;
let mut transaction = client.begin().await?;
let collection_id: CollectionId = generate_collection_id(&mut transaction).await?.into();
let initial_project_ids = project_item::Project::get_many(
&collection_create_data.projects,
&mut *transaction,
&redis,
)
.await?
.into_iter()
.map(|x| x.inner.id.into())
.collect::<Vec<ProjectId>>();
let collection_builder_actual = collection_item::CollectionBuilder {
collection_id: collection_id.into(),
user_id: current_user.id.into(),
title: collection_create_data.title,
description: collection_create_data.description,
status: CollectionStatus::Listed,
projects: initial_project_ids
.iter()
.copied()
.map(|x| x.into())
.collect(),
};
let collection_builder = collection_builder_actual.clone();
let now = Utc::now();
collection_builder_actual.insert(&mut transaction).await?;
let response = crate::models::collections::Collection {
id: collection_id,
user: collection_builder.user_id.into(),
title: collection_builder.title.clone(),
description: collection_builder.description.clone(),
created: now,
updated: now,
icon_url: None,
color: None,
status: collection_builder.status,
projects: initial_project_ids,
};
transaction.commit().await?;
Ok(HttpResponse::Ok().json(response))
.await
}
#[derive(Serialize, Deserialize)]
@@ -138,28 +76,14 @@ pub async fn collections_get(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let ids = serde_json::from_str::<Vec<&str>>(&ids.ids)?;
let ids = ids
.into_iter()
.map(|x| parse_base62(x).map(|x| database::models::CollectionId(x as i64)))
.collect::<Result<Vec<_>, _>>()?;
let collections_data = database::models::Collection::get_many(&ids, &**pool, &redis).await?;
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_READ]),
v3::collections::collections_get(
req,
web::Query(v3::collections::CollectionIds { ids: ids.ids }),
pool,
redis,
session_queue,
)
.await
.map(|x| x.1)
.ok();
let collections = filter_authorized_collections(collections_data, &user_option, &pool).await?;
Ok(HttpResponse::Ok().json(collections))
}
#[get("{id}")]
@@ -170,27 +94,7 @@ pub async fn collection_get(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
let id = database::models::CollectionId(parse_base62(&string)? as i64);
let collection_data = database::models::Collection::get(id, &**pool, &redis).await?;
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_READ]),
)
.await
.map(|x| x.1)
.ok();
if let Some(data) = collection_data {
if is_authorized_collection(&data, &user_option).await? {
return Ok(HttpResponse::Ok().json(Collection::from(data)));
}
}
Ok(HttpResponse::NotFound().body(""))
v3::collections::collection_get(req, info, pool, redis, session_queue).await
}
#[derive(Deserialize, Validate)]
@@ -216,131 +120,21 @@ pub async fn collection_edit(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_WRITE]),
let new_collection = new_collection.into_inner();
v3::collections::collection_edit(
req,
info,
pool,
web::Json(v3::collections::EditCollection {
title: new_collection.title,
description: new_collection.description,
status: new_collection.status,
new_projects: new_collection.new_projects,
}),
redis,
session_queue,
)
.await?
.1;
new_collection
.validate()
.map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?;
let string = info.into_inner().0;
let id = database::models::CollectionId(parse_base62(&string)? as i64);
let result = database::models::Collection::get(id, &**pool, &redis).await?;
if let Some(collection_item) = result {
if !can_modify_collection(&collection_item, &user) {
return Ok(HttpResponse::Unauthorized().body(""));
}
let id = collection_item.id;
let mut transaction = pool.begin().await?;
if let Some(title) = &new_collection.title {
sqlx::query!(
"
UPDATE collections
SET title = $1
WHERE (id = $2)
",
title.trim(),
id as database::models::ids::CollectionId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(description) = &new_collection.description {
sqlx::query!(
"
UPDATE collections
SET description = $1
WHERE (id = $2)
",
description,
id as database::models::ids::CollectionId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(status) = &new_collection.status {
if !(user.role.is_mod()
|| collection_item.status.is_approved() && status.can_be_requested())
{
return Err(ApiError::CustomAuthentication(
"You don't have permission to set this status!".to_string(),
));
}
sqlx::query!(
"
UPDATE collections
SET status = $1
WHERE (id = $2)
",
status.to_string(),
id as database::models::ids::CollectionId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(new_project_ids) = &new_collection.new_projects {
// Delete all existing projects
sqlx::query!(
"
DELETE FROM collections_mods
WHERE collection_id = $1
",
collection_item.id as database::models::ids::CollectionId,
)
.execute(&mut *transaction)
.await?;
let collection_item_ids = new_project_ids
.iter()
.map(|_| collection_item.id.0)
.collect_vec();
let mut validated_project_ids = Vec::new();
for project_id in new_project_ids {
let project = database::models::Project::get(project_id, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"The specified project {project_id} does not exist!"
))
})?;
validated_project_ids.push(project.inner.id.0);
}
// Insert- don't throw an error if it already exists
sqlx::query!(
"
INSERT INTO collections_mods (collection_id, mod_id)
SELECT * FROM UNNEST ($1::int8[], $2::int8[])
ON CONFLICT DO NOTHING
",
&collection_item_ids[..],
&validated_project_ids[..],
)
.execute(&mut *transaction)
.await?;
}
database::models::Collection::clear_cache(collection_item.id, &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
.await
}
#[derive(Serialize, Deserialize)]
@@ -357,82 +151,20 @@ pub async fn collection_icon_edit(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
mut payload: web::Payload,
payload: web::Payload,
session_queue: web::Data<AuthQueue>,
) -> 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,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_WRITE]),
)
.await?
.1;
let string = info.into_inner().0;
let id = database::models::CollectionId(parse_base62(&string)? as i64);
let collection_item = database::models::Collection::get(id, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified collection does not exist!".to_string())
})?;
if !can_modify_collection(&collection_item, &user) {
return Ok(HttpResponse::Unauthorized().body(""));
}
if let Some(icon) = collection_item.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, 262144, "Icons must be smaller than 256KiB").await?;
let color = crate::util::img::get_color_from_img(&bytes)?;
let hash = sha1::Sha1::from(&bytes).hexdigest();
let collection_id: CollectionId = collection_item.id.into();
let upload_data = file_host
.upload_file(
content_type,
&format!("data/{}/{}.{}", collection_id, hash, ext.ext),
bytes.freeze(),
)
.await?;
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE collections
SET icon_url = $1, color = $2
WHERE (id = $3)
",
format!("{}/{}", cdn_url, upload_data.file_name),
color.map(|x| x as i32),
collection_item.id as database::models::ids::CollectionId,
)
.execute(&mut *transaction)
.await?;
database::models::Collection::clear_cache(collection_item.id, &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::InvalidInput(format!(
"Invalid format for collection icon: {}",
ext.ext
)))
}
v3::collections::collection_icon_edit(
web::Query(v3::collections::Extension { ext: ext.ext }),
req,
info,
pool,
redis,
file_host,
payload,
session_queue,
)
.await
}
#[delete("{id}/icon")]
@@ -444,54 +176,7 @@ pub async fn delete_collection_icon(
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_WRITE]),
)
.await?
.1;
let string = info.into_inner().0;
let id = database::models::CollectionId(parse_base62(&string)? as i64);
let collection_item = database::models::Collection::get(id, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified collection does not exist!".to_string())
})?;
if !can_modify_collection(&collection_item, &user) {
return Ok(HttpResponse::Unauthorized().body(""));
}
let cdn_url = dotenvy::var("CDN_URL")?;
if let Some(icon) = collection_item.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 mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE collections
SET icon_url = NULL, color = NULL
WHERE (id = $1)
",
collection_item.id as database::models::ids::CollectionId,
)
.execute(&mut *transaction)
.await?;
database::models::Collection::clear_cache(collection_item.id, &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
v3::collections::delete_collection_icon(req, info, pool, redis, file_host, session_queue).await
}
#[delete("{id}")]
@@ -502,44 +187,5 @@ pub async fn collection_delete(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_DELETE]),
)
.await?
.1;
let string = info.into_inner().0;
let id = database::models::CollectionId(parse_base62(&string)? as i64);
let collection = database::models::Collection::get(id, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified collection does not exist!".to_string())
})?;
if !can_modify_collection(&collection, &user) {
return Ok(HttpResponse::Unauthorized().body(""));
}
let mut transaction = pool.begin().await?;
let result =
database::models::Collection::remove(collection.id, &mut transaction, &redis).await?;
database::models::Collection::clear_cache(collection.id, &redis).await?;
transaction.commit().await?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
fn can_modify_collection(
collection: &database::models::Collection,
user: &models::users::User,
) -> bool {
collection.user_id == user.id.into() || user.role.is_mod()
v3::collections::collection_delete(req, info, pool, redis, session_queue).await
}

View File

@@ -1,17 +1,11 @@
use std::sync::Arc;
use crate::auth::{get_user_from_headers, is_authorized, is_authorized_version};
use crate::database;
use crate::database::models::{project_item, report_item, thread_item, version_item};
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::ids::{ThreadMessageId, VersionId};
use crate::models::images::{Image, ImageContext};
use crate::models::reports::ReportId;
use crate::queue::session::AuthQueue;
use crate::routes::v2::threads::is_authorized_thread;
use crate::routes::ApiError;
use crate::util::routes::read_from_payload;
use crate::routes::{v3, ApiError};
use actix_web::{post, web, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
@@ -40,195 +34,26 @@ pub async fn images_add(
req: HttpRequest,
web::Query(data): web::Query<ImageUpload>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
mut payload: web::Payload,
payload: web::Payload,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
if let Some(content_type) = crate::util::ext::get_image_content_type(&data.ext) {
let mut context = ImageContext::from_str(&data.context, None);
let scopes = vec![context.relevant_scope()];
let cdn_url = dotenvy::var("CDN_URL")?;
let user = get_user_from_headers(&req, &**pool, &redis, &session_queue, Some(&scopes))
.await?
.1;
// Attempt to associated a supplied id with the context
// If the context cannot be found, or the user is not authorized to upload images for the context, return an error
match &mut context {
ImageContext::Project { project_id } => {
if let Some(id) = data.project_id {
let project = project_item::Project::get(&id, &**pool, &redis).await?;
if let Some(project) = project {
if is_authorized(&project.inner, &Some(user.clone()), &pool).await? {
*project_id = Some(project.inner.id.into());
} else {
return Err(ApiError::CustomAuthentication(
"You are not authorized to upload images for this project"
.to_string(),
));
}
} else {
return Err(ApiError::InvalidInput(
"The project could not be found.".to_string(),
));
}
}
}
ImageContext::Version { version_id } => {
if let Some(id) = data.version_id {
let version = version_item::Version::get(id.into(), &**pool, &redis).await?;
if let Some(version) = version {
if is_authorized_version(&version.inner, &Some(user.clone()), &pool).await?
{
*version_id = Some(version.inner.id.into());
} else {
return Err(ApiError::CustomAuthentication(
"You are not authorized to upload images for this version"
.to_string(),
));
}
} else {
return Err(ApiError::InvalidInput(
"The version could not be found.".to_string(),
));
}
}
}
ImageContext::ThreadMessage { thread_message_id } => {
if let Some(id) = data.thread_message_id {
let thread_message = thread_item::ThreadMessage::get(id.into(), &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(
"The thread message could not found.".to_string(),
)
})?;
let thread = thread_item::Thread::get(thread_message.thread_id, &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(
"The thread associated with the thread message could not be found"
.to_string(),
)
})?;
if is_authorized_thread(&thread, &user, &pool).await? {
*thread_message_id = Some(thread_message.id.into());
} else {
return Err(ApiError::CustomAuthentication(
"You are not authorized to upload images for this thread message"
.to_string(),
));
}
}
}
ImageContext::Report { report_id } => {
if let Some(id) = data.report_id {
let report = report_item::Report::get(id.into(), &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The report could not be found.".to_string())
})?;
let thread = thread_item::Thread::get(report.thread_id, &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(
"The thread associated with the report could not be found."
.to_string(),
)
})?;
if is_authorized_thread(&thread, &user, &pool).await? {
*report_id = Some(report.id.into());
} else {
return Err(ApiError::CustomAuthentication(
"You are not authorized to upload images for this report".to_string(),
));
}
}
}
ImageContext::Unknown => {
return Err(ApiError::InvalidInput(
"Context must be one of: project, version, thread_message, report".to_string(),
));
}
}
// Upload the image to the file host
let bytes =
read_from_payload(&mut payload, 1_048_576, "Icons must be smaller than 1MiB").await?;
let hash = sha1::Sha1::from(&bytes).hexdigest();
let upload_data = file_host
.upload_file(
content_type,
&format!("data/cached_images/{}.{}", hash, data.ext),
bytes.freeze(),
)
.await?;
let mut transaction = pool.begin().await?;
let db_image: database::models::Image = database::models::Image {
id: database::models::generate_image_id(&mut transaction).await?,
url: format!("{}/{}", cdn_url, upload_data.file_name),
size: upload_data.content_length as u64,
created: chrono::Utc::now(),
owner_id: database::models::UserId::from(user.id),
context: context.context_as_str().to_string(),
project_id: if let ImageContext::Project {
project_id: Some(id),
} = context
{
Some(database::models::ProjectId::from(id))
} else {
None
},
version_id: if let ImageContext::Version {
version_id: Some(id),
} = context
{
Some(database::models::VersionId::from(id))
} else {
None
},
thread_message_id: if let ImageContext::ThreadMessage {
thread_message_id: Some(id),
} = context
{
Some(database::models::ThreadMessageId::from(id))
} else {
None
},
report_id: if let ImageContext::Report {
report_id: Some(id),
} = context
{
Some(database::models::ReportId::from(id))
} else {
None
},
};
// Insert
db_image.insert(&mut transaction).await?;
let image = Image {
id: db_image.id.into(),
url: db_image.url,
size: db_image.size,
created: db_image.created,
owner_id: db_image.owner_id.into(),
context,
};
transaction.commit().await?;
Ok(HttpResponse::Ok().json(image))
} else {
Err(ApiError::InvalidInput(
"The specified file is not an image!".to_string(),
))
}
v3::images::images_add(
req,
web::Query(v3::images::ImageUpload {
ext: data.ext,
context: data.context,
project_id: data.project_id,
version_id: data.version_id,
thread_message_id: data.thread_message_id,
report_id: data.report_id,
}),
file_host,
payload,
pool,
redis,
session_queue,
)
.await
}

View File

@@ -9,12 +9,12 @@ pub(crate) mod project_creation;
mod projects;
mod reports;
mod statistics;
mod tags;
pub mod tags;
mod teams;
mod threads;
mod users;
mod version_creation;
mod version_file;
pub mod version_file;
mod versions;
pub use super::ApiError;

View File

@@ -1,9 +1,7 @@
use super::ApiError;
use crate::database;
use crate::database::redis::RedisPool;
use crate::models::projects::ProjectStatus;
use crate::queue::session::AuthQueue;
use crate::{auth::check_is_moderator_from_headers, models::pats::Scopes};
use crate::routes::v3;
use actix_web::{get, web, HttpRequest, HttpResponse};
use serde::Deserialize;
use sqlx::PgPool;
@@ -30,37 +28,12 @@ pub async fn get_projects(
count: web::Query<ResultCount>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
v3::moderation::get_projects(
req,
pool,
redis,
web::Query(v3::moderation::ResultCount { count: count.count }),
session_queue,
)
.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_ids(&project_ids, &**pool, &redis)
.await?
.into_iter()
.map(crate::models::projects::Project::from)
.collect();
Ok(HttpResponse::Ok().json(projects))
.await
}

View File

@@ -1,10 +1,7 @@
use crate::auth::get_user_from_headers;
use crate::database;
use crate::database::redis::RedisPool;
use crate::models::ids::NotificationId;
use crate::models::notifications::Notification;
use crate::models::pats::Scopes;
use crate::queue::session::AuthQueue;
use crate::routes::v3;
use crate::routes::ApiError;
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
@@ -36,36 +33,14 @@ pub async fn notifications_get(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_READ]),
v3::notifications::notifications_get(
req,
web::Query(v3::notifications::NotificationIds { ids: ids.ids }),
pool,
redis,
session_queue,
)
.await?
.1;
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(&notification_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))
.await
}
#[get("{id}")]
@@ -76,30 +51,7 @@ pub async fn notification_get(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_READ]),
)
.await?
.1;
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(""))
}
v3::notifications::notification_get(req, info, pool, redis, session_queue).await
}
#[patch("{id}")]
@@ -110,43 +62,7 @@ pub async fn notification_read(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_WRITE]),
)
.await?
.1;
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::read(
id.into(),
&mut transaction,
&redis,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::CustomAuthentication(
"You are not authorized to read this notification!".to_string(),
))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
v3::notifications::notification_read(req, info, pool, redis, session_queue).await
}
#[delete("{id}")]
@@ -157,43 +73,7 @@ pub async fn notification_delete(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_WRITE]),
)
.await?
.1;
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,
&redis,
)
.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(""))
}
v3::notifications::notification_delete(req, info, pool, redis, session_queue).await
}
#[patch("notifications")]
@@ -204,45 +84,14 @@ pub async fn notifications_read(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_WRITE]),
v3::notifications::notifications_read(
req,
web::Query(v3::notifications::NotificationIds { ids: ids.ids }),
pool,
redis,
session_queue,
)
.await?
.1;
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(&notification_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::read_many(
&notifications,
&mut transaction,
&redis,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
.await
}
#[delete("notifications")]
@@ -253,43 +102,12 @@ pub async fn notifications_delete(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_WRITE]),
v3::notifications::notifications_delete(
req,
web::Query(v3::notifications::NotificationIds { ids: ids.ids }),
pool,
redis,
session_queue,
)
.await?
.1;
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(&notification_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(
&notifications,
&mut transaction,
&redis,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
.await
}

View File

@@ -1,25 +1,14 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::auth::{filter_authorized_projects, get_user_from_headers};
use crate::database::models::team_item::TeamMember;
use crate::database::models::{generate_organization_id, team_item, Organization};
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::ids::base62_impl::parse_base62;
use crate::models::organizations::OrganizationId;
use crate::models::pats::Scopes;
use crate::models::teams::{OrganizationPermissions, ProjectPermissions};
use crate::models::projects::Project;
use crate::models::v2::projects::LegacyProject;
use crate::queue::session::AuthQueue;
use crate::routes::v2::project_creation::CreateError;
use crate::routes::ApiError;
use crate::util::routes::read_from_payload;
use crate::util::validate::validation_errors_to_string;
use crate::{database, models};
use crate::routes::v3::project_creation::CreateError;
use crate::routes::{v2_reroute, v3, ApiError};
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
@@ -58,82 +47,18 @@ pub async fn organization_create(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, CreateError> {
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_CREATE]),
let new_organization = new_organization.into_inner();
v3::organizations::organization_create(
req,
web::Json(v3::organizations::NewOrganization {
title: new_organization.title,
description: new_organization.description,
}),
pool.clone(),
redis.clone(),
session_queue,
)
.await?
.1;
new_organization
.validate()
.map_err(|err| CreateError::ValidationError(validation_errors_to_string(err, None)))?;
let mut transaction = pool.begin().await?;
// Try title
let title_organization_id_option: Option<u64> = parse_base62(&new_organization.title).ok();
let mut organization_strings = vec![];
if let Some(title_organization_id) = title_organization_id_option {
organization_strings.push(title_organization_id.to_string());
}
organization_strings.push(new_organization.title.clone());
let results = Organization::get_many(&organization_strings, &mut *transaction, &redis).await?;
if !results.is_empty() {
return Err(CreateError::SlugCollision);
}
let organization_id = generate_organization_id(&mut transaction).await?;
// Create organization managerial team
let team = team_item::TeamBuilder {
members: vec![team_item::TeamMemberBuilder {
user_id: current_user.id.into(),
role: models::teams::OWNER_ROLE.to_owned(),
permissions: ProjectPermissions::all(),
organization_permissions: Some(OrganizationPermissions::all()),
accepted: true,
payouts_split: Decimal::ONE_HUNDRED,
ordering: 0,
}],
};
let team_id = team.insert(&mut transaction).await?;
// Create organization
let organization = Organization {
id: organization_id,
title: new_organization.title.clone(),
description: new_organization.description.clone(),
team_id,
icon_url: None,
color: None,
};
organization.clone().insert(&mut transaction).await?;
transaction.commit().await?;
// Only member is the owner, the logged in one
let member_data = TeamMember::get_from_team_full(team_id, &**pool, &redis)
.await?
.into_iter()
.next();
let members_data = if let Some(member_data) = member_data {
vec![crate::models::teams::TeamMember::from_model(
member_data,
current_user.clone(),
false,
)]
} else {
return Err(CreateError::InvalidInput(
"Failed to get created team.".to_owned(), // should never happen
));
};
let organization = models::organizations::Organization::from(organization, members_data);
Ok(HttpResponse::Ok().json(organization))
.await
}
#[get("{id}")]
@@ -144,57 +69,7 @@ pub async fn organization_get(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_READ]),
)
.await
.map(|x| x.1)
.ok();
let user_id = current_user.as_ref().map(|x| x.id.into());
let organization_data = Organization::get(&id, &**pool, &redis).await?;
if let Some(data) = organization_data {
let members_data = TeamMember::get_from_team_full(data.team_id, &**pool, &redis).await?;
let users = crate::database::models::User::get_many_ids(
&members_data.iter().map(|x| x.user_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let logged_in = current_user
.as_ref()
.and_then(|user| {
members_data
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members: Vec<_> = members_data
.into_iter()
.filter(|x| {
logged_in
|| x.accepted
|| user_id
.map(|y: crate::database::models::UserId| y == x.user_id)
.unwrap_or(false)
})
.flat_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)
})
})
.collect();
let organization = models::organizations::Organization::from(data, team_members);
return Ok(HttpResponse::Ok().json(organization));
}
Ok(HttpResponse::NotFound().body(""))
v3::organizations::organization_get(req, info, pool.clone(), redis.clone(), session_queue).await
}
#[derive(Deserialize)]
@@ -209,72 +84,14 @@ pub async fn organizations_get(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let ids = serde_json::from_str::<Vec<&str>>(&ids.ids)?;
let organizations_data = Organization::get_many(&ids, &**pool, &redis).await?;
let team_ids = organizations_data
.iter()
.map(|x| x.team_id)
.collect::<Vec<_>>();
let teams_data = TeamMember::get_from_team_full_many(&team_ids, &**pool, &redis).await?;
let users = database::models::User::get_many_ids(
&teams_data.iter().map(|x| x.user_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_READ]),
v3::organizations::organizations_get(
req,
web::Query(v3::organizations::OrganizationIds { ids: ids.ids }),
pool,
redis,
session_queue,
)
.await
.map(|x| x.1)
.ok();
let user_id = current_user.as_ref().map(|x| x.id.into());
let mut organizations = vec![];
let mut team_groups = HashMap::new();
for item in teams_data {
team_groups.entry(item.team_id).or_insert(vec![]).push(item);
}
for data in organizations_data {
let members_data = team_groups.remove(&data.team_id).unwrap_or(vec![]);
let logged_in = current_user
.as_ref()
.and_then(|user| {
members_data
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members: Vec<_> = members_data
.into_iter()
.filter(|x| {
logged_in
|| x.accepted
|| user_id
.map(|y: crate::database::models::UserId| y == x.user_id)
.unwrap_or(false)
})
.flat_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)
})
})
.collect();
let organization = models::organizations::Organization::from(data, team_members);
organizations.push(organization);
}
Ok(HttpResponse::Ok().json(organizations))
}
#[derive(Serialize, Deserialize, Validate)]
@@ -298,132 +115,19 @@ pub async fn organizations_edit(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_WRITE]),
let new_organization = new_organization.into_inner();
v3::organizations::organizations_edit(
req,
info,
web::Json(v3::organizations::OrganizationEdit {
description: new_organization.description,
title: new_organization.title,
}),
pool.clone(),
redis.clone(),
session_queue,
)
.await?
.1;
new_organization
.validate()
.map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?;
let string = info.into_inner().0;
let result = database::models::Organization::get(&string, &**pool, &redis).await?;
if let Some(organization_item) = result {
let id = organization_item.id;
let team_member = database::models::TeamMember::get_from_user_id(
organization_item.team_id,
user.id.into(),
&**pool,
)
.await?;
let permissions =
OrganizationPermissions::get_permissions_by_role(&user.role, &team_member);
if let Some(perms) = permissions {
let mut transaction = pool.begin().await?;
if let Some(description) = &new_organization.description {
if !perms.contains(OrganizationPermissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthentication(
"You do not have the permissions to edit the description of this organization!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE organizations
SET description = $1
WHERE (id = $2)
",
description,
id as database::models::ids::OrganizationId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(title) = &new_organization.title {
if !perms.contains(OrganizationPermissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthentication(
"You do not have the permissions to edit the title of this organization!"
.to_string(),
));
}
let title_organization_id_option: Option<u64> = parse_base62(title).ok();
if let Some(title_organization_id) = title_organization_id_option {
let results = sqlx::query!(
"
SELECT EXISTS(SELECT 1 FROM organizations WHERE id=$1)
",
title_organization_id as i64
)
.fetch_one(&mut *transaction)
.await?;
if results.exists.unwrap_or(true) {
return Err(ApiError::InvalidInput(
"Title collides with other organization's id!".to_string(),
));
}
}
// Make sure the new title is different from the old one
// We are able to unwrap here because the title is always set
if !title.eq(&organization_item.title.clone()) {
let results = sqlx::query!(
"
SELECT EXISTS(SELECT 1 FROM organizations WHERE title = LOWER($1))
",
title
)
.fetch_one(&mut *transaction)
.await?;
if results.exists.unwrap_or(true) {
return Err(ApiError::InvalidInput(
"Title collides with other organization's id!".to_string(),
));
}
}
sqlx::query!(
"
UPDATE organizations
SET title = LOWER($1)
WHERE (id = $2)
",
Some(title),
id as database::models::ids::OrganizationId,
)
.execute(&mut *transaction)
.await?;
}
database::models::Organization::clear_cache(
organization_item.id,
Some(organization_item.title),
&redis,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::CustomAuthentication(
"You do not have permission to edit this organization!".to_string(),
))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
.await
}
#[delete("{id}")]
@@ -434,60 +138,8 @@ pub async fn organization_delete(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_DELETE]),
)
.await?
.1;
let string = info.into_inner().0;
let organization = database::models::Organization::get(&string, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified organization does not exist!".to_string())
})?;
if !user.role.is_admin() {
let team_member = database::models::TeamMember::get_from_user_id_organization(
organization.id,
user.id.into(),
&**pool,
)
v3::organizations::organization_delete(req, info, pool.clone(), redis.clone(), session_queue)
.await
.map_err(ApiError::Database)?
.ok_or_else(|| {
ApiError::InvalidInput("The specified organization does not exist!".to_string())
})?;
let permissions =
OrganizationPermissions::get_permissions_by_role(&user.role, &Some(team_member))
.unwrap_or_default();
if !permissions.contains(OrganizationPermissions::DELETE_ORGANIZATION) {
return Err(ApiError::CustomAuthentication(
"You don't have permission to delete this organization!".to_string(),
));
}
}
let mut transaction = pool.begin().await?;
let result =
database::models::Organization::remove(organization.id, &mut transaction, &redis).await?;
transaction.commit().await?;
database::models::Organization::clear_cache(organization.id, Some(organization.title), &redis)
.await?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[get("{id}/projects")]
@@ -498,40 +150,23 @@ pub async fn organization_projects_get(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let info = info.into_inner().0;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_READ, Scopes::PROJECT_READ]),
let response = v3::organizations::organization_projects_get(
req,
info,
pool.clone(),
redis.clone(),
session_queue,
)
.await
.map(|x| x.1)
.ok();
let possible_organization_id: Option<u64> = parse_base62(&info).ok();
use futures::TryStreamExt;
let project_ids = sqlx::query!(
"
SELECT m.id FROM organizations o
INNER JOIN mods m ON m.organization_id = o.id
WHERE (o.id = $1 AND $1 IS NOT NULL) OR (o.title = $2 AND $2 IS NOT NULL)
",
possible_organization_id.map(|x| x as i64),
info
)
.fetch_many(&**pool)
.try_filter_map(|e| async { Ok(e.right().map(|m| crate::database::models::ProjectId(m.id))) })
.try_collect::<Vec<crate::database::models::ProjectId>>()
.await?;
let projects_data =
crate::database::models::Project::get_many_ids(&project_ids, &**pool, &redis).await?;
let projects = filter_authorized_projects(projects_data, &current_user, &pool).await?;
Ok(HttpResponse::Ok().json(projects))
// Convert v3 projects to v2
match v2_reroute::extract_ok_json::<Vec<Project>>(response).await {
Ok(project) => {
let legacy_projects = LegacyProject::from_many(project, &**pool, &redis).await?;
Ok(HttpResponse::Ok().json(legacy_projects))
}
Err(response) => Ok(response),
}
}
#[derive(Deserialize)]
@@ -547,98 +182,18 @@ pub async fn organization_projects_add(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let info = info.into_inner().0;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE, Scopes::ORGANIZATION_WRITE]),
let project_info = project_info.into_inner();
v3::organizations::organization_projects_add(
req,
info,
web::Json(v3::organizations::OrganizationProjectAdd {
project_id: project_info.project_id,
}),
pool.clone(),
redis.clone(),
session_queue,
)
.await?
.1;
let organization = database::models::Organization::get(&info, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified organization does not exist!".to_string())
})?;
let project_item = database::models::Project::get(&project_info.project_id, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified project does not exist!".to_string())
})?;
if project_item.inner.organization_id.is_some() {
return Err(ApiError::InvalidInput(
"The specified project is already owned by an organization!".to_string(),
));
}
let project_team_member = database::models::TeamMember::get_from_user_id_project(
project_item.inner.id,
current_user.id.into(),
&**pool,
)
.await?
.ok_or_else(|| ApiError::InvalidInput("You are not a member of this project!".to_string()))?;
let organization_team_member = database::models::TeamMember::get_from_user_id_organization(
organization.id,
current_user.id.into(),
&**pool,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("You are not a member of this organization!".to_string())
})?;
// Require ownership of a project to add it to an organization
if !current_user.role.is_admin()
&& !project_team_member
.role
.eq(crate::models::teams::OWNER_ROLE)
{
return Err(ApiError::CustomAuthentication(
"You need to be an owner of a project to add it to an organization!".to_string(),
));
}
let permissions = OrganizationPermissions::get_permissions_by_role(
&current_user.role,
&Some(organization_team_member),
)
.unwrap_or_default();
if permissions.contains(OrganizationPermissions::ADD_PROJECT) {
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE mods
SET organization_id = $1
WHERE (id = $2)
",
organization.id as database::models::OrganizationId,
project_item.inner.id as database::models::ids::ProjectId
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
database::models::TeamMember::clear_cache(project_item.inner.team_id, &redis).await?;
database::models::Project::clear_cache(
project_item.inner.id,
project_item.inner.slug,
None,
&redis,
)
.await?;
} else {
return Err(ApiError::CustomAuthentication(
"You do not have permission to add projects to this organization!".to_string(),
));
}
Ok(HttpResponse::Ok().finish())
.await
}
#[delete("{organization_id}/projects/{project_id}")]
@@ -649,83 +204,14 @@ pub async fn organization_projects_remove(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let (organization_id, project_id) = info.into_inner();
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE, Scopes::ORGANIZATION_WRITE]),
v3::organizations::organization_projects_remove(
req,
info,
pool.clone(),
redis.clone(),
session_queue,
)
.await?
.1;
let organization = database::models::Organization::get(&organization_id, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified organization does not exist!".to_string())
})?;
let project_item = database::models::Project::get(&project_id, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified project does not exist!".to_string())
})?;
if !project_item
.inner
.organization_id
.eq(&Some(organization.id))
{
return Err(ApiError::InvalidInput(
"The specified project is not owned by this organization!".to_string(),
));
}
let organization_team_member = database::models::TeamMember::get_from_user_id_organization(
organization.id,
current_user.id.into(),
&**pool,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("You are not a member of this organization!".to_string())
})?;
let permissions = OrganizationPermissions::get_permissions_by_role(
&current_user.role,
&Some(organization_team_member),
)
.unwrap_or_default();
if permissions.contains(OrganizationPermissions::REMOVE_PROJECT) {
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE mods
SET organization_id = NULL
WHERE (id = $1)
",
project_item.inner.id as database::models::ids::ProjectId
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
database::models::TeamMember::clear_cache(project_item.inner.team_id, &redis).await?;
database::models::Project::clear_cache(
project_item.inner.id,
project_item.inner.slug,
None,
&redis,
)
.await?;
} else {
return Err(ApiError::CustomAuthentication(
"You do not have permission to add projects to this organization!".to_string(),
));
}
Ok(HttpResponse::Ok().finish())
.await
}
#[derive(Serialize, Deserialize)]
@@ -742,102 +228,20 @@ pub async fn organization_icon_edit(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
mut payload: web::Payload,
payload: web::Payload,
session_queue: web::Data<AuthQueue>,
) -> 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,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_WRITE]),
)
.await?
.1;
let string = info.into_inner().0;
let organization_item = database::models::Organization::get(&string, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified organization does not exist!".to_string())
})?;
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id(
organization_item.team_id,
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::Database)?;
let permissions =
OrganizationPermissions::get_permissions_by_role(&user.role, &team_member)
.unwrap_or_default();
if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthentication(
"You don't have permission to edit this organization's icon.".to_string(),
));
}
}
if let Some(icon) = organization_item.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, 262144, "Icons must be smaller than 256KiB").await?;
let color = crate::util::img::get_color_from_img(&bytes)?;
let hash = sha1::Sha1::from(&bytes).hexdigest();
let organization_id: OrganizationId = organization_item.id.into();
let upload_data = file_host
.upload_file(
content_type,
&format!("data/{}/{}.{}", organization_id, hash, ext.ext),
bytes.freeze(),
)
.await?;
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE organizations
SET icon_url = $1, color = $2
WHERE (id = $3)
",
format!("{}/{}", cdn_url, upload_data.file_name),
color.map(|x| x as i32),
organization_item.id as database::models::ids::OrganizationId,
)
.execute(&mut *transaction)
.await?;
database::models::Organization::clear_cache(
organization_item.id,
Some(organization_item.title),
&redis,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::InvalidInput(format!(
"Invalid format for project icon: {}",
ext.ext
)))
}
v3::organizations::organization_icon_edit(
web::Query(v3::organizations::Extension { ext: ext.ext }),
req,
info,
pool.clone(),
redis.clone(),
file_host,
payload,
session_queue,
)
.await
}
#[delete("{id}/icon")]
@@ -849,73 +253,13 @@ pub async fn delete_organization_icon(
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_WRITE]),
v3::organizations::delete_organization_icon(
req,
info,
pool.clone(),
redis.clone(),
file_host,
session_queue,
)
.await?
.1;
let string = info.into_inner().0;
let organization_item = database::models::Organization::get(&string, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified organization does not exist!".to_string())
})?;
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id(
organization_item.team_id,
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::Database)?;
let permissions =
OrganizationPermissions::get_permissions_by_role(&user.role, &team_member)
.unwrap_or_default();
if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthentication(
"You don't have permission to edit this organization's icon.".to_string(),
));
}
}
let cdn_url = dotenvy::var("CDN_URL")?;
if let Some(icon) = organization_item.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 mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE organizations
SET icon_url = NULL, color = NULL
WHERE (id = $1)
",
organization_item.id as database::models::ids::OrganizationId,
)
.execute(&mut *transaction)
.await?;
database::models::Organization::clear_cache(
organization_item.id,
Some(organization_item.title),
&redis,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
.await
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,9 @@
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::database;
use crate::database::models::image_item;
use crate::database::models::thread_item::{ThreadBuilder, ThreadMessageBuilder};
use crate::database::redis::RedisPool;
use crate::models::ids::ImageId;
use crate::models::ids::{base62_impl::parse_base62, ProjectId, UserId, VersionId};
use crate::models::images::{Image, ImageContext};
use crate::models::pats::Scopes;
use crate::models::reports::{ItemType, Report};
use crate::models::threads::{MessageBody, ThreadType};
use crate::models::reports::ItemType;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::util::img;
use crate::routes::{v3, ApiError};
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
use chrono::Utc;
use futures::StreamExt;
use serde::Deserialize;
use sqlx::PgPool;
use validator::Validate;
@@ -44,177 +33,11 @@ pub struct CreateReport {
pub async fn report_create(
req: HttpRequest,
pool: web::Data<PgPool>,
mut body: web::Payload,
body: web::Payload,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let mut transaction = pool.begin().await?;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_CREATE]),
)
.await?
.1;
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(),
closed: false,
};
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?;
for image_id in new_report.uploaded_images {
if let Some(db_image) =
image_item::Image::get(image_id.into(), &mut *transaction, &redis).await?
{
let image: Image = db_image.into();
if !matches!(image.context, ImageContext::Report { .. })
|| image.context.inner_id().is_some()
{
return Err(ApiError::InvalidInput(format!(
"Image {} is not unused and in the 'report' context",
image_id
)));
}
sqlx::query!(
"
UPDATE uploaded_images
SET report_id = $1
WHERE id = $2
",
id.0 as i64,
image_id.0 as i64
)
.execute(&mut *transaction)
.await?;
image_item::Image::clear_cache(image.id.into(), &redis).await?;
} else {
return Err(ApiError::InvalidInput(format!(
"Image {} could not be found",
image_id
)));
}
}
let thread_id = ThreadBuilder {
type_: ThreadType::Report,
members: vec![],
project_id: None,
report_id: Some(report.id),
}
.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(),
closed: false,
thread_id: thread_id.into(),
}))
v3::reports::report_create(req, pool, body, redis, session_queue).await
}
#[derive(Deserialize)]
@@ -240,65 +63,17 @@ pub async fn reports(
count: web::Query<ReportsRequestOptions>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_READ]),
v3::reports::reports(
req,
pool,
redis,
web::Query(v3::reports::ReportsRequestOptions {
count: count.count,
all: count.all,
}),
session_queue,
)
.await?
.1;
use futures::stream::TryStreamExt;
let report_ids = if user.role.is_mod() && count.all {
sqlx::query!(
"
SELECT id FROM reports
WHERE closed = FALSE
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?
} else {
sqlx::query!(
"
SELECT id FROM reports
WHERE closed = FALSE AND reporter = $1
ORDER BY created ASC
LIMIT $2;
",
user.id.0 as i64,
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<Report> = Vec::new();
for x in query_reports {
reports.push(x.into());
}
Ok(HttpResponse::Ok().json(reports))
.await
}
#[derive(Deserialize)]
@@ -314,32 +89,14 @@ pub async fn reports_get(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let report_ids: Vec<crate::database::models::ids::ReportId> =
serde_json::from_str::<Vec<crate::models::ids::ReportId>>(&ids.ids)?
.into_iter()
.map(|x| x.into())
.collect();
let reports_data =
crate::database::models::report_item::Report::get_many(&report_ids, &**pool).await?;
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_READ]),
v3::reports::reports_get(
req,
web::Query(v3::reports::ReportIds { ids: ids.ids }),
pool,
redis,
session_queue,
)
.await?
.1;
let all_reports = reports_data
.into_iter()
.filter(|x| user.role.is_mod() || x.reporter == user.id.into())
.map(|x| x.into())
.collect::<Vec<Report>>();
Ok(HttpResponse::Ok().json(all_reports))
.await
}
#[get("report/{id}")]
@@ -350,29 +107,7 @@ pub async fn report_get(
info: web::Path<(crate::models::reports::ReportId,)>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_READ]),
)
.await?
.1;
let id = info.into_inner().0.into();
let report = crate::database::models::report_item::Report::get(id, &**pool).await?;
if let Some(report) = report {
if !user.role.is_mod() && report.reporter != user.id.into() {
return Ok(HttpResponse::NotFound().body(""));
}
let report: Report = report.into();
Ok(HttpResponse::Ok().json(report))
} else {
Ok(HttpResponse::NotFound().body(""))
}
v3::reports::report_get(req, pool, redis, info, session_queue).await
}
#[derive(Deserialize, Validate)]
@@ -391,101 +126,19 @@ pub async fn report_edit(
session_queue: web::Data<AuthQueue>,
edit_report: web::Json<EditReport>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_WRITE]),
let edit_report = edit_report.into_inner();
v3::reports::report_edit(
req,
pool,
redis,
info,
session_queue,
web::Json(v3::reports::EditReport {
body: edit_report.body,
closed: edit_report.closed,
}),
)
.await?
.1;
let id = info.into_inner().0.into();
let report = crate::database::models::report_item::Report::get(id, &**pool).await?;
if let Some(report) = report {
if !user.role.is_mod() && report.reporter != user.id.into() {
return Ok(HttpResponse::NotFound().body(""));
}
let mut transaction = pool.begin().await?;
if let Some(edit_body) = &edit_report.body {
sqlx::query!(
"
UPDATE reports
SET body = $1
WHERE (id = $2)
",
edit_body,
id as crate::database::models::ids::ReportId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(edit_closed) = edit_report.closed {
if !user.role.is_mod() {
return Err(ApiError::InvalidInput(
"You cannot reopen a report!".to_string(),
));
}
ThreadMessageBuilder {
author_id: Some(user.id.into()),
body: if !edit_closed && report.closed {
MessageBody::ThreadReopen
} else {
MessageBody::ThreadClosure
},
thread_id: report.thread_id,
}
.insert(&mut transaction)
.await?;
sqlx::query!(
"
UPDATE reports
SET closed = $1
WHERE (id = $2)
",
edit_closed,
id as crate::database::models::ids::ReportId,
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
UPDATE threads
SET show_in_mod_inbox = $1
WHERE id = $2
",
!(edit_closed || report.closed),
report.thread_id.0,
)
.execute(&mut *transaction)
.await?;
}
// delete any images no longer in the body
let checkable_strings: Vec<&str> = vec![&edit_report.body]
.into_iter()
.filter_map(|x: &Option<String>| x.as_ref().map(|y| y.as_str()))
.collect();
let image_context = ImageContext::Report {
report_id: Some(id.into()),
};
img::delete_unused_images(image_context, checkable_strings, &mut transaction, &redis)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
.await
}
#[delete("report/{id}")]
@@ -496,35 +149,5 @@ pub async fn report_delete(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_DELETE]),
)
.await?;
let mut transaction = pool.begin().await?;
let id = info.into_inner().0;
let context = ImageContext::Report {
report_id: Some(id),
};
let uploaded_images =
database::models::Image::get_many_contexted(context, &mut transaction).await?;
for image in uploaded_images {
image_item::Image::remove(image.id, &mut transaction, &redis).await?;
}
let result =
crate::database::models::report_item::Report::remove_full(id.into(), &mut transaction)
.await?;
transaction.commit().await?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
v3::reports::report_delete(req, pool, info, redis, session_queue).await
}

View File

@@ -1,6 +1,5 @@
use crate::routes::ApiError;
use crate::routes::{v3, ApiError};
use actix_web::{get, web, HttpResponse};
use serde_json::json;
use sqlx::PgPool;
pub fn config(cfg: &mut web::ServiceConfig) {
@@ -9,78 +8,5 @@ pub fn config(cfg: &mut web::ServiceConfig) {
#[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))
v3::statistics::get_stats(pool).await
}

View File

@@ -1,10 +1,12 @@
use std::collections::HashMap;
use super::ApiError;
use crate::database::models;
use crate::database::models::categories::{DonationPlatform, ProjectType, ReportType, SideType};
use crate::database::models::loader_fields::LoaderFieldEnumValue;
use crate::database::redis::RedisPool;
use crate::routes::v3::tags::{LoaderData as LoaderDataV3, LoaderFieldsEnumQuery};
use crate::routes::{v2_reroute, v3};
use actix_web::{get, web, HttpResponse};
use chrono::{DateTime, Utc};
use models::categories::{Category, GameVersion, Loader};
use sqlx::PgPool;
pub fn config(cfg: &mut web::ServiceConfig) {
@@ -24,10 +26,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
#[derive(serde::Serialize, serde::Deserialize)]
pub struct CategoryData {
icon: String,
name: String,
project_type: String,
header: String,
pub icon: String,
pub name: String,
pub project_type: String,
pub header: String,
}
#[get("category")]
@@ -35,25 +37,14 @@ pub async fn category_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let results = Category::list(&**pool, &redis)
.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))
v3::tags::category_list(pool, redis).await
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct LoaderData {
icon: String,
name: String,
supported_project_types: Vec<String>,
pub icon: String,
pub name: String,
pub supported_project_types: Vec<String>,
}
#[get("loader")]
@@ -61,22 +52,26 @@ pub async fn loader_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let mut results = Loader::list(&**pool, &redis)
.await?
.into_iter()
.map(|x| LoaderData {
icon: x.icon,
name: x.loader,
supported_project_types: x.supported_project_types,
})
.collect::<Vec<_>>();
let response = v3::tags::loader_list(pool, redis).await?;
results.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(HttpResponse::Ok().json(results))
// Convert to V2 format
match v2_reroute::extract_ok_json::<Vec<LoaderDataV3>>(response).await {
Ok(loaders) => {
let loaders = loaders
.into_iter()
.map(|l| LoaderData {
icon: l.icon,
name: l.name,
supported_project_types: l.supported_project_types,
})
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(loaders))
}
Err(response) => Ok(response),
}
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, serde::Deserialize)]
pub struct GameVersionQueryData {
pub version: String,
pub version_type: String,
@@ -97,21 +92,50 @@ pub async fn game_version_list(
query: web::Query<GameVersionQuery>,
redis: web::Data<RedisPool>,
) -> 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, &redis).await?
} else {
GameVersion::list(&**pool, &redis).await?
let mut filters = HashMap::new();
if let Some(type_) = &query.type_ {
filters.insert("type".to_string(), serde_json::json!(type_));
}
.into_iter()
.map(|x| GameVersionQueryData {
version: x.version,
version_type: x.type_,
date: x.created,
major: x.major,
})
.collect();
if let Some(major) = query.major {
filters.insert("major".to_string(), serde_json::json!(major));
}
let response = v3::tags::loader_fields_list(
pool,
web::Query(LoaderFieldsEnumQuery {
loader_field: "game_versions".to_string(),
filters: Some(filters),
}),
redis,
)
.await?;
Ok(HttpResponse::Ok().json(results))
// Convert to V2 format
Ok(
match v2_reroute::extract_ok_json::<Vec<LoaderFieldEnumValue>>(response).await {
Ok(fields) => {
let fields = fields
.into_iter()
.map(|f| GameVersionQueryData {
version: f.value,
version_type: f
.metadata
.get("type")
.and_then(|m| m.as_str())
.unwrap_or_default()
.to_string(),
date: f.created,
major: f
.metadata
.get("major")
.and_then(|m| m.as_bool())
.unwrap_or_default(),
})
.collect::<Vec<_>>();
HttpResponse::Ok().json(fields)
}
Err(response) => response,
},
)
}
#[derive(serde::Serialize)]
@@ -122,17 +146,7 @@ pub struct License {
#[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)
v3::tags::license_list().await
}
#[derive(serde::Serialize)]
@@ -143,25 +157,7 @@ pub struct LicenseText {
#[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(),
))
v3::tags::license_text(params).await
}
#[derive(serde::Serialize)]
@@ -175,15 +171,7 @@ pub async fn donation_platform_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let results: Vec<DonationPlatformQueryData> = DonationPlatform::list(&**pool, &redis)
.await?
.into_iter()
.map(|x| DonationPlatformQueryData {
short: x.short,
name: x.name,
})
.collect();
Ok(HttpResponse::Ok().json(results))
v3::tags::donation_platform_list(pool, redis).await
}
#[get("report_type")]
@@ -191,8 +179,7 @@ pub async fn report_type_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let results = ReportType::list(&**pool, &redis).await?;
Ok(HttpResponse::Ok().json(results))
v3::tags::report_type_list(pool, redis).await
}
#[get("project_type")]
@@ -200,8 +187,7 @@ pub async fn project_type_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let results = ProjectType::list(&**pool, &redis).await?;
Ok(HttpResponse::Ok().json(results))
v3::tags::project_type_list(pool, redis).await
}
#[get("side_type")]
@@ -209,6 +195,24 @@ pub async fn side_type_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let results = SideType::list(&**pool, &redis).await?;
Ok(HttpResponse::Ok().json(results))
let response = v3::tags::loader_fields_list(
pool,
web::Query(LoaderFieldsEnumQuery {
loader_field: "client_side".to_string(), // same as server_side
filters: None,
}),
redis,
)
.await?;
// Convert to V2 format
Ok(
match v2_reroute::extract_ok_json::<Vec<LoaderFieldEnumValue>>(response).await {
Ok(fields) => {
let fields = fields.into_iter().map(|f| f.value).collect::<Vec<_>>();
HttpResponse::Ok().json(fields)
}
Err(response) => response,
},
)
}

View File

@@ -1,15 +1,8 @@
use crate::auth::{get_user_from_headers, is_authorized};
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::team_item::TeamAssociationId;
use crate::database::models::{Organization, Team, TeamMember, User};
use crate::database::redis::RedisPool;
use crate::database::Project;
use crate::models::notifications::NotificationBody;
use crate::models::pats::Scopes;
use crate::models::teams::{OrganizationPermissions, ProjectPermissions, TeamId};
use crate::models::users::UserId;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::routes::{v3, ApiError};
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
@@ -41,75 +34,7 @@ pub async fn team_members_get_project(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
let project_data = crate::database::models::Project::get(&string, &**pool, &redis).await?;
if let Some(project) = project_data {
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
)
.await
.map(|x| x.1)
.ok();
if !is_authorized(&project.inner, &current_user, &pool).await? {
return Ok(HttpResponse::NotFound().body(""));
}
let mut members_data =
TeamMember::get_from_team_full(project.inner.team_id, &**pool, &redis).await?;
let mut member_user_ids = members_data.iter().map(|x| x.user_id).collect::<Vec<_>>();
// Adds the organization's team members to the list of members, if the project is associated with an organization
if let Some(oid) = project.inner.organization_id {
let organization_data = Organization::get_id(oid, &**pool, &redis).await?;
if let Some(organization_data) = organization_data {
let org_team =
TeamMember::get_from_team_full(organization_data.team_id, &**pool, &redis)
.await?;
for member in org_team {
if !member_user_ids.contains(&member.user_id) {
member_user_ids.push(member.user_id);
members_data.push(member);
}
}
}
}
let users =
crate::database::models::User::get_many_ids(&member_user_ids, &**pool, &redis).await?;
let user_id = current_user.as_ref().map(|x| x.id.into());
let logged_in = current_user
.and_then(|user| {
members_data
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members: Vec<_> = members_data
.into_iter()
.filter(|x| {
logged_in
|| x.accepted
|| user_id
.map(|y: crate::database::models::UserId| y == x.user_id)
.unwrap_or(false)
})
.flat_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)
})
})
.collect();
Ok(HttpResponse::Ok().json(team_members))
} else {
Ok(HttpResponse::NotFound().body(""))
}
v3::teams::team_members_get_project(req, info, pool, redis, session_queue).await
}
#[get("{id}/members")]
@@ -120,61 +45,7 @@ pub async fn team_members_get_organization(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
let organization_data =
crate::database::models::Organization::get(&string, &**pool, &redis).await?;
if let Some(organization) = organization_data {
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_READ]),
)
.await
.map(|x| x.1)
.ok();
let members_data =
TeamMember::get_from_team_full(organization.team_id, &**pool, &redis).await?;
let users = crate::database::models::User::get_many_ids(
&members_data.iter().map(|x| x.user_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let user_id = current_user.as_ref().map(|x| x.id.into());
let logged_in = current_user
.and_then(|user| {
members_data
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members: Vec<_> = members_data
.into_iter()
.filter(|x| {
logged_in
|| x.accepted
|| user_id
.map(|y: crate::database::models::UserId| y == x.user_id)
.unwrap_or(false)
})
.flat_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)
})
})
.collect();
Ok(HttpResponse::Ok().json(team_members))
} else {
Ok(HttpResponse::NotFound().body(""))
}
v3::teams::team_members_get_organization(req, info, pool, redis, session_queue).await
}
// Returns all members of a team, but not necessarily those of a project-team's organization (unlike team_members_get_project)
@@ -186,53 +57,7 @@ pub async fn team_members_get(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let members_data = TeamMember::get_from_team_full(id.into(), &**pool, &redis).await?;
let users = crate::database::models::User::get_many_ids(
&members_data.iter().map(|x| x.user_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
)
.await
.map(|x| x.1)
.ok();
let user_id = current_user.as_ref().map(|x| x.id.into());
let logged_in = current_user
.and_then(|user| {
members_data
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members: Vec<_> = members_data
.into_iter()
.filter(|x| {
logged_in
|| x.accepted
|| user_id
.map(|y: crate::database::models::UserId| y == x.user_id)
.unwrap_or(false)
})
.flat_map(|data| {
users
.iter()
.find(|x| x.id == data.user_id)
.map(|user| crate::models::teams::TeamMember::from(data, user.clone(), !logged_in))
})
.collect();
Ok(HttpResponse::Ok().json(team_members))
v3::teams::team_members_get(req, info, pool, redis, session_queue).await
}
#[derive(Serialize, Deserialize)]
@@ -248,61 +73,14 @@ pub async fn teams_get(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> 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, &redis).await?;
let users = crate::database::models::User::get_many_ids(
&teams_data.iter().map(|x| x.user_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
v3::teams::teams_get(
req,
web::Query(v3::teams::TeamIds { ids: ids.ids }),
pool,
redis,
session_queue,
)
.await
.map(|x| x.1)
.ok();
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 (_, member_data) in &teams_groups {
let members = member_data.collect::<Vec<_>>();
let logged_in = current_user
.as_ref()
.and_then(|user| {
members
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members = members
.into_iter()
.filter(|x| logged_in || x.accepted)
.flat_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)
})
});
teams.push(team_members.collect());
}
Ok(HttpResponse::Ok().json(teams))
}
#[post("{id}/join")]
@@ -313,53 +91,7 @@ pub async fn join_team(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let team_id = info.into_inner().0.into();
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
)
.await?
.1;
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,
None,
Some(true),
None,
None,
&mut transaction,
)
.await?;
User::clear_project_cache(&[current_user.id.into()], &redis).await?;
TeamMember::clear_cache(team_id, &redis).await?;
transaction.commit().await?;
} else {
return Err(ApiError::InvalidInput(
"There is no pending request from this team".to_string(),
));
}
Ok(HttpResponse::NoContent().body(""))
v3::teams::join_team(req, info, pool, redis, session_queue).await
}
fn default_role() -> String {
@@ -394,165 +126,22 @@ pub async fn add_team_member(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> 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,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
v3::teams::add_team_member(
req,
info,
pool,
web::Json(v3::teams::NewTeamMember {
user_id: new_member.user_id,
role: new_member.role.clone(),
permissions: new_member.permissions,
organization_permissions: new_member.organization_permissions,
payouts_split: new_member.payouts_split,
ordering: new_member.ordering,
}),
redis,
session_queue,
)
.await?
.1;
let team_association = Team::get_association(team_id, &**pool)
.await?
.ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?;
let member = TeamMember::get_from_user_id(team_id, current_user.id.into(), &**pool).await?;
match team_association {
// If team is associated with a project, check if they have permissions to invite users to that project
TeamAssociationId::Project(pid) => {
let organization =
Organization::get_associated_organization_project_id(pid, &**pool).await?;
let organization_team_member = if let Some(organization) = &organization {
TeamMember::get_from_user_id(organization.team_id, current_user.id.into(), &**pool)
.await?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&current_user.role,
&member,
&organization_team_member,
)
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::MANAGE_INVITES) {
return Err(ApiError::CustomAuthentication(
"You don't have permission to invite users to this team".to_string(),
));
}
if !permissions.contains(new_member.permissions) {
return Err(ApiError::InvalidInput(
"The new member has permissions that you don't have".to_string(),
));
}
if new_member.organization_permissions.is_some() {
return Err(ApiError::InvalidInput(
"The organization permissions of a project team member cannot be set"
.to_string(),
));
}
}
// If team is associated with an organization, check if they have permissions to invite users to that organization
TeamAssociationId::Organization(_) => {
let organization_permissions =
OrganizationPermissions::get_permissions_by_role(&current_user.role, &member)
.unwrap_or_default();
if !organization_permissions.contains(OrganizationPermissions::MANAGE_INVITES) {
return Err(ApiError::CustomAuthentication(
"You don't have permission to invite users to this organization".to_string(),
));
}
if !organization_permissions
.contains(new_member.organization_permissions.unwrap_or_default())
{
return Err(ApiError::InvalidInput(
"The new member has organization permissions that you don't have".to_string(),
));
}
if !organization_permissions
.contains(OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS)
&& !new_member.permissions.is_empty()
{
return Err(ApiError::CustomAuthentication(
"You do not have permission to give this user default project permissions. Ensure 'permissions' is set if it is not, and empty (0)."
.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 =
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_id(new_member.user_id.into(), &**pool, &redis)
.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,
organization_permissions: new_member.organization_permissions,
accepted: false,
payouts_split: new_member.payouts_split,
ordering: new_member.ordering,
}
.insert(&mut transaction)
.await?;
match team_association {
TeamAssociationId::Project(pid) => {
NotificationBuilder {
body: NotificationBody::TeamInvite {
project_id: pid.into(),
team_id: team_id.into(),
invited_by: current_user.id,
role: new_member.role.clone(),
},
}
.insert(new_member.user_id.into(), &mut transaction, &redis)
.await?;
}
TeamAssociationId::Organization(oid) => {
NotificationBuilder {
body: NotificationBody::OrganizationInvite {
organization_id: oid.into(),
team_id: team_id.into(),
invited_by: current_user.id,
role: new_member.role.clone(),
},
}
.insert(new_member.user_id.into(), &mut transaction, &redis)
.await?;
}
}
TeamMember::clear_cache(team_id, &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
.await
}
#[derive(Serialize, Deserialize, Clone)]
@@ -573,143 +162,21 @@ pub async fn edit_team_member(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> 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,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
v3::teams::edit_team_member(
req,
info,
pool,
web::Json(v3::teams::EditTeamMember {
permissions: edit_member.permissions,
organization_permissions: edit_member.organization_permissions,
role: edit_member.role.clone(),
payouts_split: edit_member.payouts_split,
ordering: edit_member.ordering,
}),
redis,
session_queue,
)
.await?
.1;
let team_association = Team::get_association(id, &**pool)
.await?
.ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?;
let member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?;
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(),
));
}
match team_association {
TeamAssociationId::Project(project_id) => {
let organization =
Organization::get_associated_organization_project_id(project_id, &**pool).await?;
let organization_team_member = if let Some(organization) = &organization {
TeamMember::get_from_user_id(organization.team_id, current_user.id.into(), &**pool)
.await?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&current_user.role,
&member.clone(),
&organization_team_member,
)
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::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 !permissions.contains(new_permissions) {
return Err(ApiError::InvalidInput(
"The new permissions have permissions that you don't have".to_string(),
));
}
}
if edit_member.organization_permissions.is_some() {
return Err(ApiError::InvalidInput(
"The organization permissions of a project team member cannot be edited"
.to_string(),
));
}
}
TeamAssociationId::Organization(_) => {
let organization_permissions =
OrganizationPermissions::get_permissions_by_role(&current_user.role, &member)
.unwrap_or_default();
if !organization_permissions.contains(OrganizationPermissions::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.organization_permissions {
if !organization_permissions.contains(new_permissions) {
return Err(ApiError::InvalidInput(
"The new organization permissions have permissions that you don't have"
.to_string(),
));
}
}
if edit_member.permissions.is_some()
&& !organization_permissions
.contains(OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS)
{
return Err(ApiError::CustomAuthentication(
"You do not have permission to give this user default project permissions."
.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.organization_permissions,
edit_member.role.clone(),
None,
edit_member.payouts_split,
edit_member.ordering,
&mut transaction,
)
.await?;
TeamMember::clear_cache(id, &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
.await
}
#[derive(Deserialize)]
@@ -726,94 +193,17 @@ pub async fn transfer_ownership(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
v3::teams::transfer_ownership(
req,
info,
pool,
web::Json(v3::teams::TransferOwnership {
user_id: new_owner.user_id,
}),
redis,
session_queue,
)
.await?
.1;
// Forbid transferring ownership of a project team that is owned by an organization
// These are owned by the organization owner, and must be removed from the organization first
let pid = Team::get_association(id.into(), &**pool).await?;
if let Some(TeamAssociationId::Project(pid)) = pid {
let result = Project::get_id(pid, &**pool, &redis).await?;
if let Some(project_item) = result {
if project_item.inner.organization_id.is_some() {
return Err(ApiError::InvalidInput(
"You cannot transfer ownership of a project team that is owend by an organization"
.to_string(),
));
}
}
}
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,
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(ProjectPermissions::all()),
Some(OrganizationPermissions::all()),
Some(crate::models::teams::OWNER_ROLE.to_string()),
None,
None,
None,
&mut transaction,
)
.await?;
TeamMember::clear_cache(id.into(), &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
.await
}
#[delete("{id}/members/{user_id}")]
@@ -824,126 +214,5 @@ pub async fn remove_team_member(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> 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,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
)
.await?
.1;
let team_association = Team::get_association(id, &**pool)
.await?
.ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?;
let member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?;
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?;
// Organization attached to a project this team is attached to
match team_association {
TeamAssociationId::Project(pid) => {
let organization =
Organization::get_associated_organization_project_id(pid, &**pool).await?;
let organization_team_member = if let Some(organization) = &organization {
TeamMember::get_from_user_id(
organization.team_id,
current_user.id.into(),
&**pool,
)
.await?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&current_user.role,
&member,
&organization_team_member,
)
.unwrap_or_default();
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 Some(delete_member.user_id) == member.as_ref().map(|m| m.user_id)
|| permissions.contains(ProjectPermissions::REMOVE_MEMBER)
// true as if the permission exists, but the member does not, they are part of an org
{
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 Some(delete_member.user_id) == member.as_ref().map(|m| m.user_id)
|| permissions.contains(ProjectPermissions::MANAGE_INVITES)
// true as if the permission exists, but the member does not, they are part of an org
{
// 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(),
));
}
}
TeamAssociationId::Organization(_) => {
let organization_permissions =
OrganizationPermissions::get_permissions_by_role(&current_user.role, &member)
.unwrap_or_default();
// Organization teams requires a TeamMember, so we can 'unwrap'
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 Some(delete_member.user_id) == member.map(|m| m.user_id)
|| organization_permissions.contains(OrganizationPermissions::REMOVE_MEMBER)
{
TeamMember::delete(id, user_id, &mut transaction).await?;
} else {
return Err(ApiError::CustomAuthentication(
"You do not have permission to remove a member from this organization"
.to_string(),
));
}
} else if Some(delete_member.user_id) == member.map(|m| m.user_id)
|| organization_permissions.contains(OrganizationPermissions::MANAGE_INVITES)
{
// 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 an organization invite".to_string(),
));
}
}
}
TeamMember::clear_cache(id, &redis).await?;
User::clear_project_cache(&[delete_member.user_id], &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
v3::teams::remove_team_member(req, info, pool, redis, session_queue).await
}

View File

@@ -1,23 +1,12 @@
use std::sync::Arc;
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::database;
use crate::database::models::image_item;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::ids::ThreadMessageId;
use crate::models::images::{Image, ImageContext};
use crate::models::notifications::NotificationBody;
use crate::models::pats::Scopes;
use crate::models::projects::ProjectStatus;
use crate::models::threads::{MessageBody, Thread, ThreadId, ThreadType};
use crate::models::users::User;
use crate::models::threads::{MessageBody, ThreadId};
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::routes::{v3, ApiError};
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
use futures::TryStreamExt;
use serde::Deserialize;
use sqlx::PgPool;
@@ -33,194 +22,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(threads_get);
}
pub async fn is_authorized_thread(
thread: &database::models::Thread,
user: &User,
pool: &PgPool,
) -> Result<bool, ApiError> {
if user.role.is_mod() {
return Ok(true);
}
let user_id: database::models::UserId = user.id.into();
Ok(match thread.type_ {
ThreadType::Report => {
if let Some(report_id) = thread.report_id {
let report_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM reports WHERE id = $1 AND reporter = $2)",
report_id as database::models::ids::ReportId,
user_id as database::models::ids::UserId,
)
.fetch_one(pool)
.await?
.exists;
report_exists.unwrap_or(false)
} else {
false
}
}
ThreadType::Project => {
if let Some(project_id) = thread.project_id {
let project_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 WHERE m.id = $1)",
project_id as database::models::ids::ProjectId,
user_id as database::models::ids::UserId,
)
.fetch_one(pool)
.await?
.exists;
project_exists.unwrap_or(false)
} else {
false
}
}
ThreadType::DirectMessage => thread.members.contains(&user_id),
})
}
pub async fn filter_authorized_threads(
threads: Vec<database::models::Thread>,
user: &User,
pool: &web::Data<PgPool>,
redis: &RedisPool,
) -> Result<Vec<Thread>, ApiError> {
let user_id: database::models::UserId = user.id.into();
let mut return_threads = Vec::new();
let mut check_threads = Vec::new();
for thread in threads {
if user.role.is_mod()
|| (thread.type_ == ThreadType::DirectMessage && thread.members.contains(&user_id))
{
return_threads.push(thread);
} else {
check_threads.push(thread);
}
}
if !check_threads.is_empty() {
let project_thread_ids = check_threads
.iter()
.filter(|x| x.type_ == ThreadType::Project)
.flat_map(|x| x.project_id.map(|x| x.0))
.collect::<Vec<_>>();
if !project_thread_ids.is_empty() {
sqlx::query!(
"
SELECT m.id FROM mods m
INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2
WHERE m.id = ANY($1)
",
&*project_thread_ids,
user_id as database::models::ids::UserId,
)
.fetch_many(&***pool)
.try_for_each(|e| {
if let Some(row) = e.right() {
check_threads.retain(|x| {
let bool = x.project_id.map(|x| x.0) == Some(row.id);
if bool {
return_threads.push(x.clone());
}
!bool
});
}
futures::future::ready(Ok(()))
})
.await?;
}
let report_thread_ids = check_threads
.iter()
.filter(|x| x.type_ == ThreadType::Report)
.flat_map(|x| x.report_id.map(|x| x.0))
.collect::<Vec<_>>();
if !report_thread_ids.is_empty() {
sqlx::query!(
"
SELECT id FROM reports
WHERE id = ANY($1) AND reporter = $2
",
&*report_thread_ids,
user_id as database::models::ids::UserId,
)
.fetch_many(&***pool)
.try_for_each(|e| {
if let Some(row) = e.right() {
check_threads.retain(|x| {
let bool = x.report_id.map(|x| x.0) == Some(row.id);
if bool {
return_threads.push(x.clone());
}
!bool
});
}
futures::future::ready(Ok(()))
})
.await?;
}
}
let mut user_ids = return_threads
.iter()
.flat_map(|x| x.members.clone())
.collect::<Vec<database::models::UserId>>();
user_ids.append(
&mut return_threads
.iter()
.flat_map(|x| {
x.messages
.iter()
.filter_map(|x| x.author_id)
.collect::<Vec<_>>()
})
.collect::<Vec<database::models::UserId>>(),
);
let users: Vec<User> = database::models::User::get_many_ids(&user_ids, &***pool, redis)
.await?
.into_iter()
.map(From::from)
.collect();
let mut final_threads = Vec::new();
for thread in return_threads {
let mut authors = thread.members.clone();
authors.append(
&mut thread
.messages
.iter()
.filter_map(|x| x.author_id)
.collect::<Vec<_>>(),
);
final_threads.push(Thread::from(
thread,
users
.iter()
.filter(|x| authors.contains(&x.id.into()))
.cloned()
.collect(),
user,
));
}
Ok(final_threads)
}
#[get("{id}")]
pub async fn thread_get(
req: HttpRequest,
@@ -229,42 +30,7 @@ pub async fn thread_get(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0.into();
let thread_data = database::models::Thread::get(string, &**pool).await?;
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_READ]),
)
.await?
.1;
if let Some(mut data) = thread_data {
if is_authorized_thread(&data, &user, &pool).await? {
let authors = &mut data.members;
authors.append(
&mut data
.messages
.iter()
.filter_map(|x| x.author_id)
.collect::<Vec<_>>(),
);
let users: Vec<User> = database::models::User::get_many_ids(authors, &**pool, &redis)
.await?
.into_iter()
.map(From::from)
.collect();
return Ok(HttpResponse::Ok().json(Thread::from(data, users, &user)));
}
}
Ok(HttpResponse::NotFound().body(""))
v3::threads::thread_get(req, info, pool, redis, session_queue).await
}
#[derive(Deserialize)]
@@ -280,27 +46,14 @@ pub async fn threads_get(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_READ]),
v3::threads::threads_get(
req,
web::Query(v3::threads::ThreadIds { ids: ids.ids }),
pool,
redis,
session_queue,
)
.await?
.1;
let thread_ids: Vec<database::models::ids::ThreadId> =
serde_json::from_str::<Vec<ThreadId>>(&ids.ids)?
.into_iter()
.map(|x| x.into())
.collect();
let threads_data = database::models::Thread::get_many(&thread_ids, &**pool).await?;
let threads = filter_authorized_threads(threads_data, &user, &pool, &redis).await?;
Ok(HttpResponse::Ok().json(threads))
.await
}
#[derive(Deserialize)]
@@ -317,193 +70,18 @@ pub async fn thread_send_message(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_WRITE]),
let new_message = new_message.into_inner();
v3::threads::thread_send_message(
req,
info,
pool,
web::Json(v3::threads::NewThreadMessage {
body: new_message.body,
}),
redis,
session_queue,
)
.await?
.1;
let string: database::models::ThreadId = info.into_inner().0.into();
if let MessageBody::Text {
body,
replying_to,
private,
..
} = &new_message.body
{
if body.len() > 65536 {
return Err(ApiError::InvalidInput(
"Input body is too long!".to_string(),
));
}
if *private && !user.role.is_mod() {
return Err(ApiError::InvalidInput(
"You are not allowed to send private messages!".to_string(),
));
}
if let Some(replying_to) = replying_to {
let thread_message =
database::models::ThreadMessage::get((*replying_to).into(), &**pool).await?;
if let Some(thread_message) = thread_message {
if thread_message.thread_id != string {
return Err(ApiError::InvalidInput(
"Message replied to is from another thread!".to_string(),
));
}
} else {
return Err(ApiError::InvalidInput(
"Message replied to does not exist!".to_string(),
));
}
}
} else {
return Err(ApiError::InvalidInput(
"You may only send text messages through this route!".to_string(),
));
}
let result = database::models::Thread::get(string, &**pool).await?;
if let Some(thread) = result {
if !is_authorized_thread(&thread, &user, &pool).await? {
return Ok(HttpResponse::NotFound().body(""));
}
let mut transaction = pool.begin().await?;
let id = ThreadMessageBuilder {
author_id: Some(user.id.into()),
body: new_message.body.clone(),
thread_id: thread.id,
}
.insert(&mut transaction)
.await?;
let mod_notif = if let Some(project_id) = thread.project_id {
let project = database::models::Project::get_id(project_id, &**pool, &redis).await?;
if let Some(project) = project {
if project.inner.status != ProjectStatus::Processing && user.role.is_mod() {
let members = database::models::TeamMember::get_from_team_full(
project.inner.team_id,
&**pool,
&redis,
)
.await?;
NotificationBuilder {
body: NotificationBody::ModeratorMessage {
thread_id: thread.id.into(),
message_id: id.into(),
project_id: Some(project.inner.id.into()),
report_id: None,
},
}
.insert_many(
members.into_iter().map(|x| x.user_id).collect(),
&mut transaction,
&redis,
)
.await?;
}
}
!user.role.is_mod()
} else if let Some(report_id) = thread.report_id {
let report = database::models::report_item::Report::get(report_id, &**pool).await?;
if let Some(report) = report {
if report.closed && !user.role.is_mod() {
return Err(ApiError::InvalidInput(
"You may not reply to a closed report".to_string(),
));
}
if user.id != report.reporter.into() {
NotificationBuilder {
body: NotificationBody::ModeratorMessage {
thread_id: thread.id.into(),
message_id: id.into(),
project_id: None,
report_id: Some(report.id.into()),
},
}
.insert(report.reporter, &mut transaction, &redis)
.await?;
}
}
!user.role.is_mod()
} else {
false
};
sqlx::query!(
"
UPDATE threads
SET show_in_mod_inbox = $1
WHERE id = $2
",
mod_notif,
thread.id.0,
)
.execute(&mut *transaction)
.await?;
if let MessageBody::Text {
associated_images, ..
} = &new_message.body
{
for image_id in associated_images {
if let Some(db_image) =
image_item::Image::get((*image_id).into(), &mut *transaction, &redis).await?
{
let image: Image = db_image.into();
if !matches!(image.context, ImageContext::ThreadMessage { .. })
|| image.context.inner_id().is_some()
{
return Err(ApiError::InvalidInput(format!(
"Image {} is not unused and in the 'thread_message' context",
image_id
)));
}
sqlx::query!(
"
UPDATE uploaded_images
SET thread_message_id = $1
WHERE id = $2
",
thread.id.0,
image_id.0 as i64
)
.execute(&mut *transaction)
.await?;
image_item::Image::clear_cache(image.id.into(), &redis).await?;
} else {
return Err(ApiError::InvalidInput(format!(
"Image {} does not exist",
image_id
)));
}
}
}
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
.await
}
#[get("inbox")]
@@ -513,30 +91,7 @@ pub async fn moderation_inbox(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_READ]),
)
.await?;
let ids = sqlx::query!(
"
SELECT id
FROM threads
WHERE show_in_mod_inbox = TRUE
"
)
.fetch_many(&**pool)
.try_filter_map(|e| async { Ok(e.right().map(|m| database::models::ThreadId(m.id))) })
.try_collect::<Vec<database::models::ThreadId>>()
.await?;
let threads_data = database::models::Thread::get_many(&ids, &**pool).await?;
let threads = filter_authorized_threads(threads_data, &user, &pool, &redis).await?;
Ok(HttpResponse::Ok().json(threads))
v3::threads::moderation_inbox(req, pool, redis, session_queue).await
}
#[post("{id}/read")]
@@ -547,32 +102,7 @@ pub async fn thread_read(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_READ]),
)
.await?;
let id = info.into_inner().0;
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE threads
SET show_in_mod_inbox = FALSE
WHERE id = $1
",
id.0 as i64,
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
v3::threads::thread_read(req, info, pool, redis, session_queue).await
}
#[delete("{id}")]
@@ -584,45 +114,5 @@ pub async fn message_delete(
session_queue: web::Data<AuthQueue>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_WRITE]),
)
.await?
.1;
let result = database::models::ThreadMessage::get(info.into_inner().0.into(), &**pool).await?;
if let Some(thread) = result {
if !user.role.is_mod() && thread.author_id != Some(user.id.into()) {
return Err(ApiError::CustomAuthentication(
"You cannot delete this message!".to_string(),
));
}
let mut transaction = pool.begin().await?;
let context = ImageContext::ThreadMessage {
thread_message_id: Some(thread.id.into()),
};
let images = database::Image::get_many_contexted(context, &mut transaction).await?;
let cdn_url = dotenvy::var("CDN_URL")?;
for image in images {
let name = image.url.split(&format!("{cdn_url}/")).nth(1);
if let Some(icon_path) = name {
file_host.delete_file_version("", icon_path).await?;
}
database::Image::remove(image.id, &mut transaction, &redis).await?;
}
database::models::ThreadMessage::remove_full(thread.id, &mut transaction).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
v3::threads::message_delete(req, info, pool, redis, session_queue, file_host).await
}

View File

@@ -1,27 +1,17 @@
use crate::auth::get_user_from_headers;
use crate::database::models::User;
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::collections::{Collection, CollectionStatus};
use crate::models::notifications::Notification;
use crate::models::pats::Scopes;
use crate::models::projects::Project;
use crate::models::users::{
Badges, Payout, PayoutStatus, RecipientStatus, Role, UserId, UserPayoutData,
};
use crate::models::users::{Badges, Role};
use crate::models::v2::projects::LegacyProject;
use crate::queue::payouts::PayoutsQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::util::routes::read_from_payload;
use crate::util::validate::validation_errors_to_string;
use crate::routes::{v2_reroute, v3, ApiError};
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
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::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use validator::Validate;
@@ -54,24 +44,7 @@ pub async fn user_auth_get(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let (scopes, mut user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_READ]),
)
.await?;
if !scopes.contains(Scopes::USER_READ_EMAIL) {
user.email = None;
}
if !scopes.contains(Scopes::PAYOUTS_READ) {
user.payout_data = None;
}
Ok(HttpResponse::Ok().json(user))
v3::users::user_auth_get(req, pool, redis, session_queue).await
}
#[derive(Serialize, Deserialize)]
@@ -85,13 +58,7 @@ pub async fn users_get(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user_ids = serde_json::from_str::<Vec<String>>(&ids.ids)?;
let users_data = User::get_many(&user_ids, &**pool, &redis).await?;
let users: Vec<crate::models::users::User> = users_data.into_iter().map(From::from).collect();
Ok(HttpResponse::Ok().json(users))
v3::users::users_get(web::Query(v3::users::UserIds { ids: ids.ids }), pool, redis).await
}
#[get("{id}")]
@@ -100,14 +67,7 @@ pub async fn user_get(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user_data = User::get(&info.into_inner().0, &**pool, &redis).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(""))
}
v3::users::user_get(info, pool, redis).await
}
#[get("{user_id}/projects")]
@@ -118,39 +78,16 @@ pub async fn projects_list(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
)
.await
.map(|x| x.1)
.ok();
let response =
v3::users::projects_list(req, info, pool.clone(), redis.clone(), session_queue).await?;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
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, &redis).await?;
let response: Vec<_> =
crate::database::Project::get_many_ids(&project_data, &**pool, &redis)
.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(""))
// Convert to V2 projects
match v2_reroute::extract_ok_json::<Vec<Project>>(response).await {
Ok(project) => {
let legacy_projects = LegacyProject::from_many(project, &**pool, &redis).await?;
Ok(HttpResponse::Ok().json(legacy_projects))
}
Err(response) => Ok(response),
}
}
@@ -162,40 +99,7 @@ pub async fn collections_list(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_READ]),
)
.await
.map(|x| x.1)
.ok();
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
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_collections(id, &**pool).await?;
let response: Vec<_> =
crate::database::models::Collection::get_many(&project_data, &**pool, &redis)
.await?
.into_iter()
.filter(|x| can_view_private || matches!(x.status, CollectionStatus::Listed))
.map(Collection::from)
.collect();
Ok(HttpResponse::Ok().json(response))
} else {
Ok(HttpResponse::NotFound().body(""))
}
v3::users::collections_list(req, info, pool, redis, session_queue).await
}
#[get("{user_id}/organizations")]
@@ -206,79 +110,7 @@ pub async fn orgs_list(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
)
.await
.map(|x| x.1)
.ok();
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
let org_data = User::get_organizations(id, &**pool).await?;
let organizations_data =
crate::database::models::organization_item::Organization::get_many_ids(
&org_data, &**pool, &redis,
)
.await?;
let team_ids = organizations_data
.iter()
.map(|x| x.team_id)
.collect::<Vec<_>>();
let teams_data = crate::database::models::TeamMember::get_from_team_full_many(
&team_ids, &**pool, &redis,
)
.await?;
let users = User::get_many_ids(
&teams_data.iter().map(|x| x.user_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let mut organizations = vec![];
let mut team_groups = HashMap::new();
for item in teams_data {
team_groups.entry(item.team_id).or_insert(vec![]).push(item);
}
for data in organizations_data {
let members_data = team_groups.remove(&data.team_id).unwrap_or(vec![]);
let logged_in = user
.as_ref()
.and_then(|user| {
members_data
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members: Vec<_> = members_data
.into_iter()
.filter(|x| logged_in || x.accepted || id == x.user_id)
.flat_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)
})
})
.collect();
let organization = crate::models::organizations::Organization::from(data, team_members);
organizations.push(organization);
}
Ok(HttpResponse::Ok().json(organizations))
} else {
Ok(HttpResponse::NotFound().body(""))
}
v3::users::orgs_list(req, info, pool, redis, session_queue).await
}
lazy_static! {
@@ -316,137 +148,22 @@ pub async fn user_edit(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let (_scopes, user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_WRITE]),
let new_user = new_user.into_inner();
v3::users::user_edit(
req,
info,
web::Json(v3::users::EditUser {
username: new_user.username,
name: new_user.name,
bio: new_user.bio,
role: new_user.role,
badges: new_user.badges,
}),
pool,
redis,
session_queue,
)
.await?;
new_user
.validate()
.map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(actual_user) = id_option {
let id = actual_user.id;
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 = User::get(username, &**pool, &redis).await?;
if existing_user_id_option
.map(|x| UserId::from(x.id))
.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(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?;
}
User::clear_caches(&[(id, Some(actual_user.username))], &redis).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(""))
}
.await
}
#[derive(Serialize, Deserialize)]
@@ -463,75 +180,20 @@ pub async fn user_icon_edit(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
mut payload: web::Payload,
payload: web::Payload,
session_queue: web::Data<AuthQueue>,
) -> 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,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_WRITE]),
)
.await?
.1;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(actual_user) = id_option {
if user.id != actual_user.id.into() && !user.role.is_mod() {
return Err(ApiError::CustomAuthentication(
"You don't have permission to edit this user's icon.".to_string(),
));
}
let icon_url = actual_user.avatar_url;
let user_id: UserId = actual_user.id.into();
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),
actual_user.id as crate::database::models::ids::UserId,
)
.execute(&**pool)
.await?;
User::clear_caches(&[(actual_user.id, None)], &redis).await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Err(ApiError::InvalidInput(format!(
"Invalid format for user icon: {}",
ext.ext
)))
}
v3::users::user_icon_edit(
web::Query(v3::users::Extension { ext: ext.ext }),
req,
info,
pool,
redis,
file_host,
payload,
session_queue,
)
.await
}
#[derive(Deserialize)]
@@ -553,44 +215,18 @@ pub async fn user_delete(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_DELETE]),
let removal_type = removal_type.into_inner();
v3::users::user_delete(
req,
info,
pool,
web::Query(v3::users::RemovalType {
removal_type: removal_type.removal_type,
}),
redis,
session_queue,
)
.await?
.1;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
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 = User::remove(
id,
removal_type.removal_type == "full",
&mut transaction,
&redis,
)
.await?;
transaction.commit().await?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
.await
}
#[get("{id}/follows")]
@@ -601,52 +237,7 @@ pub async fn user_follows(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_READ]),
)
.await?
.1;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
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_ids(&project_ids, &**pool, &redis)
.await?
.into_iter()
.map(Project::from)
.collect();
Ok(HttpResponse::Ok().json(projects))
} else {
Ok(HttpResponse::NotFound().body(""))
}
v3::users::user_follows(req, info, pool, redis, session_queue).await
}
#[get("{id}/notifications")]
@@ -657,39 +248,7 @@ pub async fn user_notifications(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_READ]),
)
.await?
.1;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
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, &redis,
)
.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(""))
}
v3::users::user_notifications(req, info, pool, redis, session_queue).await
}
#[get("{id}/payouts")]
@@ -700,74 +259,7 @@ pub async fn user_payouts(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAYOUTS_READ]),
)
.await?
.1;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
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: PayoutStatus::from_string(&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(""))
}
v3::users::user_payouts(req, info, pool, redis, session_queue).await
}
#[derive(Deserialize)]
@@ -785,44 +277,18 @@ pub async fn user_payouts_fees(
session_queue: web::Data<AuthQueue>,
payouts_queue: web::Data<Mutex<PayoutsQueue>>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAYOUTS_READ]),
v3::users::user_payouts_fees(
req,
info,
web::Query(v3::users::FeeEstimateAmount {
amount: amount.amount,
}),
pool,
redis,
session_queue,
payouts_queue,
)
.await?
.1;
let actual_user = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(actual_user) = actual_user {
if !user.role.is_admin() && user.id != actual_user.id.into() {
return Err(ApiError::CustomAuthentication(
"You do not have permission to request payouts of this user!".to_string(),
));
}
if let Some(UserPayoutData {
trolley_id: Some(trolley_id),
..
}) = user.payout_data
{
let payouts = payouts_queue
.lock()
.await
.get_estimated_fees(&trolley_id, amount.amount)
.await?;
Ok(HttpResponse::Ok().json(payouts))
} else {
Err(ApiError::InvalidInput(
"You must set up your trolley account first!".to_string(),
))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
.await
}
#[derive(Deserialize)]
@@ -840,87 +306,16 @@ pub async fn user_payouts_request(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let mut payouts_queue = payouts_queue.lock().await;
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAYOUTS_WRITE]),
v3::users::user_payouts_request(
req,
info,
pool,
web::Json(v3::users::PayoutData {
amount: data.amount,
}),
payouts_queue,
redis,
session_queue,
)
.await?
.1;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
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(UserPayoutData {
trolley_id: Some(trolley_id),
trolley_status: Some(trolley_status),
balance,
..
}) = user.payout_data
{
if trolley_status == RecipientStatus::Active {
return if data.amount < balance {
let mut transaction = pool.begin().await?;
let (batch_id, payment_id) =
payouts_queue.send_payout(&trolley_id, data.amount).await?;
sqlx::query!(
"
INSERT INTO historical_payouts (user_id, amount, status, batch_id, payment_id)
VALUES ($1, $2, $3, $4, $5)
",
id as crate::database::models::ids::UserId,
data.amount,
"processing",
batch_id,
payment_id,
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
UPDATE users
SET balance = balance - $1
WHERE id = $2
",
data.amount,
id as crate::database::models::ids::UserId
)
.execute(&mut *transaction)
.await?;
User::clear_caches(&[(id, None)], &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::InvalidInput(
"You do not have enough funds to make this payout!".to_string(),
))
};
} else {
return Err(ApiError::InvalidInput(
"Please complete payout information via the trolley dashboard!".to_string(),
));
}
}
Err(ApiError::InvalidInput(
"You are not enrolled in the payouts program yet!".to_string(),
))
} else {
Ok(HttpResponse::NotFound().body(""))
}
.await
}

View File

@@ -1,37 +1,24 @@
use super::project_creation::{CreateError, UploadedFile};
use crate::auth::get_user_from_headers;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::version_item::{
DependencyBuilder, VersionBuilder, VersionFileBuilder,
};
use crate::database::models::{self, image_item, Organization};
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::images::{Image, ImageContext, ImageId};
use crate::models::notifications::NotificationBody;
use crate::models::pack::PackFileHash;
use crate::models::pats::Scopes;
use crate::models::ids::ImageId;
use crate::models::projects::{
Dependency, DependencyType, FileType, GameVersion, Loader, ProjectId, Version, VersionFile,
VersionId, VersionStatus, VersionType,
Dependency, FileType, Loader, ProjectId, Version, VersionId, VersionStatus, VersionType,
};
use crate::models::teams::ProjectPermissions;
use crate::models::v2::projects::LegacyVersion;
use crate::queue::session::AuthQueue;
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 crate::routes::v3::project_creation::CreateError;
use crate::routes::{v2_reroute, v3};
use actix_multipart::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 serde_json::json;
use sqlx::postgres::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
use validator::Validate;
fn default_requested_status() -> VersionStatus {
pub fn default_requested_status() -> VersionStatus {
VersionStatus::Listed
}
@@ -61,7 +48,7 @@ pub struct InitialVersionData {
)]
pub dependencies: Vec<Dependency>,
#[validate(length(min = 1))]
pub game_versions: Vec<GameVersion>,
pub game_versions: Vec<String>,
#[serde(alias = "version_type")]
pub release_channel: VersionType,
#[validate(length(min = 1))]
@@ -91,420 +78,72 @@ struct InitialFileData {
#[post("version")]
pub async fn version_create(
req: HttpRequest,
mut payload: Multipart,
payload: Multipart,
client: Data<PgPool>,
redis: Data<RedisPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, CreateError> {
let mut transaction = client.begin().await?;
let mut uploaded_files = Vec::new();
let payload = v2_reroute::alter_actix_multipart(
payload,
req.headers().clone(),
|legacy_create: InitialVersionData| {
// Convert input data to V3 format
let mut fields = HashMap::new();
fields.insert(
"game_versions".to_string(),
json!(legacy_create.game_versions),
);
let result = version_create_inner(
req,
&mut payload,
&mut transaction,
&redis,
&***file_host,
&mut uploaded_files,
&client,
&session_queue,
)
.await;
// TODO: Some kind of handling here to ensure project type is fine.
// We expect the version uploaded to be of loader type modpack, but there might not be a way to check here for that.
// After all, theoretically, they could be creating a genuine 'fabric' mod, and modpack no longer carries information on whether its a mod or modpack,
// as those are out to the versions.
if result.is_err() {
let undo_result =
super::project_creation::undo_uploads(&***file_host, &uploaded_files).await;
let rollback_result = transaction.rollback().await;
// Ideally this would, if the project 'should' be a modpack:
// - change the loaders to mrpack only
// - add loader fields to the project for the corresponding loaders
undo_result?;
if let Err(e) = rollback_result {
return Err(e.into());
}
} else {
transaction.commit().await?;
}
result
}
#[allow(clippy::too_many_arguments)]
async fn version_create_inner(
req: HttpRequest,
payload: &mut Multipart,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
redis: &RedisPool,
file_host: &dyn FileHost,
uploaded_files: &mut Vec<UploadedFile>,
pool: &PgPool,
session_queue: &AuthQueue,
) -> 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, redis).await?;
let all_loaders = models::categories::Loader::list(&mut **transaction, redis).await?;
let user = get_user_from_headers(
&req,
pool,
redis,
session_queue,
Some(&[Scopes::VERSION_CREATE]),
)
.await?
.1;
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?;
// Get organization attached, if exists, and the member project permissions
let organization = models::Organization::get_associated_organization_project_id(
project_id,
&mut **transaction,
)
.await?;
let organization_team_member = if let Some(organization) = &organization {
models::TeamMember::get_from_user_id(
organization.team_id,
user.id.into(),
&mut **transaction,
)
.await?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&user.role,
&team_member,
&organization_team_member,
)
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::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,
ordering: version_create_data.ordering,
});
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(),
));
}
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 {
body: NotificationBody::ProjectUpdate {
project_id,
version_id,
},
}
.insert_many(users, transaction, redis)
.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,
ordering: builder.ordering,
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,
Ok(v3::version_creation::InitialVersionData {
project_id: legacy_create.project_id,
file_parts: legacy_create.file_parts,
version_number: legacy_create.version_number,
version_title: legacy_create.version_title,
version_body: legacy_create.version_body,
dependencies: legacy_create.dependencies,
release_channel: legacy_create.release_channel,
loaders: legacy_create.loaders,
featured: legacy_create.featured,
primary_file: legacy_create.primary_file,
status: legacy_create.status,
file_types: legacy_create.file_types,
uploaded_images: legacy_create.uploaded_images,
ordering: legacy_create.ordering,
fields,
})
.collect::<Vec<_>>(),
dependencies: version_data.dependencies,
game_versions: version_data.game_versions,
loaders: version_data.loaders,
};
},
)
.await?;
let project_id = builder.project_id;
builder.insert(transaction).await?;
// Call V3 project creation
let response = v3::version_creation::version_create(
req,
payload,
client.clone(),
redis.clone(),
file_host,
session_queue,
)
.await?;
for image_id in version_data.uploaded_images {
if let Some(db_image) =
image_item::Image::get(image_id.into(), &mut **transaction, redis).await?
{
let image: Image = db_image.into();
if !matches!(image.context, ImageContext::Report { .. })
|| image.context.inner_id().is_some()
{
return Err(CreateError::InvalidInput(format!(
"Image {} is not unused and in the 'version' context",
image_id
)));
}
sqlx::query!(
"
UPDATE uploaded_images
SET version_id = $1
WHERE id = $2
",
version_id.0 as i64,
image_id.0 as i64
)
.execute(&mut **transaction)
.await?;
image_item::Image::clear_cache(image.id.into(), redis).await?;
} else {
return Err(CreateError::InvalidInput(format!(
"Image {} does not exist",
image_id
)));
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Version>(response).await {
Ok(version) => {
let v2_version = LegacyVersion::from(version);
Ok(HttpResponse::Ok().json(v2_version))
}
Err(response) => Ok(response),
}
models::Project::update_game_versions(project_id, transaction).await?;
models::Project::update_loaders(project_id, transaction).await?;
models::Project::clear_cache(project_id, None, Some(true), redis).await?;
Ok(HttpResponse::Ok().json(response))
}
// under /api/v1/version/{version_id}
@@ -512,452 +151,21 @@ async fn version_create_inner(
pub async fn upload_file_to_version(
req: HttpRequest,
url_data: web::Path<(VersionId,)>,
mut payload: Multipart,
payload: Multipart,
client: Data<PgPool>,
redis: Data<RedisPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
) -> 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(
let response = v3::version_creation::upload_file_to_version(
req,
&mut payload,
client,
&mut transaction,
redis,
&***file_host,
&mut uploaded_files,
version_id,
&session_queue,
)
.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>,
redis: Data<RedisPool>,
file_host: &dyn FileHost,
uploaded_files: &mut Vec<UploadedFile>,
version_id: models::VersionId,
session_queue: &AuthQueue,
) -> 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,
&**client,
&redis,
url_data,
payload,
client.clone(),
redis.clone(),
file_host,
session_queue,
Some(&[Scopes::VERSION_WRITE]),
)
.await?
.1;
let result = models::Version::get(version_id, &**client, &redis).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_project(
version.inner.project_id,
user.id.into(),
&mut **transaction,
)
.await?;
let organization = Organization::get_associated_organization_project_id(
version.inner.project_id,
&**client,
)
.await?;
let organization_team_member = if let Some(organization) = &organization {
models::TeamMember::get_from_user_id(
organization.team_id,
user.id.into(),
&mut **transaction,
)
.await?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&user.role,
&team_member,
&organization_team_member,
)
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::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, &redis).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 {
VersionFileBuilder::insert_many(file_builders, version_id, transaction).await?;
}
// Clear version cache
models::Version::clear_cache(&version, &redis).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(),
));
}
if let ValidationResult::Warning(msg) = validation_result {
if primary {
return Err(CreateError::InvalidInput(msg.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))
Ok(response)
}

View File

@@ -1,17 +1,11 @@
use super::ApiError;
use crate::auth::{
filter_authorized_projects, filter_authorized_versions, get_user_from_headers,
is_authorized_version,
};
use crate::database::redis::RedisPool;
use crate::models::ids::VersionId;
use crate::models::pats::Scopes;
use crate::models::projects::VersionType;
use crate::models::teams::ProjectPermissions;
use crate::models::projects::{Project, Version, VersionType};
use crate::models::v2::projects::{LegacyProject, LegacyVersion};
use crate::queue::session::AuthQueue;
use crate::{database, models};
use crate::routes::v3::version_file::{default_algorithm, HashQuery};
use crate::routes::{v2_reroute, v3};
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::collections::HashMap;
@@ -34,17 +28,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
);
}
#[derive(Serialize, Deserialize)]
pub struct HashQuery {
#[serde(default = "default_algorithm")]
pub algorithm: String,
pub version_id: Option<VersionId>,
}
fn default_algorithm() -> String {
"sha1".into()
}
// under /api/v1/version_file/{hash}
#[get("{version_id}")]
pub async fn get_version_from_hash(
@@ -55,46 +38,20 @@ pub async fn get_version_from_hash(
hash_query: web::Query<HashQuery>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let hash = info.into_inner().0.to_lowercase();
let file = database::models::Version::get_file_from_hash(
hash_query.algorithm.clone(),
hash,
hash_query.version_id.map(|x| x.into()),
&**pool,
&redis,
)
.await?;
if let Some(file) = file {
let version = database::models::Version::get(file.version_id, &**pool, &redis).await?;
if let Some(version) = version {
if !is_authorized_version(&version.inner, &user_option, &pool).await? {
return Ok(HttpResponse::NotFound().body(""));
}
let response =
v3::version_file::get_version_from_hash(req, info, pool, redis, hash_query, session_queue)
.await;
Ok(HttpResponse::Ok().json(models::projects::Version::from(version)))
} else {
Ok(HttpResponse::NotFound().body(""))
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Version>(response?).await {
Ok(version) => {
let v2_version = LegacyVersion::from(version);
Ok(HttpResponse::Ok().json(v2_version))
}
} else {
Ok(HttpResponse::NotFound().body(""))
Err(response) => Ok(response),
}
}
#[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(
@@ -105,44 +62,7 @@ pub async fn download_version(
hash_query: web::Query<HashQuery>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let hash = info.into_inner().0.to_lowercase();
let file = database::models::Version::get_file_from_hash(
hash_query.algorithm.clone(),
hash,
hash_query.version_id.map(|x| x.into()),
&**pool,
&redis,
)
.await?;
if let Some(file) = file {
let version = database::models::Version::get(file.version_id, &**pool, &redis).await?;
if let Some(version) = version {
if !is_authorized_version(&version.inner, &user_option, &pool).await? {
return Ok(HttpResponse::NotFound().body(""));
}
Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", &*file.url))
.json(DownloadRedirect { url: file.url }))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
v3::version_file::download_version(req, info, pool, redis, hash_query, session_queue).await
}
// under /api/v1/version_file/{hash}
@@ -155,113 +75,10 @@ pub async fn delete_file(
hash_query: web::Query<HashQuery>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_WRITE]),
)
.await?
.1;
let hash = info.into_inner().0.to_lowercase();
let file = database::models::Version::get_file_from_hash(
hash_query.algorithm.clone(),
hash,
hash_query.version_id.map(|x| x.into()),
&**pool,
&redis,
)
.await?;
if let Some(row) = file {
if !user.role.is_admin() {
let team_member = database::models::TeamMember::get_from_user_id_version(
row.version_id,
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::Database)?;
let organization =
database::models::Organization::get_associated_organization_project_id(
row.project_id,
&**pool,
)
.await
.map_err(ApiError::Database)?;
let organization_team_member = if let Some(organization) = &organization {
database::models::TeamMember::get_from_user_id_organization(
organization.id,
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::Database)?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&user.role,
&team_member,
&organization_team_member,
)
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::DELETE_VERSION) {
return Err(ApiError::CustomAuthentication(
"You don't have permission to delete this file!".to_string(),
));
}
}
let version = database::models::Version::get(row.version_id, &**pool, &redis).await?;
if let Some(version) = version {
if version.files.len() < 2 {
return Err(ApiError::InvalidInput(
"Versions must have at least one file uploaded to them".to_string(),
));
}
database::models::Version::clear_cache(&version, &redis).await?;
}
let mut transaction = pool.begin().await?;
sqlx::query!(
"
DELETE FROM hashes
WHERE file_id = $1
",
row.id.0
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
DELETE FROM files
WHERE files.id = $1
",
row.id.0,
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
v3::version_file::delete_file(req, info, pool, redis, hash_query, session_queue).await
}
#[derive(Deserialize)]
#[derive(Serialize, Deserialize)]
pub struct UpdateData {
pub loaders: Option<Vec<String>>,
pub game_versions: Option<Vec<String>>,
@@ -278,65 +95,40 @@ pub async fn get_update_from_hash(
update_data: web::Json<UpdateData>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let hash = info.into_inner().0.to_lowercase();
if let Some(file) = database::models::Version::get_file_from_hash(
hash_query.algorithm.clone(),
hash,
hash_query.version_id.map(|x| x.into()),
&**pool,
&redis,
)
.await?
{
if let Some(project) =
database::models::Project::get_id(file.project_id, &**pool, &redis).await?
{
let mut versions =
database::models::Version::get_many(&project.versions, &**pool, &redis)
.await?
.into_iter()
.filter(|x| {
let mut bool = true;
if let Some(version_types) = &update_data.version_types {
bool &= version_types
.iter()
.any(|y| y.as_str() == x.inner.version_type);
}
if let Some(loaders) = &update_data.loaders {
bool &= x.loaders.iter().any(|y| loaders.contains(y));
}
if let Some(game_versions) = &update_data.game_versions {
bool &= x.game_versions.iter().any(|y| game_versions.contains(y));
}
bool
})
.sorted()
.collect::<Vec<_>>();
if let Some(first) = versions.pop() {
if !is_authorized_version(&first.inner, &user_option, &pool).await? {
return Ok(HttpResponse::NotFound().body(""));
}
return Ok(HttpResponse::Ok().json(models::projects::Version::from(first)));
}
}
let update_data = update_data.into_inner();
let mut loader_fields = HashMap::new();
let mut game_versions = vec![];
for gv in update_data.game_versions.into_iter().flatten() {
game_versions.push(serde_json::json!(gv.clone()));
}
if !game_versions.is_empty() {
loader_fields.insert("game_versions".to_string(), game_versions);
}
let update_data = v3::version_file::UpdateData {
loaders: update_data.loaders.clone(),
version_types: update_data.version_types.clone(),
loader_fields: Some(loader_fields),
};
Ok(HttpResponse::NotFound().body(""))
let response = v3::version_file::get_update_from_hash(
req,
info,
pool,
redis,
hash_query,
web::Json(update_data),
session_queue,
)
.await?;
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Version>(response).await {
Ok(version) => {
let v2_version = LegacyVersion::from(version);
Ok(HttpResponse::Ok().json(v2_version))
}
Err(response) => Ok(response),
}
}
// Requests above with multiple versions below
@@ -356,44 +148,34 @@ pub async fn get_versions_from_hashes(
file_data: web::Json<FileHashes>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let files = database::models::Version::get_files_from_hash(
file_data.algorithm.clone(),
&file_data.hashes,
&**pool,
&redis,
let file_data = file_data.into_inner();
let file_data = v3::version_file::FileHashes {
algorithm: file_data.algorithm,
hashes: file_data.hashes,
};
let response = v3::version_file::get_versions_from_hashes(
req,
pool,
redis,
web::Json(file_data),
session_queue,
)
.await?;
let version_ids = files.iter().map(|x| x.version_id).collect::<Vec<_>>();
let versions_data = filter_authorized_versions(
database::models::Version::get_many(&version_ids, &**pool, &redis).await?,
&user_option,
&pool,
)
.await?;
let mut response = HashMap::new();
for version in versions_data {
for file in files.iter().filter(|x| x.version_id == version.id.into()) {
if let Some(hash) = file.hashes.get(&file_data.algorithm) {
response.insert(hash.clone(), version.clone());
}
// Convert to V2
match v2_reroute::extract_ok_json::<HashMap<String, Version>>(response).await {
Ok(versions) => {
let v2_versions = versions
.into_iter()
.map(|(hash, version)| {
let v2_version = LegacyVersion::from(version);
(hash, v2_version)
})
.collect::<HashMap<_, _>>();
Ok(HttpResponse::Ok().json(v2_versions))
}
Err(response) => Ok(response),
}
Ok(HttpResponse::Ok().json(response))
}
#[post("project")]
@@ -404,45 +186,46 @@ pub async fn get_projects_from_hashes(
file_data: web::Json<FileHashes>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let files = database::models::Version::get_files_from_hash(
file_data.algorithm.clone(),
&file_data.hashes,
&**pool,
&redis,
let file_data = file_data.into_inner();
let file_data = v3::version_file::FileHashes {
algorithm: file_data.algorithm,
hashes: file_data.hashes,
};
let response = v3::version_file::get_projects_from_hashes(
req,
pool.clone(),
redis.clone(),
web::Json(file_data),
session_queue,
)
.await?;
let project_ids = files.iter().map(|x| x.project_id).collect::<Vec<_>>();
// Convert to V2
match v2_reroute::extract_ok_json::<HashMap<String, Project>>(response).await {
Ok(projects_hashes) => {
let hash_to_project_id = projects_hashes
.iter()
.map(|(hash, project)| {
let project_id = project.id;
(hash.clone(), project_id)
})
.collect::<HashMap<_, _>>();
let legacy_projects =
LegacyProject::from_many(projects_hashes.into_values().collect(), &**pool, &redis)
.await?;
let legacy_projects_hashes = hash_to_project_id
.into_iter()
.filter_map(|(hash, project_id)| {
let legacy_project =
legacy_projects.iter().find(|x| x.id == project_id)?.clone();
Some((hash, legacy_project))
})
.collect::<HashMap<_, _>>();
let projects_data = filter_authorized_projects(
database::models::Project::get_many_ids(&project_ids, &**pool, &redis).await?,
&user_option,
&pool,
)
.await?;
let mut response = HashMap::new();
for project in projects_data {
for file in files.iter().filter(|x| x.project_id == project.id.into()) {
if let Some(hash) = file.hashes.get(&file_data.algorithm) {
response.insert(hash.clone(), project.clone());
}
Ok(HttpResponse::Ok().json(legacy_projects_hashes))
}
Err(response) => Ok(response),
}
Ok(HttpResponse::Ok().json(response))
}
#[derive(Deserialize)]
@@ -463,85 +246,44 @@ pub async fn update_files(
update_data: web::Json<ManyUpdateData>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let files = database::models::Version::get_files_from_hash(
update_data.algorithm.clone(),
&update_data.hashes,
&**pool,
&redis,
)
.await?;
let projects = database::models::Project::get_many_ids(
&files.iter().map(|x| x.project_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let all_versions = database::models::Version::get_many(
&projects
.iter()
.flat_map(|x| x.versions.clone())
.collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let mut response = HashMap::new();
for project in projects {
for file in files.iter().filter(|x| x.project_id == project.inner.id) {
let version = all_versions
.iter()
.filter(|x| x.inner.project_id == file.project_id)
.filter(|x| {
let mut bool = true;
if let Some(version_types) = &update_data.version_types {
bool &= version_types
.iter()
.any(|y| y.as_str() == x.inner.version_type);
}
if let Some(loaders) = &update_data.loaders {
bool &= x.loaders.iter().any(|y| loaders.contains(y));
}
if let Some(game_versions) = &update_data.game_versions {
bool &= x.game_versions.iter().any(|y| game_versions.contains(y));
}
bool
})
.sorted()
.next();
if let Some(version) = version {
if is_authorized_version(&version.inner, &user_option, &pool).await? {
if let Some(hash) = file.hashes.get(&update_data.algorithm) {
response.insert(
hash.clone(),
models::projects::Version::from(version.clone()),
);
}
}
}
}
let update_data = update_data.into_inner();
let mut loader_fields = HashMap::new();
let mut game_versions = vec![];
for gv in update_data.game_versions.into_iter().flatten() {
game_versions.push(serde_json::json!(gv.clone()));
}
if !game_versions.is_empty() {
loader_fields.insert("game_versions".to_string(), game_versions);
}
let update_data = v3::version_file::ManyUpdateData {
loaders: update_data.loaders.clone(),
version_types: update_data.version_types.clone(),
loader_fields: Some(loader_fields),
algorithm: update_data.algorithm,
hashes: update_data.hashes,
};
Ok(HttpResponse::Ok().json(response))
let response =
v3::version_file::update_files(req, pool, redis, web::Json(update_data), session_queue)
.await?;
// Convert response to V2 format
match v2_reroute::extract_ok_json::<HashMap<String, Version>>(response).await {
Ok(returned_versions) => {
let v3_versions = returned_versions
.into_iter()
.map(|(hash, version)| {
let v2_version = LegacyVersion::from(version);
(hash, v2_version)
})
.collect::<HashMap<_, _>>();
Ok(HttpResponse::Ok().json(v3_versions))
}
Err(response) => Ok(response),
}
}
#[derive(Deserialize)]
#[derive(Serialize, Deserialize)]
pub struct FileUpdateData {
pub hash: String,
pub loaders: Option<Vec<String>>,
@@ -564,86 +306,52 @@ pub async fn update_individual_files(
update_data: web::Json<ManyFileUpdateData>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let files = database::models::Version::get_files_from_hash(
update_data.algorithm.clone(),
&update_data
let update_data = update_data.into_inner();
let update_data = v3::version_file::ManyFileUpdateData {
algorithm: update_data.algorithm,
hashes: update_data
.hashes
.iter()
.map(|x| x.hash.clone())
.collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let projects = database::models::Project::get_many_ids(
&files.iter().map(|x| x.project_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let all_versions = database::models::Version::get_many(
&projects
.iter()
.flat_map(|x| x.versions.clone())
.collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let mut response = HashMap::new();
for project in projects {
for file in files.iter().filter(|x| x.project_id == project.inner.id) {
if let Some(hash) = file.hashes.get(&update_data.algorithm) {
if let Some(query_file) = update_data.hashes.iter().find(|x| &x.hash == hash) {
let version = all_versions
.iter()
.filter(|x| x.inner.project_id == file.project_id)
.filter(|x| {
let mut bool = true;
if let Some(version_types) = &query_file.version_types {
bool &= version_types
.iter()
.any(|y| y.as_str() == x.inner.version_type);
}
if let Some(loaders) = &query_file.loaders {
bool &= x.loaders.iter().any(|y| loaders.contains(y));
}
if let Some(game_versions) = &query_file.game_versions {
bool &= x.game_versions.iter().any(|y| game_versions.contains(y));
}
bool
})
.sorted()
.next();
if let Some(version) = version {
if is_authorized_version(&version.inner, &user_option, &pool).await? {
response.insert(
hash.clone(),
models::projects::Version::from(version.clone()),
);
}
}
.into_iter()
.map(|x| {
let mut loader_fields = HashMap::new();
let mut game_versions = vec![];
for gv in x.game_versions.into_iter().flatten() {
game_versions.push(serde_json::json!(gv.clone()));
}
}
}
}
if !game_versions.is_empty() {
loader_fields.insert("game_versions".to_string(), game_versions);
}
v3::version_file::FileUpdateData {
hash: x.hash.clone(),
loaders: x.loaders.clone(),
loader_fields: Some(loader_fields),
version_types: x.version_types,
}
})
.collect(),
};
Ok(HttpResponse::Ok().json(response))
let response = v3::version_file::update_individual_files(
req,
pool,
redis,
web::Json(update_data),
session_queue,
)
.await?;
// Convert response to V2 format
match v2_reroute::extract_ok_json::<HashMap<String, Version>>(response).await {
Ok(returned_versions) => {
let v3_versions = returned_versions
.into_iter()
.map(|(hash, version)| {
let v2_version = LegacyVersion::from(version);
(hash, v2_version)
})
.collect::<HashMap<_, _>>();
Ok(HttpResponse::Ok().json(v3_versions))
}
Err(response) => Ok(response),
}
}

View File

@@ -1,20 +1,13 @@
use std::collections::HashMap;
use super::ApiError;
use crate::auth::{
filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version,
};
use crate::database;
use crate::database::models::version_item::{DependencyBuilder, LoaderVersion, VersionVersion};
use crate::database::models::{image_item, Organization};
use crate::database::redis::RedisPool;
use crate::models;
use crate::models::ids::base62_impl::parse_base62;
use crate::models::images::ImageContext;
use crate::models::pats::Scopes;
use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType};
use crate::models::teams::ProjectPermissions;
use crate::models::ids::VersionId;
use crate::models::projects::{Dependency, FileType, Version, VersionStatus, VersionType};
use crate::models::v2::projects::LegacyVersion;
use crate::queue::session::AuthQueue;
use crate::util::img;
use crate::util::validate::validation_errors_to_string;
use crate::routes::{v2_reroute, v3};
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@@ -54,115 +47,46 @@ pub async fn version_list(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
let result = database::models::Project::get(&string, &**pool, &redis).await?;
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
if let Some(project) = result {
if !is_authorized(&project.inner, &user_option, &pool).await? {
return Ok(HttpResponse::NotFound().body(""));
}
let version_filters = filters
.game_versions
.as_ref()
.map(|x| serde_json::from_str::<Vec<String>>(x).unwrap_or_default());
let loader_filters = filters
.loaders
.as_ref()
.map(|x| serde_json::from_str::<Vec<String>>(x).unwrap_or_default());
let mut versions = database::models::Version::get_many(&project.versions, &**pool, &redis)
.await?
.into_iter()
.skip(filters.offset.unwrap_or(0))
.take(filters.limit.unwrap_or(usize::MAX))
.filter(|x| {
let mut bool = true;
if let Some(version_type) = filters.version_type {
bool &= &*x.inner.version_type == version_type.as_str();
let loader_fields = if let Some(game_versions) = filters.game_versions {
// TODO: extract this logic which is similar to the other v2->v3 version_file functions
let mut loader_fields = HashMap::new();
serde_json::from_str::<Vec<String>>(&game_versions)
.ok()
.and_then(|versions| {
let mut game_versions: Vec<serde_json::Value> = vec![];
for gv in versions {
game_versions.push(serde_json::json!(gv.clone()));
}
if let Some(loaders) = &loader_filters {
bool &= x.loaders.iter().any(|y| loaders.contains(y));
}
if let Some(game_versions) = &version_filters {
bool &= x.game_versions.iter().any(|y| game_versions.contains(y));
}
bool
loader_fields.insert("game_versions".to_string(), game_versions);
serde_json::to_string(&loader_fields).ok()
})
.collect::<Vec<_>>();
} else {
None
};
let mut response = versions
.iter()
.filter(|version| {
filters
.featured
.map(|featured| featured == version.inner.featured)
.unwrap_or(true)
})
.cloned()
.collect::<Vec<_>>();
let filters = v3::versions::VersionListFilters {
loader_fields,
loaders: filters.loaders,
featured: filters.featured,
version_type: filters.version_type,
limit: filters.limit,
offset: filters.offset,
};
versions.sort();
// 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, &redis),
database::models::categories::GameVersion::list_filter(
None,
Some(true),
&**pool,
&redis,
),
)
let response =
v3::versions::version_list(req, info, web::Query(filters), pool, redis, session_queue)
.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));
}
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Vec<Version>>(response).await {
Ok(versions) => {
let v2_versions = versions
.into_iter()
.map(LegacyVersion::from)
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(v2_versions))
}
response.sort();
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(""))
Err(response) => Ok(response),
}
}
@@ -176,41 +100,16 @@ pub async fn version_project_get(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner();
let result = database::models::Project::get(&id.0, &**pool, &redis).await?;
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
if let Some(project) = result {
if !is_authorized(&project.inner, &user_option, &pool).await? {
return Ok(HttpResponse::NotFound().body(""));
}
let versions =
database::models::Version::get_many(&project.versions, &**pool, &redis).await?;
let id_opt = parse_base62(&id.1).ok();
let version = versions
.into_iter()
.find(|x| Some(x.inner.id.0 as u64) == id_opt || x.inner.version_number == id.1);
if let Some(version) = version {
if is_authorized_version(&version.inner, &user_option, &pool).await? {
return Ok(HttpResponse::Ok().json(models::projects::Version::from(version)));
}
let response =
v3::versions::version_project_get_helper(req, id, pool, redis, session_queue).await?;
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Version>(response).await {
Ok(version) => {
let v2_version = LegacyVersion::from(version);
Ok(HttpResponse::Ok().json(v2_version))
}
Err(response) => Ok(response),
}
Ok(HttpResponse::NotFound().body(""))
}
#[derive(Serialize, Deserialize)]
@@ -226,26 +125,21 @@ pub async fn versions_get(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> 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(&version_ids, &**pool, &redis).await?;
let ids = v3::versions::VersionIds { ids: ids.ids };
let response =
v3::versions::versions_get(req, web::Query(ids), pool, redis, session_queue).await?;
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let versions = filter_authorized_versions(versions_data, &user_option, &pool).await?;
Ok(HttpResponse::Ok().json(versions))
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Vec<Version>>(response).await {
Ok(versions) => {
let v2_versions = versions
.into_iter()
.map(LegacyVersion::from)
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(v2_versions))
}
Err(response) => Ok(response),
}
}
#[get("{version_id}")]
@@ -257,26 +151,15 @@ pub async fn version_get(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let version_data = database::models::Version::get(id.into(), &**pool, &redis).await?;
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.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)));
let response = v3::versions::version_get_helper(req, id, pool, redis, session_queue).await?;
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Version>(response).await {
Ok(version) => {
let v2_version = LegacyVersion::from(version);
Ok(HttpResponse::Ok().json(v2_version))
}
Err(response) => Ok(response),
}
Ok(HttpResponse::NotFound().body(""))
}
#[derive(Serialize, Deserialize, Validate)]
@@ -299,7 +182,7 @@ pub struct EditVersion {
custom(function = "crate::util::validate::validate_deps")
)]
pub dependencies: Option<Vec<Dependency>>,
pub game_versions: Option<Vec<models::projects::GameVersion>>,
pub game_versions: Option<Vec<String>>,
pub loaders: Option<Vec<models::projects::Loader>>,
pub featured: Option<bool>,
pub primary_file: Option<(String, String)>,
@@ -319,415 +202,56 @@ pub struct EditVersionFileType {
#[patch("{id}")]
pub async fn version_edit(
req: HttpRequest,
info: web::Path<(models::ids::VersionId,)>,
info: web::Path<(VersionId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
new_version: web::Json<EditVersion>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_WRITE]),
)
.await?
.1;
let new_version = new_version.into_inner();
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(id, &**pool, &redis).await?;
if let Some(version_item) = result {
let project_item =
database::models::Project::get_id(version_item.inner.project_id, &**pool, &redis)
.await?;
let team_member = database::models::TeamMember::get_from_user_id_project(
version_item.inner.project_id,
user.id.into(),
&**pool,
)
.await?;
let organization = Organization::get_associated_organization_project_id(
version_item.inner.project_id,
&**pool,
)
.await?;
let organization_team_member = if let Some(organization) = &organization {
database::models::TeamMember::get_from_user_id(
organization.team_id,
user.id.into(),
&**pool,
)
.await?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&user.role,
&team_member,
&organization_team_member,
let mut fields = HashMap::new();
if new_version.game_versions.is_some() {
fields.insert(
"game_versions".to_string(),
serde_json::json!(new_version.game_versions),
);
if let Some(perms) = permissions {
if !perms.contains(ProjectPermissions::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>>();
DependencyBuilder::insert_many(
builders,
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?;
let mut version_versions = Vec::new();
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(),
)
})?;
version_versions.push(VersionVersion::new(game_version_id, id));
}
VersionVersion::insert_many(version_versions, &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?;
let mut loader_versions = Vec::new();
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(),
)
})?;
loader_versions.push(LoaderVersion::new(loader_id, id));
}
LoaderVersion::insert_many(loader_versions, &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?;
}
}
if let Some(ordering) = &new_version.ordering {
sqlx::query!(
"
UPDATE versions
SET ordering = $1
WHERE (id = $2)
",
ordering.to_owned() as Option<i32>,
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await?;
}
// delete any images no longer in the changelog
let checkable_strings: Vec<&str> = vec![&new_version.changelog]
.into_iter()
.filter_map(|x| x.as_ref().map(|y| y.as_str()))
.collect();
let context = ImageContext::Version {
version_id: Some(version_item.inner.id.into()),
};
img::delete_unused_images(context, checkable_strings, &mut transaction, &redis).await?;
database::models::Version::clear_cache(&version_item, &redis).await?;
database::models::Project::clear_cache(
version_item.inner.project_id,
None,
Some(true),
&redis,
)
.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(""))
}
let new_version = v3::versions::EditVersion {
name: new_version.name,
version_number: new_version.version_number,
changelog: new_version.changelog,
version_type: new_version.version_type,
dependencies: new_version.dependencies,
loaders: new_version.loaders,
featured: new_version.featured,
primary_file: new_version.primary_file,
downloads: new_version.downloads,
status: new_version.status,
file_types: new_version.file_types.map(|v| {
v.into_iter()
.map(|evft| v3::versions::EditVersionFileType {
algorithm: evft.algorithm,
hash: evft.hash,
file_type: evft.file_type,
})
.collect::<Vec<_>>()
}),
ordering: new_version.ordering,
fields,
};
let response = v3::versions::version_edit(
req,
info,
pool,
redis,
web::Json(serde_json::to_value(new_version)?),
session_queue,
)
.await?;
Ok(response)
}
#[derive(Deserialize)]
@@ -745,92 +269,18 @@ pub async fn version_schedule(
scheduling_data: web::Json<SchedulingData>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_WRITE]),
v3::versions::version_schedule(
req,
info,
pool,
redis,
web::Json(v3::versions::SchedulingData {
time: scheduling_data.time,
requested_status: scheduling_data.requested_status,
}),
session_queue,
)
.await?
.1;
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(string.into(), &**pool, &redis).await?;
if let Some(version_item) = result {
let team_member = database::models::TeamMember::get_from_user_id_project(
version_item.inner.project_id,
user.id.into(),
&**pool,
)
.await?;
let organization_item =
database::models::Organization::get_associated_organization_project_id(
version_item.inner.project_id,
&**pool,
)
.await
.map_err(ApiError::Database)?;
let organization_team_member = if let Some(organization) = &organization_item {
database::models::TeamMember::get_from_user_id(
organization.team_id,
user.id.into(),
&**pool,
)
.await?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&user.role,
&team_member,
&organization_team_member,
)
.unwrap_or_default();
if !user.role.is_mod() && !permissions.contains(ProjectPermissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthentication(
"You do not have permission to edit this version's scheduling data!".to_string(),
));
}
let mut transaction = pool.begin().await?;
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(&mut *transaction)
.await?;
database::models::Version::clear_cache(&version_item, &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
.await
}
#[delete("{version_id}")]
@@ -841,81 +291,5 @@ pub async fn version_delete(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_DELETE]),
)
.await?
.1;
let id = info.into_inner().0;
let version = database::models::Version::get(id.into(), &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified version does not exist!".to_string())
})?;
if !user.role.is_admin() {
let team_member = database::models::TeamMember::get_from_user_id_project(
version.inner.project_id,
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::Database)?;
let organization =
Organization::get_associated_organization_project_id(version.inner.project_id, &**pool)
.await?;
let organization_team_member = if let Some(organization) = &organization {
database::models::TeamMember::get_from_user_id(
organization.team_id,
user.id.into(),
&**pool,
)
.await?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&user.role,
&team_member,
&organization_team_member,
)
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::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 context = ImageContext::Version {
version_id: Some(version.inner.id.into()),
};
let uploaded_images =
database::models::Image::get_many_contexted(context, &mut transaction).await?;
for image in uploaded_images {
image_item::Image::remove(image.id, &mut transaction, &redis).await?;
}
let result =
database::models::Version::remove_full(version.inner.id, &redis, &mut transaction).await?;
database::models::Project::clear_cache(version.inner.project_id, None, Some(true), &redis)
.await?;
transaction.commit().await?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
v3::versions::version_delete(req, info, pool, redis, session_queue).await
}

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

@@ -0,0 +1,112 @@
use super::v3::project_creation::CreateError;
use crate::util::actix::{generate_multipart, MultipartSegment, MultipartSegmentData};
use actix_multipart::Multipart;
use actix_web::http::header::{HeaderMap, TryIntoHeaderPair};
use actix_web::HttpResponse;
use futures::{stream, StreamExt};
use serde_json::json;
pub async fn extract_ok_json<T>(response: HttpResponse) -> Result<T, HttpResponse>
where
T: serde::de::DeserializeOwned,
{
if response.status() == actix_web::http::StatusCode::OK {
let failure_http_response = || {
HttpResponse::InternalServerError().json(json!({
"error": "reroute_error",
"description": "Could not parse response from V2 redirection of route."
}))
};
// Takes json out of HttpResponse, mutates it, then regenerates the HttpResponse
let body = response.into_body();
let bytes = actix_web::body::to_bytes(body)
.await
.map_err(|_| failure_http_response())?;
let json_value: T = serde_json::from_slice(&bytes).map_err(|_| failure_http_response())?;
Ok(json_value)
} else {
Err(response)
}
}
pub async fn alter_actix_multipart<T, U>(
mut multipart: Multipart,
mut headers: HeaderMap,
mut closure: impl FnMut(T) -> Result<U, CreateError>,
) -> Result<Multipart, CreateError>
where
T: serde::de::DeserializeOwned,
U: serde::Serialize,
{
let mut segments: Vec<MultipartSegment> = Vec::new();
if let Some(field) = multipart.next().await {
let mut field = field?;
let content_disposition = field.content_disposition().clone();
let field_name = content_disposition.get_name().unwrap_or("");
let field_filename = content_disposition.get_filename();
let field_content_type = field.content_type();
let field_content_type = field_content_type.map(|ct| ct.to_string());
let mut buffer = Vec::new();
while let Some(chunk) = field.next().await {
let data = chunk?;
buffer.extend_from_slice(&data);
}
{
let json_value: T = serde_json::from_slice(&buffer)?;
let json_value: U = closure(json_value)?;
buffer = serde_json::to_vec(&json_value)?;
}
segments.push(MultipartSegment {
name: field_name.to_string(),
filename: field_filename.map(|s| s.to_string()),
content_type: field_content_type,
data: MultipartSegmentData::Binary(buffer),
})
}
while let Some(field) = multipart.next().await {
let mut field = field?;
let content_disposition = field.content_disposition().clone();
let field_name = content_disposition.get_name().unwrap_or("");
let field_filename = content_disposition.get_filename();
let field_content_type = field.content_type();
let field_content_type = field_content_type.map(|ct| ct.to_string());
let mut buffer = Vec::new();
while let Some(chunk) = field.next().await {
let data = chunk?;
buffer.extend_from_slice(&data);
}
segments.push(MultipartSegment {
name: field_name.to_string(),
filename: field_filename.map(|s| s.to_string()),
content_type: field_content_type,
data: MultipartSegmentData::Binary(buffer),
})
}
let (boundary, payload) = generate_multipart(segments);
match (
"Content-Type",
format!("multipart/form-data; boundary={}", boundary).as_str(),
)
.try_into_pair()
{
Ok((key, value)) => {
headers.insert(key, value);
}
Err(err) => {
CreateError::InvalidInput(format!("Error inserting test header: {:?}.", err));
}
};
let new_multipart = Multipart::new(&headers, stream::once(async { Ok(payload) }));
Ok(new_multipart)
}

View File

@@ -0,0 +1,611 @@
use super::ApiError;
use crate::database::redis::RedisPool;
use crate::{
auth::{filter_authorized_projects, filter_authorized_versions, get_user_from_headers},
database::models::{project_item, user_item, version_item},
models::{
ids::{
base62_impl::{parse_base62, to_base62},
ProjectId, VersionId,
},
pats::Scopes,
},
queue::session::AuthQueue,
};
use actix_web::{web, HttpRequest, HttpResponse};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sqlx::postgres::types::PgInterval;
use sqlx::PgPool;
use std::collections::HashMap;
use std::convert::TryInto;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("analytics")
.route("playtime", web::get().to(playtimes_get))
.route("views", web::get().to(views_get))
.route("downloads", web::get().to(downloads_get))
.route("revenue", web::get().to(revenue_get))
.route(
"countries/downloads",
web::get().to(countries_downloads_get),
)
.route("countries/views", web::get().to(countries_views_get)),
);
}
/// The json data to be passed to fetch analytic data
/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out.
/// start_date and end_date are optional, and default to two weeks ago, and the maximum date respectively.
/// resolution_minutes is optional. This refers to the window by which we are looking (every day, every minute, etc) and defaults to 1440 (1 day)
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GetData {
// only one of project_ids or version_ids should be used
// if neither are provided, all projects the user has access to will be used
pub project_ids: Option<String>,
pub version_ids: Option<String>,
pub start_date: Option<DateTime<Utc>>, // defaults to 2 weeks ago
pub end_date: Option<DateTime<Utc>>, // defaults to now
pub resolution_minutes: Option<u32>, // defaults to 1 day. Ignored in routes that do not aggregate over a resolution (eg: /countries)
}
/// Get playtime data for a set of projects or versions
/// Data is returned as a hashmap of project/version ids to a hashmap of days to playtime data
/// eg:
/// {
/// "4N1tEhnO": {
/// "20230824": 23
/// }
///}
/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out.
#[derive(Serialize, Deserialize, Clone)]
pub struct FetchedPlaytime {
pub time: u64,
pub total_seconds: u64,
pub loader_seconds: HashMap<String, u64>,
pub game_version_seconds: HashMap<String, u64>,
pub parent_seconds: HashMap<VersionId, u64>,
}
pub async fn playtimes_get(
req: HttpRequest,
clickhouse: web::Data<clickhouse::Client>,
data: web::Query<GetData>,
session_queue: web::Data<AuthQueue>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
)
.await
.map(|x| x.1)?;
let project_ids = data
.project_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
let version_ids = data
.version_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
if project_ids.is_some() && version_ids.is_some() {
return Err(ApiError::InvalidInput(
"Only one of 'project_ids' or 'version_ids' should be used.".to_string(),
));
}
let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2));
let end_date = data.end_date.unwrap_or(Utc::now());
let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24);
// Convert String list to list of ProjectIds or VersionIds
// - Filter out unauthorized projects/versions
// - If no project_ids or version_ids are provided, we default to all projects the user has access to
let (project_ids, version_ids) =
filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?;
// Get the views
let playtimes = crate::clickhouse::fetch_playtimes(
project_ids,
version_ids,
start_date,
end_date,
resolution_minutes,
clickhouse.into_inner(),
)
.await?;
let mut hm = HashMap::new();
for playtime in playtimes {
let id_string = to_base62(playtime.id);
if !hm.contains_key(&id_string) {
hm.insert(id_string.clone(), HashMap::new());
}
if let Some(hm) = hm.get_mut(&id_string) {
hm.insert(playtime.time, playtime.total_seconds);
}
}
Ok(HttpResponse::Ok().json(hm))
}
/// Get view data for a set of projects or versions
/// Data is returned as a hashmap of project/version ids to a hashmap of days to views
/// eg:
/// {
/// "4N1tEhnO": {
/// "20230824": 1090
/// }
///}
/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out.
pub async fn views_get(
req: HttpRequest,
clickhouse: web::Data<clickhouse::Client>,
data: web::Query<GetData>,
session_queue: web::Data<AuthQueue>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
)
.await
.map(|x| x.1)?;
let project_ids = data
.project_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
let version_ids = data
.version_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
if project_ids.is_some() && version_ids.is_some() {
return Err(ApiError::InvalidInput(
"Only one of 'project_ids' or 'version_ids' should be used.".to_string(),
));
}
let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2));
let end_date = data.end_date.unwrap_or(Utc::now());
let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24);
// Convert String list to list of ProjectIds or VersionIds
// - Filter out unauthorized projects/versions
// - If no project_ids or version_ids are provided, we default to all projects the user has access to
let (project_ids, version_ids) =
filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?;
// Get the views
let views = crate::clickhouse::fetch_views(
project_ids,
version_ids,
start_date,
end_date,
resolution_minutes,
clickhouse.into_inner(),
)
.await?;
let mut hm = HashMap::new();
for views in views {
let id_string = to_base62(views.id);
if !hm.contains_key(&id_string) {
hm.insert(id_string.clone(), HashMap::new());
}
if let Some(hm) = hm.get_mut(&id_string) {
hm.insert(views.time, views.total_views);
}
}
Ok(HttpResponse::Ok().json(hm))
}
/// Get download data for a set of projects or versions
/// Data is returned as a hashmap of project/version ids to a hashmap of days to downloads
/// eg:
/// {
/// "4N1tEhnO": {
/// "20230824": 32
/// }
///}
/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out.
pub async fn downloads_get(
req: HttpRequest,
clickhouse: web::Data<clickhouse::Client>,
data: web::Query<GetData>,
session_queue: web::Data<AuthQueue>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
)
.await
.map(|x| x.1)?;
let project_ids = data
.project_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
let version_ids = data
.version_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
if project_ids.is_some() && version_ids.is_some() {
return Err(ApiError::InvalidInput(
"Only one of 'project_ids' or 'version_ids' should be used.".to_string(),
));
}
let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2));
let end_date = data.end_date.unwrap_or(Utc::now());
let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24);
// Convert String list to list of ProjectIds or VersionIds
// - Filter out unauthorized projects/versions
// - If no project_ids or version_ids are provided, we default to all projects the user has access to
let (project_ids, version_ids) =
filter_allowed_ids(project_ids, version_ids, user_option, &pool, &redis).await?;
// Get the downloads
let downloads = crate::clickhouse::fetch_downloads(
project_ids,
version_ids,
start_date,
end_date,
resolution_minutes,
clickhouse.into_inner(),
)
.await?;
let mut hm = HashMap::new();
for downloads in downloads {
let id_string = to_base62(downloads.id);
if !hm.contains_key(&id_string) {
hm.insert(id_string.clone(), HashMap::new());
}
if let Some(hm) = hm.get_mut(&id_string) {
hm.insert(downloads.time, downloads.total_downloads);
}
}
Ok(HttpResponse::Ok().json(hm))
}
/// Get payout data for a set of projects
/// Data is returned as a hashmap of project ids to a hashmap of days to amount earned per day
/// eg:
/// {
/// "4N1tEhnO": {
/// "20230824": 0.001
/// }
///}
/// ONLY project IDs can be used. Unauthorized projects will be filtered out.
pub async fn revenue_get(
req: HttpRequest,
data: web::Query<GetData>,
session_queue: web::Data<AuthQueue>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAYOUTS_READ]),
)
.await
.map(|x| x.1)?;
let project_ids = data
.project_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2));
let end_date = data.end_date.unwrap_or(Utc::now());
let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24);
// Round up/down to nearest duration as we are using pgadmin, does not have rounding in the fetch command
// Round start_date down to nearest resolution
let diff = start_date.timestamp() % (resolution_minutes as i64 * 60);
let start_date = start_date - Duration::seconds(diff);
// Round end_date up to nearest resolution
let diff = end_date.timestamp() % (resolution_minutes as i64 * 60);
let end_date = end_date + Duration::seconds((resolution_minutes as i64 * 60) - diff);
// Convert String list to list of ProjectIds or VersionIds
// - Filter out unauthorized projects/versions
// - If no project_ids or version_ids are provided, we default to all projects the user has access to
let (project_ids, _) = filter_allowed_ids(project_ids, None, user, &pool, &redis).await?;
let duration: PgInterval = Duration::minutes(resolution_minutes as i64)
.try_into()
.map_err(|_| ApiError::InvalidInput("Invalid resolution_minutes".to_string()))?;
// Get the revenue data
let payouts_values = sqlx::query!(
"
SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start
FROM payouts_values
WHERE mod_id = ANY($1) AND created BETWEEN $2 AND $3
GROUP by mod_id, interval_start ORDER BY interval_start
",
&project_ids.unwrap_or_default().into_iter().map(|x| x.0 as i64).collect::<Vec<_>>(),
start_date,
end_date,
duration,
)
.fetch_all(&**pool)
.await?;
let mut hm = HashMap::new();
for value in payouts_values {
if let Some(mod_id) = value.mod_id {
if let Some(amount) = value.amount_sum {
if let Some(interval_start) = value.interval_start {
let id_string = to_base62(mod_id as u64);
if !hm.contains_key(&id_string) {
hm.insert(id_string.clone(), HashMap::new());
}
if let Some(hm) = hm.get_mut(&id_string) {
hm.insert(interval_start.timestamp(), amount);
}
}
}
}
}
Ok(HttpResponse::Ok().json(hm))
}
/// Get country data for a set of projects or versions
/// Data is returned as a hashmap of project/version ids to a hashmap of coutnry to downloads.
/// Unknown countries are labeled "".
/// This is usuable to see significant performing countries per project
/// eg:
/// {
/// "4N1tEhnO": {
/// "CAN": 22
/// }
///}
/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out.
/// For this endpoint, provided dates are a range to aggregate over, not specific days to fetch
pub async fn countries_downloads_get(
req: HttpRequest,
clickhouse: web::Data<clickhouse::Client>,
data: web::Query<GetData>,
session_queue: web::Data<AuthQueue>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
)
.await
.map(|x| x.1)?;
let project_ids = data
.project_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
let version_ids = data
.version_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
if project_ids.is_some() && version_ids.is_some() {
return Err(ApiError::InvalidInput(
"Only one of 'project_ids' or 'version_ids' should be used.".to_string(),
));
}
let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2));
let end_date = data.end_date.unwrap_or(Utc::now());
// Convert String list to list of ProjectIds or VersionIds
// - Filter out unauthorized projects/versions
// - If no project_ids or version_ids are provided, we default to all projects the user has access to
let (project_ids, version_ids) =
filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?;
// Get the countries
let countries = crate::clickhouse::fetch_countries(
project_ids,
version_ids,
start_date,
end_date,
clickhouse.into_inner(),
)
.await?;
let mut hm = HashMap::new();
for views in countries {
let id_string = to_base62(views.id);
if !hm.contains_key(&id_string) {
hm.insert(id_string.clone(), HashMap::new());
}
if let Some(hm) = hm.get_mut(&id_string) {
hm.insert(views.country, views.total_downloads);
}
}
Ok(HttpResponse::Ok().json(hm))
}
/// Get country data for a set of projects or versions
/// Data is returned as a hashmap of project/version ids to a hashmap of coutnry to views.
/// Unknown countries are labeled "".
/// This is usuable to see significant performing countries per project
/// eg:
/// {
/// "4N1tEhnO": {
/// "CAN": 56165
/// }
///}
/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out.
/// For this endpoint, provided dates are a range to aggregate over, not specific days to fetch
pub async fn countries_views_get(
req: HttpRequest,
clickhouse: web::Data<clickhouse::Client>,
data: web::Query<GetData>,
session_queue: web::Data<AuthQueue>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ANALYTICS]),
)
.await
.map(|x| x.1)?;
let project_ids = data
.project_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
let version_ids = data
.version_ids
.as_ref()
.map(|ids| serde_json::from_str::<Vec<String>>(ids))
.transpose()?;
if project_ids.is_some() && version_ids.is_some() {
return Err(ApiError::InvalidInput(
"Only one of 'project_ids' or 'version_ids' should be used.".to_string(),
));
}
let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2));
let end_date = data.end_date.unwrap_or(Utc::now());
// Convert String list to list of ProjectIds or VersionIds
// - Filter out unauthorized projects/versions
// - If no project_ids or version_ids are provided, we default to all projects the user has access to
let (project_ids, version_ids) =
filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?;
// Get the countries
let countries = crate::clickhouse::fetch_countries(
project_ids,
version_ids,
start_date,
end_date,
clickhouse.into_inner(),
)
.await?;
let mut hm = HashMap::new();
for views in countries {
let id_string = to_base62(views.id);
if !hm.contains_key(&id_string) {
hm.insert(id_string.clone(), HashMap::new());
}
if let Some(hm) = hm.get_mut(&id_string) {
hm.insert(views.country, views.total_views);
}
}
Ok(HttpResponse::Ok().json(hm))
}
async fn filter_allowed_ids(
mut project_ids: Option<Vec<String>>,
version_ids: Option<Vec<String>>,
user: crate::models::users::User,
pool: &web::Data<PgPool>,
redis: &RedisPool,
) -> Result<(Option<Vec<ProjectId>>, Option<Vec<VersionId>>), ApiError> {
if project_ids.is_some() && version_ids.is_some() {
return Err(ApiError::InvalidInput(
"Only one of 'project_ids' or 'version_ids' should be used.".to_string(),
));
}
// If no project_ids or version_ids are provided, we default to all projects the user has access to
if project_ids.is_none() && version_ids.is_none() {
project_ids = Some(
user_item::User::get_projects(user.id.into(), &***pool, redis)
.await?
.into_iter()
.map(|x| ProjectId::from(x).to_string())
.collect(),
);
}
// Convert String list to list of ProjectIds or VersionIds
// - Filter out unauthorized projects/versions
let project_ids = if let Some(project_ids) = project_ids {
// Submitted project_ids are filtered by the user's permissions
let ids = project_ids
.iter()
.map(|id| Ok(ProjectId(parse_base62(id)?).into()))
.collect::<Result<Vec<_>, ApiError>>()?;
let projects = project_item::Project::get_many_ids(&ids, &***pool, redis).await?;
let ids: Vec<ProjectId> = filter_authorized_projects(projects, &Some(user.clone()), pool)
.await?
.into_iter()
.map(|x| x.id)
.collect::<Vec<_>>();
Some(ids)
} else {
None
};
let version_ids = if let Some(version_ids) = version_ids {
// Submitted version_ids are filtered by the user's permissions
let ids = version_ids
.iter()
.map(|id| Ok(VersionId(parse_base62(id)?).into()))
.collect::<Result<Vec<_>, ApiError>>()?;
let versions = version_item::Version::get_many(&ids, &***pool, redis).await?;
let ids: Vec<VersionId> = filter_authorized_versions(versions, &Some(user), pool)
.await?
.into_iter()
.map(|x| x.id)
.collect::<Vec<_>>();
Some(ids)
} else {
None
};
// Only one of project_ids or version_ids will be Some
Ok((project_ids, version_ids))
}

View File

@@ -0,0 +1,538 @@
use crate::auth::checks::{filter_authorized_collections, is_authorized_collection};
use crate::auth::get_user_from_headers;
use crate::database::models::{collection_item, generate_collection_id, project_item};
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::collections::{Collection, CollectionStatus};
use crate::models::ids::base62_impl::parse_base62;
use crate::models::ids::{CollectionId, ProjectId};
use crate::models::pats::Scopes;
use crate::queue::session::AuthQueue;
use crate::routes::v3::project_creation::CreateError;
use crate::routes::ApiError;
use crate::util::routes::read_from_payload;
use crate::util::validate::validation_errors_to_string;
use crate::{database, models};
use actix_web::web::Data;
use actix_web::{web, HttpRequest, HttpResponse};
use chrono::Utc;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("collections", web::get().to(collections_get));
cfg.route("collection", web::post().to(collection_create));
cfg.service(
web::scope("collection")
.route("{id}", web::get().to(collection_get))
.route("{id}", web::delete().to(collection_delete))
.route("{id}", web::patch().to(collection_edit))
.route("{id}/icon", web::patch().to(collection_icon_edit))
.route("{id}/icon", web::delete().to(delete_collection_icon)),
);
}
#[derive(Serialize, Deserialize, Validate, Clone)]
pub struct CollectionCreateData {
#[validate(
length(min = 3, max = 64),
custom(function = "crate::util::validate::validate_name")
)]
/// The title or name of the project.
pub title: String,
#[validate(length(min = 3, max = 255))]
/// A short description of the collection.
pub description: String,
#[validate(length(max = 32))]
#[serde(default = "Vec::new")]
/// A list of initial projects to use with the created collection
pub projects: Vec<String>,
}
pub async fn collection_create(
req: HttpRequest,
collection_create_data: web::Json<CollectionCreateData>,
client: Data<PgPool>,
redis: Data<RedisPool>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, CreateError> {
let collection_create_data = collection_create_data.into_inner();
// The currently logged in user
let current_user = get_user_from_headers(
&req,
&**client,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_CREATE]),
)
.await?
.1;
collection_create_data
.validate()
.map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?;
let mut transaction = client.begin().await?;
let collection_id: CollectionId = generate_collection_id(&mut transaction).await?.into();
let initial_project_ids = project_item::Project::get_many(
&collection_create_data.projects,
&mut *transaction,
&redis,
)
.await?
.into_iter()
.map(|x| x.inner.id.into())
.collect::<Vec<ProjectId>>();
let collection_builder_actual = collection_item::CollectionBuilder {
collection_id: collection_id.into(),
user_id: current_user.id.into(),
title: collection_create_data.title,
description: collection_create_data.description,
status: CollectionStatus::Listed,
projects: initial_project_ids
.iter()
.copied()
.map(|x| x.into())
.collect(),
};
let collection_builder = collection_builder_actual.clone();
let now = Utc::now();
collection_builder_actual.insert(&mut transaction).await?;
let response = crate::models::collections::Collection {
id: collection_id,
user: collection_builder.user_id.into(),
title: collection_builder.title.clone(),
description: collection_builder.description.clone(),
created: now,
updated: now,
icon_url: None,
color: None,
status: collection_builder.status,
projects: initial_project_ids,
};
transaction.commit().await?;
Ok(HttpResponse::Ok().json(response))
}
#[derive(Serialize, Deserialize)]
pub struct CollectionIds {
pub ids: String,
}
pub async fn collections_get(
req: HttpRequest,
web::Query(ids): web::Query<CollectionIds>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let ids = serde_json::from_str::<Vec<&str>>(&ids.ids)?;
let ids = ids
.into_iter()
.map(|x| parse_base62(x).map(|x| database::models::CollectionId(x as i64)))
.collect::<Result<Vec<_>, _>>()?;
let collections_data = database::models::Collection::get_many(&ids, &**pool, &redis).await?;
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_READ]),
)
.await
.map(|x| x.1)
.ok();
let collections = filter_authorized_collections(collections_data, &user_option, &pool).await?;
Ok(HttpResponse::Ok().json(collections))
}
pub async fn collection_get(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
let id = database::models::CollectionId(parse_base62(&string)? as i64);
let collection_data = database::models::Collection::get(id, &**pool, &redis).await?;
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_READ]),
)
.await
.map(|x| x.1)
.ok();
if let Some(data) = collection_data {
if is_authorized_collection(&data, &user_option).await? {
return Ok(HttpResponse::Ok().json(Collection::from(data)));
}
}
Ok(HttpResponse::NotFound().body(""))
}
#[derive(Deserialize, Validate)]
pub struct EditCollection {
#[validate(
length(min = 3, max = 64),
custom(function = "crate::util::validate::validate_name")
)]
pub title: Option<String>,
#[validate(length(min = 3, max = 256))]
pub description: Option<String>,
pub status: Option<CollectionStatus>,
#[validate(length(max = 64))]
pub new_projects: Option<Vec<String>>,
}
pub async fn collection_edit(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
new_collection: web::Json<EditCollection>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_WRITE]),
)
.await?
.1;
new_collection
.validate()
.map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?;
let string = info.into_inner().0;
let id = database::models::CollectionId(parse_base62(&string)? as i64);
let result = database::models::Collection::get(id, &**pool, &redis).await?;
if let Some(collection_item) = result {
if !can_modify_collection(&collection_item, &user) {
return Ok(HttpResponse::Unauthorized().body(""));
}
let id = collection_item.id;
let mut transaction = pool.begin().await?;
if let Some(title) = &new_collection.title {
sqlx::query!(
"
UPDATE collections
SET title = $1
WHERE (id = $2)
",
title.trim(),
id as database::models::ids::CollectionId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(description) = &new_collection.description {
sqlx::query!(
"
UPDATE collections
SET description = $1
WHERE (id = $2)
",
description,
id as database::models::ids::CollectionId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(status) = &new_collection.status {
if !(user.role.is_mod()
|| collection_item.status.is_approved() && status.can_be_requested())
{
return Err(ApiError::CustomAuthentication(
"You don't have permission to set this status!".to_string(),
));
}
sqlx::query!(
"
UPDATE collections
SET status = $1
WHERE (id = $2)
",
status.to_string(),
id as database::models::ids::CollectionId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(new_project_ids) = &new_collection.new_projects {
// Delete all existing projects
sqlx::query!(
"
DELETE FROM collections_mods
WHERE collection_id = $1
",
collection_item.id as database::models::ids::CollectionId,
)
.execute(&mut *transaction)
.await?;
let collection_item_ids = new_project_ids
.iter()
.map(|_| collection_item.id.0)
.collect_vec();
let mut validated_project_ids = Vec::new();
for project_id in new_project_ids {
let project = database::models::Project::get(project_id, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"The specified project {project_id} does not exist!"
))
})?;
validated_project_ids.push(project.inner.id.0);
}
// Insert- don't throw an error if it already exists
sqlx::query!(
"
INSERT INTO collections_mods (collection_id, mod_id)
SELECT * FROM UNNEST ($1::int8[], $2::int8[])
ON CONFLICT DO NOTHING
",
&collection_item_ids[..],
&validated_project_ids[..],
)
.execute(&mut *transaction)
.await?;
}
database::models::Collection::clear_cache(collection_item.id, &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Serialize, Deserialize)]
pub struct Extension {
pub ext: String,
}
#[allow(clippy::too_many_arguments)]
pub async fn collection_icon_edit(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
mut payload: web::Payload,
session_queue: web::Data<AuthQueue>,
) -> 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,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_WRITE]),
)
.await?
.1;
let string = info.into_inner().0;
let id = database::models::CollectionId(parse_base62(&string)? as i64);
let collection_item = database::models::Collection::get(id, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified collection does not exist!".to_string())
})?;
if !can_modify_collection(&collection_item, &user) {
return Ok(HttpResponse::Unauthorized().body(""));
}
if let Some(icon) = collection_item.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, 262144, "Icons must be smaller than 256KiB").await?;
let color = crate::util::img::get_color_from_img(&bytes)?;
let hash = sha1::Sha1::from(&bytes).hexdigest();
let collection_id: CollectionId = collection_item.id.into();
let upload_data = file_host
.upload_file(
content_type,
&format!("data/{}/{}.{}", collection_id, hash, ext.ext),
bytes.freeze(),
)
.await?;
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE collections
SET icon_url = $1, color = $2
WHERE (id = $3)
",
format!("{}/{}", cdn_url, upload_data.file_name),
color.map(|x| x as i32),
collection_item.id as database::models::ids::CollectionId,
)
.execute(&mut *transaction)
.await?;
database::models::Collection::clear_cache(collection_item.id, &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::InvalidInput(format!(
"Invalid format for collection icon: {}",
ext.ext
)))
}
}
pub async fn delete_collection_icon(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_WRITE]),
)
.await?
.1;
let string = info.into_inner().0;
let id = database::models::CollectionId(parse_base62(&string)? as i64);
let collection_item = database::models::Collection::get(id, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified collection does not exist!".to_string())
})?;
if !can_modify_collection(&collection_item, &user) {
return Ok(HttpResponse::Unauthorized().body(""));
}
let cdn_url = dotenvy::var("CDN_URL")?;
if let Some(icon) = collection_item.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 mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE collections
SET icon_url = NULL, color = NULL
WHERE (id = $1)
",
collection_item.id as database::models::ids::CollectionId,
)
.execute(&mut *transaction)
.await?;
database::models::Collection::clear_cache(collection_item.id, &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}
pub async fn collection_delete(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_DELETE]),
)
.await?
.1;
let string = info.into_inner().0;
let id = database::models::CollectionId(parse_base62(&string)? as i64);
let collection = database::models::Collection::get(id, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified collection does not exist!".to_string())
})?;
if !can_modify_collection(&collection, &user) {
return Ok(HttpResponse::Unauthorized().body(""));
}
let mut transaction = pool.begin().await?;
let result =
database::models::Collection::remove(collection.id, &mut transaction, &redis).await?;
database::models::Collection::clear_cache(collection.id, &redis).await?;
transaction.commit().await?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
fn can_modify_collection(
collection: &database::models::Collection,
user: &models::users::User,
) -> bool {
collection.user_id == user.id.into() || user.role.is_mod()
}

234
src/routes/v3/images.rs Normal file
View File

@@ -0,0 +1,234 @@
use std::sync::Arc;
use crate::auth::{get_user_from_headers, is_authorized, is_authorized_version};
use crate::database;
use crate::database::models::{project_item, report_item, thread_item, version_item};
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::ids::{ThreadMessageId, VersionId};
use crate::models::images::{Image, ImageContext};
use crate::models::reports::ReportId;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::util::routes::read_from_payload;
use actix_web::{web, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use super::threads::is_authorized_thread;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("image", web::post().to(images_add));
}
#[derive(Serialize, Deserialize)]
pub struct ImageUpload {
pub ext: String,
// Context must be an allowed context
// currently: project, version, thread_message, report
pub context: String,
// Optional context id to associate with
pub project_id: Option<String>, // allow slug or id
pub version_id: Option<VersionId>,
pub thread_message_id: Option<ThreadMessageId>,
pub report_id: Option<ReportId>,
}
pub async fn images_add(
req: HttpRequest,
web::Query(data): web::Query<ImageUpload>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
mut payload: web::Payload,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
if let Some(content_type) = crate::util::ext::get_image_content_type(&data.ext) {
let mut context = ImageContext::from_str(&data.context, None);
let scopes = vec![context.relevant_scope()];
let cdn_url = dotenvy::var("CDN_URL")?;
let user = get_user_from_headers(&req, &**pool, &redis, &session_queue, Some(&scopes))
.await?
.1;
// Attempt to associated a supplied id with the context
// If the context cannot be found, or the user is not authorized to upload images for the context, return an error
match &mut context {
ImageContext::Project { project_id } => {
if let Some(id) = data.project_id {
let project = project_item::Project::get(&id, &**pool, &redis).await?;
if let Some(project) = project {
if is_authorized(&project.inner, &Some(user.clone()), &pool).await? {
*project_id = Some(project.inner.id.into());
} else {
return Err(ApiError::CustomAuthentication(
"You are not authorized to upload images for this project"
.to_string(),
));
}
} else {
return Err(ApiError::InvalidInput(
"The project could not be found.".to_string(),
));
}
}
}
ImageContext::Version { version_id } => {
if let Some(id) = data.version_id {
let version = version_item::Version::get(id.into(), &**pool, &redis).await?;
if let Some(version) = version {
if is_authorized_version(&version.inner, &Some(user.clone()), &pool).await?
{
*version_id = Some(version.inner.id.into());
} else {
return Err(ApiError::CustomAuthentication(
"You are not authorized to upload images for this version"
.to_string(),
));
}
} else {
return Err(ApiError::InvalidInput(
"The version could not be found.".to_string(),
));
}
}
}
ImageContext::ThreadMessage { thread_message_id } => {
if let Some(id) = data.thread_message_id {
let thread_message = thread_item::ThreadMessage::get(id.into(), &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(
"The thread message could not found.".to_string(),
)
})?;
let thread = thread_item::Thread::get(thread_message.thread_id, &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(
"The thread associated with the thread message could not be found"
.to_string(),
)
})?;
if is_authorized_thread(&thread, &user, &pool).await? {
*thread_message_id = Some(thread_message.id.into());
} else {
return Err(ApiError::CustomAuthentication(
"You are not authorized to upload images for this thread message"
.to_string(),
));
}
}
}
ImageContext::Report { report_id } => {
if let Some(id) = data.report_id {
let report = report_item::Report::get(id.into(), &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The report could not be found.".to_string())
})?;
let thread = thread_item::Thread::get(report.thread_id, &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(
"The thread associated with the report could not be found."
.to_string(),
)
})?;
if is_authorized_thread(&thread, &user, &pool).await? {
*report_id = Some(report.id.into());
} else {
return Err(ApiError::CustomAuthentication(
"You are not authorized to upload images for this report".to_string(),
));
}
}
}
ImageContext::Unknown => {
return Err(ApiError::InvalidInput(
"Context must be one of: project, version, thread_message, report".to_string(),
));
}
}
// Upload the image to the file host
let bytes =
read_from_payload(&mut payload, 1_048_576, "Icons must be smaller than 1MiB").await?;
let hash = sha1::Sha1::from(&bytes).hexdigest();
let upload_data = file_host
.upload_file(
content_type,
&format!("data/cached_images/{}.{}", hash, data.ext),
bytes.freeze(),
)
.await?;
let mut transaction = pool.begin().await?;
let db_image: database::models::Image = database::models::Image {
id: database::models::generate_image_id(&mut transaction).await?,
url: format!("{}/{}", cdn_url, upload_data.file_name),
size: upload_data.content_length as u64,
created: chrono::Utc::now(),
owner_id: database::models::UserId::from(user.id),
context: context.context_as_str().to_string(),
project_id: if let ImageContext::Project {
project_id: Some(id),
} = context
{
Some(database::models::ProjectId::from(id))
} else {
None
},
version_id: if let ImageContext::Version {
version_id: Some(id),
} = context
{
Some(database::models::VersionId::from(id))
} else {
None
},
thread_message_id: if let ImageContext::ThreadMessage {
thread_message_id: Some(id),
} = context
{
Some(database::models::ThreadMessageId::from(id))
} else {
None
},
report_id: if let ImageContext::Report {
report_id: Some(id),
} = context
{
Some(database::models::ReportId::from(id))
} else {
None
},
};
// Insert
db_image.insert(&mut transaction).await?;
let image = Image {
id: db_image.id.into(),
url: db_image.url,
size: db_image.size,
created: db_image.created,
owner_id: db_image.owner_id.into(),
context,
};
transaction.commit().await?;
Ok(HttpResponse::Ok().json(image))
} else {
Err(ApiError::InvalidInput(
"The specified file is not an image!".to_string(),
))
}
}

View File

@@ -3,13 +3,42 @@ use crate::{auth::oauth, util::cors::default_cors};
use actix_web::{web, HttpResponse};
use serde_json::json;
pub mod analytics_get;
pub mod collections;
pub mod images;
pub mod moderation;
pub mod notifications;
pub mod organizations;
pub mod project_creation;
pub mod projects;
pub mod reports;
pub mod statistics;
pub mod tags;
pub mod teams;
pub mod threads;
pub mod users;
pub mod version_creation;
pub mod version_file;
pub mod versions;
pub mod oauth_clients;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("v3")
.wrap(default_cors())
.route("", web::get().to(hello_world))
.configure(analytics_get::config)
.configure(collections::config)
.configure(images::config)
.configure(organizations::config)
.configure(project_creation::config)
.configure(projects::config)
.configure(reports::config)
.configure(tags::config)
.configure(teams::config)
.configure(threads::config)
.configure(version_file::config)
.configure(versions::config)
.configure(oauth::config)
.configure(oauth_clients::config),
);

View File

@@ -0,0 +1,65 @@
use super::ApiError;
use crate::database;
use crate::database::redis::RedisPool;
use crate::models::projects::ProjectStatus;
use crate::queue::session::AuthQueue;
use crate::{auth::check_is_moderator_from_headers, models::pats::Scopes};
use actix_web::{web, HttpRequest, HttpResponse};
use serde::Deserialize;
use sqlx::PgPool;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("moderation/projects", web::get().to(get_projects));
}
#[derive(Deserialize)]
pub struct ResultCount {
#[serde(default = "default_count")]
pub count: i16,
}
fn default_count() -> i16 {
100
}
pub async fn get_projects(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
count: web::Query<ResultCount>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
)
.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_ids(&project_ids, &**pool, &redis)
.await?
.into_iter()
.map(crate::models::projects::Project::from)
.collect();
Ok(HttpResponse::Ok().json(projects))
}

View File

@@ -0,0 +1,289 @@
use crate::auth::get_user_from_headers;
use crate::database;
use crate::database::redis::RedisPool;
use crate::models::ids::NotificationId;
use crate::models::notifications::Notification;
use crate::models::pats::Scopes;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use actix_web::{web, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("notifications", web::get().to(notifications_get));
cfg.route("notifications", web::patch().to(notifications_read));
cfg.route("notifications", web::delete().to(notifications_delete));
cfg.service(
web::scope("notification")
.route("{id}", web::get().to(notification_get))
.route("{id}", web::patch().to(notification_read))
.route("{id}", web::delete().to(notification_delete)),
);
}
#[derive(Serialize, Deserialize)]
pub struct NotificationIds {
pub ids: String,
}
pub async fn notifications_get(
req: HttpRequest,
web::Query(ids): web::Query<NotificationIds>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_READ]),
)
.await?
.1;
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(&notification_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))
}
pub async fn notification_get(
req: HttpRequest,
info: web::Path<(NotificationId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_READ]),
)
.await?
.1;
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(""))
}
}
pub async fn notification_read(
req: HttpRequest,
info: web::Path<(NotificationId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_WRITE]),
)
.await?
.1;
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::read(
id.into(),
&mut transaction,
&redis,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::CustomAuthentication(
"You are not authorized to read this notification!".to_string(),
))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
pub async fn notification_delete(
req: HttpRequest,
info: web::Path<(NotificationId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_WRITE]),
)
.await?
.1;
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,
&redis,
)
.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(""))
}
}
pub async fn notifications_read(
req: HttpRequest,
web::Query(ids): web::Query<NotificationIds>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_WRITE]),
)
.await?
.1;
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(&notification_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::read_many(
&notifications,
&mut transaction,
&redis,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}
pub async fn notifications_delete(
req: HttpRequest,
web::Query(ids): web::Query<NotificationIds>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_WRITE]),
)
.await?
.1;
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(&notification_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(
&notifications,
&mut transaction,
&redis,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}

View File

@@ -35,7 +35,7 @@ use crate::{
pats::Scopes,
},
queue::session::AuthQueue,
routes::v2::project_creation::CreateError,
routes::v3::project_creation::CreateError,
util::validate::validation_errors_to_string,
};

View File

@@ -0,0 +1,918 @@
use std::collections::HashMap;
use std::sync::Arc;
use super::ApiError;
use crate::auth::{filter_authorized_projects, get_user_from_headers};
use crate::database::models::team_item::TeamMember;
use crate::database::models::{generate_organization_id, team_item, Organization};
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::ids::base62_impl::parse_base62;
use crate::models::organizations::OrganizationId;
use crate::models::pats::Scopes;
use crate::models::teams::{OrganizationPermissions, ProjectPermissions};
use crate::queue::session::AuthQueue;
use crate::routes::v3::project_creation::CreateError;
use crate::util::routes::read_from_payload;
use crate::util::validate::validation_errors_to_string;
use crate::{database, models};
use actix_web::{web, HttpRequest, HttpResponse};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("organization")
.route("{id}/projects", web::get().to(organization_projects_get))
.route("{id}", web::get().to(organization_get))
.route("{id}", web::patch().to(organizations_edit))
.route("{id}", web::delete().to(organization_delete))
.route("{id}/projects", web::post().to(organization_projects_add))
.route(
"{id}/projects",
web::delete().to(organization_projects_remove),
)
.route("{id}/icon", web::patch().to(organization_icon_edit))
.route("{id}/icon", web::delete().to(delete_organization_icon))
.route(
"{id}/members",
web::get().to(super::teams::team_members_get_organization),
),
);
}
pub async fn organization_projects_get(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let info = info.into_inner().0;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_READ, Scopes::PROJECT_READ]),
)
.await
.map(|x| x.1)
.ok();
let possible_organization_id: Option<u64> = parse_base62(&info).ok();
use futures::TryStreamExt;
let project_ids = sqlx::query!(
"
SELECT m.id FROM organizations o
INNER JOIN mods m ON m.organization_id = o.id
WHERE (o.id = $1 AND $1 IS NOT NULL) OR (o.title = $2 AND $2 IS NOT NULL)
",
possible_organization_id.map(|x| x as i64),
info
)
.fetch_many(&**pool)
.try_filter_map(|e| async { Ok(e.right().map(|m| crate::database::models::ProjectId(m.id))) })
.try_collect::<Vec<crate::database::models::ProjectId>>()
.await?;
let projects_data =
crate::database::models::Project::get_many_ids(&project_ids, &**pool, &redis).await?;
let projects = filter_authorized_projects(projects_data, &current_user, &pool).await?;
Ok(HttpResponse::Ok().json(projects))
}
#[derive(Deserialize, Validate)]
pub struct NewOrganization {
#[validate(
length(min = 3, max = 64),
regex = "crate::util::validate::RE_URL_SAFE"
)]
// Title of the organization, also used as slug
pub title: String,
#[validate(length(min = 3, max = 256))]
pub description: String,
}
pub async fn organization_create(
req: HttpRequest,
new_organization: web::Json<NewOrganization>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, CreateError> {
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_CREATE]),
)
.await?
.1;
new_organization
.validate()
.map_err(|err| CreateError::ValidationError(validation_errors_to_string(err, None)))?;
let mut transaction = pool.begin().await?;
// Try title
let title_organization_id_option: Option<OrganizationId> =
serde_json::from_str(&format!("\"{}\"", new_organization.title)).ok();
let mut organization_strings = vec![];
if let Some(title_organization_id) = title_organization_id_option {
organization_strings.push(title_organization_id.to_string());
}
organization_strings.push(new_organization.title.clone());
let results = Organization::get_many(&organization_strings, &mut *transaction, &redis).await?;
if !results.is_empty() {
return Err(CreateError::SlugCollision);
}
let organization_id = generate_organization_id(&mut transaction).await?;
// Create organization managerial team
let team = team_item::TeamBuilder {
members: vec![team_item::TeamMemberBuilder {
user_id: current_user.id.into(),
role: crate::models::teams::OWNER_ROLE.to_owned(),
permissions: ProjectPermissions::all(),
organization_permissions: Some(OrganizationPermissions::all()),
accepted: true,
payouts_split: Decimal::ONE_HUNDRED,
ordering: 0,
}],
};
let team_id = team.insert(&mut transaction).await?;
// Create organization
let organization = Organization {
id: organization_id,
title: new_organization.title.clone(),
description: new_organization.description.clone(),
team_id,
icon_url: None,
color: None,
};
organization.clone().insert(&mut transaction).await?;
transaction.commit().await?;
// Only member is the owner, the logged in one
let member_data = TeamMember::get_from_team_full(team_id, &**pool, &redis)
.await?
.into_iter()
.next();
let members_data = if let Some(member_data) = member_data {
vec![crate::models::teams::TeamMember::from_model(
member_data,
current_user.clone(),
false,
)]
} else {
return Err(CreateError::InvalidInput(
"Failed to get created team.".to_owned(), // should never happen
));
};
let organization = models::organizations::Organization::from(organization, members_data);
Ok(HttpResponse::Ok().json(organization))
}
pub async fn organization_get(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_READ]),
)
.await
.map(|x| x.1)
.ok();
let user_id = current_user.as_ref().map(|x| x.id.into());
let organization_data = Organization::get(&id, &**pool, &redis).await?;
if let Some(data) = organization_data {
let members_data = TeamMember::get_from_team_full(data.team_id, &**pool, &redis).await?;
let users = crate::database::models::User::get_many_ids(
&members_data.iter().map(|x| x.user_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let logged_in = current_user
.as_ref()
.and_then(|user| {
members_data
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members: Vec<_> = members_data
.into_iter()
.filter(|x| {
logged_in
|| x.accepted
|| user_id
.map(|y: crate::database::models::UserId| y == x.user_id)
.unwrap_or(false)
})
.flat_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)
})
})
.collect();
let organization = models::organizations::Organization::from(data, team_members);
return Ok(HttpResponse::Ok().json(organization));
}
Ok(HttpResponse::NotFound().body(""))
}
#[derive(Deserialize)]
pub struct OrganizationIds {
pub ids: String,
}
pub async fn organizations_get(
req: HttpRequest,
web::Query(ids): web::Query<OrganizationIds>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let ids = serde_json::from_str::<Vec<&str>>(&ids.ids)?;
let organizations_data = Organization::get_many(&ids, &**pool, &redis).await?;
let team_ids = organizations_data
.iter()
.map(|x| x.team_id)
.collect::<Vec<_>>();
let teams_data = TeamMember::get_from_team_full_many(&team_ids, &**pool, &redis).await?;
let users = crate::database::models::User::get_many_ids(
&teams_data.iter().map(|x| x.user_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_READ]),
)
.await
.map(|x| x.1)
.ok();
let user_id = current_user.as_ref().map(|x| x.id.into());
let mut organizations = vec![];
let mut team_groups = HashMap::new();
for item in teams_data {
team_groups.entry(item.team_id).or_insert(vec![]).push(item);
}
for data in organizations_data {
let members_data = team_groups.remove(&data.team_id).unwrap_or(vec![]);
let logged_in = current_user
.as_ref()
.and_then(|user| {
members_data
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members: Vec<_> = members_data
.into_iter()
.filter(|x| {
logged_in
|| x.accepted
|| user_id
.map(|y: crate::database::models::UserId| y == x.user_id)
.unwrap_or(false)
})
.flat_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)
})
})
.collect();
let organization = models::organizations::Organization::from(data, team_members);
organizations.push(organization);
}
Ok(HttpResponse::Ok().json(organizations))
}
#[derive(Serialize, Deserialize, Validate)]
pub struct OrganizationEdit {
#[validate(length(min = 3, max = 256))]
pub description: Option<String>,
#[validate(
length(min = 3, max = 64),
regex = "crate::util::validate::RE_URL_SAFE"
)]
// Title of the organization, also used as slug
pub title: Option<String>,
}
pub async fn organizations_edit(
req: HttpRequest,
info: web::Path<(String,)>,
new_organization: web::Json<OrganizationEdit>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_WRITE]),
)
.await?
.1;
new_organization
.validate()
.map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?;
let string = info.into_inner().0;
let result = database::models::Organization::get(&string, &**pool, &redis).await?;
if let Some(organization_item) = result {
let id = organization_item.id;
let team_member = database::models::TeamMember::get_from_user_id(
organization_item.team_id,
user.id.into(),
&**pool,
)
.await?;
let permissions =
OrganizationPermissions::get_permissions_by_role(&user.role, &team_member);
if let Some(perms) = permissions {
let mut transaction = pool.begin().await?;
if let Some(description) = &new_organization.description {
if !perms.contains(OrganizationPermissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthentication(
"You do not have the permissions to edit the description of this organization!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE organizations
SET description = $1
WHERE (id = $2)
",
description,
id as database::models::ids::OrganizationId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(title) = &new_organization.title {
if !perms.contains(OrganizationPermissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthentication(
"You do not have the permissions to edit the title of this organization!"
.to_string(),
));
}
let title_organization_id_option: Option<u64> = parse_base62(title).ok();
if let Some(title_organization_id) = title_organization_id_option {
let results = sqlx::query!(
"
SELECT EXISTS(SELECT 1 FROM organizations WHERE id=$1)
",
title_organization_id as i64
)
.fetch_one(&mut *transaction)
.await?;
if results.exists.unwrap_or(true) {
return Err(ApiError::InvalidInput(
"Title collides with other organization's id!".to_string(),
));
}
}
// Make sure the new title is different from the old one
// We are able to unwrap here because the title is always set
if !title.eq(&organization_item.title.clone()) {
let results = sqlx::query!(
"
SELECT EXISTS(SELECT 1 FROM organizations WHERE title = LOWER($1))
",
title
)
.fetch_one(&mut *transaction)
.await?;
if results.exists.unwrap_or(true) {
return Err(ApiError::InvalidInput(
"Title collides with other organization's id!".to_string(),
));
}
}
sqlx::query!(
"
UPDATE organizations
SET title = LOWER($1)
WHERE (id = $2)
",
Some(title),
id as database::models::ids::OrganizationId,
)
.execute(&mut *transaction)
.await?;
}
database::models::Organization::clear_cache(
organization_item.id,
Some(organization_item.title),
&redis,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::CustomAuthentication(
"You do not have permission to edit this organization!".to_string(),
))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
pub async fn organization_delete(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_DELETE]),
)
.await?
.1;
let string = info.into_inner().0;
let organization = database::models::Organization::get(&string, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified organization does not exist!".to_string())
})?;
if !user.role.is_admin() {
let team_member = database::models::TeamMember::get_from_user_id_organization(
organization.id,
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::Database)?
.ok_or_else(|| {
ApiError::InvalidInput("The specified organization does not exist!".to_string())
})?;
let permissions =
OrganizationPermissions::get_permissions_by_role(&user.role, &Some(team_member))
.unwrap_or_default();
if !permissions.contains(OrganizationPermissions::DELETE_ORGANIZATION) {
return Err(ApiError::CustomAuthentication(
"You don't have permission to delete this organization!".to_string(),
));
}
}
let mut transaction = pool.begin().await?;
let result =
database::models::Organization::remove(organization.id, &mut transaction, &redis).await?;
transaction.commit().await?;
database::models::Organization::clear_cache(organization.id, Some(organization.title), &redis)
.await?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Deserialize)]
pub struct OrganizationProjectAdd {
pub project_id: String, // Also allow title/slug
}
pub async fn organization_projects_add(
req: HttpRequest,
info: web::Path<(String,)>,
project_info: web::Json<OrganizationProjectAdd>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let info = info.into_inner().0;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE, Scopes::ORGANIZATION_WRITE]),
)
.await?
.1;
let organization = database::models::Organization::get(&info, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified organization does not exist!".to_string())
})?;
let project_item = database::models::Project::get(&project_info.project_id, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified project does not exist!".to_string())
})?;
if project_item.inner.organization_id.is_some() {
return Err(ApiError::InvalidInput(
"The specified project is already owned by an organization!".to_string(),
));
}
let project_team_member = database::models::TeamMember::get_from_user_id_project(
project_item.inner.id,
current_user.id.into(),
&**pool,
)
.await?
.ok_or_else(|| ApiError::InvalidInput("You are not a member of this project!".to_string()))?;
let organization_team_member = database::models::TeamMember::get_from_user_id_organization(
organization.id,
current_user.id.into(),
&**pool,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("You are not a member of this organization!".to_string())
})?;
// Require ownership of a project to add it to an organization
if !current_user.role.is_admin()
&& !project_team_member
.role
.eq(crate::models::teams::OWNER_ROLE)
{
return Err(ApiError::CustomAuthentication(
"You need to be an owner of a project to add it to an organization!".to_string(),
));
}
let permissions = OrganizationPermissions::get_permissions_by_role(
&current_user.role,
&Some(organization_team_member),
)
.unwrap_or_default();
if permissions.contains(OrganizationPermissions::ADD_PROJECT) {
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE mods
SET organization_id = $1
WHERE (id = $2)
",
organization.id as database::models::OrganizationId,
project_item.inner.id as database::models::ids::ProjectId
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
database::models::TeamMember::clear_cache(project_item.inner.team_id, &redis).await?;
database::models::Project::clear_cache(
project_item.inner.id,
project_item.inner.slug,
None,
&redis,
)
.await?;
} else {
return Err(ApiError::CustomAuthentication(
"You do not have permission to add projects to this organization!".to_string(),
));
}
Ok(HttpResponse::Ok().finish())
}
pub async fn organization_projects_remove(
req: HttpRequest,
info: web::Path<(String, String)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let (organization_id, project_id) = info.into_inner();
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE, Scopes::ORGANIZATION_WRITE]),
)
.await?
.1;
let organization = database::models::Organization::get(&organization_id, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified organization does not exist!".to_string())
})?;
let project_item = database::models::Project::get(&project_id, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified project does not exist!".to_string())
})?;
if !project_item
.inner
.organization_id
.eq(&Some(organization.id))
{
return Err(ApiError::InvalidInput(
"The specified project is not owned by this organization!".to_string(),
));
}
let organization_team_member = database::models::TeamMember::get_from_user_id_organization(
organization.id,
current_user.id.into(),
&**pool,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("You are not a member of this organization!".to_string())
})?;
let permissions = OrganizationPermissions::get_permissions_by_role(
&current_user.role,
&Some(organization_team_member),
)
.unwrap_or_default();
if permissions.contains(OrganizationPermissions::REMOVE_PROJECT) {
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE mods
SET organization_id = NULL
WHERE (id = $1)
",
project_item.inner.id as database::models::ids::ProjectId
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
database::models::TeamMember::clear_cache(project_item.inner.team_id, &redis).await?;
database::models::Project::clear_cache(
project_item.inner.id,
project_item.inner.slug,
None,
&redis,
)
.await?;
} else {
return Err(ApiError::CustomAuthentication(
"You do not have permission to add projects to this organization!".to_string(),
));
}
Ok(HttpResponse::Ok().finish())
}
#[derive(Serialize, Deserialize)]
pub struct Extension {
pub ext: String,
}
#[allow(clippy::too_many_arguments)]
pub async fn organization_icon_edit(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
mut payload: web::Payload,
session_queue: web::Data<AuthQueue>,
) -> 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,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_WRITE]),
)
.await?
.1;
let string = info.into_inner().0;
let organization_item = database::models::Organization::get(&string, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified organization does not exist!".to_string())
})?;
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id(
organization_item.team_id,
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::Database)?;
let permissions =
OrganizationPermissions::get_permissions_by_role(&user.role, &team_member)
.unwrap_or_default();
if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthentication(
"You don't have permission to edit this organization's icon.".to_string(),
));
}
}
if let Some(icon) = organization_item.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, 262144, "Icons must be smaller than 256KiB").await?;
let color = crate::util::img::get_color_from_img(&bytes)?;
let hash = sha1::Sha1::from(&bytes).hexdigest();
let organization_id: OrganizationId = organization_item.id.into();
let upload_data = file_host
.upload_file(
content_type,
&format!("data/{}/{}.{}", organization_id, hash, ext.ext),
bytes.freeze(),
)
.await?;
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE organizations
SET icon_url = $1, color = $2
WHERE (id = $3)
",
format!("{}/{}", cdn_url, upload_data.file_name),
color.map(|x| x as i32),
organization_item.id as database::models::ids::OrganizationId,
)
.execute(&mut *transaction)
.await?;
database::models::Organization::clear_cache(
organization_item.id,
Some(organization_item.title),
&redis,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::InvalidInput(format!(
"Invalid format for project icon: {}",
ext.ext
)))
}
}
pub async fn delete_organization_icon(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_WRITE]),
)
.await?
.1;
let string = info.into_inner().0;
let organization_item = database::models::Organization::get(&string, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput("The specified organization does not exist!".to_string())
})?;
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id(
organization_item.team_id,
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::Database)?;
let permissions =
OrganizationPermissions::get_permissions_by_role(&user.role, &team_member)
.unwrap_or_default();
if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthentication(
"You don't have permission to edit this organization's icon.".to_string(),
));
}
}
let cdn_url = dotenvy::var("CDN_URL")?;
if let Some(icon) = organization_item.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 mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE organizations
SET icon_url = NULL, color = NULL
WHERE (id = $1)
",
organization_item.id as database::models::ids::OrganizationId,
)
.execute(&mut *transaction)
.await?;
database::models::Organization::clear_cache(
organization_item.id,
Some(organization_item.title),
&redis,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}

View File

@@ -0,0 +1,975 @@
use super::version_creation::InitialVersionData;
use crate::auth::{get_user_from_headers, AuthenticationError};
use crate::database::models::loader_fields::{
Loader, LoaderField, LoaderFieldEnumValue, VersionField,
};
use crate::database::models::thread_item::ThreadBuilder;
use crate::database::models::{self, image_item, User};
use crate::database::redis::RedisPool;
use crate::file_hosting::{FileHost, FileHostingError};
use crate::models::error::ApiError;
use crate::models::ids::{ImageId, OrganizationId};
use crate::models::images::{Image, ImageContext};
use crate::models::pats::Scopes;
use crate::models::projects::{
DonationLink, License, MonetizationStatus, ProjectId, ProjectStatus, VersionId, VersionStatus,
};
use crate::models::teams::ProjectPermissions;
use crate::models::threads::ThreadType;
use crate::models::users::UserId;
use crate::queue::session::AuthQueue;
use crate::search::indexing::IndexingError;
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::{self, Data};
use actix_web::{HttpRequest, HttpResponse};
use chrono::Utc;
use futures::stream::StreamExt;
use image::ImageError;
use itertools::Itertools;
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.route("create", web::post().to(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),
#[error("Reroute Error: {0}")]
RerouteError(#[from] reqwest::Error),
}
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,
CreateError::RerouteError(..) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
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",
CreateError::RerouteError(..) => "reroute_error",
},
description: &self.to_string(),
})
}
}
pub fn default_project_type() -> String {
"mod".to_string()
}
fn default_requested_status() -> ProjectStatus {
ProjectStatus::Approved
}
#[derive(Serialize, Deserialize, Validate, Clone)]
pub 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 = 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,
#[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,
// Associations to uploaded images in body/description
#[validate(length(max = 10))]
#[serde(default)]
pub uploaded_images: Vec<ImageId>,
/// The id of the organization to create the project in
pub organization_id: Option<OrganizationId>,
}
#[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(())
}
pub async fn project_create(
req: HttpRequest,
mut payload: Multipart,
client: Data<PgPool>,
redis: Data<RedisPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: Data<AuthQueue>,
) -> 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,
&redis,
&session_queue,
)
.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
*/
#[allow(clippy::too_many_arguments)]
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,
redis: &RedisPool,
session_queue: &AuthQueue,
) -> 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,
pool,
redis,
session_queue,
Some(&[Scopes::PROJECT_CREATE]),
)
.await?
.1;
let project_id: ProjectId = models::generate_project_id(transaction).await?.into();
let all_loaders = models::loader_fields::Loader::list(&mut **transaction, redis).await?;
let project_create_data: ProjectCreateData;
let mut versions;
let mut versions_map = std::collections::HashMap::new();
let mut gallery_urls = Vec::new();
{
// 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_loaders,
transaction,
redis,
)
.await?,
);
}
project_create_data = create_data;
}
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.0,
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();
// TODO: maybe redundant is this calculation done elsewhere?
// 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(),
&created_version.version_fields,
version_data.loaders.clone(),
version_data.primary_file.is_some(),
version_data.primary_file.as_deref() == Some(name),
None,
transaction,
redis,
)
.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 ids = models::categories::Category::get_ids(category, &mut **transaction).await?;
if ids.is_empty() {
return Err(CreateError::InvalidCategory(category.clone()));
}
// TODO: We should filter out categories that don't match the project type of any of the versions
// ie: if mod and modpack both share a name this should only have modpack if it only has a modpack as a version
categories.extend(ids.values());
}
let mut additional_categories =
Vec::with_capacity(project_create_data.additional_categories.len());
for category in &project_create_data.additional_categories {
let ids = models::categories::Category::get_ids(category, &mut **transaction).await?;
if ids.is_empty() {
return Err(CreateError::InvalidCategory(category.clone()));
}
// TODO: We should filter out categories that don't match the project type of any of the versions
// ie: if mod and modpack both share a name this should only have modpack if it only has a modpack as a version
additional_categories.extend(ids.values());
}
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(),
// Allow all permissions for project creator, even if attached to a project
permissions: ProjectPermissions::all(),
organization_permissions: None,
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",
)));
}
}
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::categories::DonationPlatform::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_actual = models::project_item::ProjectBuilder {
project_id: project_id.into(),
team_id,
organization_id: project_create_data.organization_id.map(|x| x.into()),
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),
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),
monetization_status: MonetizationStatus::Monetized,
};
let project_builder = project_builder_actual.clone();
let now = Utc::now();
let id = project_builder_actual.insert(&mut *transaction).await?;
User::clear_project_cache(&[current_user.id.into()], redis).await?;
for image_id in project_create_data.uploaded_images {
if let Some(db_image) =
image_item::Image::get(image_id.into(), &mut **transaction, redis).await?
{
let image: Image = db_image.into();
if !matches!(image.context, ImageContext::Project { .. })
|| image.context.inner_id().is_some()
{
return Err(CreateError::InvalidInput(format!(
"Image {} is not unused and in the 'project' context",
image_id
)));
}
sqlx::query!(
"
UPDATE uploaded_images
SET mod_id = $1
WHERE id = $2
",
id as models::ids::ProjectId,
image_id.0 as i64
)
.execute(&mut **transaction)
.await?;
image_item::Image::clear_cache(image.id.into(), redis).await?;
} else {
return Err(CreateError::InvalidInput(format!(
"Image {} does not exist",
image_id
)));
}
}
let thread_id = ThreadBuilder {
type_: ThreadType::Project,
members: vec![],
project_id: Some(id),
report_id: None,
}
.insert(&mut *transaction)
.await?;
let loaders = project_builder
.initial_versions
.iter()
.flat_map(|v| v.loaders.clone())
.unique()
.collect::<Vec<_>>();
let (project_types, games) = Loader::list(&mut **transaction, redis)
.await?
.into_iter()
.fold(
(Vec::new(), Vec::new()),
|(mut project_types, mut games), loader| {
if loaders.contains(&loader.id) {
project_types.extend(loader.supported_project_types);
games.extend(loader.supported_games.iter().map(|x| x.name().to_string()));
}
(project_types, games)
},
);
let response = crate::models::projects::Project {
id: project_id,
slug: project_builder.slug.clone(),
project_types,
games,
team: team_id.into(),
organization: project_create_data.organization_id,
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(),
},
downloads: 0,
followers: 0,
categories: project_create_data.categories,
additional_categories: project_create_data.additional_categories,
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,
color: project_builder.color,
thread_id: thread_id.into(),
monetization_status: MonetizationStatus::Monetized,
};
Ok(HttpResponse::Ok().json(response))
}
}
async fn create_initial_version(
version_data: &InitialVersionData,
project_id: ProjectId,
author: UserId,
all_loaders: &[models::loader_fields::Loader],
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
redis: &RedisPool,
) -> 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 loaders = version_data
.loaders
.iter()
.map(|x| {
all_loaders
.iter()
.find(|y| y.loader == x.0)
.ok_or_else(|| CreateError::InvalidLoader(x.0.clone()))
.map(|y| y.id)
})
.collect::<Result<Vec<models::LoaderId>, CreateError>>()?;
let loader_fields = LoaderField::get_fields(&mut **transaction, redis).await?;
let mut version_fields = vec![];
let mut loader_field_enum_values =
LoaderFieldEnumValue::list_many_loader_fields(&loader_fields, &mut **transaction, redis)
.await?;
for (key, value) in version_data.fields.iter() {
let loader_field = loader_fields
.iter()
.find(|lf| &lf.field == key)
.ok_or_else(|| {
CreateError::InvalidInput(format!("Loader field '{key}' does not exist!"))
})?;
let enum_variants = loader_field_enum_values
.remove(&loader_field.id)
.unwrap_or_default();
let vf: VersionField = VersionField::check_parse(
version_id.into(),
loader_field.clone(),
value.clone(),
enum_variants,
)
.map_err(CreateError::InvalidInput)?;
version_fields.push(vf);
}
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,
loaders,
version_fields,
featured: version_data.featured,
status: VersionStatus::Listed,
version_type: version_data.release_channel.to_string(),
requested_status: None,
ordering: version_data.ordering,
};
Ok(version)
}
async fn process_icon_upload(
uploaded_files: &mut Vec<UploadedFile>,
id: u64,
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/{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()))
}
}

2495
src/routes/v3/projects.rs Normal file

File diff suppressed because it is too large Load Diff

524
src/routes/v3/reports.rs Normal file
View File

@@ -0,0 +1,524 @@
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::database;
use crate::database::models::image_item;
use crate::database::models::thread_item::{ThreadBuilder, ThreadMessageBuilder};
use crate::database::redis::RedisPool;
use crate::models::ids::ImageId;
use crate::models::ids::{base62_impl::parse_base62, ProjectId, UserId, VersionId};
use crate::models::images::{Image, ImageContext};
use crate::models::pats::Scopes;
use crate::models::reports::{ItemType, Report};
use crate::models::threads::{MessageBody, ThreadType};
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::util::img;
use actix_web::{web, HttpRequest, HttpResponse};
use chrono::Utc;
use futures::StreamExt;
use serde::Deserialize;
use sqlx::PgPool;
use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("report", web::post().to(report_create));
cfg.route("report", web::get().to(reports));
cfg.route("reports", web::get().to(reports_get));
cfg.route("report/{id}", web::get().to(report_get));
cfg.route("report/{id}", web::patch().to(report_edit));
cfg.route("report/{id}", web::delete().to(report_delete));
}
#[derive(Deserialize, Validate)]
pub struct CreateReport {
pub report_type: String,
pub item_id: String,
pub item_type: ItemType,
pub body: String,
// Associations to uploaded images
#[validate(length(max = 10))]
#[serde(default)]
pub uploaded_images: Vec<ImageId>,
}
pub async fn report_create(
req: HttpRequest,
pool: web::Data<PgPool>,
mut body: web::Payload,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let mut transaction = pool.begin().await?;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_CREATE]),
)
.await?
.1;
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(),
closed: false,
};
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?;
for image_id in new_report.uploaded_images {
if let Some(db_image) =
image_item::Image::get(image_id.into(), &mut *transaction, &redis).await?
{
let image: Image = db_image.into();
if !matches!(image.context, ImageContext::Report { .. })
|| image.context.inner_id().is_some()
{
return Err(ApiError::InvalidInput(format!(
"Image {} is not unused and in the 'report' context",
image_id
)));
}
sqlx::query!(
"
UPDATE uploaded_images
SET report_id = $1
WHERE id = $2
",
id.0 as i64,
image_id.0 as i64
)
.execute(&mut *transaction)
.await?;
image_item::Image::clear_cache(image.id.into(), &redis).await?;
} else {
return Err(ApiError::InvalidInput(format!(
"Image {} could not be found",
image_id
)));
}
}
let thread_id = ThreadBuilder {
type_: ThreadType::Report,
members: vec![],
project_id: None,
report_id: Some(report.id),
}
.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(),
closed: false,
thread_id: thread_id.into(),
}))
}
#[derive(Deserialize)]
pub struct ReportsRequestOptions {
#[serde(default = "default_count")]
pub count: i16,
#[serde(default = "default_all")]
pub all: bool,
}
fn default_count() -> i16 {
100
}
fn default_all() -> bool {
true
}
pub async fn reports(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
count: web::Query<ReportsRequestOptions>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_READ]),
)
.await?
.1;
use futures::stream::TryStreamExt;
let report_ids = if user.role.is_mod() && count.all {
sqlx::query!(
"
SELECT id FROM reports
WHERE closed = FALSE
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?
} else {
sqlx::query!(
"
SELECT id FROM reports
WHERE closed = FALSE AND reporter = $1
ORDER BY created ASC
LIMIT $2;
",
user.id.0 as i64,
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<Report> = Vec::new();
for x in query_reports {
reports.push(x.into());
}
Ok(HttpResponse::Ok().json(reports))
}
#[derive(Deserialize)]
pub struct ReportIds {
pub ids: String,
}
pub async fn reports_get(
req: HttpRequest,
web::Query(ids): web::Query<ReportIds>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let report_ids: Vec<crate::database::models::ids::ReportId> =
serde_json::from_str::<Vec<crate::models::ids::ReportId>>(&ids.ids)?
.into_iter()
.map(|x| x.into())
.collect();
let reports_data =
crate::database::models::report_item::Report::get_many(&report_ids, &**pool).await?;
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_READ]),
)
.await?
.1;
let all_reports = reports_data
.into_iter()
.filter(|x| user.role.is_mod() || x.reporter == user.id.into())
.map(|x| x.into())
.collect::<Vec<Report>>();
Ok(HttpResponse::Ok().json(all_reports))
}
pub async fn report_get(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
info: web::Path<(crate::models::reports::ReportId,)>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_READ]),
)
.await?
.1;
let id = info.into_inner().0.into();
let report = crate::database::models::report_item::Report::get(id, &**pool).await?;
if let Some(report) = report {
if !user.role.is_mod() && report.reporter != user.id.into() {
return Ok(HttpResponse::NotFound().body(""));
}
let report: Report = report.into();
Ok(HttpResponse::Ok().json(report))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Deserialize, Validate)]
pub struct EditReport {
#[validate(length(max = 65536))]
pub body: Option<String>,
pub closed: Option<bool>,
}
pub async fn report_edit(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
info: web::Path<(crate::models::reports::ReportId,)>,
session_queue: web::Data<AuthQueue>,
edit_report: web::Json<EditReport>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_WRITE]),
)
.await?
.1;
let id = info.into_inner().0.into();
let report = crate::database::models::report_item::Report::get(id, &**pool).await?;
if let Some(report) = report {
if !user.role.is_mod() && report.reporter != user.id.into() {
return Ok(HttpResponse::NotFound().body(""));
}
let mut transaction = pool.begin().await?;
if let Some(edit_body) = &edit_report.body {
sqlx::query!(
"
UPDATE reports
SET body = $1
WHERE (id = $2)
",
edit_body,
id as crate::database::models::ids::ReportId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(edit_closed) = edit_report.closed {
if !user.role.is_mod() {
return Err(ApiError::InvalidInput(
"You cannot reopen a report!".to_string(),
));
}
ThreadMessageBuilder {
author_id: Some(user.id.into()),
body: if !edit_closed && report.closed {
MessageBody::ThreadReopen
} else {
MessageBody::ThreadClosure
},
thread_id: report.thread_id,
}
.insert(&mut transaction)
.await?;
sqlx::query!(
"
UPDATE reports
SET closed = $1
WHERE (id = $2)
",
edit_closed,
id as crate::database::models::ids::ReportId,
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
UPDATE threads
SET show_in_mod_inbox = $1
WHERE id = $2
",
!(edit_closed || report.closed),
report.thread_id.0,
)
.execute(&mut *transaction)
.await?;
}
// delete any images no longer in the body
let checkable_strings: Vec<&str> = vec![&edit_report.body]
.into_iter()
.filter_map(|x: &Option<String>| x.as_ref().map(|y| y.as_str()))
.collect();
let image_context = ImageContext::Report {
report_id: Some(id.into()),
};
img::delete_unused_images(image_context, checkable_strings, &mut transaction, &redis)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
pub async fn report_delete(
req: HttpRequest,
pool: web::Data<PgPool>,
info: web::Path<(crate::models::reports::ReportId,)>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::REPORT_DELETE]),
)
.await?;
let mut transaction = pool.begin().await?;
let id = info.into_inner().0;
let context = ImageContext::Report {
report_id: Some(id),
};
let uploaded_images =
database::models::Image::get_many_contexted(context, &mut transaction).await?;
for image in uploaded_images {
image_item::Image::remove(image.id, &mut transaction, &redis).await?;
}
let result =
crate::database::models::report_item::Report::remove_full(id.into(), &mut transaction)
.await?;
transaction.commit().await?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

View File

@@ -0,0 +1,85 @@
use crate::routes::ApiError;
use actix_web::{web, HttpResponse};
use serde_json::json;
use sqlx::PgPool;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("statistics", web::get().to(get_stats));
}
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))
}

210
src/routes/v3/tags.rs Normal file
View File

@@ -0,0 +1,210 @@
use std::collections::HashMap;
use super::ApiError;
use crate::database::models::categories::{Category, DonationPlatform, ProjectType, ReportType};
use crate::database::models::loader_fields::{
Loader, LoaderField, LoaderFieldEnumValue, LoaderFieldType,
};
use crate::database::redis::RedisPool;
use actix_web::{web, HttpResponse};
use serde_json::Value;
use sqlx::PgPool;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("tag")
.route("category", web::get().to(category_list))
.route("loader", web::get().to(loader_list)),
)
.route("loader_fields", web::get().to(loader_fields_list))
.route("license", web::get().to(license_list))
.route("license/{id}", web::get().to(license_text))
.route("donation_platform", web::get().to(donation_platform_list))
.route("report_type", web::get().to(report_type_list))
.route("project_type", web::get().to(project_type_list));
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct CategoryData {
pub icon: String,
pub name: String,
pub project_type: String,
pub header: String,
}
pub async fn category_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let results = Category::list(&**pool, &redis)
.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))
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct LoaderData {
pub icon: String,
pub name: String,
pub supported_project_types: Vec<String>,
pub supported_games: Vec<String>,
}
pub async fn loader_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let mut results = Loader::list(&**pool, &redis)
.await?
.into_iter()
.map(|x| LoaderData {
icon: x.icon,
name: x.loader,
supported_project_types: x.supported_project_types,
supported_games: x
.supported_games
.iter()
.map(|x| x.name().to_string())
.collect(),
})
.collect::<Vec<_>>();
results.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(HttpResponse::Ok().json(results))
}
#[derive(serde::Deserialize, serde::Serialize)]
pub struct LoaderFieldsEnumQuery {
pub loader_field: String,
pub filters: Option<HashMap<String, Value>>, // For metadata
}
// Provides the variants for any enumerable loader field.
pub async fn loader_fields_list(
pool: web::Data<PgPool>,
query: web::Query<LoaderFieldsEnumQuery>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let query = query.into_inner();
let loader_field = LoaderField::get_field(&query.loader_field, &**pool, &redis)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"'{}' was not a valid loader field.",
query.loader_field
))
})?;
let loader_field_enum_id = match loader_field.field_type {
LoaderFieldType::Enum(enum_id) | LoaderFieldType::ArrayEnum(enum_id) => enum_id,
_ => {
return Err(ApiError::InvalidInput(format!(
"'{}' is not an enumerable field, but an '{}' field.",
query.loader_field,
loader_field.field_type.to_str()
)))
}
};
let results: Vec<_> = if let Some(filters) = query.filters {
LoaderFieldEnumValue::list_filter(loader_field_enum_id, filters, &**pool, &redis).await?
} else {
LoaderFieldEnumValue::list(loader_field_enum_id, &**pool, &redis).await?
};
Ok(HttpResponse::Ok().json(results))
}
#[derive(serde::Serialize)]
pub struct License {
short: String,
name: String,
}
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,
}
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,
}
pub async fn donation_platform_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let results: Vec<DonationPlatformQueryData> = DonationPlatform::list(&**pool, &redis)
.await?
.into_iter()
.map(|x| DonationPlatformQueryData {
short: x.short,
name: x.name,
})
.collect();
Ok(HttpResponse::Ok().json(results))
}
pub async fn report_type_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let results = ReportType::list(&**pool, &redis).await?;
Ok(HttpResponse::Ok().json(results))
}
pub async fn project_type_list(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let results = ProjectType::list(&**pool, &redis).await?;
Ok(HttpResponse::Ok().json(results))
}

943
src/routes/v3/teams.rs Normal file
View File

@@ -0,0 +1,943 @@
use crate::auth::{get_user_from_headers, is_authorized};
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::team_item::TeamAssociationId;
use crate::database::models::{Organization, Team, TeamMember, User};
use crate::database::redis::RedisPool;
use crate::database::Project;
use crate::models::notifications::NotificationBody;
use crate::models::pats::Scopes;
use crate::models::teams::{OrganizationPermissions, ProjectPermissions, TeamId};
use crate::models::users::UserId;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use actix_web::{web, HttpRequest, HttpResponse};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("teams", web::get().to(teams_get));
cfg.service(
web::scope("team")
.route("{id}/members", web::get().to(team_members_get))
.route("{id}/members/{user_id}", web::patch().to(edit_team_member))
.route(
"{id}/members/{user_id}",
web::delete().to(remove_team_member),
)
.route("{id}/members", web::post().to(add_team_member))
.route("{id}/join", web::post().to(join_team))
.route("{id}/owner", web::patch().to(transfer_ownership)),
);
}
// Returns all members of a project,
// including the team members of the project's team, but
// also the members of the organization's team if the project is associated with an organization
// (Unlike team_members_get_project, which only returns the members of the project's team)
pub async fn team_members_get_project(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
let project_data = crate::database::models::Project::get(&string, &**pool, &redis).await?;
if let Some(project) = project_data {
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
)
.await
.map(|x| x.1)
.ok();
if !is_authorized(&project.inner, &current_user, &pool).await? {
return Ok(HttpResponse::NotFound().body(""));
}
let mut members_data =
TeamMember::get_from_team_full(project.inner.team_id, &**pool, &redis).await?;
let mut member_user_ids = members_data.iter().map(|x| x.user_id).collect::<Vec<_>>();
// Adds the organization's team members to the list of members, if the project is associated with an organization
if let Some(oid) = project.inner.organization_id {
let organization_data = Organization::get_id(oid, &**pool, &redis).await?;
if let Some(organization_data) = organization_data {
let org_team =
TeamMember::get_from_team_full(organization_data.team_id, &**pool, &redis)
.await?;
for member in org_team {
if !member_user_ids.contains(&member.user_id) {
member_user_ids.push(member.user_id);
members_data.push(member);
}
}
}
}
let users =
crate::database::models::User::get_many_ids(&member_user_ids, &**pool, &redis).await?;
let user_id = current_user.as_ref().map(|x| x.id.into());
let logged_in = current_user
.and_then(|user| {
members_data
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members: Vec<_> = members_data
.into_iter()
.filter(|x| {
logged_in
|| x.accepted
|| user_id
.map(|y: crate::database::models::UserId| y == x.user_id)
.unwrap_or(false)
})
.flat_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)
})
})
.collect();
Ok(HttpResponse::Ok().json(team_members))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
pub async fn team_members_get_organization(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
let organization_data =
crate::database::models::Organization::get(&string, &**pool, &redis).await?;
if let Some(organization) = organization_data {
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::ORGANIZATION_READ]),
)
.await
.map(|x| x.1)
.ok();
let members_data =
TeamMember::get_from_team_full(organization.team_id, &**pool, &redis).await?;
let users = crate::database::models::User::get_many_ids(
&members_data.iter().map(|x| x.user_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let user_id = current_user.as_ref().map(|x| x.id.into());
let logged_in = current_user
.and_then(|user| {
members_data
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members: Vec<_> = members_data
.into_iter()
.filter(|x| {
logged_in
|| x.accepted
|| user_id
.map(|y: crate::database::models::UserId| y == x.user_id)
.unwrap_or(false)
})
.flat_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)
})
})
.collect();
Ok(HttpResponse::Ok().json(team_members))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
// Returns all members of a team, but not necessarily those of a project-team's organization (unlike team_members_get_project)
pub async fn team_members_get(
req: HttpRequest,
info: web::Path<(TeamId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let members_data = TeamMember::get_from_team_full(id.into(), &**pool, &redis).await?;
let users = crate::database::models::User::get_many_ids(
&members_data.iter().map(|x| x.user_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
)
.await
.map(|x| x.1)
.ok();
let user_id = current_user.as_ref().map(|x| x.id.into());
let logged_in = current_user
.and_then(|user| {
members_data
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members: Vec<_> = members_data
.into_iter()
.filter(|x| {
logged_in
|| x.accepted
|| user_id
.map(|y: crate::database::models::UserId| y == x.user_id)
.unwrap_or(false)
})
.flat_map(|data| {
users
.iter()
.find(|x| x.id == data.user_id)
.map(|user| crate::models::teams::TeamMember::from(data, user.clone(), !logged_in))
})
.collect();
Ok(HttpResponse::Ok().json(team_members))
}
#[derive(Serialize, Deserialize)]
pub struct TeamIds {
pub ids: String,
}
pub async fn teams_get(
req: HttpRequest,
web::Query(ids): web::Query<TeamIds>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> 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, &redis).await?;
let users = crate::database::models::User::get_many_ids(
&teams_data.iter().map(|x| x.user_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
)
.await
.map(|x| x.1)
.ok();
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 (_, member_data) in &teams_groups {
let members = member_data.collect::<Vec<_>>();
let logged_in = current_user
.as_ref()
.and_then(|user| {
members
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members = members
.into_iter()
.filter(|x| logged_in || x.accepted)
.flat_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)
})
});
teams.push(team_members.collect());
}
Ok(HttpResponse::Ok().json(teams))
}
pub async fn join_team(
req: HttpRequest,
info: web::Path<(TeamId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let team_id = info.into_inner().0.into();
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
)
.await?
.1;
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,
None,
Some(true),
None,
None,
&mut transaction,
)
.await?;
User::clear_project_cache(&[current_user.id.into()], &redis).await?;
TeamMember::clear_cache(team_id, &redis).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)]
pub permissions: ProjectPermissions,
#[serde(default)]
pub organization_permissions: Option<OrganizationPermissions>,
#[serde(default)]
pub payouts_split: Decimal,
#[serde(default = "default_ordering")]
pub ordering: i64,
}
pub async fn add_team_member(
req: HttpRequest,
info: web::Path<(TeamId,)>,
pool: web::Data<PgPool>,
new_member: web::Json<NewTeamMember>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> 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,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
)
.await?
.1;
let team_association = Team::get_association(team_id, &**pool)
.await?
.ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?;
let member = TeamMember::get_from_user_id(team_id, current_user.id.into(), &**pool).await?;
match team_association {
// If team is associated with a project, check if they have permissions to invite users to that project
TeamAssociationId::Project(pid) => {
let organization =
Organization::get_associated_organization_project_id(pid, &**pool).await?;
let organization_team_member = if let Some(organization) = &organization {
TeamMember::get_from_user_id(organization.team_id, current_user.id.into(), &**pool)
.await?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&current_user.role,
&member,
&organization_team_member,
)
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::MANAGE_INVITES) {
return Err(ApiError::CustomAuthentication(
"You don't have permission to invite users to this team".to_string(),
));
}
if !permissions.contains(new_member.permissions) {
return Err(ApiError::InvalidInput(
"The new member has permissions that you don't have".to_string(),
));
}
if new_member.organization_permissions.is_some() {
return Err(ApiError::InvalidInput(
"The organization permissions of a project team member cannot be set"
.to_string(),
));
}
}
// If team is associated with an organization, check if they have permissions to invite users to that organization
TeamAssociationId::Organization(_) => {
let organization_permissions =
OrganizationPermissions::get_permissions_by_role(&current_user.role, &member)
.unwrap_or_default();
if !organization_permissions.contains(OrganizationPermissions::MANAGE_INVITES) {
return Err(ApiError::CustomAuthentication(
"You don't have permission to invite users to this organization".to_string(),
));
}
if !organization_permissions
.contains(new_member.organization_permissions.unwrap_or_default())
{
return Err(ApiError::InvalidInput(
"The new member has organization permissions that you don't have".to_string(),
));
}
if !organization_permissions
.contains(OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS)
&& !new_member.permissions.is_empty()
{
return Err(ApiError::CustomAuthentication(
"You do not have permission to give this user default project permissions. Ensure 'permissions' is set if it is not, and empty (0)."
.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 =
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_id(new_member.user_id.into(), &**pool, &redis)
.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,
organization_permissions: new_member.organization_permissions,
accepted: false,
payouts_split: new_member.payouts_split,
ordering: new_member.ordering,
}
.insert(&mut transaction)
.await?;
match team_association {
TeamAssociationId::Project(pid) => {
NotificationBuilder {
body: NotificationBody::TeamInvite {
project_id: pid.into(),
team_id: team_id.into(),
invited_by: current_user.id,
role: new_member.role.clone(),
},
}
.insert(new_member.user_id.into(), &mut transaction, &redis)
.await?;
}
TeamAssociationId::Organization(oid) => {
NotificationBuilder {
body: NotificationBody::OrganizationInvite {
organization_id: oid.into(),
team_id: team_id.into(),
invited_by: current_user.id,
role: new_member.role.clone(),
},
}
.insert(new_member.user_id.into(), &mut transaction, &redis)
.await?;
}
}
TeamMember::clear_cache(team_id, &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}
#[derive(Serialize, Deserialize, Clone)]
pub struct EditTeamMember {
pub permissions: Option<ProjectPermissions>,
pub organization_permissions: Option<OrganizationPermissions>,
pub role: Option<String>,
pub payouts_split: Option<Decimal>,
pub ordering: Option<i64>,
}
pub async fn edit_team_member(
req: HttpRequest,
info: web::Path<(TeamId, UserId)>,
pool: web::Data<PgPool>,
edit_member: web::Json<EditTeamMember>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> 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,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
)
.await?
.1;
let team_association = Team::get_association(id, &**pool)
.await?
.ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?;
let member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?;
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(),
));
}
match team_association {
TeamAssociationId::Project(project_id) => {
let organization =
Organization::get_associated_organization_project_id(project_id, &**pool).await?;
let organization_team_member = if let Some(organization) = &organization {
TeamMember::get_from_user_id(organization.team_id, current_user.id.into(), &**pool)
.await?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&current_user.role,
&member.clone(),
&organization_team_member,
)
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::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 !permissions.contains(new_permissions) {
return Err(ApiError::InvalidInput(
"The new permissions have permissions that you don't have".to_string(),
));
}
}
if edit_member.organization_permissions.is_some() {
return Err(ApiError::InvalidInput(
"The organization permissions of a project team member cannot be edited"
.to_string(),
));
}
}
TeamAssociationId::Organization(_) => {
let organization_permissions =
OrganizationPermissions::get_permissions_by_role(&current_user.role, &member)
.unwrap_or_default();
if !organization_permissions.contains(OrganizationPermissions::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.organization_permissions {
if !organization_permissions.contains(new_permissions) {
return Err(ApiError::InvalidInput(
"The new organization permissions have permissions that you don't have"
.to_string(),
));
}
}
if edit_member.permissions.is_some()
&& !organization_permissions
.contains(OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS)
{
return Err(ApiError::CustomAuthentication(
"You do not have permission to give this user default project permissions."
.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.organization_permissions,
edit_member.role.clone(),
None,
edit_member.payouts_split,
edit_member.ordering,
&mut transaction,
)
.await?;
TeamMember::clear_cache(id, &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}
#[derive(Deserialize)]
pub struct TransferOwnership {
pub user_id: UserId,
}
pub async fn transfer_ownership(
req: HttpRequest,
info: web::Path<(TeamId,)>,
pool: web::Data<PgPool>,
new_owner: web::Json<TransferOwnership>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
)
.await?
.1;
// Forbid transferring ownership of a project team that is owned by an organization
// These are owned by the organization owner, and must be removed from the organization first
let pid = Team::get_association(id.into(), &**pool).await?;
if let Some(TeamAssociationId::Project(pid)) = pid {
let result = Project::get_id(pid, &**pool, &redis).await?;
if let Some(project_item) = result {
if project_item.inner.organization_id.is_some() {
return Err(ApiError::InvalidInput(
"You cannot transfer ownership of a project team that is owend by an organization"
.to_string(),
));
}
}
}
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,
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(ProjectPermissions::all()),
Some(OrganizationPermissions::all()),
Some(crate::models::teams::OWNER_ROLE.to_string()),
None,
None,
None,
&mut transaction,
)
.await?;
TeamMember::clear_cache(id.into(), &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}
pub async fn remove_team_member(
req: HttpRequest,
info: web::Path<(TeamId, UserId)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> 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,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_WRITE]),
)
.await?
.1;
let team_association = Team::get_association(id, &**pool)
.await?
.ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?;
let member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?;
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?;
// Organization attached to a project this team is attached to
match team_association {
TeamAssociationId::Project(pid) => {
let organization =
Organization::get_associated_organization_project_id(pid, &**pool).await?;
let organization_team_member = if let Some(organization) = &organization {
TeamMember::get_from_user_id(
organization.team_id,
current_user.id.into(),
&**pool,
)
.await?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&current_user.role,
&member,
&organization_team_member,
)
.unwrap_or_default();
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 Some(delete_member.user_id) == member.as_ref().map(|m| m.user_id)
|| permissions.contains(ProjectPermissions::REMOVE_MEMBER)
// true as if the permission exists, but the member does not, they are part of an org
{
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 Some(delete_member.user_id) == member.as_ref().map(|m| m.user_id)
|| permissions.contains(ProjectPermissions::MANAGE_INVITES)
// true as if the permission exists, but the member does not, they are part of an org
{
// 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(),
));
}
}
TeamAssociationId::Organization(_) => {
let organization_permissions =
OrganizationPermissions::get_permissions_by_role(&current_user.role, &member)
.unwrap_or_default();
// Organization teams requires a TeamMember, so we can 'unwrap'
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 Some(delete_member.user_id) == member.map(|m| m.user_id)
|| organization_permissions.contains(OrganizationPermissions::REMOVE_MEMBER)
{
TeamMember::delete(id, user_id, &mut transaction).await?;
} else {
return Err(ApiError::CustomAuthentication(
"You do not have permission to remove a member from this organization"
.to_string(),
));
}
} else if Some(delete_member.user_id) == member.map(|m| m.user_id)
|| organization_permissions.contains(OrganizationPermissions::MANAGE_INVITES)
{
// 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 an organization invite".to_string(),
));
}
}
}
TeamMember::clear_cache(id, &redis).await?;
User::clear_project_cache(&[delete_member.user_id], &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

622
src/routes/v3/threads.rs Normal file
View File

@@ -0,0 +1,622 @@
use std::sync::Arc;
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::database;
use crate::database::models::image_item;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::ids::ThreadMessageId;
use crate::models::images::{Image, ImageContext};
use crate::models::notifications::NotificationBody;
use crate::models::pats::Scopes;
use crate::models::projects::ProjectStatus;
use crate::models::threads::{MessageBody, Thread, ThreadId, ThreadType};
use crate::models::users::User;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use actix_web::{web, HttpRequest, HttpResponse};
use futures::TryStreamExt;
use serde::Deserialize;
use sqlx::PgPool;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("thread")
.route("{id}", web::get().to(thread_get))
.route("inbox", web::get().to(moderation_inbox))
.route("{id}", web::post().to(thread_send_message))
.route("{id}/read", web::post().to(thread_read)),
);
cfg.service(web::scope("message").route("{id}", web::delete().to(message_delete)));
cfg.route("threads", web::get().to(threads_get));
}
pub async fn is_authorized_thread(
thread: &database::models::Thread,
user: &User,
pool: &PgPool,
) -> Result<bool, ApiError> {
if user.role.is_mod() {
return Ok(true);
}
let user_id: database::models::UserId = user.id.into();
Ok(match thread.type_ {
ThreadType::Report => {
if let Some(report_id) = thread.report_id {
let report_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM reports WHERE id = $1 AND reporter = $2)",
report_id as database::models::ids::ReportId,
user_id as database::models::ids::UserId,
)
.fetch_one(pool)
.await?
.exists;
report_exists.unwrap_or(false)
} else {
false
}
}
ThreadType::Project => {
if let Some(project_id) = thread.project_id {
let project_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 WHERE m.id = $1)",
project_id as database::models::ids::ProjectId,
user_id as database::models::ids::UserId,
)
.fetch_one(pool)
.await?
.exists;
project_exists.unwrap_or(false)
} else {
false
}
}
ThreadType::DirectMessage => thread.members.contains(&user_id),
})
}
pub async fn filter_authorized_threads(
threads: Vec<database::models::Thread>,
user: &User,
pool: &web::Data<PgPool>,
redis: &RedisPool,
) -> Result<Vec<Thread>, ApiError> {
let user_id: database::models::UserId = user.id.into();
let mut return_threads = Vec::new();
let mut check_threads = Vec::new();
for thread in threads {
if user.role.is_mod()
|| (thread.type_ == ThreadType::DirectMessage && thread.members.contains(&user_id))
{
return_threads.push(thread);
} else {
check_threads.push(thread);
}
}
if !check_threads.is_empty() {
let project_thread_ids = check_threads
.iter()
.filter(|x| x.type_ == ThreadType::Project)
.flat_map(|x| x.project_id.map(|x| x.0))
.collect::<Vec<_>>();
if !project_thread_ids.is_empty() {
sqlx::query!(
"
SELECT m.id FROM mods m
INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2
WHERE m.id = ANY($1)
",
&*project_thread_ids,
user_id as database::models::ids::UserId,
)
.fetch_many(&***pool)
.try_for_each(|e| {
if let Some(row) = e.right() {
check_threads.retain(|x| {
let bool = x.project_id.map(|x| x.0) == Some(row.id);
if bool {
return_threads.push(x.clone());
}
!bool
});
}
futures::future::ready(Ok(()))
})
.await?;
}
let report_thread_ids = check_threads
.iter()
.filter(|x| x.type_ == ThreadType::Report)
.flat_map(|x| x.report_id.map(|x| x.0))
.collect::<Vec<_>>();
if !report_thread_ids.is_empty() {
sqlx::query!(
"
SELECT id FROM reports
WHERE id = ANY($1) AND reporter = $2
",
&*report_thread_ids,
user_id as database::models::ids::UserId,
)
.fetch_many(&***pool)
.try_for_each(|e| {
if let Some(row) = e.right() {
check_threads.retain(|x| {
let bool = x.report_id.map(|x| x.0) == Some(row.id);
if bool {
return_threads.push(x.clone());
}
!bool
});
}
futures::future::ready(Ok(()))
})
.await?;
}
}
let mut user_ids = return_threads
.iter()
.flat_map(|x| x.members.clone())
.collect::<Vec<database::models::UserId>>();
user_ids.append(
&mut return_threads
.iter()
.flat_map(|x| {
x.messages
.iter()
.filter_map(|x| x.author_id)
.collect::<Vec<_>>()
})
.collect::<Vec<database::models::UserId>>(),
);
let users: Vec<User> = database::models::User::get_many_ids(&user_ids, &***pool, redis)
.await?
.into_iter()
.map(From::from)
.collect();
let mut final_threads = Vec::new();
for thread in return_threads {
let mut authors = thread.members.clone();
authors.append(
&mut thread
.messages
.iter()
.filter_map(|x| x.author_id)
.collect::<Vec<_>>(),
);
final_threads.push(Thread::from(
thread,
users
.iter()
.filter(|x| authors.contains(&x.id.into()))
.cloned()
.collect(),
user,
));
}
Ok(final_threads)
}
pub async fn thread_get(
req: HttpRequest,
info: web::Path<(ThreadId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0.into();
let thread_data = database::models::Thread::get(string, &**pool).await?;
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_READ]),
)
.await?
.1;
if let Some(mut data) = thread_data {
if is_authorized_thread(&data, &user, &pool).await? {
let authors = &mut data.members;
authors.append(
&mut data
.messages
.iter()
.filter_map(|x| x.author_id)
.collect::<Vec<_>>(),
);
let users: Vec<User> = database::models::User::get_many_ids(authors, &**pool, &redis)
.await?
.into_iter()
.map(From::from)
.collect();
return Ok(HttpResponse::Ok().json(Thread::from(data, users, &user)));
}
}
Ok(HttpResponse::NotFound().body(""))
}
#[derive(Deserialize)]
pub struct ThreadIds {
pub ids: String,
}
pub async fn threads_get(
req: HttpRequest,
web::Query(ids): web::Query<ThreadIds>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_READ]),
)
.await?
.1;
let thread_ids: Vec<database::models::ids::ThreadId> =
serde_json::from_str::<Vec<ThreadId>>(&ids.ids)?
.into_iter()
.map(|x| x.into())
.collect();
let threads_data = database::models::Thread::get_many(&thread_ids, &**pool).await?;
let threads = filter_authorized_threads(threads_data, &user, &pool, &redis).await?;
Ok(HttpResponse::Ok().json(threads))
}
#[derive(Deserialize)]
pub struct NewThreadMessage {
pub body: MessageBody,
}
pub async fn thread_send_message(
req: HttpRequest,
info: web::Path<(ThreadId,)>,
pool: web::Data<PgPool>,
new_message: web::Json<NewThreadMessage>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_WRITE]),
)
.await?
.1;
let string: database::models::ThreadId = info.into_inner().0.into();
if let MessageBody::Text {
body,
replying_to,
private,
..
} = &new_message.body
{
if body.len() > 65536 {
return Err(ApiError::InvalidInput(
"Input body is too long!".to_string(),
));
}
if *private && !user.role.is_mod() {
return Err(ApiError::InvalidInput(
"You are not allowed to send private messages!".to_string(),
));
}
if let Some(replying_to) = replying_to {
let thread_message =
database::models::ThreadMessage::get((*replying_to).into(), &**pool).await?;
if let Some(thread_message) = thread_message {
if thread_message.thread_id != string {
return Err(ApiError::InvalidInput(
"Message replied to is from another thread!".to_string(),
));
}
} else {
return Err(ApiError::InvalidInput(
"Message replied to does not exist!".to_string(),
));
}
}
} else {
return Err(ApiError::InvalidInput(
"You may only send text messages through this route!".to_string(),
));
}
let result = database::models::Thread::get(string, &**pool).await?;
if let Some(thread) = result {
if !is_authorized_thread(&thread, &user, &pool).await? {
return Ok(HttpResponse::NotFound().body(""));
}
let mut transaction = pool.begin().await?;
let id = ThreadMessageBuilder {
author_id: Some(user.id.into()),
body: new_message.body.clone(),
thread_id: thread.id,
}
.insert(&mut transaction)
.await?;
let mod_notif = if let Some(project_id) = thread.project_id {
let project = database::models::Project::get_id(project_id, &**pool, &redis).await?;
if let Some(project) = project {
if project.inner.status != ProjectStatus::Processing && user.role.is_mod() {
let members = database::models::TeamMember::get_from_team_full(
project.inner.team_id,
&**pool,
&redis,
)
.await?;
NotificationBuilder {
body: NotificationBody::ModeratorMessage {
thread_id: thread.id.into(),
message_id: id.into(),
project_id: Some(project.inner.id.into()),
report_id: None,
},
}
.insert_many(
members.into_iter().map(|x| x.user_id).collect(),
&mut transaction,
&redis,
)
.await?;
}
}
!user.role.is_mod()
} else if let Some(report_id) = thread.report_id {
let report = database::models::report_item::Report::get(report_id, &**pool).await?;
if let Some(report) = report {
if report.closed && !user.role.is_mod() {
return Err(ApiError::InvalidInput(
"You may not reply to a closed report".to_string(),
));
}
if user.id != report.reporter.into() {
NotificationBuilder {
body: NotificationBody::ModeratorMessage {
thread_id: thread.id.into(),
message_id: id.into(),
project_id: None,
report_id: Some(report.id.into()),
},
}
.insert(report.reporter, &mut transaction, &redis)
.await?;
}
}
!user.role.is_mod()
} else {
false
};
sqlx::query!(
"
UPDATE threads
SET show_in_mod_inbox = $1
WHERE id = $2
",
mod_notif,
thread.id.0,
)
.execute(&mut *transaction)
.await?;
if let MessageBody::Text {
associated_images, ..
} = &new_message.body
{
for image_id in associated_images {
if let Some(db_image) =
image_item::Image::get((*image_id).into(), &mut *transaction, &redis).await?
{
let image: Image = db_image.into();
if !matches!(image.context, ImageContext::ThreadMessage { .. })
|| image.context.inner_id().is_some()
{
return Err(ApiError::InvalidInput(format!(
"Image {} is not unused and in the 'thread_message' context",
image_id
)));
}
sqlx::query!(
"
UPDATE uploaded_images
SET thread_message_id = $1
WHERE id = $2
",
thread.id.0,
image_id.0 as i64
)
.execute(&mut *transaction)
.await?;
image_item::Image::clear_cache(image.id.into(), &redis).await?;
} else {
return Err(ApiError::InvalidInput(format!(
"Image {} does not exist",
image_id
)));
}
}
}
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
pub async fn moderation_inbox(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_READ]),
)
.await?;
let ids = sqlx::query!(
"
SELECT id
FROM threads
WHERE show_in_mod_inbox = TRUE
"
)
.fetch_many(&**pool)
.try_filter_map(|e| async { Ok(e.right().map(|m| database::models::ThreadId(m.id))) })
.try_collect::<Vec<database::models::ThreadId>>()
.await?;
let threads_data = database::models::Thread::get_many(&ids, &**pool).await?;
let threads = filter_authorized_threads(threads_data, &user, &pool, &redis).await?;
Ok(HttpResponse::Ok().json(threads))
}
pub async fn thread_read(
req: HttpRequest,
info: web::Path<(ThreadId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_READ]),
)
.await?;
let id = info.into_inner().0;
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE threads
SET show_in_mod_inbox = FALSE
WHERE id = $1
",
id.0 as i64,
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}
pub async fn message_delete(
req: HttpRequest,
info: web::Path<(ThreadMessageId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::THREAD_WRITE]),
)
.await?
.1;
let result = database::models::ThreadMessage::get(info.into_inner().0.into(), &**pool).await?;
if let Some(thread) = result {
if !user.role.is_mod() && thread.author_id != Some(user.id.into()) {
return Err(ApiError::CustomAuthentication(
"You cannot delete this message!".to_string(),
));
}
let mut transaction = pool.begin().await?;
let context = ImageContext::ThreadMessage {
thread_message_id: Some(thread.id.into()),
};
let images = database::Image::get_many_contexted(context, &mut transaction).await?;
let cdn_url = dotenvy::var("CDN_URL")?;
for image in images {
let name = image.url.split(&format!("{cdn_url}/")).nth(1);
if let Some(icon_path) = name {
file_host.delete_file_version("", icon_path).await?;
}
database::Image::remove(image.id, &mut transaction, &redis).await?;
}
database::models::ThreadMessage::remove_full(thread.id, &mut transaction).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

913
src/routes/v3/users.rs Normal file
View File

@@ -0,0 +1,913 @@
use std::{collections::HashMap, sync::Arc};
use actix_web::{web, HttpRequest, HttpResponse};
use lazy_static::lazy_static;
use regex::Regex;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::PgPool;
use tokio::sync::Mutex;
use validator::Validate;
use crate::{
auth::get_user_from_headers,
database::{models::User, redis::RedisPool},
file_hosting::FileHost,
models::{
collections::{Collection, CollectionStatus},
ids::UserId,
notifications::Notification,
pats::Scopes,
projects::Project,
users::{Badges, Payout, PayoutStatus, RecipientStatus, Role, UserPayoutData},
},
queue::{payouts::PayoutsQueue, session::AuthQueue},
util::{routes::read_from_payload, validate::validation_errors_to_string},
};
use super::ApiError;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("user", web::get().to(user_auth_get));
cfg.route("users", web::get().to(users_get));
cfg.service(
web::scope("user")
.route("{user_id}/projects", web::get().to(projects_list))
.route("{id}", web::get().to(user_get))
.route("{user_id}/collections", web::get().to(collections_list))
.route("{user_id}/organizations", web::get().to(orgs_list))
.route("{id}", web::patch().to(user_edit))
.route("{id}/icon", web::patch().to(user_icon_edit))
.route("{id}", web::delete().to(user_delete))
.route("{id}/follows", web::get().to(user_follows))
.route("{id}/notifications", web::get().to(user_notifications))
.route("{id}/payouts", web::get().to(user_payouts))
.route("{id}/payouts_fees", web::get().to(user_payouts_fees))
.route("{id}/payouts", web::post().to(user_payouts_request)),
);
}
pub async fn projects_list(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
)
.await
.map(|x| x.1)
.ok();
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
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, &redis).await?;
let response: Vec<_> =
crate::database::Project::get_many_ids(&project_data, &**pool, &redis)
.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(""))
}
}
pub async fn user_auth_get(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let (scopes, mut user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_READ]),
)
.await?;
if !scopes.contains(Scopes::USER_READ_EMAIL) {
user.email = None;
}
if !scopes.contains(Scopes::PAYOUTS_READ) {
user.payout_data = None;
}
Ok(HttpResponse::Ok().json(user))
}
#[derive(Serialize, Deserialize)]
pub struct UserIds {
pub ids: String,
}
pub async fn users_get(
web::Query(ids): web::Query<UserIds>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user_ids = serde_json::from_str::<Vec<String>>(&ids.ids)?;
let users_data = User::get_many(&user_ids, &**pool, &redis).await?;
let users: Vec<crate::models::users::User> = users_data.into_iter().map(From::from).collect();
Ok(HttpResponse::Ok().json(users))
}
pub async fn user_get(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let user_data = User::get(&info.into_inner().0, &**pool, &redis).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(""))
}
}
pub async fn collections_list(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::COLLECTION_READ]),
)
.await
.map(|x| x.1)
.ok();
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
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_collections(id, &**pool).await?;
let response: Vec<_> =
crate::database::models::Collection::get_many(&project_data, &**pool, &redis)
.await?
.into_iter()
.filter(|x| can_view_private || matches!(x.status, CollectionStatus::Listed))
.map(Collection::from)
.collect();
Ok(HttpResponse::Ok().json(response))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
pub async fn orgs_list(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ]),
)
.await
.map(|x| x.1)
.ok();
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
let org_data = User::get_organizations(id, &**pool).await?;
let organizations_data =
crate::database::models::organization_item::Organization::get_many_ids(
&org_data, &**pool, &redis,
)
.await?;
let team_ids = organizations_data
.iter()
.map(|x| x.team_id)
.collect::<Vec<_>>();
let teams_data = crate::database::models::TeamMember::get_from_team_full_many(
&team_ids, &**pool, &redis,
)
.await?;
let users = User::get_many_ids(
&teams_data.iter().map(|x| x.user_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let mut organizations = vec![];
let mut team_groups = HashMap::new();
for item in teams_data {
team_groups.entry(item.team_id).or_insert(vec![]).push(item);
}
for data in organizations_data {
let members_data = team_groups.remove(&data.team_id).unwrap_or(vec![]);
let logged_in = user
.as_ref()
.and_then(|user| {
members_data
.iter()
.find(|x| x.user_id == user.id.into() && x.accepted)
})
.is_some();
let team_members: Vec<_> = members_data
.into_iter()
.filter(|x| logged_in || x.accepted || id == x.user_id)
.flat_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)
})
})
.collect();
let organization = crate::models::organizations::Organization::from(data, team_members);
organizations.push(organization);
}
Ok(HttpResponse::Ok().json(organizations))
} 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(length(max = 160))]
pub bio: Option<Option<String>>,
pub role: Option<Role>,
pub badges: Option<Badges>,
}
pub async fn user_edit(
req: HttpRequest,
info: web::Path<(String,)>,
new_user: web::Json<EditUser>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let (_scopes, user) = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_WRITE]),
)
.await?;
new_user
.validate()
.map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(actual_user) = id_option {
let id = actual_user.id;
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 = User::get(username, &**pool, &redis).await?;
if existing_user_id_option
.map(|x| UserId::from(x.id))
.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(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?;
}
User::clear_caches(&[(id, Some(actual_user.username))], &redis).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,
}
#[allow(clippy::too_many_arguments)]
pub async fn user_icon_edit(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
mut payload: web::Payload,
session_queue: web::Data<AuthQueue>,
) -> 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,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_WRITE]),
)
.await?
.1;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(actual_user) = id_option {
if user.id != actual_user.id.into() && !user.role.is_mod() {
return Err(ApiError::CustomAuthentication(
"You don't have permission to edit this user's icon.".to_string(),
));
}
let icon_url = actual_user.avatar_url;
let user_id: UserId = actual_user.id.into();
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),
actual_user.id as crate::database::models::ids::UserId,
)
.execute(&**pool)
.await?;
User::clear_caches(&[(actual_user.id, None)], &redis).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")]
pub removal_type: String,
}
fn default_removal() -> String {
"partial".into()
}
pub async fn user_delete(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
removal_type: web::Query<RemovalType>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_DELETE]),
)
.await?
.1;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
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 = User::remove(
id,
removal_type.removal_type == "full",
&mut transaction,
&redis,
)
.await?;
transaction.commit().await?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
pub async fn user_follows(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::USER_READ]),
)
.await?
.1;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
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_ids(&project_ids, &**pool, &redis)
.await?
.into_iter()
.map(Project::from)
.collect();
Ok(HttpResponse::Ok().json(projects))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
pub async fn user_notifications(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::NOTIFICATION_READ]),
)
.await?
.1;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
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, &redis,
)
.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(""))
}
}
pub async fn user_payouts(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAYOUTS_READ]),
)
.await?
.1;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
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: PayoutStatus::from_string(&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 FeeEstimateAmount {
pub amount: Decimal,
}
pub async fn user_payouts_fees(
req: HttpRequest,
info: web::Path<(String,)>,
web::Query(amount): web::Query<FeeEstimateAmount>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
payouts_queue: web::Data<Mutex<PayoutsQueue>>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAYOUTS_READ]),
)
.await?
.1;
let actual_user = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(actual_user) = actual_user {
if !user.role.is_admin() && user.id != actual_user.id.into() {
return Err(ApiError::CustomAuthentication(
"You do not have permission to request payouts of this user!".to_string(),
));
}
if let Some(UserPayoutData {
trolley_id: Some(trolley_id),
..
}) = user.payout_data
{
let payouts = payouts_queue
.lock()
.await
.get_estimated_fees(&trolley_id, amount.amount)
.await?;
Ok(HttpResponse::Ok().json(payouts))
} else {
Err(ApiError::InvalidInput(
"You must set up your trolley account first!".to_string(),
))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Deserialize)]
pub struct PayoutData {
pub amount: Decimal,
}
pub async fn user_payouts_request(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
data: web::Json<PayoutData>,
payouts_queue: web::Data<Mutex<PayoutsQueue>>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let mut payouts_queue = payouts_queue.lock().await;
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAYOUTS_WRITE]),
)
.await?
.1;
let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) {
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(UserPayoutData {
trolley_id: Some(trolley_id),
trolley_status: Some(trolley_status),
balance,
..
}) = user.payout_data
{
if trolley_status == RecipientStatus::Active {
return if data.amount < balance {
let mut transaction = pool.begin().await?;
let (batch_id, payment_id) =
payouts_queue.send_payout(&trolley_id, data.amount).await?;
sqlx::query!(
"
INSERT INTO historical_payouts (user_id, amount, status, batch_id, payment_id)
VALUES ($1, $2, $3, $4, $5)
",
id as crate::database::models::ids::UserId,
data.amount,
"processing",
batch_id,
payment_id,
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
UPDATE users
SET balance = balance - $1
WHERE id = $2
",
data.amount,
id as crate::database::models::ids::UserId
)
.execute(&mut *transaction)
.await?;
User::clear_caches(&[(id, None)], &redis).await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::InvalidInput(
"You do not have enough funds to make this payout!".to_string(),
))
};
} else {
return Err(ApiError::InvalidInput(
"Please complete payout information via the trolley dashboard!".to_string(),
));
}
}
Err(ApiError::InvalidInput(
"You are not enrolled in the payouts program yet!".to_string(),
))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

View File

@@ -0,0 +1,969 @@
use super::project_creation::{CreateError, UploadedFile};
use crate::auth::get_user_from_headers;
use crate::database::models::loader_fields::{LoaderField, LoaderFieldEnumValue, VersionField};
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::version_item::{
DependencyBuilder, VersionBuilder, VersionFileBuilder,
};
use crate::database::models::{self, image_item, Organization};
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::images::{Image, ImageContext, ImageId};
use crate::models::notifications::NotificationBody;
use crate::models::pack::PackFileHash;
use crate::models::pats::Scopes;
use crate::models::projects::{skip_nulls, DependencyType};
use crate::models::projects::{
Dependency, FileType, Loader, ProjectId, Version, VersionFile, VersionId, VersionStatus,
VersionType,
};
use crate::models::teams::ProjectPermissions;
use crate::queue::session::AuthQueue;
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::{web, HttpRequest, HttpResponse};
use chrono::Utc;
use futures::stream::StreamExt;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
use validator::Validate;
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>,
#[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>>,
// Associations to uploaded images in changelog
#[validate(length(max = 10))]
#[serde(default)]
pub uploaded_images: Vec<ImageId>,
// The ordering relative to other versions
pub ordering: Option<i32>,
// Flattened loader fields
// All other fields are loader-specific VersionFields
// These are flattened during serialization
#[serde(deserialize_with = "skip_nulls")]
#[serde(flatten)]
pub fields: HashMap<String, serde_json::Value>,
}
#[derive(Serialize, Deserialize, Clone)]
struct InitialFileData {
#[serde(default = "HashMap::new")]
pub file_types: HashMap<String, Option<FileType>>,
}
// under `/api/v1/version`
pub async fn version_create(
req: HttpRequest,
mut payload: Multipart,
client: Data<PgPool>,
redis: Data<RedisPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: Data<AuthQueue>,
) -> 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,
&redis,
&***file_host,
&mut uploaded_files,
&client,
&session_queue,
)
.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 version_create_inner(
req: HttpRequest,
payload: &mut Multipart,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
redis: &RedisPool,
file_host: &dyn FileHost,
uploaded_files: &mut Vec<UploadedFile>,
pool: &PgPool,
session_queue: &AuthQueue,
) -> Result<HttpResponse, CreateError> {
let cdn_url = dotenvy::var("CDN_URL")?;
let mut initial_version_data = None;
let mut version_builder = None;
let mut selected_loaders = None;
let user = get_user_from_headers(
&req,
pool,
redis,
session_queue,
Some(&[Scopes::VERSION_CREATE]),
)
.await?
.1;
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
if models::Project::get_id(project_id, &mut **transaction, redis)
.await?
.is_none()
{
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?;
// Get organization attached, if exists, and the member project permissions
let organization = models::Organization::get_associated_organization_project_id(
project_id,
&mut **transaction,
)
.await?;
let organization_team_member = if let Some(organization) = &organization {
models::TeamMember::get_from_user_id(
organization.team_id,
user.id.into(),
&mut **transaction,
)
.await?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&user.role,
&team_member,
&organization_team_member,
)
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::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 all_loaders =
models::loader_fields::Loader::list(&mut **transaction, redis).await?;
let loader_fields = LoaderField::get_fields(&mut **transaction, redis).await?;
let mut version_fields = vec![];
let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields(
&loader_fields,
&mut **transaction,
redis,
)
.await?;
for (key, value) in version_create_data.fields.iter() {
let loader_field = loader_fields
.iter()
.find(|lf| &lf.field == key)
.ok_or_else(|| {
CreateError::InvalidInput(format!(
"Loader field '{key}' does not exist!"
))
})?;
let enum_variants = loader_field_enum_values
.remove(&loader_field.id)
.unwrap_or_default();
let vf: VersionField = VersionField::check_parse(
version_id.into(),
loader_field.clone(),
value.clone(),
enum_variants,
)
.map_err(CreateError::InvalidInput)?;
version_fields.push(vf);
}
let loaders = version_create_data
.loaders
.iter()
.map(|x| {
all_loaders
.iter()
.find(|y| y.loader == x.0)
.cloned()
.ok_or_else(|| CreateError::InvalidLoader(x.0.clone()))
})
.collect::<Result<Vec<_>, _>>()?;
selected_loaders = Some(loaders.clone());
let loader_ids = loaders.iter().map(|y| y.id).collect_vec();
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,
loaders: loader_ids,
version_fields,
version_type: version_create_data.release_channel.to_string(),
featured: version_create_data.featured,
status: version_create_data.status,
requested_status: None,
ordering: version_create_data.ordering,
});
return Ok(());
}
let version = version_builder.as_mut().ok_or_else(|| {
CreateError::InvalidInput(String::from("`data` field must come before file fields"))
})?;
let loaders = selected_loaders.as_ref().ok_or_else(|| {
CreateError::InvalidInput(String::from("`data` field must come before file fields"))
})?;
let loaders = loaders
.iter()
.map(|x| Loader(x.loader.clone()))
.collect::<Vec<_>>();
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(),
&version.version_fields,
loaders,
version_data.primary_file.is_some(),
version_data.primary_file.as_deref() == Some(name),
version_data.file_types.get(name).copied().flatten(),
transaction,
redis,
)
.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(),
));
}
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 {
body: NotificationBody::ProjectUpdate {
project_id,
version_id,
},
}
.insert_many(users, &mut *transaction, redis)
.await?;
let loader_structs = selected_loaders.unwrap_or_default();
let (all_project_types, all_games): (Vec<String>, Vec<String>) =
loader_structs.iter().fold((vec![], vec![]), |mut acc, x| {
acc.0.extend_from_slice(&x.supported_project_types);
acc.1
.extend(x.supported_games.iter().map(|x| x.name().to_string()));
acc
});
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(),
project_types: all_project_types,
games: all_games,
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,
ordering: builder.ordering,
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,
loaders: version_data.loaders,
fields: version_data.fields,
};
let project_id = builder.project_id;
builder.insert(transaction).await?;
for image_id in version_data.uploaded_images {
if let Some(db_image) =
image_item::Image::get(image_id.into(), &mut **transaction, redis).await?
{
let image: Image = db_image.into();
if !matches!(image.context, ImageContext::Report { .. })
|| image.context.inner_id().is_some()
{
return Err(CreateError::InvalidInput(format!(
"Image {} is not unused and in the 'version' context",
image_id
)));
}
sqlx::query!(
"
UPDATE uploaded_images
SET version_id = $1
WHERE id = $2
",
version_id.0 as i64,
image_id.0 as i64
)
.execute(&mut **transaction)
.await?;
image_item::Image::clear_cache(image.id.into(), redis).await?;
} else {
return Err(CreateError::InvalidInput(format!(
"Image {} does not exist",
image_id
)));
}
}
models::Project::clear_cache(project_id, None, Some(true), redis).await?;
Ok(HttpResponse::Ok().json(response))
}
pub async fn upload_file_to_version(
req: HttpRequest,
url_data: web::Path<(VersionId,)>,
mut payload: Multipart,
client: Data<PgPool>,
redis: Data<RedisPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
) -> 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,
redis,
&***file_host,
&mut uploaded_files,
version_id,
&session_queue,
)
.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>,
redis: Data<RedisPool>,
file_host: &dyn FileHost,
uploaded_files: &mut Vec<UploadedFile>,
version_id: models::VersionId,
session_queue: &AuthQueue,
) -> 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,
&**client,
&redis,
session_queue,
Some(&[Scopes::VERSION_WRITE]),
)
.await?
.1;
let result = models::Version::get(version_id, &**client, &redis).await?;
let version = match result {
Some(v) => v,
None => {
return Err(CreateError::InvalidInput(
"An invalid version id was supplied".to_string(),
));
}
};
let all_loaders = models::loader_fields::Loader::list(&mut **transaction, &redis).await?;
// TODO: this coded is reused a lot, it should be refactored into a function
let selected_loaders = version
.loaders
.iter()
.map(|x| {
all_loaders
.iter()
.find(|y| &y.loader == x)
.cloned()
.ok_or_else(|| CreateError::InvalidLoader(x.clone()))
})
.collect::<Result<Vec<_>, _>>()?;
if models::Project::get_id(version.inner.project_id, &mut **transaction, &redis)
.await?
.is_none()
{
return Err(CreateError::InvalidInput(
"An invalid project id was supplied".to_string(),
));
}
if !user.role.is_admin() {
let team_member = models::TeamMember::get_from_user_id_project(
version.inner.project_id,
user.id.into(),
&mut **transaction,
)
.await?;
let organization = Organization::get_associated_organization_project_id(
version.inner.project_id,
&**client,
)
.await?;
let organization_team_member = if let Some(organization) = &organization {
models::TeamMember::get_from_user_id(
organization.team_id,
user.id.into(),
&mut **transaction,
)
.await?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&user.role,
&team_member,
&organization_team_member,
)
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::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 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 loaders = selected_loaders
.iter()
.map(|x| Loader(x.loader.clone()))
.collect::<Vec<_>>();
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(),
&version.version_fields,
loaders,
true,
false,
file_data.file_types.get(name).copied().flatten(),
transaction,
&redis,
)
.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 {
VersionFileBuilder::insert_many(file_builders, version_id, &mut *transaction).await?;
}
// Clear version cache
models::Version::clear_cache(&version, &redis).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,
version_fields: &[VersionField],
loaders: Vec<Loader>,
ignore_primary: bool,
force_primary: bool,
file_type: Option<FileType>,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
redis: &RedisPool,
) -> 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(),
loaders.clone(),
file_type,
version_fields.to_vec(),
&mut *transaction,
redis,
)
.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 = (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))
}

View File

@@ -0,0 +1,664 @@
use super::ApiError;
use crate::auth::{
filter_authorized_projects, filter_authorized_versions, get_user_from_headers,
is_authorized_version,
};
use crate::database::redis::RedisPool;
use crate::models::ids::VersionId;
use crate::models::pats::Scopes;
use crate::models::projects::VersionType;
use crate::models::teams::ProjectPermissions;
use crate::queue::session::AuthQueue;
use crate::{database, models};
use actix_web::{web, HttpRequest, HttpResponse};
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")
.route("version_id", web::get().to(get_version_from_hash))
.route("{version_id}/update", web::post().to(get_update_from_hash))
.route("project", web::post().to(get_projects_from_hashes))
.route("{version_id}", web::delete().to(delete_file))
.route("{version_id}/download", web::get().to(download_version)),
);
cfg.service(
web::scope("version_files")
.route("update", web::post().to(update_files))
.route("update_individual", web::post().to(update_individual_files))
.route("", web::post().to(get_versions_from_hashes)),
);
}
pub async fn get_version_from_hash(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
hash_query: web::Query<HashQuery>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let hash = info.into_inner().0.to_lowercase();
let file = database::models::Version::get_file_from_hash(
hash_query.algorithm.clone(),
hash,
hash_query.version_id.map(|x| x.into()),
&**pool,
&redis,
)
.await?;
if let Some(file) = file {
let version = database::models::Version::get(file.version_id, &**pool, &redis).await?;
if let Some(version) = version {
if !is_authorized_version(&version.inner, &user_option, &pool).await? {
return Ok(HttpResponse::NotFound().body(""));
}
Ok(HttpResponse::Ok().json(models::projects::Version::from(version)))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Serialize, Deserialize)]
pub struct HashQuery {
#[serde(default = "default_algorithm")]
pub algorithm: String,
pub version_id: Option<VersionId>,
}
pub fn default_algorithm() -> String {
"sha1".into()
}
#[derive(Serialize, Deserialize)]
pub struct UpdateData {
pub loaders: Option<Vec<String>>,
pub version_types: Option<Vec<VersionType>>,
/*
Loader fields to filter with:
"game_versions": ["1.16.5", "1.17"]
Returns if it matches any of the values
*/
pub loader_fields: Option<HashMap<String, Vec<serde_json::Value>>>,
}
pub async fn get_update_from_hash(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
hash_query: web::Query<HashQuery>,
update_data: web::Json<UpdateData>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let hash = info.into_inner().0.to_lowercase();
if let Some(file) = database::models::Version::get_file_from_hash(
hash_query.algorithm.clone(),
hash,
hash_query.version_id.map(|x| x.into()),
&**pool,
&redis,
)
.await?
{
if let Some(project) =
database::models::Project::get_id(file.project_id, &**pool, &redis).await?
{
let versions = database::models::Version::get_many(&project.versions, &**pool, &redis)
.await?
.into_iter()
.filter(|x| {
let mut bool = true;
if let Some(version_types) = &update_data.version_types {
bool &= version_types
.iter()
.any(|y| y.as_str() == x.inner.version_type);
}
if let Some(loaders) = &update_data.loaders {
bool &= x.loaders.iter().any(|y| loaders.contains(y));
}
if let Some(loader_fields) = &update_data.loader_fields {
for (key, values) in loader_fields {
bool &= if let Some(x_vf) =
x.version_fields.iter().find(|y| y.field_name == *key)
{
values.iter().any(|v| x_vf.value.contains_json_value(v))
} else {
true
};
}
}
bool
})
.sorted();
if let Some(first) = versions.last() {
if !is_authorized_version(&first.inner, &user_option, &pool).await? {
return Ok(HttpResponse::NotFound().body(""));
}
return Ok(HttpResponse::Ok().json(models::projects::Version::from(first)));
}
}
}
Ok(HttpResponse::NotFound().body(""))
}
// Requests above with multiple versions below
#[derive(Deserialize)]
pub struct FileHashes {
#[serde(default = "default_algorithm")]
pub algorithm: String,
pub hashes: Vec<String>,
}
pub async fn get_versions_from_hashes(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_data: web::Json<FileHashes>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let files = database::models::Version::get_files_from_hash(
file_data.algorithm.clone(),
&file_data.hashes,
&**pool,
&redis,
)
.await?;
let version_ids = files.iter().map(|x| x.version_id).collect::<Vec<_>>();
let versions_data = filter_authorized_versions(
database::models::Version::get_many(&version_ids, &**pool, &redis).await?,
&user_option,
&pool,
)
.await?;
let mut response = HashMap::new();
for version in versions_data {
for file in files.iter().filter(|x| x.version_id == version.id.into()) {
if let Some(hash) = file.hashes.get(&file_data.algorithm) {
response.insert(hash.clone(), version.clone());
}
}
}
Ok(HttpResponse::Ok().json(response))
}
pub async fn get_projects_from_hashes(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_data: web::Json<FileHashes>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let files = database::models::Version::get_files_from_hash(
file_data.algorithm.clone(),
&file_data.hashes,
&**pool,
&redis,
)
.await?;
let project_ids = files.iter().map(|x| x.project_id).collect::<Vec<_>>();
let projects_data = filter_authorized_projects(
database::models::Project::get_many_ids(&project_ids, &**pool, &redis).await?,
&user_option,
&pool,
)
.await?;
let mut response = HashMap::new();
for project in projects_data {
for file in files.iter().filter(|x| x.project_id == project.id.into()) {
if let Some(hash) = file.hashes.get(&file_data.algorithm) {
response.insert(hash.clone(), project.clone());
}
}
}
Ok(HttpResponse::Ok().json(response))
}
#[derive(Deserialize)]
pub struct ManyUpdateData {
#[serde(default = "default_algorithm")]
pub algorithm: String,
pub hashes: Vec<String>,
pub loaders: Option<Vec<String>>,
pub loader_fields: Option<HashMap<String, Vec<serde_json::Value>>>,
pub version_types: Option<Vec<VersionType>>,
}
pub async fn update_files(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
update_data: web::Json<ManyUpdateData>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let files = database::models::Version::get_files_from_hash(
update_data.algorithm.clone(),
&update_data.hashes,
&**pool,
&redis,
)
.await?;
let projects = database::models::Project::get_many_ids(
&files.iter().map(|x| x.project_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let all_versions = database::models::Version::get_many(
&projects
.iter()
.flat_map(|x| x.versions.clone())
.collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let mut response = HashMap::new();
for project in projects {
for file in files.iter().filter(|x| x.project_id == project.inner.id) {
let version = all_versions
.iter()
.filter(|x| x.inner.project_id == file.project_id)
.filter(|x| {
// TODO: Behaviour here is repeated in a few other filtering places, should be abstracted
let mut bool = true;
if let Some(version_types) = &update_data.version_types {
bool &= version_types
.iter()
.any(|y| y.as_str() == x.inner.version_type);
}
if let Some(loaders) = &update_data.loaders {
bool &= x.loaders.iter().any(|y| loaders.contains(y));
}
if let Some(loader_fields) = &update_data.loader_fields {
for (key, values) in loader_fields {
bool &= if let Some(x_vf) =
x.version_fields.iter().find(|y| y.field_name == *key)
{
values.iter().any(|v| x_vf.value.contains_json_value(v))
} else {
true
};
}
}
bool
})
.sorted()
.last();
if let Some(version) = version {
if is_authorized_version(&version.inner, &user_option, &pool).await? {
if let Some(hash) = file.hashes.get(&update_data.algorithm) {
response.insert(
hash.clone(),
models::projects::Version::from(version.clone()),
);
}
}
}
}
}
Ok(HttpResponse::Ok().json(response))
}
#[derive(Deserialize)]
pub struct FileUpdateData {
pub hash: String,
pub loaders: Option<Vec<String>>,
pub loader_fields: Option<HashMap<String, Vec<serde_json::Value>>>,
pub version_types: Option<Vec<VersionType>>,
}
#[derive(Deserialize)]
pub struct ManyFileUpdateData {
#[serde(default = "default_algorithm")]
pub algorithm: String,
pub hashes: Vec<FileUpdateData>,
}
pub async fn update_individual_files(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
update_data: web::Json<ManyFileUpdateData>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let files = database::models::Version::get_files_from_hash(
update_data.algorithm.clone(),
&update_data
.hashes
.iter()
.map(|x| x.hash.clone())
.collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let projects = database::models::Project::get_many_ids(
&files.iter().map(|x| x.project_id).collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let all_versions = database::models::Version::get_many(
&projects
.iter()
.flat_map(|x| x.versions.clone())
.collect::<Vec<_>>(),
&**pool,
&redis,
)
.await?;
let mut response = HashMap::new();
for project in projects {
for file in files.iter().filter(|x| x.project_id == project.inner.id) {
if let Some(hash) = file.hashes.get(&update_data.algorithm) {
if let Some(query_file) = update_data.hashes.iter().find(|x| &x.hash == hash) {
let version = all_versions
.iter()
.filter(|x| x.inner.project_id == file.project_id)
.filter(|x| {
let mut bool = true;
if let Some(version_types) = &query_file.version_types {
bool &= version_types
.iter()
.any(|y| y.as_str() == x.inner.version_type);
}
if let Some(loaders) = &query_file.loaders {
bool &= x.loaders.iter().any(|y| loaders.contains(y));
}
if let Some(loader_fields) = &query_file.loader_fields {
for (key, values) in loader_fields {
bool &= if let Some(x_vf) =
x.version_fields.iter().find(|y| y.field_name == *key)
{
values.iter().any(|v| x_vf.value.contains_json_value(v))
} else {
true
};
}
}
bool
})
.sorted()
.last();
if let Some(version) = version {
if is_authorized_version(&version.inner, &user_option, &pool).await? {
response.insert(
hash.clone(),
models::projects::Version::from(version.clone()),
);
}
}
}
}
}
}
Ok(HttpResponse::Ok().json(response))
}
// under /api/v1/version_file/{hash}
pub async fn delete_file(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
hash_query: web::Query<HashQuery>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_WRITE]),
)
.await?
.1;
let hash = info.into_inner().0.to_lowercase();
let file = database::models::Version::get_file_from_hash(
hash_query.algorithm.clone(),
hash,
hash_query.version_id.map(|x| x.into()),
&**pool,
&redis,
)
.await?;
if let Some(row) = file {
if !user.role.is_admin() {
let team_member = database::models::TeamMember::get_from_user_id_version(
row.version_id,
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::Database)?;
let organization =
database::models::Organization::get_associated_organization_project_id(
row.project_id,
&**pool,
)
.await
.map_err(ApiError::Database)?;
let organization_team_member = if let Some(organization) = &organization {
database::models::TeamMember::get_from_user_id_organization(
organization.id,
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::Database)?
} else {
None
};
let permissions = ProjectPermissions::get_permissions_by_role(
&user.role,
&team_member,
&organization_team_member,
)
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::DELETE_VERSION) {
return Err(ApiError::CustomAuthentication(
"You don't have permission to delete this file!".to_string(),
));
}
}
let version = database::models::Version::get(row.version_id, &**pool, &redis).await?;
if let Some(version) = version {
if version.files.len() < 2 {
return Err(ApiError::InvalidInput(
"Versions must have at least one file uploaded to them".to_string(),
));
}
database::models::Version::clear_cache(&version, &redis).await?;
}
let mut transaction = pool.begin().await?;
sqlx::query!(
"
DELETE FROM hashes
WHERE file_id = $1
",
row.id.0
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
DELETE FROM files
WHERE files.id = $1
",
row.id.0,
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Serialize, Deserialize)]
pub struct DownloadRedirect {
pub url: String,
}
// under /api/v1/version_file/{hash}/download
pub async fn download_version(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
hash_query: web::Query<HashQuery>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::VERSION_READ]),
)
.await
.map(|x| x.1)
.ok();
let hash = info.into_inner().0.to_lowercase();
let file = database::models::Version::get_file_from_hash(
hash_query.algorithm.clone(),
hash,
hash_query.version_id.map(|x| x.into()),
&**pool,
&redis,
)
.await?;
if let Some(file) = file {
let version = database::models::Version::get(file.version_id, &**pool, &redis).await?;
if let Some(version) = version {
if !is_authorized_version(&version.inner, &user_option, &pool).await? {
return Ok(HttpResponse::NotFound().body(""));
}
Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", &*file.url))
.json(DownloadRedirect { url: file.url }))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

1005
src/routes/v3/versions.rs Normal file

File diff suppressed because it is too large Load Diff