Final V2 Changes (#212)

* Redo dependencies, add rejection reasons, make notifications more readable

* Fix errors, add dependency route, finish PR

* Fix clippy errors
This commit is contained in:
Geometrically 2021-06-16 09:05:35 -07:00 committed by GitHub
parent 2a4caa856e
commit d2c2503cfa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 2365 additions and 1303 deletions

6
.env
View File

@ -1,9 +1,15 @@
DEBUG=true
RUST_LOG=info,sqlx::query=warn
SITE_URL=https://modrinth.com
CDN_URL=https://cdn.modrinth.com
MODERATION_DISCORD_WEBHOOK=
CLOUDFLARE_INTEGRATION=false
DATABASE_URL=postgresql://labrinth:labrinth@localhost/labrinth
DATABASE_MIN_CONNECTIONS=0
DATABASE_MAX_CONNECTIONS=16
MEILISEARCH_ADDR=http://localhost:7700
MEILISEARCH_KEY=modrinth

View File

@ -0,0 +1,18 @@
INSERT INTO statuses (status) VALUES ('archived');
ALTER TABLE notifications
ADD COLUMN type varchar(256);
ALTER TABLE mods
ADD COLUMN rejection_reason varchar(2000),
ADD COLUMN rejection_body varchar(65536);
DROP TABLE dependencies;
CREATE TABLE dependencies (
id serial PRIMARY KEY,
dependent_id bigint REFERENCES versions ON UPDATE CASCADE NOT NULL,
dependency_type varchar(255) NOT NULL,
dependency_id bigint REFERENCES versions ON UPDATE CASCADE,
mod_dependency_id bigint REFERENCES mods ON UPDATE CASCADE
);

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
//pub mod project_query_cache;
#[macro_export]
macro_rules! generate_cache {
($name:ident,$id:ty, $val:ty, $cache_name:ident, $mod_name:ident, $getter_name:ident, $setter_name:ident) => {
($name:ident,$id:ty, $val:ty, $cache_name:ident, $mod_name:ident, $getter_name:ident, $setter_name:ident, $remover_name:ident) => {
pub mod $mod_name {
use cached::async_mutex::Mutex;
use cached::{Cached, SizedCache};
@ -20,6 +20,10 @@ macro_rules! generate_cache {
let mut cache = $cache_name.lock().await;
Cached::cache_set(&mut *cache, id, val.clone());
}
pub async fn $remover_name<'a>(id: $id) {
let mut cache = $cache_name.lock().await;
Cached::cache_remove(&mut *cache, &id);
}
}
};
}
@ -31,7 +35,8 @@ generate_cache!(
PROJECT_CACHE,
project_cache,
get_cache_project,
set_cache_project
set_cache_project,
remove_cache_project
);
generate_cache!(
query_project,
@ -40,5 +45,6 @@ generate_cache!(
QUERY_PROJECT_CACHE,
query_project_cache,
get_cache_query_project,
set_cache_query_project
set_cache_query_project,
remove_cache_query_project
);

View File

@ -1,4 +1,4 @@
mod cache;
pub mod cache;
pub mod models;
mod postgres_database;
pub use models::Project;

View File

@ -113,7 +113,7 @@ pub struct TeamId(pub i64);
#[sqlx(transparent)]
pub struct TeamMemberId(pub i64);
#[derive(Copy, Clone, Debug, Type)]
#[derive(Copy, Clone, Debug, Type, PartialEq)]
#[sqlx(transparent)]
pub struct ProjectId(pub i64);
#[derive(Copy, Clone, Debug, Type)]
@ -133,7 +133,7 @@ pub struct LicenseId(pub i32);
#[sqlx(transparent)]
pub struct DonationPlatformId(pub i32);
#[derive(Copy, Clone, Debug, Type)]
#[derive(Copy, Clone, Debug, Type, PartialEq)]
#[sqlx(transparent)]
pub struct VersionId(pub i64);
#[derive(Copy, Clone, Debug, Type)]

View File

@ -2,6 +2,7 @@ use super::ids::*;
use crate::database::models::DatabaseError;
pub struct NotificationBuilder {
pub notification_type: Option<String>,
pub title: String,
pub text: String,
pub link: String,
@ -16,6 +17,7 @@ pub struct NotificationActionBuilder {
pub struct Notification {
pub id: NotificationId,
pub user_id: UserId,
pub notification_type: Option<String>,
pub title: String,
pub text: String,
pub link: String,
@ -64,6 +66,7 @@ impl NotificationBuilder {
Notification {
id,
user_id: user,
notification_type: self.notification_type.clone(),
title: self.title.clone(),
text: self.text.clone(),
link: self.link.clone(),
@ -87,17 +90,18 @@ impl Notification {
sqlx::query!(
"
INSERT INTO notifications (
id, user_id, title, text, link
id, user_id, title, text, link, type
)
VALUES (
$1, $2, $3, $4, $5
$1, $2, $3, $4, $5, $6
)
",
self.id as NotificationId,
self.user_id as UserId,
&self.title,
&self.text,
&self.link
&self.link,
self.notification_type
)
.execute(&mut *transaction)
.await?;
@ -118,7 +122,7 @@ impl Notification {
{
let result = sqlx::query!(
"
SELECT n.user_id, n.title, n.text, n.link, n.created, n.read,
SELECT n.user_id, n.title, n.text, n.link, n.created, n.read, n.type notification_type,
STRING_AGG(DISTINCT na.id || ', ' || na.title || ', ' || na.action_route || ', ' || na.action_route_method, ' ,') actions
FROM notifications n
LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id
@ -150,6 +154,7 @@ impl Notification {
Ok(Some(Notification {
id,
user_id: UserId(row.user_id),
notification_type: row.notification_type,
title: row.title,
text: row.text,
link: row.link,
@ -174,7 +179,7 @@ impl Notification {
let notification_ids_parsed: Vec<i64> = notification_ids.into_iter().map(|x| x.0).collect();
sqlx::query!(
"
SELECT n.id, n.user_id, n.title, n.text, n.link, n.created, n.read,
SELECT n.id, n.user_id, n.title, n.text, n.link, n.created, n.read, n.type notification_type,
STRING_AGG(DISTINCT na.id || ', ' || na.title || ', ' || na.action_route || ', ' || na.action_route_method, ' ,') actions
FROM notifications n
LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id
@ -207,6 +212,7 @@ impl Notification {
Notification {
id,
user_id: UserId(row.user_id),
notification_type: row.notification_type,
title: row.title,
text: row.text,
link: row.link,
@ -231,7 +237,7 @@ impl Notification {
sqlx::query!(
"
SELECT n.id, n.user_id, n.title, n.text, n.link, n.created, n.read,
SELECT n.id, n.user_id, n.title, n.text, n.link, n.created, n.read, n.type notification_type,
STRING_AGG(DISTINCT na.id || ', ' || na.title || ', ' || na.action_route || ', ' || na.action_route_method, ' ,') actions
FROM notifications n
LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id
@ -263,6 +269,7 @@ impl Notification {
Notification {
id,
user_id: UserId(row.user_id),
notification_type: row.notification_type,
title: row.title,
text: row.text,
link: row.link,
@ -276,13 +283,10 @@ impl Notification {
.await
}
pub async fn remove<'a, 'b, E>(
pub async fn remove(
id: NotificationId,
exec: E,
) -> Result<Option<()>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Option<()>, sqlx::error::Error> {
sqlx::query!(
"
DELETE FROM notifications_actions
@ -290,7 +294,7 @@ impl Notification {
",
id as NotificationId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -300,7 +304,36 @@ impl Notification {
",
id as NotificationId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
Ok(Some(()))
}
pub async fn remove_many(
notification_ids: Vec<NotificationId>,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Option<()>, sqlx::error::Error> {
let notification_ids_parsed: Vec<i64> = notification_ids.into_iter().map(|x| x.0).collect();
sqlx::query!(
"
DELETE FROM notifications_actions
WHERE notification_id IN (SELECT * FROM UNNEST($1::bigint[]))
",
&notification_ids_parsed
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
DELETE FROM notifications
WHERE id IN (SELECT * FROM UNNEST($1::bigint[]))
",
&notification_ids_parsed
)
.execute(&mut *transaction)
.await?;
Ok(Some(()))

View File

@ -88,6 +88,8 @@ impl ProjectBuilder {
server_side: self.server_side,
license: self.license,
slug: self.slug,
rejection_reason: None,
rejection_body: None,
};
project_struct.insert(&mut *transaction).await?;
@ -141,6 +143,8 @@ pub struct Project {
pub server_side: SideTypeId,
pub license: LicenseId,
pub slug: Option<String>,
pub rejection_reason: Option<String>,
pub rejection_body: Option<String>,
}
impl Project {
@ -204,7 +208,8 @@ impl Project {
icon_url, body, body_url, published,
updated, status,
issues_url, source_url, wiki_url, discord_url, license_url,
team_id, client_side, server_side, license, slug
team_id, client_side, server_side, license, slug,
rejection_reason, rejection_body
FROM mods
WHERE id = $1
",
@ -237,6 +242,8 @@ impl Project {
slug: row.slug,
body: row.body,
follows: row.follows,
rejection_reason: row.rejection_reason,
rejection_body: row.rejection_body,
}))
} else {
Ok(None)
@ -259,7 +266,8 @@ impl Project {
icon_url, body, body_url, published,
updated, status,
issues_url, source_url, wiki_url, discord_url, license_url,
team_id, client_side, server_side, license, slug
team_id, client_side, server_side, license, slug,
rejection_reason, rejection_body
FROM mods
WHERE id IN (SELECT * FROM UNNEST($1::bigint[]))
",
@ -290,6 +298,8 @@ impl Project {
slug: m.slug,
body: m.body,
follows: m.follows,
rejection_reason: m.rejection_reason,
rejection_body: m.rejection_body,
}))
})
.try_collect::<Vec<Project>>()
@ -298,20 +308,17 @@ impl Project {
Ok(projects)
}
pub async fn remove_full<'a, 'b, E>(
pub async fn remove_full(
id: ProjectId,
exec: E,
) -> Result<Option<()>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Option<()>, sqlx::error::Error> {
let result = sqlx::query!(
"
SELECT team_id FROM mods WHERE id = $1
",
id as ProjectId,
)
.fetch_optional(exec)
.fetch_optional(&mut *transaction)
.await?;
let team_id: TeamId = if let Some(id) = result {
@ -327,7 +334,7 @@ impl Project {
",
id as ProjectId
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -337,7 +344,7 @@ impl Project {
",
id as ProjectId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -347,7 +354,7 @@ impl Project {
",
id as ProjectId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -357,7 +364,7 @@ impl Project {
",
id as ProjectId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -367,7 +374,7 @@ impl Project {
",
id as ProjectId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
use futures::TryStreamExt;
@ -378,15 +385,24 @@ impl Project {
",
id as ProjectId,
)
.fetch_many(exec)
.fetch_many(&mut *transaction)
.try_filter_map(|e| async { Ok(e.right().map(|c| VersionId(c.id))) })
.try_collect::<Vec<VersionId>>()
.await?;
for version in versions {
super::Version::remove_full(version, exec).await?;
super::Version::remove_full(version, transaction).await?;
}
sqlx::query!(
"
DELETE FROM dependencies WHERE mod_dependency_id = $1
",
id as ProjectId,
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
DELETE FROM mods
@ -394,7 +410,7 @@ impl Project {
",
id as ProjectId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -404,7 +420,7 @@ impl Project {
",
team_id as TeamId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -414,7 +430,7 @@ impl Project {
",
team_id as TeamId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
Ok(Some(()))
@ -552,7 +568,7 @@ impl Project {
executor: E,
) -> Result<Option<QueryProject>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
@ -560,7 +576,7 @@ impl Project {
m.icon_url icon_url, m.body body, m.body_url body_url, m.published published,
m.updated updated, m.status status,
m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url,
m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug,
m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.rejection_reason rejection_reason, m.rejection_body rejection_body,
s.status status_name, cs.name client_side_type, ss.name server_side_type, l.short short, l.name license_name, pt.name project_type_name,
STRING_AGG(DISTINCT c.category, ',') categories, STRING_AGG(DISTINCT v.id::text, ',') versions
FROM mods m
@ -605,6 +621,8 @@ impl Project {
slug: m.slug.clone(),
body: m.body.clone(),
follows: m.follows,
rejection_reason: m.rejection_reason,
rejection_body: m.rejection_body,
},
project_type: m.project_type_name,
categories: m
@ -647,7 +665,7 @@ impl Project {
m.icon_url icon_url, m.body body, m.body_url body_url, m.published published,
m.updated updated, m.status status,
m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url,
m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug,
m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.rejection_reason rejection_reason, m.rejection_body rejection_body,
s.status status_name, cs.name client_side_type, ss.name server_side_type, l.short short, l.name license_name, pt.name project_type_name,
STRING_AGG(DISTINCT c.category, ',') categories, STRING_AGG(DISTINCT v.id::text, ',') versions
FROM mods m
@ -689,7 +707,9 @@ impl Project {
license: LicenseId(m.license),
slug: m.slug.clone(),
body: m.body.clone(),
follows: m.follows
follows: m.follows,
rejection_reason: m.rejection_reason,
rejection_body: m.rejection_body,
},
project_type: m.project_type_name,
categories: m.categories.unwrap_or_default().split(',').map(|x| x.to_string()).collect(),

View File

@ -238,10 +238,10 @@ impl User {
Ok(projects)
}
pub async fn remove<'a, 'b, E>(id: UserId, exec: E) -> Result<Option<()>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
pub async fn remove(
id: UserId,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Option<()>, sqlx::error::Error> {
let deleted_user: UserId = crate::models::users::DELETED_USER.into();
sqlx::query!(
@ -254,7 +254,7 @@ impl User {
id as UserId,
crate::models::teams::OWNER_ROLE
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -266,7 +266,7 @@ impl User {
deleted_user as UserId,
id as UserId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
use futures::TryStreamExt;
@ -277,7 +277,7 @@ impl User {
",
id as UserId,
)
.fetch_many(exec)
.fetch_many(&mut *transaction)
.try_filter_map(|e| async { Ok(e.right().map(|m| m.id as i64)) })
.try_collect::<Vec<i64>>()
.await?;
@ -289,7 +289,7 @@ impl User {
",
id as UserId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -299,7 +299,7 @@ impl User {
",
id as UserId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -309,7 +309,7 @@ impl User {
",
id as UserId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -319,7 +319,7 @@ impl User {
",
&notifications
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -329,7 +329,7 @@ impl User {
",
id as UserId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -339,19 +339,16 @@ impl User {
",
id as UserId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
Ok(Some(()))
}
pub async fn remove_full<'a, 'b, E>(
pub async fn remove_full(
id: UserId,
exec: E,
) -> Result<Option<()>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Option<()>, sqlx::error::Error> {
use futures::TryStreamExt;
let projects: Vec<ProjectId> = sqlx::query!(
"
@ -362,13 +359,14 @@ impl User {
id as UserId,
crate::models::teams::OWNER_ROLE
)
.fetch_many(exec)
.fetch_many(&mut *transaction)
.try_filter_map(|e| async { Ok(e.right().map(|m| ProjectId(m.id))) })
.try_collect::<Vec<ProjectId>>()
.await?;
for project_id in projects {
let _result = super::project_item::Project::remove_full(project_id, exec).await?;
let _result =
super::project_item::Project::remove_full(project_id, transaction).await?;
}
let notifications: Vec<i64> = sqlx::query!(
@ -378,7 +376,7 @@ impl User {
",
id as UserId,
)
.fetch_many(exec)
.fetch_many(&mut *transaction)
.try_filter_map(|e| async { Ok(e.right().map(|m| m.id as i64)) })
.try_collect::<Vec<i64>>()
.await?;
@ -390,7 +388,7 @@ impl User {
",
id as UserId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -400,7 +398,7 @@ impl User {
",
&notifications
)
.execute(exec)
.execute(&mut *transaction)
.await?;
let deleted_user: UserId = crate::models::users::DELETED_USER.into();
@ -414,7 +412,7 @@ impl User {
deleted_user as UserId,
id as UserId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -424,7 +422,7 @@ impl User {
",
id as UserId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -434,7 +432,7 @@ impl User {
",
id as UserId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
Ok(Some(()))

View File

@ -10,13 +10,62 @@ pub struct VersionBuilder {
pub version_number: String,
pub changelog: String,
pub files: Vec<VersionFileBuilder>,
pub dependencies: Vec<(VersionId, String)>,
pub dependencies: Vec<DependencyBuilder>,
pub game_versions: Vec<GameVersionId>,
pub loaders: Vec<LoaderId>,
pub release_channel: ChannelId,
pub featured: bool,
}
pub struct DependencyBuilder {
pub project_id: Option<ProjectId>,
pub version_id: Option<VersionId>,
pub dependency_type: String,
}
impl DependencyBuilder {
pub async fn insert(
self,
version_id: VersionId,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), DatabaseError> {
let version_dependency_id = if let Some(project_id) = self.project_id {
sqlx::query!(
"
SELECT version.id id FROM (
SELECT DISTINCT ON(v.id) v.id, v.date_published FROM versions v
INNER JOIN game_versions_versions gvv ON gvv.joining_version_id = v.id AND gvv.game_version_id IN (SELECT game_version_id FROM game_versions_versions WHERE joining_version_id = $2)
INNER JOIN loaders_versions lv ON lv.version_id = v.id AND lv.loader_id IN (SELECT loader_id FROM loaders_versions WHERE version_id = $2)
WHERE v.mod_id = $1
) AS version
ORDER BY version.date_published DESC
LIMIT 1
",
project_id as ProjectId,
version_id as VersionId,
)
.fetch_optional(&mut *transaction).await?.map(|x| VersionId(x.id))
} else {
self.version_id
};
sqlx::query!(
"
INSERT INTO dependencies (dependent_id, dependency_type, dependency_id, mod_dependency_id)
VALUES ($1, $2, $3, $4)
",
version_id as VersionId,
self.dependency_type,
version_dependency_id.map(|x| x.0),
self.project_id.map(|x| x.0),
)
.execute(&mut *transaction)
.await?;
Ok(())
}
}
pub struct VersionFileBuilder {
pub url: String,
pub filename: String,
@ -105,20 +154,10 @@ impl VersionBuilder {
}
for dependency in self.dependencies {
sqlx::query!(
"
INSERT INTO dependencies (dependent_id, dependency_id, dependency_type)
VALUES ($1, $2, $3)
",
self.version_id as VersionId,
dependency.0 as VersionId,
dependency.1,
)
.execute(&mut *transaction)
.await?;
dependency.insert(self.version_id, transaction).await?;
}
for loader in self.loaders {
for loader in self.loaders.clone() {
sqlx::query!(
"
INSERT INTO loaders_versions (loader_id, version_id)
@ -131,7 +170,7 @@ impl VersionBuilder {
.await?;
}
for game_version in self.game_versions {
for game_version in self.game_versions.clone() {
sqlx::query!(
"
INSERT INTO game_versions_versions (game_version_id, joining_version_id)
@ -144,6 +183,42 @@ impl VersionBuilder {
.await?;
}
// Sync dependencies
use futures::stream::TryStreamExt;
let dependencies = sqlx::query!(
"
SELECT d.id id
FROM versions v
INNER JOIN dependencies d ON d.dependent_id = v.id
INNER JOIN game_versions_versions gvv ON gvv.joining_version_id = v.id AND gvv.game_version_id IN (SELECT * FROM UNNEST($2::integer[]))
INNER JOIN loaders_versions lv ON lv.version_id = v.id AND lv.loader_id IN (SELECT * FROM UNNEST($3::integer[]))
WHERE v.mod_id = $1
",
self.project_id as ProjectId,
&self.game_versions.iter().map(|x| x.0).collect::<Vec<i32>>(),
&self.loaders.iter().map(|x| x.0).collect::<Vec<i32>>(),
)
.fetch_many(&mut *transaction)
.try_filter_map(|e| async {
Ok(e.right().map(|d| d.id as i64))
})
.try_collect::<Vec<i64>>()
.await?;
sqlx::query!(
"
UPDATE dependencies
SET dependency_id = $2
WHERE id IN (SELECT * FROM UNNEST($1::bigint[]))
",
&dependencies,
self.version_id as VersionId,
)
.execute(&mut *transaction)
.await?;
Ok(self.version_id)
}
}
@ -200,17 +275,17 @@ impl Version {
}
// TODO: someone verify this
pub async fn remove_full<'a, E>(id: VersionId, exec: E) -> Result<Option<()>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
pub async fn remove_full(
id: VersionId,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Option<()>, sqlx::Error> {
let result = sqlx::query!(
"
SELECT EXISTS(SELECT 1 FROM versions WHERE id = $1)
",
id as VersionId,
)
.fetch_one(exec)
.fetch_one(&mut *transaction)
.await?;
if !result.exists.unwrap_or(false) {
@ -224,7 +299,33 @@ impl Version {
",
id as VersionId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
use futures::TryStreamExt;
let game_versions: Vec<i32> = sqlx::query!(
"
SELECT game_version_id id FROM game_versions_versions
WHERE joining_version_id = $1
",
id as VersionId,
)
.fetch_many(&mut *transaction)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.id)) })
.try_collect::<Vec<i32>>()
.await?;
let loaders: Vec<i32> = sqlx::query!(
"
SELECT loader_id id FROM loaders_versions
WHERE version_id = $1
",
id as VersionId,
)
.fetch_many(&mut *transaction)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.id)) })
.try_collect::<Vec<i32>>()
.await?;
sqlx::query!(
@ -234,7 +335,7 @@ impl Version {
",
id as VersionId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -244,7 +345,7 @@ impl Version {
",
id as VersionId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -254,11 +355,9 @@ impl Version {
",
id as VersionId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
use futures::TryStreamExt;
let files = sqlx::query!(
"
SELECT files.id, files.url, files.filename, files.is_primary FROM files
@ -266,7 +365,7 @@ impl Version {
",
id as VersionId,
)
.fetch_many(exec)
.fetch_many(&mut *transaction)
.try_filter_map(|e| async {
Ok(e.right().map(|c| VersionFile {
id: FileId(c.id),
@ -301,7 +400,7 @@ impl Version {
",
id as VersionId
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -311,54 +410,71 @@ impl Version {
",
id as VersionId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
// Sync dependencies
let project_id = sqlx::query!(
"
SELECT mod_id FROM versions WHERE id = $1
",
id as VersionId,
)
.fetch_one(&mut *transaction)
.await?;
let new_version_id = sqlx::query!(
"
SELECT v.id id
FROM versions v
INNER JOIN game_versions_versions gvv ON gvv.joining_version_id = v.id AND gvv.game_version_id IN (SELECT * FROM UNNEST($2::integer[]))
INNER JOIN loaders_versions lv ON lv.version_id = v.id AND lv.loader_id IN (SELECT * FROM UNNEST($3::integer[]))
WHERE v.mod_id = $1
ORDER BY v.date_published DESC
LIMIT 1
",
project_id.mod_id,
&game_versions,
&loaders,
)
.fetch_optional(&mut *transaction)
.await?
.map(|x| x.id);
sqlx::query!(
"
UPDATE dependencies
SET dependency_id = $2
WHERE dependency_id = $1
",
id as VersionId,
new_version_id,
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
DELETE FROM dependencies WHERE mod_dependency_id = NULL AND dependency_id = NULL
",
)
.execute(&mut *transaction)
.await?;
// delete version
sqlx::query!(
"
DELETE FROM versions WHERE id = $1
",
id as VersionId,
)
.execute(exec)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
DELETE FROM dependencies WHERE dependent_id = $1
",
id as VersionId,
)
.execute(exec)
.await?;
Ok(Some(()))
}
pub async fn get_dependencies<'a, E>(
id: VersionId,
exec: E,
) -> Result<Vec<VersionId>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
use futures::stream::TryStreamExt;
let vec = sqlx::query!(
"
SELECT dependency_id id FROM dependencies
WHERE dependent_id = $1
",
id as VersionId,
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|v| VersionId(v.id))) })
.try_collect::<Vec<VersionId>>()
.await?;
Ok(vec)
}
pub async fn get_project_versions<'a, E>(
project_id: ProjectId,
game_versions: Option<Vec<String>>,
@ -491,7 +607,7 @@ impl Version {
STRING_AGG(DISTINCT gv.version, ',') game_versions, STRING_AGG(DISTINCT l.loader, ',') loaders,
STRING_AGG(DISTINCT f.id || ', ' || f.filename || ', ' || f.is_primary || ', ' || f.url, ' ,') files,
STRING_AGG(DISTINCT h.algorithm || ', ' || encode(h.hash, 'escape') || ', ' || h.file_id, ' ,') hashes,
STRING_AGG(DISTINCT d.dependency_id || ', ' || d.dependency_type, ' ,') dependencies
STRING_AGG(DISTINCT COALESCE(d.dependency_id, 0) || ', ' || COALESCE(d.mod_dependency_id, 0) || ', ' || d.dependency_type, ' ,') dependencies
FROM versions v
INNER JOIN release_channels rc on v.release_channel = rc.id
LEFT OUTER JOIN game_versions_versions gvv on v.id = gvv.joining_version_id
@ -557,11 +673,24 @@ impl Version {
.for_each(|f| {
let dependency: Vec<&str> = f.split(", ").collect();
if dependency.len() >= 2 {
dependencies.push((
VersionId(dependency[0].parse().unwrap_or(0)),
dependency[1].to_string(),
))
if dependency.len() >= 3 {
dependencies.push(QueryDependency {
project_id: match &*dependency[2] {
"0" => None,
_ => match dependency[2].parse() {
Ok(x) => Some(ProjectId(x)),
Err(_) => None,
},
},
version_id: match &*dependency[0] {
"0" => None,
_ => match dependency[0].parse() {
Ok(x) => Some(VersionId(x)),
Err(_) => None,
},
},
dependency_type: dependency[1].to_string(),
});
}
});
@ -615,7 +744,7 @@ impl Version {
STRING_AGG(DISTINCT gv.version, ',') game_versions, STRING_AGG(DISTINCT l.loader, ',') loaders,
STRING_AGG(DISTINCT f.id || ', ' || f.filename || ', ' || f.is_primary || ', ' || f.url, ' ,') files,
STRING_AGG(DISTINCT h.algorithm || ', ' || encode(h.hash, 'escape') || ', ' || h.file_id, ' ,') hashes,
STRING_AGG(DISTINCT d.dependency_id || ', ' || d.dependency_type, ' ,') dependencies
STRING_AGG(DISTINCT COALESCE(d.dependency_id, 0) || ', ' || COALESCE(d.mod_dependency_id, 0) || ', ' || d.dependency_type, ' ,') dependencies
FROM versions v
INNER JOIN release_channels rc on v.release_channel = rc.id
LEFT OUTER JOIN game_versions_versions gvv on v.id = gvv.joining_version_id
@ -678,8 +807,28 @@ impl Version {
v.dependencies.unwrap_or_default().split(" ,").for_each(|f| {
let dependency: Vec<&str> = f.split(", ").collect();
if dependency.len() >= 2 {
dependencies.push((VersionId(dependency[0].parse().unwrap_or(0)), dependency[1].to_string()))
if dependency.len() >= 3 {
dependencies.push(QueryDependency {
project_id: match &*dependency[2] {
"0" => None,
_ => {
match dependency[2].parse() {
Ok(x) => Some(ProjectId(x)),
Err(_) => None,
}
},
},
version_id: match &*dependency[0] {
"0" => None,
_ => {
match dependency[0].parse() {
Ok(x) => Some(VersionId(x)),
Err(_) => None,
}
},
},
dependency_type: dependency[1].to_string()
});
}
});
@ -743,7 +892,14 @@ pub struct QueryVersion {
pub game_versions: Vec<String>,
pub loaders: Vec<String>,
pub featured: bool,
pub dependencies: Vec<(VersionId, String)>,
pub dependencies: Vec<QueryDependency>,
}
#[derive(Clone)]
pub struct QueryDependency {
pub project_id: Option<ProjectId>,
pub version_id: Option<VersionId>,
pub dependency_type: String,
}
#[derive(Clone)]

View File

@ -11,7 +11,20 @@ pub async fn connect() -> Result<PgPool, sqlx::Error> {
let database_url = dotenv::var("DATABASE_URL").expect("`DATABASE_URL` not in .env");
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(
dotenv::var("DATABASE_MIN_CONNECTIONS")
.ok()
.map(|x| x.parse::<u32>().ok())
.flatten()
.unwrap_or(16),
)
.max_connections(
dotenv::var("DATABASE_MAX_CONNECTIONS")
.ok()
.map(|x| x.parse::<u32>().ok())
.flatten()
.unwrap_or(16),
)
.connect(&database_url)
.await?;

View File

@ -11,13 +11,13 @@ use search::indexing::index_projects;
use search::indexing::IndexingSettings;
use std::sync::Arc;
mod auth;
mod database;
mod file_hosting;
mod models;
mod routes;
mod scheduler;
mod search;
mod util;
mod validate;
#[derive(Debug, Options)]
@ -265,9 +265,23 @@ async fn main() -> std::io::Result<()> {
.with_identifier(|req| {
let connection_info = req.connection_info();
let ip = String::from(
connection_info
.remote_addr()
.ok_or(ARError::IdentificationError)?,
if dotenv::var("CLOUDFLARE_INTEGRATION")
.ok()
.map(|i| i.parse().unwrap())
.unwrap_or(false)
{
if let Some(header) = req.headers().get("CF-Connecting-IP") {
header.to_str().map_err(|_| ARError::IdentificationError)?
} else {
connection_info
.remote_addr()
.ok_or(ARError::IdentificationError)?
}
} else {
connection_info
.remote_addr()
.ok_or(ARError::IdentificationError)?
},
);
let ignore_ips = dotenv::var("RATE_LIMIT_IGNORE_IPS")
@ -277,16 +291,16 @@ async fn main() -> std::io::Result<()> {
if ignore_ips.contains(&ip) {
// At an even distribution of numbers, this will allow at the most
// 3000 requests per minute from the frontend, which is reasonable
// (50 requests per second)
let random = rand::thread_rng().gen_range(1, 15);
// 18000 requests per minute from the frontend, which is reasonable
// (300 requests per second)
let random = rand::thread_rng().gen_range(1, 30);
return Ok(format!("{}-{}", ip, random));
}
Ok(ip)
})
.with_interval(std::time::Duration::from_secs(60))
.with_max_requests(200),
.with_max_requests(300),
)
.wrap(sentry_actix::Sentry::new())
.data(pool.clone())
@ -335,6 +349,7 @@ fn check_env_vars() -> bool {
failed |= true;
}
failed |= check_var::<String>("SITE_URL");
failed |= check_var::<String>("CDN_URL");
failed |= check_var::<String>("DATABASE_URL");
failed |= check_var::<String>("MEILISEARCH_ADDR");

View File

@ -12,6 +12,8 @@ pub struct NotificationId(pub u64);
pub struct Notification {
pub id: NotificationId,
pub user_id: UserId,
#[serde(rename = "type")]
pub type_: Option<String>,
pub title: String,
pub text: String,
pub link: String,

View File

@ -12,13 +12,13 @@ use validator::Validate;
pub struct ProjectId(pub u64);
/// The ID of a specific version of a project
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
#[serde(from = "Base62Id")]
#[serde(into = "Base62Id")]
pub struct VersionId(pub u64);
/// A project returned from the API
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
pub struct Project {
/// The ID of the project, encoded as a base62 string.
pub id: ProjectId,
@ -40,8 +40,12 @@ pub struct Project {
pub published: DateTime<Utc>,
/// The date at which the project was first published.
pub updated: DateTime<Utc>,
/// The status of the project
pub status: ProjectStatus,
/// The rejection data of the project
pub rejection_data: Option<RejectionReason>,
/// The license of this project
pub license: License,
@ -73,6 +77,12 @@ pub struct Project {
pub donation_urls: Option<Vec<DonationLink>>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct RejectionReason {
pub reason: String,
pub body: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "kebab-case")]
pub enum SideType {
@ -134,6 +144,7 @@ pub struct DonationLink {
#[serde(rename_all = "lowercase")]
pub enum ProjectStatus {
Approved,
Archived,
Rejected,
Draft,
Unlisted,
@ -155,6 +166,7 @@ impl ProjectStatus {
"approved" => ProjectStatus::Approved,
"draft" => ProjectStatus::Draft,
"unlisted" => ProjectStatus::Unlisted,
"archived" => ProjectStatus::Archived,
_ => ProjectStatus::Unknown,
}
}
@ -166,6 +178,7 @@ impl ProjectStatus {
ProjectStatus::Unlisted => "unlisted",
ProjectStatus::Processing => "processing",
ProjectStatus::Unknown => "unknown",
ProjectStatus::Archived => "archived",
}
}
@ -177,6 +190,7 @@ impl ProjectStatus {
ProjectStatus::Unlisted => false,
ProjectStatus::Processing => true,
ProjectStatus::Unknown => true,
ProjectStatus::Archived => false,
}
}
@ -240,9 +254,11 @@ pub struct VersionFile {
/// version's functionality
#[derive(Serialize, Deserialize, Clone)]
pub struct Dependency {
/// The filename of the file.
pub version_id: VersionId,
/// Whether the file is the primary file of a version
/// The specific version id that the dependency uses
pub version_id: Option<VersionId>,
/// The project ID that the dependency is synced with and auto-updated
pub project_id: Option<ProjectId>,
/// The type of the dependency
pub dependency_type: DependencyType,
}

View File

@ -1,9 +1,9 @@
use crate::auth::get_github_user_from_token;
use crate::database::models::{generate_state_id, User};
use crate::models::error::ApiError;
use crate::models::ids::base62_impl::{parse_base62, to_base62};
use crate::models::ids::DecodingError;
use crate::models::users::Role;
use crate::util::auth::get_github_user_from_token;
use actix_web::http::StatusCode;
use actix_web::web::{scope, Data, Query, ServiceConfig};
use actix_web::{get, HttpResponse};
@ -32,7 +32,7 @@ pub enum AuthorizationError {
#[error("Invalid Authentication credentials")]
InvalidCredentialsError,
#[error("Authentication Error: {0}")]
AuthenticationError(#[from] crate::auth::AuthenticationError),
AuthenticationError(#[from] crate::util::auth::AuthenticationError),
#[error("Error while decoding Base62")]
DecodingError(#[from] DecodingError),
}
@ -129,78 +129,82 @@ pub async fn auth_callback(
let mut transaction = client.begin().await?;
let state_id = parse_base62(&*info.state)?;
let result = sqlx::query!(
let result_option = sqlx::query!(
"
SELECT url,expires FROM states
WHERE id = $1
",
state_id as i64
)
.fetch_one(&mut *transaction)
.fetch_optional(&mut *transaction)
.await?;
let now = Utc::now();
let duration = result.expires.signed_duration_since(now);
if let Some(result) = result_option {
let now = Utc::now();
let duration = result.expires.signed_duration_since(now);
if duration.num_seconds() < 0 {
return Err(AuthorizationError::InvalidCredentialsError);
}
if duration.num_seconds() < 0 {
return Err(AuthorizationError::InvalidCredentialsError);
}
sqlx::query!(
"
sqlx::query!(
"
DELETE FROM states
WHERE id = $1
",
state_id as i64
)
.execute(&mut *transaction)
.await?;
let client_id = dotenv::var("GITHUB_CLIENT_ID")?;
let client_secret = dotenv::var("GITHUB_CLIENT_SECRET")?;
let url = format!(
"https://github.com/login/oauth/access_token?client_id={}&client_secret={}&code={}",
client_id, client_secret, info.code
);
let token: AccessToken = reqwest::Client::new()
.post(&url)
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await?
.json()
state_id as i64
)
.execute(&mut *transaction)
.await?;
let user = get_github_user_from_token(&*token.access_token).await?;
let client_id = dotenv::var("GITHUB_CLIENT_ID")?;
let client_secret = dotenv::var("GITHUB_CLIENT_SECRET")?;
let user_result = User::get_from_github_id(user.id, &mut *transaction).await?;
match user_result {
Some(x) => info!("{:?}", x.id),
None => {
let user_id = crate::database::models::generate_user_id(&mut transaction).await?;
let url = format!(
"https://github.com/login/oauth/access_token?client_id={}&client_secret={}&code={}",
client_id, client_secret, info.code
);
User {
id: user_id,
github_id: Some(user.id as i64),
username: user.login,
name: user.name,
email: user.email,
avatar_url: Some(user.avatar_url),
bio: user.bio,
created: Utc::now(),
role: Role::Developer.to_string(),
}
.insert(&mut transaction)
let token: AccessToken = reqwest::Client::new()
.post(&url)
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await?
.json()
.await?;
let user = get_github_user_from_token(&*token.access_token).await?;
let user_result = User::get_from_github_id(user.id, &mut *transaction).await?;
match user_result {
Some(x) => info!("{:?}", x.id),
None => {
let user_id = crate::database::models::generate_user_id(&mut transaction).await?;
User {
id: user_id,
github_id: Some(user.id as i64),
username: user.login,
name: user.name,
email: user.email,
avatar_url: Some(user.avatar_url),
bio: user.bio,
created: Utc::now(),
role: Role::Developer.to_string(),
}
.insert(&mut transaction)
.await?;
}
}
transaction.commit().await?;
let redirect_url = format!("{}?code={}", result.url, token.access_token);
Ok(HttpResponse::TemporaryRedirect()
.header("Location", &*redirect_url)
.json(AuthorizationInit { url: redirect_url }))
} else {
Err(AuthorizationError::InvalidCredentialsError)
}
transaction.commit().await?;
let redirect_url = format!("{}?code={}", result.url, token.access_token);
Ok(HttpResponse::TemporaryRedirect()
.header("Location", &*redirect_url)
.json(AuthorizationInit { url: redirect_url }))
}

View File

@ -1,7 +1,7 @@
use crate::auth::get_user_from_headers;
use crate::database;
use crate::models::projects::ProjectId;
use crate::routes::ApiError;
use crate::util::auth::get_user_from_headers;
use actix_web::{get, web, HttpRequest, HttpResponse};
use sqlx::PgPool;
use yaserde_derive::YaSerialize;

View File

@ -55,7 +55,8 @@ pub fn projects_config(cfg: &mut web::ServiceConfig) {
.service(projects::project_follow)
.service(projects::project_unfollow)
.service(teams::team_members_get_project)
.service(web::scope("{project_id}").service(versions::version_list)),
.service(web::scope("{project_id}").service(versions::version_list))
.service(projects::dependency_list),
);
}
@ -119,6 +120,7 @@ pub fn teams_config(cfg: &mut web::ServiceConfig) {
pub fn notifications_config(cfg: &mut web::ServiceConfig) {
cfg.service(notifications::notifications_get);
cfg.service(notifications::notification_delete);
cfg.service(
web::scope("notification")
@ -152,13 +154,13 @@ pub enum ApiError {
#[error("Deserialization error: {0}")]
JsonError(#[from] serde_json::Error),
#[error("Authentication Error: {0}")]
AuthenticationError(#[from] crate::auth::AuthenticationError),
AuthenticationError(#[from] crate::util::auth::AuthenticationError),
#[error("Authentication Error: {0}")]
CustomAuthenticationError(String),
#[error("Invalid Input: {0}")]
InvalidInputError(String),
#[error("Error while validating input: {0}")]
ValidationError(#[from] validator::ValidationErrors),
ValidationError(String),
#[error("Search Error: {0}")]
SearchError(#[from] meilisearch_sdk::errors::Error),
#[error("Indexing Error: {0}")]

View File

@ -1,7 +1,7 @@
use super::ApiError;
use crate::auth::check_is_moderator_from_headers;
use crate::database;
use crate::models::projects::{Project, ProjectStatus};
use crate::util::auth::check_is_moderator_from_headers;
use actix_web::{get, web, HttpRequest, HttpResponse};
use serde::Deserialize;
use sqlx::PgPool;

View File

@ -1,8 +1,8 @@
use crate::auth::get_user_from_headers;
use crate::database;
use crate::models::ids::NotificationId;
use crate::models::notifications::{Notification, NotificationAction};
use crate::routes::ApiError;
use crate::util::auth::get_user_from_headers;
use actix_web::{delete, get, web, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
@ -70,6 +70,7 @@ pub fn convert_notification(
Notification {
id: notif.id.into(),
user_id: notif.user_id.into(),
type_: notif.notification_type,
title: notif.title,
text: notif.text,
link: notif.link,
@ -101,7 +102,12 @@ pub async fn notification_delete(
if let Some(data) = notification_data {
if data.user_id == user.id.into() || user.role.is_mod() {
database::models::notification_item::Notification::remove(id.into(), &**pool).await?;
let mut transaction = pool.begin().await?;
database::models::notification_item::Notification::remove(id.into(), &mut transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
@ -113,3 +119,38 @@ pub async fn notification_delete(
Ok(HttpResponse::NotFound().body(""))
}
}
#[delete("notifications")]
pub async fn notifications_delete(
req: HttpRequest,
web::Query(ids): web::Query<NotificationIds>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let notification_ids = serde_json::from_str::<Vec<NotificationId>>(&*ids.ids)?
.into_iter()
.map(|x| x.into())
.collect();
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_mod() {
notifications.push(notification.id);
}
}
database::models::notification_item::Notification::remove_many(notifications, &mut transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}

View File

@ -1,4 +1,3 @@
use crate::auth::{get_user_from_headers, AuthenticationError};
use crate::database::models;
use crate::file_hosting::{FileHost, FileHostingError};
use crate::models::error::ApiError;
@ -8,13 +7,13 @@ use crate::models::projects::{
use crate::models::users::UserId;
use crate::routes::version_creation::InitialVersionData;
use crate::search::indexing::{queue::CreationQueue, IndexingError};
use crate::util::auth::{get_user_from_headers, AuthenticationError};
use crate::util::validate::validation_errors_to_string;
use actix_multipart::{Field, Multipart};
use actix_web::http::StatusCode;
use actix_web::web::Data;
use actix_web::{post, HttpRequest, HttpResponse};
use futures::stream::StreamExt;
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool;
use std::sync::Arc;
@ -36,7 +35,7 @@ pub enum CreateError {
#[error("Error while parsing JSON: {0}")]
SerDeError(#[from] serde_json::Error),
#[error("Error while validating input: {0}")]
ValidationError(#[from] validator::ValidationErrors),
ValidationError(String),
#[error("Error while uploading file")]
FileHostingError(#[from] FileHostingError),
#[error("Error while validating uploaded file: {0}")]
@ -116,10 +115,6 @@ impl actix_web::ResponseError for CreateError {
}
}
lazy_static! {
static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_-]*$").unwrap();
}
fn default_project_type() -> String {
"mod".to_string()
}
@ -134,7 +129,10 @@ struct ProjectCreateData {
#[serde(default = "default_project_type")]
/// The project type of this mod
pub project_type: String,
#[validate(length(min = 3, max = 64), regex = "RE_URL_SAFE")]
#[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,
@ -153,6 +151,7 @@ struct ProjectCreateData {
pub server_side: SideType,
#[validate(length(max = 64))]
#[validate]
/// A list of initial versions to upload with the created project
pub initial_versions: Vec<InitialVersionData>,
#[validate(length(max = 3))]
@ -326,7 +325,9 @@ pub async fn project_create_inner(
}
let create_data: ProjectCreateData = serde_json::from_slice(&data)?;
create_data.validate()?;
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();
@ -498,6 +499,12 @@ pub async fn project_create_inner(
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 status_id = models::StatusId::get_id(&status, &mut *transaction)
@ -590,6 +597,7 @@ pub async fn project_create_inner(
published: now,
updated: now,
status: status.clone(),
rejection_data: None,
license: License {
id: project_create_data.license_id.clone(),
name: "".to_string(),
@ -622,6 +630,12 @@ pub async fn project_create_inner(
)
.await?;
indexing_queue.add(index_project);
if let Ok(webhook_url) = dotenv::var("MODERATION_DISCORD_WEBHOOK") {
crate::util::webhook::send_discord_webhook(response.clone(), webhook_url)
.await
.ok();
}
}
Ok(HttpResponse::Ok().json(response))
@ -643,7 +657,9 @@ async fn create_initial_version(
)));
}
version_data.validate()?;
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();
@ -684,7 +700,11 @@ async fn create_initial_version(
let dependencies = version_data
.dependencies
.iter()
.map(|x| ((x.version_id).into(), x.dependency_type.to_string()))
.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(),
})
.collect::<Vec<_>>();
let version = models::version_item::VersionBuilder {

View File

@ -1,21 +1,23 @@
use crate::auth::get_user_from_headers;
use crate::database;
use crate::database::cache::project_cache::remove_cache_project;
use crate::database::cache::query_project_cache::remove_cache_query_project;
use crate::file_hosting::FileHost;
use crate::models;
use crate::models::projects::{
DonationLink, License, ProjectId, ProjectStatus, SearchRequest, SideType,
DonationLink, License, ProjectId, ProjectStatus, RejectionReason, SearchRequest, SideType,
};
use crate::models::teams::Permissions;
use crate::routes::ApiError;
use crate::search::indexing::queue::CreationQueue;
use crate::search::{search_for_project, SearchConfig, SearchError};
use crate::util::auth::get_user_from_headers;
use crate::util::validate::validation_errors_to_string;
use actix_web::web::Data;
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
use futures::StreamExt;
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
use validator::Validate;
@ -91,7 +93,8 @@ pub async fn project_get(
let string = info.into_inner().0;
let project_data =
database::models::Project::get_full_from_slug_or_project_id(string, &**pool).await?;
database::models::Project::get_full_from_slug_or_project_id(string.clone(), &**pool)
.await?;
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
@ -129,6 +132,94 @@ pub async fn project_get(
}
}
struct DependencyInfo {
pub project: Option<models::projects::Project>,
pub version: Option<models::projects::Version>,
}
#[get("dependencies")]
pub async fn dependency_list(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
let result = database::models::Project::get_from_slug_or_project_id(string, &**pool).await?;
if let Some(project) = result {
let id = project.id;
use futures::stream::TryStreamExt;
let dependencies = sqlx::query!(
"
SELECT d.dependent_id, d.dependency_id, d.mod_dependency_id
FROM versions v
INNER JOIN dependencies d ON d.dependent_id = v.id
WHERE v.mod_id = $1
",
id as database::models::ProjectId
)
.fetch_many(&**pool)
.try_filter_map(|e| async {
Ok(e.right().map(|x| {
(
database::models::VersionId(x.dependent_id),
x.dependency_id.map(database::models::VersionId),
x.mod_dependency_id.map(database::models::ProjectId),
)
}))
})
.try_collect::<Vec<(
database::models::VersionId,
Option<database::models::VersionId>,
Option<database::models::ProjectId>,
)>>()
.await?;
let projects = database::Project::get_many_full(
dependencies.iter().map(|x| x.2).flatten().collect(),
&**pool,
)
.await?;
let versions = database::Version::get_many_full(
dependencies.iter().map(|x| x.1).flatten().collect(),
&**pool,
)
.await?;
let mut response: HashMap<models::projects::VersionId, DependencyInfo> = HashMap::new();
for dependency in dependencies {
response.insert(
dependency.0.into(),
DependencyInfo {
project: if let Some(id) = dependency.2 {
projects
.iter()
.find(|x| x.inner.id == id)
.map(|x| convert_project(x.clone()))
} else {
None
},
version: if let Some(id) = dependency.1 {
versions
.iter()
.find(|x| x.id == id)
.map(|x| super::versions::convert_version(x.clone()))
} else {
None
},
},
);
}
Ok(HttpResponse::NotFound().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
pub fn convert_project(
data: database::models::project_item::QueryProject,
) -> models::projects::Project {
@ -146,6 +237,14 @@ pub fn convert_project(
published: m.published,
updated: m.updated,
status: data.status,
rejection_data: if let Some(reason) = m.rejection_reason {
Some(RejectionReason {
reason,
body: m.rejection_body,
})
} else {
None
},
license: License {
id: data.license_id,
name: data.license_name,
@ -175,10 +274,6 @@ pub fn convert_project(
}
}
lazy_static! {
static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_-]*$").unwrap();
}
/// A project returned from the API
#[derive(Serialize, Deserialize, Validate)]
pub struct EditProject {
@ -188,7 +283,6 @@ pub struct EditProject {
pub description: Option<String>,
#[validate(length(max = 65536))]
pub body: Option<String>,
pub status: Option<ProjectStatus>,
#[validate(length(max = 3))]
pub categories: Option<Vec<String>>,
#[serde(
@ -236,8 +330,26 @@ pub struct EditProject {
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
#[validate(length(min = 3, max = 64), regex = "RE_URL_SAFE")]
#[validate(
length(min = 3, max = 64),
regex = "crate::util::validate::RE_URL_SAFE"
)]
pub slug: Option<Option<String>>,
pub status: Option<ProjectStatus>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
#[validate(length(max = 2000))]
pub rejection_reason: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
#[validate(length(max = 65536))]
pub rejection_body: Option<Option<String>>,
}
#[patch("{id}")]
@ -251,11 +363,14 @@ pub async fn project_edit(
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
new_project.validate()?;
new_project
.validate()
.map_err(|err| ApiError::ValidationError(validation_errors_to_string(err, None)))?;
let string = info.into_inner().0;
let result =
database::models::Project::get_full_from_slug_or_project_id(string, &**pool).await?;
database::models::Project::get_full_from_slug_or_project_id(string.clone(), &**pool)
.await?;
if let Some(project_item) = result {
let id = project_item.inner.id;
@ -337,6 +452,12 @@ pub async fn project_edit(
));
}
if status == &ProjectStatus::Processing && project_item.versions.is_empty() {
return Err(ApiError::InvalidInputError(String::from(
"Project submitted for review with no initial versions",
)));
}
let status_id = database::models::StatusId::get_id(&status, &mut *transaction)
.await?
.ok_or_else(|| {
@ -357,6 +478,30 @@ pub async fn project_edit(
.execute(&mut *transaction)
.await?;
if project_item.status == ProjectStatus::Processing {
sqlx::query!(
"
UPDATE mods
SET rejection_reason = NULL
WHERE (id = $1)
",
id as database::models::ids::ProjectId,
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
UPDATE mods
SET rejection_body = NULL
WHERE (id = $1)
",
id as database::models::ids::ProjectId,
)
.execute(&mut *transaction)
.await?;
}
if project_item.status.is_searchable() && !status.is_searchable() {
delete_from_index(id.into(), config).await?;
} else if !project_item.status.is_searchable() && status.is_searchable() {
@ -365,6 +510,15 @@ pub async fn project_edit(
.await?;
indexing_queue.add(index_project);
if let Ok(webhook_url) = dotenv::var("MODERATION_DISCORD_WEBHOOK") {
crate::util::webhook::send_discord_webhook(
convert_project(project_item.clone()),
webhook_url,
)
.await
.ok();
}
}
}
@ -684,6 +838,48 @@ pub async fn project_edit(
}
}
if let Some(rejection_reason) = &new_project.rejection_reason {
if !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the rejection reason of this project!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE mods
SET rejection_reason = $1
WHERE (id = $2)
",
rejection_reason.as_deref(),
id as database::models::ids::ProjectId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(rejection_body) = &new_project.rejection_body {
if !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the rejection body of this project!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE mods
SET rejection_body = $1
WHERE (id = $2)
",
rejection_body.as_deref(),
id as database::models::ids::ProjectId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(body) = &new_project.body {
if !perms.contains(Permissions::EDIT_BODY) {
return Err(ApiError::CustomAuthenticationError(
@ -705,6 +901,9 @@ pub async fn project_edit(
.await?;
}
remove_cache_project(string.clone()).await;
remove_cache_query_project(string).await;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
@ -736,11 +935,12 @@ pub async fn project_icon_edit(
let user = get_user_from_headers(req.headers(), &**pool).await?;
let string = info.into_inner().0;
let project_item = database::models::Project::get_from_slug_or_project_id(string, &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError("The specified project does not exist!".to_string())
})?;
let project_item =
database::models::Project::get_from_slug_or_project_id(string.clone(), &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError("The specified project does not exist!".to_string())
})?;
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id(
@ -782,12 +982,14 @@ pub async fn project_icon_edit(
)));
}
let hash = sha1::Sha1::from(bytes.clone()).hexdigest();
let project_id: ProjectId = project_item.id.into();
let upload_data = file_host
.upload_file(
content_type,
&format!("data/{}/icon.{}", project_id, ext.ext),
&format!("data/{}/{}.{}", project_id, hash, ext.ext),
bytes.to_vec(),
)
.await?;
@ -804,6 +1006,9 @@ pub async fn project_icon_edit(
.execute(&**pool)
.await?;
remove_cache_project(string.clone()).await;
remove_cache_query_project(string).await;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::InvalidInputError(format!(
@ -823,7 +1028,7 @@ pub async fn project_delete(
let user = get_user_from_headers(req.headers(), &**pool).await?;
let string = info.into_inner().0;
let project = database::models::Project::get_from_slug_or_project_id(string, &**pool)
let project = database::models::Project::get_from_slug_or_project_id(string.clone(), &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError("The specified project does not exist!".to_string())
@ -851,7 +1056,14 @@ pub async fn project_delete(
}
}
let result = database::models::Project::remove_full(project.id, &**pool).await?;
let mut transaction = pool.begin().await?;
let result = database::models::Project::remove_full(project.id, &mut transaction).await?;
remove_cache_project(string.clone()).await;
remove_cache_query_project(string).await;
transaction.commit().await?;
delete_from_index(project.id.into(), config).await?;
@ -893,6 +1105,8 @@ pub async fn project_follow(
.unwrap_or(false);
if !following {
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE mods
@ -901,7 +1115,7 @@ pub async fn project_follow(
",
project_id as database::models::ids::ProjectId,
)
.execute(&**pool)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -912,9 +1126,11 @@ pub async fn project_follow(
user_id as database::models::ids::UserId,
project_id as database::models::ids::ProjectId
)
.execute(&**pool)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::InvalidInputError(
@ -954,6 +1170,8 @@ pub async fn project_unfollow(
.unwrap_or(false);
if following {
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE mods
@ -962,7 +1180,7 @@ pub async fn project_unfollow(
",
project_id as database::models::ids::ProjectId,
)
.execute(&**pool)
.execute(&mut *transaction)
.await?;
sqlx::query!(
@ -973,9 +1191,11 @@ pub async fn project_unfollow(
user_id as database::models::ids::UserId,
project_id as database::models::ids::ProjectId
)
.execute(&**pool)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::InvalidInputError(

View File

@ -1,7 +1,7 @@
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::models::ids::{ProjectId, UserId, VersionId};
use crate::models::reports::{ItemType, Report};
use crate::routes::ApiError;
use crate::util::auth::{check_is_moderator_from_headers, get_user_from_headers};
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
use futures::StreamExt;
use serde::Deserialize;

View File

@ -1,7 +1,7 @@
use super::ApiError;
use crate::auth::check_is_admin_from_headers;
use crate::database::models;
use crate::database::models::categories::{DonationPlatform, License, ProjectType, ReportType};
use crate::util::auth::check_is_admin_from_headers;
use actix_web::{delete, get, put, web, HttpRequest, HttpResponse};
use models::categories::{Category, GameVersion, Loader};
use sqlx::PgPool;

View File

@ -1,4 +1,3 @@
use crate::auth::get_user_from_headers;
use crate::database::models::notification_item::{NotificationActionBuilder, NotificationBuilder};
use crate::database::models::team_item::QueryTeamMember;
use crate::database::models::TeamMember;
@ -6,6 +5,7 @@ use crate::models::ids::ProjectId;
use crate::models::teams::{Permissions, TeamId};
use crate::models::users::UserId;
use crate::routes::ApiError;
use crate::util::auth::get_user_from_headers;
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
@ -246,6 +246,7 @@ pub async fn add_team_member(
let team: TeamId = team_id.into();
NotificationBuilder {
notification_type: Some("team_invite".to_string()),
title: "You have been invited to join a team!".to_string(),
text: format!(
"Team invite from {} to join the team for project {}",

View File

@ -1,4 +1,3 @@
use crate::auth::get_user_from_headers;
use crate::database::models::User;
use crate::file_hosting::FileHost;
use crate::models::notifications::Notification;
@ -6,6 +5,8 @@ use crate::models::projects::{Project, ProjectStatus};
use crate::models::users::{Role, UserId};
use crate::routes::notifications::convert_notification;
use crate::routes::ApiError;
use crate::util::auth::get_user_from_headers;
use crate::util::validate::validation_errors_to_string;
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
use futures::StreamExt;
use lazy_static::lazy_static;
@ -166,7 +167,9 @@ pub async fn user_edit(
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
new_user.validate()?;
new_user
.validate()
.map_err(|err| ApiError::ValidationError(validation_errors_to_string(err, None)))?;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
@ -396,13 +399,17 @@ pub async fn user_delete(
));
}
let mut transaction = pool.begin().await?;
let result;
if &*removal_type.removal_type == "full" {
result = crate::database::models::User::remove_full(id, &**pool).await?;
result = crate::database::models::User::remove_full(id, &mut transaction).await?;
} else {
result = crate::database::models::User::remove(id, &**pool).await?;
result = crate::database::models::User::remove(id, &mut transaction).await?;
};
transaction.commit().await?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {

View File

@ -1,8 +1,8 @@
use crate::auth::check_is_moderator_from_headers;
use crate::database;
use crate::models::projects::{Project, ProjectStatus};
use crate::routes::moderation::ResultCount;
use crate::routes::ApiError;
use crate::util::auth::check_is_moderator_from_headers;
use actix_web::web;
use actix_web::{get, HttpRequest, HttpResponse};
use sqlx::PgPool;

View File

@ -1,4 +1,3 @@
use crate::auth::get_user_from_headers;
use crate::file_hosting::FileHost;
use crate::models::projects::SearchRequest;
use crate::routes::project_creation::{project_create_inner, undo_uploads, CreateError};
@ -6,6 +5,7 @@ use crate::routes::projects::{convert_project, ProjectIds};
use crate::routes::ApiError;
use crate::search::indexing::queue::CreationQueue;
use crate::search::{search_for_project, SearchConfig, SearchError};
use crate::util::auth::get_user_from_headers;
use crate::{database, models};
use actix_multipart::Multipart;
use actix_web::web;

View File

@ -1,8 +1,8 @@
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::models::ids::ReportId;
use crate::models::projects::{ProjectId, VersionId};
use crate::models::users::UserId;
use crate::routes::ApiError;
use crate::util::auth::{check_is_moderator_from_headers, get_user_from_headers};
use actix_web::web;
use actix_web::{get, post, HttpRequest, HttpResponse};
use chrono::{DateTime, Utc};

View File

@ -1,6 +1,6 @@
use crate::auth::check_is_admin_from_headers;
use crate::database::models::categories::{Category, GameVersion, Loader, ProjectType};
use crate::routes::ApiError;
use crate::util::auth::check_is_admin_from_headers;
use actix_web::{get, put, web};
use actix_web::{HttpRequest, HttpResponse};
use sqlx::PgPool;

View File

@ -1,7 +1,7 @@
use crate::auth::get_user_from_headers;
use crate::models::teams::{Permissions, TeamId};
use crate::models::users::UserId;
use crate::routes::ApiError;
use crate::util::auth::get_user_from_headers;
use actix_web::{get, web, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;

View File

@ -1,8 +1,8 @@
use crate::auth::get_user_from_headers;
use crate::database::models::User;
use crate::models::ids::UserId;
use crate::models::projects::{ProjectId, ProjectStatus};
use crate::routes::ApiError;
use crate::util::auth::get_user_from_headers;
use actix_web::web;
use actix_web::{get, HttpRequest, HttpResponse};
use sqlx::PgPool;

View File

@ -1,10 +1,10 @@
use crate::auth::get_user_from_headers;
use crate::file_hosting::FileHost;
use crate::models::ids::{ProjectId, UserId, VersionId};
use crate::models::projects::{Dependency, GameVersion, Loader, Version, VersionFile, VersionType};
use crate::models::teams::Permissions;
use crate::routes::versions::{convert_version, VersionIds, VersionListFilters};
use crate::routes::ApiError;
use crate::util::auth::get_user_from_headers;
use crate::{database, models, Pepper};
use actix_web::{delete, get, web, HttpRequest, HttpResponse};
use chrono::{DateTime, Utc};

View File

@ -1,4 +1,3 @@
use crate::auth::get_user_from_headers;
use crate::database::models;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::version_item::{VersionBuilder, VersionFileBuilder};
@ -8,28 +7,27 @@ use crate::models::projects::{
};
use crate::models::teams::Permissions;
use crate::routes::project_creation::{CreateError, UploadedFile};
use crate::util::auth::get_user_from_headers;
use crate::util::validate::validation_errors_to_string;
use crate::validate::{validate_file, ValidationResult};
use actix_multipart::{Field, Multipart};
use actix_web::web::Data;
use actix_web::{post, HttpRequest, HttpResponse};
use futures::stream::StreamExt;
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool;
use validator::Validate;
lazy_static! {
static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_\-.]*$").unwrap();
}
#[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 = 64), regex = "RE_URL_SAFE")]
#[validate(
length(min = 1, max = 64),
regex = "crate::util::validate::RE_URL_SAFE"
)]
pub version_number: String,
#[validate(length(min = 3, max = 256))]
pub version_title: String,
@ -127,7 +125,9 @@ async fn version_create_inner(
));
}
version_create_data.validate()?;
version_create_data.validate().map_err(|err| {
CreateError::ValidationError(validation_errors_to_string(err, None))
})?;
let project_id: models::ProjectId = version_create_data.project_id.unwrap().into();
@ -234,7 +234,11 @@ async fn version_create_inner(
let dependencies = version_create_data
.dependencies
.iter()
.map(|x| ((x.version_id).into(), x.dependency_type.to_string()))
.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(),
})
.collect::<Vec<_>>();
version_builder = Some(VersionBuilder {
@ -332,9 +336,10 @@ async fn version_create_inner(
let version_id: VersionId = builder.version_id.into();
NotificationBuilder {
title: "A project you followed has been updated!".to_string(),
notification_type: Some("project_update".to_string()),
title: format!("**{}** has been updated!", result.title),
text: format!(
"Project {} has been updated to version {}",
"The project, {}, has released a new version: {}",
result.title,
version_data.version_number.clone()
),

View File

@ -1,9 +1,9 @@
use super::ApiError;
use crate::auth::get_user_from_headers;
use crate::file_hosting::FileHost;
use crate::models;
use crate::models::projects::{GameVersion, Loader};
use crate::models::teams::Permissions;
use crate::util::auth::get_user_from_headers;
use crate::{database, Pepper};
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
@ -118,7 +118,19 @@ async fn download_version_inner(
pepper: &web::Data<Pepper>,
) -> Result<(), ApiError> {
let real_ip = req.connection_info();
let ip_option = real_ip.borrow().remote_addr();
let ip_option = if dotenv::var("CLOUDFLARE_INTEGRATION")
.ok()
.map(|i| i.parse().unwrap())
.unwrap_or(false)
{
if let Some(header) = req.headers().get("CF-Connecting-IP") {
header.to_str().ok()
} else {
real_ip.borrow().remote_addr()
}
} else {
real_ip.borrow().remote_addr()
};
if let Some(ip) = ip_option {
let hash = sha1::Sha1::from(format!("{}{}", ip, pepper.pepper)).hexdigest();

View File

@ -1,12 +1,11 @@
use super::ApiError;
use crate::auth::get_user_from_headers;
use crate::database;
use crate::models;
use crate::models::projects::{Dependency, DependencyType};
use crate::models::teams::Permissions;
use crate::util::auth::get_user_from_headers;
use crate::util::validate::validation_errors_to_string;
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use validator::Validate;
@ -189,8 +188,9 @@ pub fn convert_version(
.dependencies
.into_iter()
.map(|d| Dependency {
version_id: d.0.into(),
dependency_type: DependencyType::from_str(&*d.1),
version_id: d.version_id.map(|x| x.into()),
project_id: d.project_id.map(|x| x.into()),
dependency_type: DependencyType::from_str(&*d.dependency_type),
})
.collect(),
game_versions: data
@ -206,15 +206,14 @@ pub fn convert_version(
}
}
lazy_static! {
static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_-]*$").unwrap();
}
#[derive(Serialize, Deserialize, Validate)]
pub struct EditVersion {
#[validate(length(min = 3, max = 256))]
pub name: Option<String>,
#[validate(length(min = 1, max = 64), regex = "RE_URL_SAFE")]
#[validate(
length(min = 1, max = 64),
regex = "crate::util::validate::RE_URL_SAFE"
)]
pub version_number: Option<String>,
#[validate(length(max = 65536))]
pub changelog: Option<String>,
@ -236,7 +235,9 @@ pub async fn version_edit(
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
new_version.validate()?;
new_version
.validate()
.map_err(|err| ApiError::ValidationError(validation_errors_to_string(err, None)))?;
let version_id = info.into_inner().0;
let id = version_id.into();
@ -332,21 +333,17 @@ pub async fn version_edit(
.execute(&mut *transaction)
.await?;
for dependency in dependencies {
let dependency_id: database::models::ids::VersionId =
dependency.version_id.clone().into();
let builders = dependencies
.iter()
.map(|x| database::models::version_item::DependencyBuilder {
project_id: x.project_id.clone().map(|x| x.into()),
version_id: x.version_id.clone().map(|x| x.into()),
dependency_type: x.dependency_type.to_string(),
})
.collect::<Vec<database::models::version_item::DependencyBuilder>>();
sqlx::query!(
"
INSERT INTO dependencies (dependent_id, dependency_id, dependency_type)
VALUES ($1, $2, $3)
",
id as database::models::ids::VersionId,
dependency_id as database::models::ids::VersionId,
dependency.dependency_type.as_str()
)
.execute(&mut *transaction)
.await?;
for dependency in builders {
dependency.insert(version_item.id, &mut transaction).await?;
}
}
@ -533,7 +530,11 @@ pub async fn version_delete(
}
}
let result = database::models::Version::remove_full(id.into(), &**pool).await?;
let mut transaction = pool.begin().await?;
let result = database::models::Version::remove_full(id.into(), &mut transaction).await?;
transaction.commit().await?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))

3
src/util/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod auth;
pub mod validate;
pub mod webhook;

55
src/util/validate.rs Normal file
View File

@ -0,0 +1,55 @@
use lazy_static::lazy_static;
use regex::Regex;
use validator::{ValidationErrors, ValidationErrorsKind};
lazy_static! {
pub static ref RE_URL_SAFE: Regex = Regex::new(r#"^[a-zA-Z0-9!@$()`.+,_"-]*$"#).unwrap();
}
//TODO: In order to ensure readability, only the first error is printed, this may need to be expanded on in the future!
pub fn validation_errors_to_string(errors: ValidationErrors, adder: Option<String>) -> String {
let mut output = String::new();
let map = errors.into_errors();
let key_option = map.keys().next().copied();
if let Some(field) = key_option {
if let Some(error) = map.get(field) {
return match error {
ValidationErrorsKind::Struct(errors) => {
validation_errors_to_string(*errors.clone(), Some(format!("of item {}", field)))
}
ValidationErrorsKind::List(list) => {
if let Some(errors) = list.get(&0) {
output.push_str(&*validation_errors_to_string(
*errors.clone(),
Some(format!("of list {} with index 0", field)),
));
}
output
}
ValidationErrorsKind::Field(errors) => {
if let Some(error) = errors.get(0) {
if let Some(adder) = adder {
output.push_str(&*format!(
"Field {} {} failed validation with error {}",
field, adder, error.code
));
} else {
output.push_str(&*format!(
"Field {} failed validation with error {}",
field, error.code
));
}
}
output
}
};
}
}
"".to_string()
}

107
src/util/webhook.rs Normal file
View File

@ -0,0 +1,107 @@
use crate::models::projects::Project;
use chrono::{DateTime, Utc};
use serde::Serialize;
#[derive(Serialize)]
struct DiscordEmbed {
pub title: String,
pub description: String,
pub url: String,
pub timestamp: DateTime<Utc>,
pub color: u32,
pub fields: Vec<DiscordEmbedField>,
pub image: DiscordEmbedImage,
}
#[derive(Serialize)]
struct DiscordEmbedField {
pub name: String,
pub value: String,
pub inline: bool,
}
#[derive(Serialize)]
struct DiscordEmbedImage {
pub url: Option<String>,
}
#[derive(Serialize)]
struct DiscordWebhook {
pub embeds: Vec<DiscordEmbed>,
}
pub async fn send_discord_webhook(
project: Project,
webhook_url: String,
) -> Result<(), reqwest::Error> {
let mut fields = Vec::new();
fields.push(DiscordEmbedField {
name: "id".to_string(),
value: project.id.to_string(),
inline: true,
});
if let Some(slug) = project.slug.clone() {
fields.push(DiscordEmbedField {
name: "slug".to_string(),
value: slug,
inline: true,
});
}
fields.push(DiscordEmbedField {
name: "project_type".to_string(),
value: project.project_type.to_string(),
inline: true,
});
fields.push(DiscordEmbedField {
name: "client_side".to_string(),
value: project.client_side.to_string(),
inline: true,
});
fields.push(DiscordEmbedField {
name: "server_side".to_string(),
value: project.server_side.to_string(),
inline: true,
});
fields.push(DiscordEmbedField {
name: "categories".to_string(),
value: project.categories.join(", "),
inline: true,
});
let embed = DiscordEmbed {
url: format!(
"{}/mod/{}",
dotenv::var("SITE_URL").unwrap_or_default(),
project
.clone()
.slug
.unwrap_or_else(|| project.id.to_string())
),
title: project.title,
description: project.description,
timestamp: project.published,
color: 6137157,
fields,
image: DiscordEmbedImage {
url: project.icon_url,
},
};
let client = reqwest::Client::new();
client
.post(&webhook_url)
.json(&DiscordWebhook {
embeds: vec![embed],
})
.send()
.await?;
Ok(())
}