Project Types, Code Cleanup, and Rename Mods -> Projects (#192)

* Initial work for modpacks and project types

* Code cleanup, fix some issues

* Username route getting, remove pointless tests

* Base validator types + fixes

* Fix strange IML generation

* Multiple hash requests for version files

* Fix docker build (hopefully)

* Legacy routes

* Finish validator architecture

* Update rust version in dockerfile

* Added caching and fixed typo (#203)

* Added caching and fixed typo

* Fixed clippy error

* Removed log for cache

* Add final validators, fix how loaders are handled and add icons to tags

* Fix search module

* Fix parts of legacy API not working

Co-authored-by: Redblueflame <contact@redblueflame.com>
This commit is contained in:
Geometrically
2021-05-30 15:02:07 -07:00
committed by GitHub
parent 712424c339
commit 16db28060c
55 changed files with 6656 additions and 3908 deletions

2
.env
View File

@@ -28,7 +28,7 @@ LOCAL_INDEX_INTERVAL=3600
# 30 minutes
VERSION_INDEX_INTERVAL=1800
GITHUB_CLIENT_ID=3acffb2e808d16d4b226
GITHUB_CLIENT_ID=none
GITHUB_CLIENT_SECRET=none
RATE_LIMIT_IGNORE_IPS='[]'

73
.idea/labrinth.iml generated
View File

@@ -3,6 +3,79 @@
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/ahash-7eac216debe9f38a/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/async-trait-1f1149946021f68f/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/backtrace-a81dd5b1d549b1fe/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/bitflags-f9113967e336fcbe/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/brotli-sys-cb7557720acb5147/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/bzip2-sys-eaaf2f410b5f53aa/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/const_fn-083a7d629a9dd60f/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/cookie-abe2724f87f58612/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/crc-312e8aeb69db3079/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/crc32fast-098f9153d31ba9cd/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/crossbeam-utils-5bfd8a6166e7f6db/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/crossbeam-utils-ab308bfacfd03956/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/curl-6ddc5714a8986856/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/curl-sys-8ee7f7f4784d489b/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/encoding_rs-b7969e2119dfc51e/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/failure_derive-711d9019bf1d05c7/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/futures-channel-e6098f658bcb2591/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/futures-core-438eabbe001915aa/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/futures-macro-9cc56fcce7840df6/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/futures-task-ee55e29f27db9b98/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/futures-util-a1175a80ced777ce/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/generic-array-5440520ddf822692/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/getrandom-950b65ede84eb5e1/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/getrandom-de6c07f96ef855eb/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/hashbrown-ae09dc04d2f34e91/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/httparse-9599bdafe9a55039/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/indexmap-89e57c5173b349d9/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/isahc-86aa24d4580a977e/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/labrinth-3e3d312c8607dd17/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/lexical-core-8bf0f1a2d1b43ece/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/libc-d707e48de33d48d9/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/libnghttp2-sys-a51e5a3d7e3464ce/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/libz-sys-d2384f20b2f9b583/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/log-7c8712f109ca5294/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/maybe-uninit-19304d5387c9a95a/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/memchr-44de47fae7f7702f/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/mime_guess-ef6330e686a1be7f/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/miniz_oxide-c3cab35945b7a047/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/native-tls-3fea547460520a21/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/nom-43b66ebbb07372cf/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/nom-82a5ac3f69d853fd/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/num-integer-882a92c01312881d/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/num-traits-9247bc1ce3fdb48a/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/openssl-901f3ac4ff42e527/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/openssl-sys-139c8d838997f47a/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/pin-project-internal-01048751181af249/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/proc-macro-error-attr-a6a60d53cc2b710b/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/proc-macro-error-c8210b250a97a5c4/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/proc-macro-hack-d5e47daad3405e02/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/proc-macro-nested-a3ffff2430ef2c60/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/proc-macro2-9d6e979cb1b79e98/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/radium-5e75940e19c92849/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/ring-0c62041ed1124a76/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/rustversion-07fd960262c655bb/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/ryu-ec3b0518e33f72cd/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/sentry-contexts-061540b96a2797f0/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/serde-6f6f4c19e9ef78e0/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/serde_derive-9db413d6fd81e485/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/serde_json-e891f6d1195c8922/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/standback-619b4e48d02f2a1b/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/syn-df093b2222f7c0e4/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/time-2568f1074dc4b3c3/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/tokio-e723dcf9898f8aa3/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/typenum-913dc171f8a5b7c3/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/unicase-58734f758c305d57/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/v_escape-cbf9195349484203/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/v_htmlescape-f705d5a87b606c7e/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/value-bag-9890a878f3f6e4ee/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/whoami-9edf4b12be590553/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/const_fn-b8ca4a53a9eb8acd/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/labrinth-175a4aa21ea52414/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/proc-macro2-7094aab0eee55116/out" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/target/debug/build/sentry-contexts-ca4afd3cc95c36c4/out" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />

1147
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "labrinth"
version = "0.1.0"
version = "0.2.0"
#Team members, please add your emails and usernames
authors = ["geometrically <jai.a@tuta.io>", "Redblueflame <contact@redblueflame.com>", "Aeledfyr <aeledfyr@gmail.com>", "Charalampos Fanoulis <yo@fanoulis.dev>", "AppleTheGolden <scotsbox@protonmail.com>"]
edition = "2018"
@@ -12,11 +12,11 @@ path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "3.1.0"
actix-rt = "1.1.1"
actix-files = "0.4.0"
actix-web = "3.3.2"
actix-rt = "1.1.0"
actix-files = "0.5.0"
actix-multipart = "0.3.0"
actix-cors = "0.4.1"
actix-cors = "0.5.4"
actix-ratelimit = "0.3.0"
meilisearch-sdk = "0.6.0"
@@ -35,6 +35,10 @@ base64 = "0.13.0"
sha1 = { version = "0.6.0", features = ["std"] }
sha2 = "0.9.2"
bitflags = "1.2.1"
zip = "0.5.12"
validator = { version = "0.13", features = ["derive"] }
regex = "1.5.4"
gumdrop = "0.8.0"
dotenv = "0.15"
@@ -52,3 +56,5 @@ sqlx = { version = "0.4.2", features = ["runtime-actix-rustls", "postgres", "chr
sentry = { version = "0.22.0", features = ["log"] }
sentry-actix = "0.22.0"
cached = "0.23.0"

View File

@@ -1,4 +1,4 @@
FROM rust:1.45.1 as build
FROM rust:1.52.1 as build
ENV PKG_CONFIG_ALLOW_CROSS=1
WORKDIR /usr/src/labrinth

View File

@@ -0,0 +1,31 @@
ALTER TABLE users ADD CONSTRAINT username_unique UNIQUE (username);
CREATE TABLE project_types (
id serial PRIMARY KEY,
name varchar(64) UNIQUE NOT NULL
);
INSERT INTO project_types (name) VALUES ('mod');
INSERT INTO project_types (name) VALUES ('modpack');
CREATE TABLE loaders_project_types (
joining_loader_id int REFERENCES loaders ON UPDATE CASCADE NOT NULL,
joining_project_type_id int REFERENCES project_types ON UPDATE CASCADE NOT NULL,
PRIMARY KEY (joining_loader_id, joining_project_type_id)
);
ALTER TABLE mods
ADD COLUMN project_type integer REFERENCES project_types NOT NULL default 1;
ALTER TABLE categories
ADD COLUMN project_type integer REFERENCES project_types NOT NULL default 1,
ADD COLUMN icon varchar(20000) NOT NULL default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>';
ALTER TABLE loaders
ADD COLUMN icon varchar(20000) NOT NULL default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>';
ALTER TABLE mods
ALTER COLUMN project_type DROP DEFAULT;
ALTER TABLE categories
ALTER COLUMN project_type DROP DEFAULT;

File diff suppressed because it is too large Load Diff

44
src/database/cache/mod.rs vendored Normal file
View File

@@ -0,0 +1,44 @@
//pub mod project_cache;
//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) => {
pub mod $mod_name {
use cached::async_mutex::Mutex;
use cached::{Cached, SizedCache};
use lazy_static::lazy_static;
lazy_static! {
static ref $cache_name: Mutex<SizedCache<$id, $val>> =
Mutex::new(SizedCache::with_size(400));
}
pub async fn $getter_name<'a>(id: $id) -> Option<$val> {
let mut cache = $cache_name.lock().await;
Cached::cache_get(&mut *cache, &id).map(|e| e.clone())
}
pub async fn $setter_name<'a>(id: $id, val: &$val) {
let mut cache = $cache_name.lock().await;
Cached::cache_set(&mut *cache, id, val.clone());
}
}
};
}
generate_cache!(
project,
String,
crate::database::Project,
PROJECT_CACHE,
project_cache,
get_cache_project,
set_cache_project
);
generate_cache!(
query_project,
String,
crate::database::models::project_item::QueryProject,
QUERY_PROJECT_CACHE,
query_project_cache,
get_cache_query_project,
set_cache_query_project
);

View File

@@ -1,7 +1,7 @@
mod cache;
pub mod models;
mod postgres_database;
pub use models::Mod;
pub use models::Project;
pub use models::Version;
pub use postgres_database::check_for_migrations;
pub use postgres_database::connect;

View File

@@ -2,19 +2,30 @@ use super::ids::*;
use super::DatabaseError;
use futures::TryStreamExt;
pub struct ProjectType {
pub id: ProjectTypeId,
pub name: String,
}
pub struct Loader {
pub id: LoaderId,
pub loader: String,
pub icon: String,
pub supported_project_types: Vec<String>,
}
pub struct GameVersion {
pub id: GameVersionId,
pub version: String,
pub version_type: String,
pub date: chrono::DateTime<chrono::Utc>,
}
pub struct Category {
pub id: CategoryId,
pub category: String,
pub project_type: String,
pub icon: String,
}
pub struct ReportType {
@@ -36,11 +47,17 @@ pub struct DonationPlatform {
pub struct CategoryBuilder<'a> {
pub name: Option<&'a str>,
pub project_type: Option<&'a ProjectTypeId>,
pub icon: Option<&'a str>,
}
impl Category {
pub fn builder() -> CategoryBuilder<'static> {
CategoryBuilder { name: None }
CategoryBuilder {
name: None,
project_type: None,
icon: None,
}
}
pub async fn get_id<'a, E>(name: &str, exec: E) -> Result<Option<CategoryId>, DatabaseError>
@@ -59,7 +76,36 @@ impl Category {
SELECT id FROM categories
WHERE category = $1
",
name
name,
)
.fetch_optional(exec)
.await?;
Ok(result.map(|r| CategoryId(r.id)))
}
pub async fn get_id_project<'a, E>(
name: &str,
project_type: ProjectTypeId,
exec: E,
) -> Result<Option<CategoryId>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(DatabaseError::InvalidIdentifier(name.to_string()));
}
let result = sqlx::query!(
"
SELECT id FROM categories
WHERE category = $1 AND project_type = $2
",
name,
project_type as ProjectTypeId
)
.fetch_optional(exec)
.await?;
@@ -84,18 +130,27 @@ impl Category {
Ok(result.category)
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<String>, DatabaseError>
pub async fn list<'a, E>(exec: E) -> Result<Vec<Category>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT category FROM categories
SELECT c.id id, c.category category, c.icon icon, pt.name project_type
FROM categories c
INNER JOIN project_types pt ON c.project_type = pt.id
"
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.category)) })
.try_collect::<Vec<String>>()
.try_filter_map(|e| async {
Ok(e.right().map(|c| Category {
id: CategoryId(c.id),
category: c.category,
project_type: c.project_type,
icon: c.icon,
}))
})
.try_collect::<Vec<Category>>()
.await?;
Ok(result)
@@ -133,24 +188,49 @@ impl<'a> CategoryBuilder<'a> {
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
Ok(Self { name: Some(name) })
Ok(Self {
name: Some(name),
..self
})
} else {
Err(DatabaseError::InvalidIdentifier(name.to_string()))
}
}
pub fn project_type(
self,
project_type: &'a ProjectTypeId,
) -> Result<CategoryBuilder<'a>, DatabaseError> {
Ok(Self {
project_type: Some(project_type),
..self
})
}
pub fn icon(self, icon: &'a str) -> Result<CategoryBuilder<'a>, DatabaseError> {
Ok(Self {
icon: Some(icon),
..self
})
}
pub async fn insert<'b, E>(self, exec: E) -> Result<CategoryId, DatabaseError>
where
E: sqlx::Executor<'b, Database = sqlx::Postgres>,
{
let id = *self
.project_type
.ok_or_else(|| DatabaseError::Other("No project type specified.".to_string()))?;
let result = sqlx::query!(
"
INSERT INTO categories (category)
VALUES ($1)
ON CONFLICT (category) DO NOTHING
INSERT INTO categories (category, project_type, icon)
VALUES ($1, $2, $3)
ON CONFLICT (category, project_type, icon) DO NOTHING
RETURNING id
",
self.name
self.name,
id as ProjectTypeId,
self.icon
)
.fetch_one(exec)
.await?;
@@ -161,11 +241,17 @@ impl<'a> CategoryBuilder<'a> {
pub struct LoaderBuilder<'a> {
pub name: Option<&'a str>,
pub icon: Option<&'a str>,
pub supported_project_types: Option<&'a [ProjectTypeId]>,
}
impl Loader {
pub fn builder() -> LoaderBuilder<'static> {
LoaderBuilder { name: None }
LoaderBuilder {
name: None,
icon: None,
supported_project_types: None,
}
}
pub async fn get_id<'a, E>(name: &str, exec: E) -> Result<Option<LoaderId>, DatabaseError>
@@ -209,24 +295,41 @@ impl Loader {
Ok(result.loader)
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<String>, DatabaseError>
pub async fn list<'a, E>(exec: E) -> Result<Vec<Loader>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT loader FROM loaders
SELECT l.id id, l.loader loader, l.icon icon,
STRING_AGG(DISTINCT pt.name, ',') project_types
FROM loaders l
LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id
LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id
GROUP BY l.id;
"
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.loader)) })
.try_collect::<Vec<String>>()
.try_filter_map(|e| async {
Ok(e.right().map(|x| Loader {
id: LoaderId(x.id),
loader: x.loader,
icon: x.icon,
supported_project_types: x
.project_types
.unwrap_or_default()
.split(',')
.map(|x| x.to_string())
.collect(),
}))
})
.try_collect::<Vec<_>>()
.await?;
Ok(result)
}
// TODO: remove loaders with mods using them
// TODO: remove loaders with projects using them
pub async fn remove<'a, E>(name: &str, exec: E) -> Result<Option<()>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
@@ -259,28 +362,74 @@ impl<'a> LoaderBuilder<'a> {
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
Ok(Self { name: Some(name) })
Ok(Self {
name: Some(name),
..self
})
} else {
Err(DatabaseError::InvalidIdentifier(name.to_string()))
}
}
pub async fn insert<'b, E>(self, exec: E) -> Result<LoaderId, DatabaseError>
where
E: sqlx::Executor<'b, Database = sqlx::Postgres>,
{
pub fn icon(self, icon: &'a str) -> Result<LoaderBuilder<'a>, DatabaseError> {
Ok(Self {
icon: Some(icon),
..self
})
}
pub fn supported_project_types(
self,
supported_project_types: &'a [ProjectTypeId],
) -> Result<LoaderBuilder<'a>, DatabaseError> {
Ok(Self {
supported_project_types: Some(supported_project_types),
..self
})
}
pub async fn insert(
self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<LoaderId, super::DatabaseError> {
let result = sqlx::query!(
"
INSERT INTO loaders (loader)
VALUES ($1)
ON CONFLICT (loader) DO NOTHING
INSERT INTO loaders (loader, icon)
VALUES ($1, $2)
ON CONFLICT (loader, icon) DO NOTHING
RETURNING id
",
self.name
self.name,
self.icon
)
.fetch_one(exec)
.fetch_one(&mut *transaction)
.await?;
if let Some(project_types) = self.supported_project_types {
sqlx::query!(
"
DELETE FROM loaders_project_types
WHERE joining_loader_id = $1
",
result.id
)
.execute(&mut *transaction)
.await?;
for project_type in project_types {
sqlx::query!(
"
INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id)
VALUES ($1, $2)
",
result.id,
project_type.0,
)
.execute(&mut *transaction)
.await?;
}
}
Ok(LoaderId(result.id))
}
}
@@ -341,19 +490,24 @@ impl GameVersion {
Ok(result.version)
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<String>, DatabaseError>
pub async fn list<'a, E>(exec: E) -> Result<Vec<GameVersion>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT version FROM game_versions
SELECT gv.id id, gv.version version_, gv.type type_, gv.created created FROM game_versions gv
ORDER BY created DESC
"
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.version)) })
.try_collect::<Vec<String>>()
.try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion {
id: GameVersionId(c.id),
version: c.version_,
version_type: c.type_,
date: c.created
})) })
.try_collect::<Vec<GameVersion>>()
.await?;
Ok(result)
@@ -363,7 +517,7 @@ impl GameVersion {
version_type_option: Option<&str>,
major_option: Option<bool>,
exec: E,
) -> Result<Vec<String>, DatabaseError>
) -> Result<Vec<GameVersion>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
@@ -373,7 +527,7 @@ impl GameVersion {
if let Some(major) = major_option {
result = sqlx::query!(
"
SELECT version FROM game_versions
SELECT gv.id id, gv.version version_, gv.type type_, gv.created created FROM game_versions gv
WHERE major = $1 AND type = $2
ORDER BY created DESC
",
@@ -381,35 +535,50 @@ impl GameVersion {
version_type
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.version)) })
.try_collect::<Vec<String>>()
.try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion {
id: GameVersionId(c.id),
version: c.version_,
version_type: c.type_,
date: c.created
})) })
.try_collect::<Vec<GameVersion>>()
.await?;
} else {
result = sqlx::query!(
"
SELECT version FROM game_versions
SELECT gv.id id, gv.version version_, gv.type type_, gv.created created FROM game_versions gv
WHERE type = $1
ORDER BY created DESC
",
version_type
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.version)) })
.try_collect::<Vec<String>>()
.try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion {
id: GameVersionId(c.id),
version: c.version_,
version_type: c.type_,
date: c.created
})) })
.try_collect::<Vec<GameVersion>>()
.await?;
}
} else if let Some(major) = major_option {
result = sqlx::query!(
"
SELECT version FROM game_versions
SELECT gv.id id, gv.version version_, gv.type type_, gv.created created FROM game_versions gv
WHERE major = $1
ORDER BY created DESC
",
major
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.version)) })
.try_collect::<Vec<String>>()
.try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion {
id: GameVersionId(c.id),
version: c.version_,
version_type: c.type_,
date: c.created
})) })
.try_collect::<Vec<GameVersion>>()
.await?;
} else {
result = Vec::new();
@@ -867,7 +1036,6 @@ impl ReportType {
Ok(result)
}
// TODO: remove loaders with mods using them
pub async fn remove<'a, E>(name: &str, exec: E) -> Result<Option<()>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
@@ -925,3 +1093,156 @@ impl<'a> ReportTypeBuilder<'a> {
Ok(ReportTypeId(result.id))
}
}
pub struct ProjectTypeBuilder<'a> {
pub name: Option<&'a str>,
}
impl ProjectType {
pub fn builder() -> ProjectTypeBuilder<'static> {
ProjectTypeBuilder { name: None }
}
pub async fn get_id<'a, E>(name: &str, exec: E) -> Result<Option<ProjectTypeId>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(DatabaseError::InvalidIdentifier(name.to_string()));
}
let result = sqlx::query!(
"
SELECT id FROM project_types
WHERE name = $1
",
name
)
.fetch_optional(exec)
.await?;
Ok(result.map(|r| ProjectTypeId(r.id)))
}
pub async fn get_many_id<'a, E>(
names: &Vec<String>,
exec: E,
) -> Result<Vec<ProjectType>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let project_types = sqlx::query!(
"
SELECT id, name FROM project_types
WHERE name IN (SELECT * FROM UNNEST($1::varchar[]))
",
names
)
.fetch_many(exec)
.try_filter_map(|e| async {
Ok(e.right().map(|x| ProjectType {
id: ProjectTypeId(x.id),
name: x.name,
}))
})
.try_collect::<Vec<ProjectType>>()
.await?;
Ok(project_types)
}
pub async fn get_name<'a, E>(id: ProjectTypeId, exec: E) -> Result<String, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT name FROM project_types
WHERE id = $1
",
id as ProjectTypeId
)
.fetch_one(exec)
.await?;
Ok(result.name)
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<String>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT name FROM project_types
"
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.name)) })
.try_collect::<Vec<String>>()
.await?;
Ok(result)
}
// TODO: remove loaders with mods using them
pub async fn remove<'a, E>(name: &str, exec: E) -> Result<Option<()>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
use sqlx::Done;
let result = sqlx::query!(
"
DELETE FROM project_types
WHERE name = $1
",
name
)
.execute(exec)
.await?;
if result.rows_affected() == 0 {
// Nothing was deleted
Ok(None)
} else {
Ok(Some(()))
}
}
}
impl<'a> ProjectTypeBuilder<'a> {
/// The name of the project type. Must be ASCII alphanumeric or `-`/`_`
pub fn name(self, name: &'a str) -> Result<ProjectTypeBuilder<'a>, DatabaseError> {
if name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
Ok(Self { name: Some(name) })
} else {
Err(DatabaseError::InvalidIdentifier(name.to_string()))
}
}
pub async fn insert<'b, E>(self, exec: E) -> Result<ProjectTypeId, DatabaseError>
where
E: sqlx::Executor<'b, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
INSERT INTO project_types (name)
VALUES ($1)
ON CONFLICT (name) DO NOTHING
RETURNING id
",
self.name
)
.fetch_one(exec)
.await?;
Ok(ProjectTypeId(result.id))
}
}

View File

@@ -38,11 +38,11 @@ macro_rules! generate_ids {
}
generate_ids!(
pub generate_mod_id,
ModId,
pub generate_project_id,
ProjectId,
8,
"SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)",
ModId
ProjectId
);
generate_ids!(
pub generate_version_id,
@@ -115,7 +115,11 @@ pub struct TeamMemberId(pub i64);
#[derive(Copy, Clone, Debug, Type)]
#[sqlx(transparent)]
pub struct ModId(pub i64);
pub struct ProjectId(pub i64);
#[derive(Copy, Clone, Debug, Type)]
#[sqlx(transparent)]
pub struct ProjectTypeId(pub i32);
#[derive(Copy, Clone, Debug, Type)]
#[sqlx(transparent)]
pub struct StatusId(pub i32);
@@ -169,14 +173,14 @@ pub struct NotificationActionId(pub i32);
use crate::models::ids;
impl From<ids::ModId> for ModId {
fn from(id: ids::ModId) -> Self {
ModId(id.0 as i64)
impl From<ids::ProjectId> for ProjectId {
fn from(id: ids::ProjectId) -> Self {
ProjectId(id.0 as i64)
}
}
impl From<ModId> for ids::ModId {
fn from(id: ModId) -> Self {
ids::ModId(id.0 as u64)
impl From<ProjectId> for ids::ProjectId {
fn from(id: ProjectId) -> Self {
ids::ProjectId(id.0 as u64)
}
}
impl From<ids::UserId> for UserId {

View File

@@ -5,15 +5,15 @@ use thiserror::Error;
pub mod categories;
pub mod ids;
pub mod mod_item;
pub mod notification_item;
pub mod project_item;
pub mod report_item;
pub mod team_item;
pub mod user_item;
pub mod version_item;
pub use ids::*;
pub use mod_item::Mod;
pub use project_item::Project;
pub use team_item::Team;
pub use team_item::TeamMember;
pub use user_item::User;
@@ -62,7 +62,7 @@ impl ids::ChannelId {
impl ids::StatusId {
pub async fn get_id<'a, E>(
status: &crate::models::mods::ModStatus,
status: &crate::models::projects::ProjectStatus,
exec: E,
) -> Result<Option<Self>, DatabaseError>
where
@@ -84,7 +84,7 @@ impl ids::StatusId {
impl ids::SideTypeId {
pub async fn get_id<'a, E>(
side: &crate::models::mods::SideType,
side: &crate::models::projects::SideType,
exec: E,
) -> Result<Option<Self>, DatabaseError>
where
@@ -122,3 +122,22 @@ impl ids::DonationPlatformId {
Ok(result.map(|r| ids::DonationPlatformId(r.id)))
}
}
impl ids::ProjectTypeId {
pub async fn get_id<'a, E>(project_type: String, exec: E) -> Result<Option<Self>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT id FROM project_types
WHERE name = $1
",
project_type
)
.fetch_optional(exec)
.await?;
Ok(result.map(|r| ids::ProjectTypeId(r.id)))
}
}

View File

@@ -179,7 +179,8 @@ impl Notification {
FROM notifications n
LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id
WHERE n.id IN (SELECT * FROM UNNEST($1::bigint[]))
GROUP BY n.id, n.user_id;
GROUP BY n.id, n.user_id
ORDER BY n.created DESC;
",
&notification_ids_parsed
)

View File

@@ -1,7 +1,11 @@
use super::ids::*;
use crate::database::cache::project_cache::{get_cache_project, set_cache_project};
use crate::database::cache::query_project_cache::{
get_cache_query_project, set_cache_query_project,
};
#[derive(Clone, Debug)]
pub struct DonationUrl {
pub mod_id: ModId,
pub project_id: ProjectId,
pub platform_id: DonationPlatformId,
pub platform_short: String,
pub platform_name: String,
@@ -22,7 +26,7 @@ impl DonationUrl {
$1, $2, $3
)
",
self.mod_id as ModId,
self.project_id as ProjectId,
self.platform_id as DonationPlatformId,
self.url,
)
@@ -33,8 +37,9 @@ impl DonationUrl {
}
}
pub struct ModBuilder {
pub mod_id: ModId,
pub struct ProjectBuilder {
pub project_id: ProjectId,
pub project_type_id: ProjectTypeId,
pub team_id: TeamId,
pub title: String,
pub description: String,
@@ -55,13 +60,14 @@ pub struct ModBuilder {
pub donation_urls: Vec<DonationUrl>,
}
impl ModBuilder {
impl ProjectBuilder {
pub async fn insert(
self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<ModId, super::DatabaseError> {
let mod_struct = Mod {
id: self.mod_id,
) -> Result<ProjectId, super::DatabaseError> {
let project_struct = Project {
id: self.project_id,
project_type: self.project_type_id,
team_id: self.team_id,
title: self.title,
description: self.description,
@@ -83,15 +89,15 @@ impl ModBuilder {
license: self.license,
slug: self.slug,
};
mod_struct.insert(&mut *transaction).await?;
project_struct.insert(&mut *transaction).await?;
for mut version in self.initial_versions {
version.mod_id = self.mod_id;
version.project_id = self.project_id;
version.insert(&mut *transaction).await?;
}
for mut donation in self.donation_urls {
donation.mod_id = self.mod_id;
donation.project_id = self.project_id;
donation.insert(&mut *transaction).await?;
}
@@ -101,19 +107,20 @@ impl ModBuilder {
INSERT INTO mods_categories (joining_mod_id, joining_category_id)
VALUES ($1, $2)
",
self.mod_id as ModId,
self.project_id as ProjectId,
category as CategoryId,
)
.execute(&mut *transaction)
.await?;
}
Ok(self.mod_id)
Ok(self.project_id)
}
}
pub struct Mod {
pub id: ModId,
#[derive(Clone, Debug)]
pub struct Project {
pub id: ProjectId,
pub project_type: ProjectTypeId,
pub team_id: TeamId,
pub title: String,
pub description: String,
@@ -136,7 +143,7 @@ pub struct Mod {
pub slug: Option<String>,
}
impl Mod {
impl Project {
pub async fn insert(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
@@ -148,17 +155,17 @@ impl Mod {
published, downloads, icon_url, issues_url,
source_url, wiki_url, status, discord_url,
client_side, server_side, license_url, license,
slug
slug, project_type
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11, $12, $13,
$14, $15, $16, $17,
LOWER($18)
LOWER($18), $19
)
",
self.id as ModId,
self.id as ProjectId,
self.team_id as TeamId,
&self.title,
&self.description,
@@ -175,7 +182,8 @@ impl Mod {
self.server_side as SideTypeId,
self.license_url.as_ref(),
self.license as LicenseId,
self.slug.as_ref()
self.slug.as_ref(),
self.project_type as ProjectTypeId
)
.execute(&mut *transaction)
.await?;
@@ -183,13 +191,16 @@ impl Mod {
Ok(())
}
pub async fn get<'a, 'b, E>(id: ModId, executor: E) -> Result<Option<Self>, sqlx::error::Error>
pub async fn get<'a, 'b, E>(
id: ProjectId,
executor: E,
) -> Result<Option<Self>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT title, description, downloads, follows,
SELECT project_type, title, description, downloads, follows,
icon_url, body, body_url, published,
updated, status,
issues_url, source_url, wiki_url, discord_url, license_url,
@@ -197,14 +208,15 @@ impl Mod {
FROM mods
WHERE id = $1
",
id as ModId,
id as ProjectId,
)
.fetch_optional(executor)
.await?;
if let Some(row) = result {
Ok(Some(Mod {
Ok(Some(Project {
id,
project_type: ProjectTypeId(row.project_type),
team_id: TeamId(row.team_id),
title: row.title,
description: row.description,
@@ -231,16 +243,19 @@ impl Mod {
}
}
pub async fn get_many<'a, E>(mod_ids: Vec<ModId>, exec: E) -> Result<Vec<Mod>, sqlx::Error>
pub async fn get_many<'a, E>(
project_ids: Vec<ProjectId>,
exec: E,
) -> Result<Vec<Project>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
use futures::stream::TryStreamExt;
let mod_ids_parsed: Vec<i64> = mod_ids.into_iter().map(|x| x.0).collect();
let mods = sqlx::query!(
let project_ids_parsed: Vec<i64> = project_ids.into_iter().map(|x| x.0).collect();
let projects = sqlx::query!(
"
SELECT id, title, description, downloads, follows,
SELECT id, project_type, title, description, downloads, follows,
icon_url, body, body_url, published,
updated, status,
issues_url, source_url, wiki_url, discord_url, license_url,
@@ -248,12 +263,13 @@ impl Mod {
FROM mods
WHERE id IN (SELECT * FROM UNNEST($1::bigint[]))
",
&mod_ids_parsed
&project_ids_parsed
)
.fetch_many(exec)
.try_filter_map(|e| async {
Ok(e.right().map(|m| Mod {
id: ModId(m.id),
Ok(e.right().map(|m| Project {
id: ProjectId(m.id),
project_type: ProjectTypeId(m.project_type),
team_id: TeamId(m.team_id),
title: m.title,
description: m.description,
@@ -276,14 +292,14 @@ impl Mod {
follows: m.follows,
}))
})
.try_collect::<Vec<Mod>>()
.try_collect::<Vec<Project>>()
.await?;
Ok(mods)
Ok(projects)
}
pub async fn remove_full<'a, 'b, E>(
id: ModId,
id: ProjectId,
exec: E,
) -> Result<Option<()>, sqlx::error::Error>
where
@@ -293,7 +309,7 @@ impl Mod {
"
SELECT team_id FROM mods WHERE id = $1
",
id as ModId,
id as ProjectId,
)
.fetch_optional(exec)
.await?;
@@ -309,7 +325,7 @@ impl Mod {
DELETE FROM mod_follows
WHERE mod_id = $1
",
id as ModId
id as ProjectId
)
.execute(exec)
.await?;
@@ -319,7 +335,7 @@ impl Mod {
DELETE FROM mod_follows
WHERE mod_id = $1
",
id as ModId,
id as ProjectId,
)
.execute(exec)
.await?;
@@ -329,7 +345,7 @@ impl Mod {
DELETE FROM reports
WHERE mod_id = $1
",
id as ModId,
id as ProjectId,
)
.execute(exec)
.await?;
@@ -339,7 +355,7 @@ impl Mod {
DELETE FROM mods_categories
WHERE joining_mod_id = $1
",
id as ModId,
id as ProjectId,
)
.execute(exec)
.await?;
@@ -349,7 +365,7 @@ impl Mod {
DELETE FROM mods_donations
WHERE joining_mod_id = $1
",
id as ModId,
id as ProjectId,
)
.execute(exec)
.await?;
@@ -360,7 +376,7 @@ impl Mod {
SELECT id FROM versions
WHERE mod_id = $1
",
id as ModId,
id as ProjectId,
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| VersionId(c.id))) })
@@ -376,7 +392,7 @@ impl Mod {
DELETE FROM mods
WHERE id = $1
",
id as ModId,
id as ProjectId,
)
.execute(exec)
.await?;
@@ -407,7 +423,7 @@ impl Mod {
pub async fn get_full_from_slug<'a, 'b, E>(
slug: &str,
executor: E,
) -> Result<Option<QueryMod>, sqlx::error::Error>
) -> Result<Option<QueryProject>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
@@ -421,49 +437,154 @@ impl Mod {
.fetch_optional(executor)
.await?;
if let Some(mod_id) = id {
Mod::get_full(ModId(mod_id.id), executor).await
if let Some(project_id) = id {
Project::get_full(ProjectId(project_id.id), executor).await
} else {
Ok(None)
}
}
pub async fn get_full<'a, 'b, E>(
id: ModId,
pub async fn get_from_slug<'a, 'b, E>(
slug: &str,
executor: E,
) -> Result<Option<QueryMod>, sqlx::error::Error>
) -> Result<Option<Project>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let id = sqlx::query!(
"
SELECT id FROM mods
WHERE LOWER(slug) = LOWER($1)
",
slug
)
.fetch_optional(executor)
.await?;
if let Some(project_id) = id {
Project::get(ProjectId(project_id.id), executor).await
} else {
Ok(None)
}
}
pub async fn get_from_slug_or_project_id<'a, 'b, E>(
slug_or_project_id: String,
executor: E,
) -> Result<Option<Project>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
// Check in the cache
let cached = get_cache_project(slug_or_project_id.clone()).await;
if let Some(data) = cached {
return Ok(Some(data));
}
let id_option =
crate::models::ids::base62_impl::parse_base62(&*slug_or_project_id.clone()).ok();
if let Some(id) = id_option {
let mut project = Project::get(ProjectId(id as i64), executor).await?;
if project.is_none() {
project = Project::get_from_slug(&slug_or_project_id, executor).await?;
}
// Cache the response
if let Some(data) = project {
set_cache_project(slug_or_project_id.clone(), &data).await;
Ok(Some(data))
} else {
Ok(None)
}
} else {
let project = Project::get_from_slug(&slug_or_project_id, executor).await?;
// Capture the data, and try to cache it
if let Some(data) = project {
set_cache_project(slug_or_project_id.clone(), &data).await;
Ok(Some(data))
} else {
Ok(None)
}
}
}
pub async fn get_full_from_slug_or_project_id<'a, 'b, E>(
slug_or_project_id: String,
executor: E,
) -> Result<Option<QueryProject>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
// Query cache
let cached = get_cache_query_project(slug_or_project_id.clone()).await;
if let Some(data) = cached {
return Ok(Some(data));
}
let id_option =
crate::models::ids::base62_impl::parse_base62(&*slug_or_project_id.clone()).ok();
if let Some(id) = id_option {
let mut project = Project::get_full(ProjectId(id as i64), executor).await?;
if project.is_none() {
project = Project::get_full_from_slug(&slug_or_project_id, executor).await?;
}
// Save the variable
if let Some(data) = project {
set_cache_query_project(slug_or_project_id.clone(), &data).await;
Ok(Some(data))
} else {
Ok(None)
}
} else {
let project = Project::get_full_from_slug(&slug_or_project_id, executor).await?;
if let Some(data) = project {
set_cache_query_project(slug_or_project_id.clone(), &data).await;
Ok(Some(data))
} else {
Ok(None)
}
}
}
pub async fn get_full<'a, 'b, E>(
id: ProjectId,
executor: E,
) -> Result<Option<QueryProject>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let result = sqlx::query!(
"
SELECT m.id id, m.title title, m.description description, m.downloads downloads, m.follows follows,
SELECT m.id id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,
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,
s.status status_name, cs.name client_side_type, ss.name server_side_type, l.short short, l.name license_name,
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
LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id
LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id
LEFT OUTER JOIN versions v ON v.mod_id = m.id
INNER JOIN project_types pt ON pt.id = m.project_type
INNER JOIN statuses s ON s.id = m.status
INNER JOIN side_types cs ON m.client_side = cs.id
INNER JOIN side_types ss ON m.server_side = ss.id
INNER JOIN licenses l ON m.license = l.id
WHERE m.id = $1
GROUP BY m.id, s.id, cs.id, ss.id, l.id;
GROUP BY m.id, s.id, cs.id, ss.id, l.id, pt.id;
",
id as ModId,
id as ProjectId,
)
.fetch_optional(executor)
.await?;
if let Some(m) = result {
Ok(Some(QueryMod {
inner: Mod {
id: ModId(m.id),
Ok(Some(QueryProject {
inner: Project {
id: ProjectId(m.id),
project_type: ProjectTypeId(m.project_type),
team_id: TeamId(m.team_id),
title: m.title.clone(),
description: m.description.clone(),
@@ -485,6 +606,7 @@ impl Mod {
body: m.body.clone(),
follows: m.follows,
},
project_type: m.project_type_name,
categories: m
.categories
.unwrap_or_default()
@@ -498,11 +620,11 @@ impl Mod {
.map(|x| VersionId(x.parse().unwrap_or_default()))
.collect(),
donation_urls: vec![],
status: crate::models::mods::ModStatus::from_str(&m.status_name),
status: crate::models::projects::ProjectStatus::from_str(&m.status_name),
license_id: m.short,
license_name: m.license_name,
client_side: crate::models::mods::SideType::from_str(&m.client_side_type),
server_side: crate::models::mods::SideType::from_str(&m.server_side_type),
client_side: crate::models::projects::SideType::from_str(&m.client_side_type),
server_side: crate::models::projects::SideType::from_str(&m.server_side_type),
}))
} else {
Ok(None)
@@ -510,42 +632,44 @@ impl Mod {
}
pub async fn get_many_full<'a, E>(
mod_ids: Vec<ModId>,
project_ids: Vec<ProjectId>,
exec: E,
) -> Result<Vec<QueryMod>, sqlx::Error>
) -> Result<Vec<QueryProject>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
use futures::TryStreamExt;
let mod_ids_parsed: Vec<i64> = mod_ids.into_iter().map(|x| x.0).collect();
let project_ids_parsed: Vec<i64> = project_ids.into_iter().map(|x| x.0).collect();
sqlx::query!(
"
SELECT m.id id, m.title title, m.description description, m.downloads downloads, m.follows follows,
SELECT m.id id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,
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,
s.status status_name, cs.name client_side_type, ss.name server_side_type, l.short short, l.name license_name,
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
LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id
LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id
LEFT OUTER JOIN versions v ON v.mod_id = m.id
INNER JOIN project_types pt ON pt.id = m.project_type
INNER JOIN statuses s ON s.id = m.status
INNER JOIN side_types cs ON m.client_side = cs.id
INNER JOIN side_types ss ON m.server_side = ss.id
INNER JOIN licenses l ON m.license = l.id
WHERE m.id IN (SELECT * FROM UNNEST($1::bigint[]))
GROUP BY m.id, s.id, cs.id, ss.id, l.id;
GROUP BY m.id, s.id, cs.id, ss.id, l.id, pt.id;
",
&mod_ids_parsed
&project_ids_parsed
)
.fetch_many(exec)
.try_filter_map(|e| async {
Ok(e.right().map(|m| QueryMod {
inner: Mod {
id: ModId(m.id),
Ok(e.right().map(|m| QueryProject {
inner: Project {
id: ProjectId(m.id),
project_type: ProjectTypeId(m.project_type),
team_id: TeamId(m.team_id),
title: m.title.clone(),
description: m.description.clone(),
@@ -567,30 +691,31 @@ impl Mod {
body: m.body.clone(),
follows: m.follows
},
project_type: m.project_type_name,
categories: m.categories.unwrap_or_default().split(',').map(|x| x.to_string()).collect(),
versions: m.versions.unwrap_or_default().split(',').map(|x| VersionId(x.parse().unwrap_or_default())).collect(),
donation_urls: vec![],
status: crate::models::mods::ModStatus::from_str(&m.status_name),
status: crate::models::projects::ProjectStatus::from_str(&m.status_name),
license_id: m.short,
license_name: m.license_name,
client_side: crate::models::mods::SideType::from_str(&m.client_side_type),
server_side: crate::models::mods::SideType::from_str(&m.server_side_type),
client_side: crate::models::projects::SideType::from_str(&m.client_side_type),
server_side: crate::models::projects::SideType::from_str(&m.server_side_type),
}))
})
.try_collect::<Vec<QueryMod>>()
.try_collect::<Vec<QueryProject>>()
.await
}
}
pub struct QueryMod {
pub inner: Mod,
#[derive(Clone, Debug)]
pub struct QueryProject {
pub inner: Project,
pub project_type: String,
pub categories: Vec<String>,
pub versions: Vec<VersionId>,
pub donation_urls: Vec<DonationUrl>,
pub status: crate::models::mods::ModStatus,
pub status: crate::models::projects::ProjectStatus,
pub license_id: String,
pub license_name: String,
pub client_side: crate::models::mods::SideType,
pub server_side: crate::models::mods::SideType,
pub client_side: crate::models::projects::SideType,
pub server_side: crate::models::projects::SideType,
}

View File

@@ -3,7 +3,7 @@ use super::ids::*;
pub struct Report {
pub id: ReportId,
pub report_type_id: ReportTypeId,
pub mod_id: Option<ModId>,
pub project_id: Option<ProjectId>,
pub version_id: Option<VersionId>,
pub user_id: Option<UserId>,
pub body: String,
@@ -14,7 +14,7 @@ pub struct Report {
pub struct QueryReport {
pub id: ReportId,
pub report_type: String,
pub mod_id: Option<ModId>,
pub project_id: Option<ProjectId>,
pub version_id: Option<VersionId>,
pub user_id: Option<UserId>,
pub body: String,
@@ -40,7 +40,7 @@ impl Report {
",
self.id as ReportId,
self.report_type_id as ReportTypeId,
self.mod_id.map(|x| x.0 as i64),
self.project_id.map(|x| x.0 as i64),
self.version_id.map(|x| x.0 as i64),
self.user_id.map(|x| x.0 as i64),
self.body,
@@ -72,7 +72,7 @@ impl Report {
Ok(Some(QueryReport {
id,
report_type: row.name,
mod_id: row.mod_id.map(ModId),
project_id: row.mod_id.map(ProjectId),
version_id: row.version_id.map(VersionId),
user_id: row.user_id.map(UserId),
body: row.body,
@@ -108,7 +108,7 @@ impl Report {
Ok(e.right().map(|row| QueryReport {
id: ReportId(row.id),
report_type: row.name,
mod_id: row.mod_id.map(ModId),
project_id: row.mod_id.map(ProjectId),
version_id: row.version_id.map(VersionId),
user_id: row.user_id.map(UserId),
body: row.body,

View File

@@ -61,7 +61,7 @@ impl TeamBuilder {
}
}
/// A team of users who control a mod
/// A team of users who control a project
pub struct Team {
/// The id of the team
pub id: TeamId,
@@ -412,8 +412,8 @@ impl TeamMember {
Ok(())
}
pub async fn get_from_user_id_mod<'a, 'b, E>(
id: ModId,
pub async fn get_from_user_id_project<'a, 'b, E>(
id: ProjectId,
user_id: UserId,
executor: E,
) -> Result<Option<Self>, super::DatabaseError>
@@ -426,7 +426,7 @@ impl TeamMember {
INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 AND accepted = TRUE
WHERE m.id = $1
",
id as ModId,
id as ProjectId,
user_id as UserId
)
.fetch_optional(executor)

View File

@@ -1,4 +1,4 @@
use super::ids::{ModId, UserId};
use super::ids::{ProjectId, UserId};
pub struct User {
pub id: UserId,
@@ -24,7 +24,7 @@ impl User {
avatar_url, bio, created
)
VALUES (
$1, $2, LOWER($3), $4, $5,
$1, $2, $3, $4, $5,
$6, $7, $8
)
",
@@ -186,17 +186,17 @@ impl User {
Ok(users)
}
pub async fn get_mods<'a, E>(
pub async fn get_projects<'a, E>(
user_id: UserId,
status: &str,
exec: E,
) -> Result<Vec<ModId>, sqlx::Error>
) -> Result<Vec<ProjectId>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
use futures::stream::TryStreamExt;
let mods = sqlx::query!(
let projects = sqlx::query!(
"
SELECT m.id FROM mods m
INNER JOIN team_members tm ON tm.team_id = m.team_id
@@ -206,23 +206,23 @@ impl User {
status,
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|m| ModId(m.id))) })
.try_collect::<Vec<ModId>>()
.try_filter_map(|e| async { Ok(e.right().map(|m| ProjectId(m.id))) })
.try_collect::<Vec<ProjectId>>()
.await?;
Ok(mods)
Ok(projects)
}
pub async fn get_mods_private<'a, E>(
pub async fn get_projects_private<'a, E>(
user_id: UserId,
exec: E,
) -> Result<Vec<ModId>, sqlx::Error>
) -> Result<Vec<ProjectId>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
use futures::stream::TryStreamExt;
let mods = sqlx::query!(
let projects = sqlx::query!(
"
SELECT m.id FROM mods m
INNER JOIN team_members tm ON tm.team_id = m.team_id
@@ -231,11 +231,11 @@ impl User {
user_id as UserId,
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|m| ModId(m.id))) })
.try_collect::<Vec<ModId>>()
.try_filter_map(|e| async { Ok(e.right().map(|m| ProjectId(m.id))) })
.try_collect::<Vec<ProjectId>>()
.await?;
Ok(mods)
Ok(projects)
}
pub async fn remove<'a, 'b, E>(id: UserId, exec: E) -> Result<Option<()>, sqlx::error::Error>
@@ -353,7 +353,7 @@ impl User {
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
use futures::TryStreamExt;
let mods: Vec<ModId> = sqlx::query!(
let projects: Vec<ProjectId> = sqlx::query!(
"
SELECT m.id FROM mods m
INNER JOIN team_members tm ON tm.team_id = m.team_id
@@ -363,12 +363,12 @@ impl User {
crate::models::teams::OWNER_ROLE
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|m| ModId(m.id))) })
.try_collect::<Vec<ModId>>()
.try_filter_map(|e| async { Ok(e.right().map(|m| ProjectId(m.id))) })
.try_collect::<Vec<ProjectId>>()
.await?;
for mod_id in mods {
let _result = super::mod_item::Mod::remove_full(mod_id, exec).await?;
for project_id in projects {
let _result = super::project_item::Project::remove_full(project_id, exec).await?;
}
let notifications: Vec<i64> = sqlx::query!(
@@ -439,4 +439,56 @@ impl User {
Ok(Some(()))
}
pub async fn get_id_from_username_or_id<'a, 'b, E>(
username_or_id: String,
executor: E,
) -> Result<Option<UserId>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let id_option = crate::models::ids::base62_impl::parse_base62(&*username_or_id).ok();
if let Some(id) = id_option {
let id = UserId(id as i64);
let mut user_id = sqlx::query!(
"
SELECT id FROM users
WHERE id = $1
",
id as UserId
)
.fetch_optional(executor)
.await?
.map(|x| UserId(x.id));
if user_id.is_none() {
user_id = sqlx::query!(
"
SELECT id FROM users
WHERE LOWER(username) = LOWER($1)
",
username_or_id
)
.fetch_optional(executor)
.await?
.map(|x| UserId(x.id));
}
Ok(user_id)
} else {
let id = sqlx::query!(
"
SELECT id FROM users
WHERE LOWER(username) = LOWER($1)
",
username_or_id
)
.fetch_optional(executor)
.await?;
Ok(id.map(|x| UserId(x.id)))
}
}
}

View File

@@ -4,7 +4,7 @@ use std::collections::HashMap;
pub struct VersionBuilder {
pub version_id: VersionId,
pub mod_id: ModId,
pub project_id: ProjectId,
pub author_id: UserId,
pub name: String,
pub version_number: String,
@@ -75,7 +75,7 @@ impl VersionBuilder {
) -> Result<VersionId, DatabaseError> {
let version = Version {
id: self.version_id,
mod_id: self.mod_id,
project_id: self.project_id,
author_id: self.author_id,
name: self.name,
version_number: self.version_number,
@@ -95,7 +95,7 @@ impl VersionBuilder {
SET updated = NOW()
WHERE id = $1
",
self.mod_id as ModId,
self.project_id as ProjectId,
)
.execute(&mut *transaction)
.await?;
@@ -150,7 +150,7 @@ impl VersionBuilder {
pub struct Version {
pub id: VersionId,
pub mod_id: ModId,
pub project_id: ProjectId,
pub author_id: UserId,
pub name: String,
pub version_number: String,
@@ -182,7 +182,7 @@ impl Version {
)
",
self.id as VersionId,
self.mod_id as ModId,
self.project_id as ProjectId,
self.author_id as UserId,
&self.name,
&self.version_number,
@@ -359,8 +359,8 @@ impl Version {
Ok(vec)
}
pub async fn get_mod_versions<'a, E>(
mod_id: ModId,
pub async fn get_project_versions<'a, E>(
project_id: ProjectId,
game_versions: Option<Vec<String>>,
loaders: Option<Vec<String>>,
exec: E,
@@ -382,7 +382,7 @@ impl Version {
) AS version
ORDER BY version.date_published ASC
",
mod_id as ModId,
project_id as ProjectId,
&game_versions.unwrap_or_default(),
&loaders.unwrap_or_default(),
)
@@ -417,7 +417,7 @@ impl Version {
if let Some(row) = result {
Ok(Some(Version {
id,
mod_id: ModId(row.mod_id),
project_id: ProjectId(row.mod_id),
author_id: UserId(row.author_id),
name: row.name,
version_number: row.version_number,
@@ -450,6 +450,7 @@ impl Version {
v.release_channel, v.featured
FROM versions v
WHERE v.id IN (SELECT * FROM UNNEST($1::bigint[]))
ORDER BY v.date_published ASC
",
&version_ids_parsed
)
@@ -457,7 +458,7 @@ impl Version {
.try_filter_map(|e| async {
Ok(e.right().map(|v| Version {
id: VersionId(v.id),
mod_id: ModId(v.mod_id),
project_id: ProjectId(v.mod_id),
author_id: UserId(v.author_id),
name: v.name,
version_number: v.version_number,
@@ -480,7 +481,7 @@ impl Version {
executor: E,
) -> Result<Option<QueryVersion>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
@@ -566,7 +567,7 @@ impl Version {
Ok(Some(QueryVersion {
id: VersionId(v.id),
mod_id: ModId(v.mod_id),
project_id: ProjectId(v.mod_id),
author_id: UserId(v.author_id),
name: v.version_name,
version_number: v.version_number,
@@ -625,7 +626,8 @@ impl Version {
LEFT OUTER JOIN hashes h on f.id = h.file_id
LEFT OUTER JOIN dependencies d on v.id = d.dependent_id
WHERE v.id IN (SELECT * FROM UNNEST($1::bigint[]))
GROUP BY v.id, rc.id;
GROUP BY v.id, rc.id
ORDER BY v.date_published ASC;
",
&version_ids_parsed
)
@@ -683,7 +685,7 @@ impl Version {
QueryVersion {
id: VersionId(v.id),
mod_id: ModId(v.mod_id),
project_id: ProjectId(v.mod_id),
author_id: UserId(v.author_id),
name: v.version_name,
version_number: v.version_number,
@@ -727,7 +729,7 @@ pub struct FileHash {
#[derive(Clone)]
pub struct QueryVersion {
pub id: VersionId,
pub mod_id: ModId,
pub project_id: ProjectId,
pub author_id: UserId,
pub name: String,
pub version_number: String,

View File

@@ -79,61 +79,3 @@ impl FileHost for BackblazeHost {
})
}
}
/*#[cfg(test)]
mod tests {
use super::*;
use authorization::*;
use delete::*;
use upload::*;
#[actix_rt::test]
async fn test_authorization() {
println!("{}", dotenv::var("BACKBLAZE_BUCKET_ID").unwrap());
let authorization_data = authorize_account(
&dotenv::var("BACKBLAZE_KEY_ID").unwrap(),
&dotenv::var("BACKBLAZE_KEY").unwrap(),
)
.await
.unwrap();
get_upload_url(
&authorization_data,
&dotenv::var("BACKBLAZE_BUCKET_ID").unwrap(),
)
.await
.unwrap();
}
#[actix_rt::test]
async fn test_file_management() {
let authorization_data = authorize_account(
&dotenv::var("BACKBLAZE_KEY_ID").unwrap(),
&dotenv::var("BACKBLAZE_KEY").unwrap(),
)
.await
.unwrap();
let upload_url_data = get_upload_url(
&authorization_data,
&dotenv::var("BACKBLAZE_BUCKET_ID").unwrap(),
)
.await
.unwrap();
let upload_data = upload_file(
&upload_url_data,
"text/plain",
"test.txt",
"test file".to_string().into_bytes(),
)
.await
.unwrap();
delete_file_version(
&authorization_data,
&upload_data.file_id,
&upload_data.file_name,
)
.await
.unwrap();
}
}*/

View File

@@ -76,32 +76,3 @@ impl FileHost for S3Host {
})
}
}
#[cfg(test)]
mod tests {
use crate::file_hosting::s3_host::S3Host;
use crate::file_hosting::FileHost;
#[actix_rt::test]
async fn test_file_management() {
let s3_host = S3Host::new(
&*dotenv::var("S3_BUCKET_NAME").unwrap(),
&*dotenv::var("S3_REGION").unwrap(),
&*dotenv::var("S3_URL").unwrap(),
&*dotenv::var("S3_ACCESS_TOKEN").unwrap(),
&*dotenv::var("S3_SECRET").unwrap(),
)
.unwrap();
s3_host
.upload_file(
"text/plain",
"test.txt",
"test file".to_string().into_bytes(),
)
.await
.unwrap();
s3_host.delete_file_version("", "test.txt").await.unwrap();
}
}

View File

@@ -7,7 +7,7 @@ use env_logger::Env;
use gumdrop::Options;
use log::{error, info, warn};
use rand::Rng;
use search::indexing::index_mods;
use search::indexing::index_projects;
use search::indexing::IndexingSettings;
use std::sync::Arc;
@@ -18,6 +18,7 @@ mod models;
mod routes;
mod scheduler;
mod search;
mod validate;
#[derive(Debug, Options)]
struct Config {
@@ -158,13 +159,10 @@ async fn main() -> std::io::Result<()> {
return;
}
info!("Indexing local database");
let settings = IndexingSettings {
index_local: true,
index_external: false,
};
let result = index_mods(pool_ref, settings, &thread_search_config).await;
let settings = IndexingSettings { index_local: true };
let result = index_projects(pool_ref, settings, &thread_search_config).await;
if let Err(e) = result {
warn!("Local mod indexing failed: {:?}", e);
warn!("Local project indexing failed: {:?}", e);
}
info!("Done indexing local database");
}
@@ -229,12 +227,12 @@ async fn main() -> std::io::Result<()> {
if local_skip {
return;
}
info!("Indexing created mod queue");
info!("Indexing created project queue");
let result = search::indexing::queue::index_queue(&*queue, &thread_search_config).await;
if let Err(e) = result {
warn!("Indexing created mods failed: {:?}", e);
warn!("Indexing created projects failed: {:?}", e);
}
info!("Done indexing created mod queue");
info!("Done indexing created project queue");
}
});
@@ -252,13 +250,12 @@ async fn main() -> std::io::Result<()> {
HttpServer::new(move || {
App::new()
.wrap(
Cors::new()
Cors::default()
.allowed_methods(vec!["GET", "POST", "DELETE", "PATCH", "PUT"])
.allowed_headers(vec![http::header::AUTHORIZATION, http::header::ACCEPT])
.allowed_header(http::header::CONTENT_TYPE)
.send_wildcard()
.max_age(3600)
.finish(),
.allow_any_origin()
.max_age(3600),
)
.wrap(
// This is a hacky workaround to allowing the frontend server-side renderer to have
@@ -297,19 +294,9 @@ async fn main() -> std::io::Result<()> {
.data(indexing_queue.clone())
.data(search_config.clone())
.data(ip_salt.clone())
.configure(routes::v1_config)
.configure(routes::v2_config)
.service(routes::index_get)
.service(
web::scope("/api/v1/")
.configure(routes::auth_config)
.configure(routes::tags_config)
.configure(routes::mods_config)
.configure(routes::versions_config)
.configure(routes::teams_config)
.configure(routes::users_config)
.configure(routes::moderation_config)
.configure(routes::reports_config)
.configure(routes::notifications_config),
)
.service(web::scope("/maven/").configure(routes::maven_config))
.default_service(web::get().to(routes::not_found))
})

View File

@@ -1,7 +1,7 @@
use thiserror::Error;
pub use super::mods::{ModId, VersionId};
pub use super::notifications::NotificationId;
pub use super::projects::{ProjectId, VersionId};
pub use super::reports::ReportId;
pub use super::teams::TeamId;
pub use super::users::UserId;
@@ -105,7 +105,7 @@ macro_rules! base62_id_impl {
impl_base62_display!($struct);
}
}
base62_id_impl!(ModId, ModId);
base62_id_impl!(ProjectId, ProjectId);
base62_id_impl!(UserId, UserId);
base62_id_impl!(VersionId, VersionId);
base62_id_impl!(TeamId, TeamId);

View File

@@ -1,7 +1,7 @@
pub mod error;
pub mod ids;
pub mod mods;
pub mod notifications;
pub mod projects;
pub mod reports;
pub mod teams;
pub mod users;

View File

@@ -3,74 +3,77 @@ use super::teams::TeamId;
use super::users::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use validator::Validate;
/// The ID of a specific mod, encoded as base62 for usage in the API
/// The ID of a specific project, encoded as base62 for usage in the API
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "Base62Id")]
#[serde(into = "Base62Id")]
pub struct ModId(pub u64);
pub struct ProjectId(pub u64);
/// The ID of a specific version of a mod
/// The ID of a specific version of a project
#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "Base62Id")]
#[serde(into = "Base62Id")]
pub struct VersionId(pub u64);
/// A mod returned from the API
/// A project returned from the API
#[derive(Serialize, Deserialize)]
pub struct Mod {
/// The ID of the mod, encoded as a base62 string.
pub id: ModId,
/// The slug of a mod, used for vanity URLs
pub struct Project {
/// The ID of the project, encoded as a base62 string.
pub id: ProjectId,
/// The slug of a project, used for vanity URLs
pub slug: Option<String>,
/// The team of people that has ownership of this mod.
/// The project type of the project
pub project_type: String,
/// The team of people that has ownership of this project.
pub team: TeamId,
/// The title or name of the mod.
/// The title or name of the project.
pub title: String,
/// A short description of the mod.
/// A short description of the project.
pub description: String,
/// A long form description of the mod.
/// A long form description of the project.
pub body: String,
/// The link to the long description of the mod. (Deprecated), being replaced by `body`
/// The link to the long description of the project. (Deprecated), being replaced by `body`
pub body_url: Option<String>,
/// The date at which the mod was first published.
/// The date at which the project was first published.
pub published: DateTime<Utc>,
/// The date at which the mod was first published.
/// The date at which the project was first published.
pub updated: DateTime<Utc>,
/// The status of the mod
pub status: ModStatus,
/// The license of this mod
/// The status of the project
pub status: ProjectStatus,
/// The license of this project
pub license: License,
/// The support range for the client mod
/// The support range for the client project*
pub client_side: SideType,
/// The support range for the server mod
/// The support range for the server project
pub server_side: SideType,
/// The total number of downloads the mod has had.
/// The total number of downloads the project has had.
pub downloads: u32,
/// The total number of followers this mod has accumulated
/// The total number of followers this project has accumulated
pub followers: u32,
/// A list of the categories that the mod is in.
/// A list of the categories that the project is in.
pub categories: Vec<String>,
/// A list of ids for versions of the mod.
/// A list of ids for versions of the project.
pub versions: Vec<VersionId>,
/// The URL of the icon of the mod
/// The URL of the icon of the project
pub icon_url: Option<String>,
/// An optional link to where to submit bugs or issues with the mod.
/// An optional link to where to submit bugs or issues with the project.
pub issues_url: Option<String>,
/// An optional link to the source code for the mod.
/// An optional link to the source code for the project.
pub source_url: Option<String>,
/// An optional link to the mod's wiki page or other relevant information.
/// An optional link to the project's wiki page or other relevant information.
pub wiki_url: Option<String>,
/// An optional link to the mod's discord
/// An optional link to the project's discord
pub discord_url: Option<String>,
/// An optional list of all donation links the mod has
/// An optional list of all donation links the project has
pub donation_urls: Option<Vec<DonationLink>>,
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "kebab-case")]
pub enum SideType {
Required,
@@ -113,22 +116,23 @@ pub struct License {
pub url: Option<String>,
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Validate, Clone)]
pub struct DonationLink {
pub id: String,
pub platform: String,
#[validate(url)]
pub url: String,
}
/// A status decides the visbility of a mod in search, URLs, and the whole site itself.
/// Approved - Mod is displayed on search, and accessible by URL
/// Rejected - Mod is not displayed on search, and not accessible by URL (Temporary state, mod can reapply)
/// Draft - Mod is not displayed on search, and not accessible by URL
/// Unlisted - Mod is not displayed on search, but accessible by URL
/// Processing - Mod is not displayed on search, and not accessible by URL (Temporary state, mod under review)
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq)]
/// A status decides the visbility of a project in search, URLs, and the whole site itself.
/// Approved - Project is displayed on search, and accessible by URL
/// Rejected - Project is not displayed on search, and not accessible by URL (Temporary state, project can reapply)
/// Draft - Project is not displayed on search, and not accessible by URL
/// Unlisted - Project is not displayed on search, but accessible by URL
/// Processing - Project is not displayed on search, and not accessible by URL (Temporary state, project under review)
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Debug)]
#[serde(rename_all = "lowercase")]
pub enum ModStatus {
pub enum ProjectStatus {
Approved,
Rejected,
Draft,
@@ -137,57 +141,57 @@ pub enum ModStatus {
Unknown,
}
impl std::fmt::Display for ModStatus {
impl std::fmt::Display for ProjectStatus {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{}", self.as_str())
}
}
impl ModStatus {
pub fn from_str(string: &str) -> ModStatus {
impl ProjectStatus {
pub fn from_str(string: &str) -> ProjectStatus {
match string {
"processing" => ModStatus::Processing,
"rejected" => ModStatus::Rejected,
"approved" => ModStatus::Approved,
"draft" => ModStatus::Draft,
"unlisted" => ModStatus::Unlisted,
_ => ModStatus::Unknown,
"processing" => ProjectStatus::Processing,
"rejected" => ProjectStatus::Rejected,
"approved" => ProjectStatus::Approved,
"draft" => ProjectStatus::Draft,
"unlisted" => ProjectStatus::Unlisted,
_ => ProjectStatus::Unknown,
}
}
pub fn as_str(&self) -> &'static str {
match self {
ModStatus::Approved => "approved",
ModStatus::Rejected => "rejected",
ModStatus::Draft => "draft",
ModStatus::Unlisted => "unlisted",
ModStatus::Processing => "processing",
ModStatus::Unknown => "unknown",
ProjectStatus::Approved => "approved",
ProjectStatus::Rejected => "rejected",
ProjectStatus::Draft => "draft",
ProjectStatus::Unlisted => "unlisted",
ProjectStatus::Processing => "processing",
ProjectStatus::Unknown => "unknown",
}
}
pub fn is_hidden(&self) -> bool {
match self {
ModStatus::Approved => false,
ModStatus::Rejected => true,
ModStatus::Draft => true,
ModStatus::Unlisted => false,
ModStatus::Processing => true,
ModStatus::Unknown => true,
ProjectStatus::Approved => false,
ProjectStatus::Rejected => true,
ProjectStatus::Draft => true,
ProjectStatus::Unlisted => false,
ProjectStatus::Processing => true,
ProjectStatus::Unknown => true,
}
}
pub fn is_searchable(&self) -> bool {
matches!(self, ModStatus::Approved)
matches!(self, ProjectStatus::Approved)
}
}
/// A specific version of a mod
/// A specific version of a project
#[derive(Serialize, Deserialize)]
pub struct Version {
/// The ID of the version, encoded as a base62 string.
pub id: VersionId,
/// The ID of the mod this version is for.
pub mod_id: ModId,
/// The ID of the project this version is for.
pub project_id: ProjectId,
/// The ID of the author who published this version
pub author_id: UserId,
/// Whether the version is featured or not
@@ -197,9 +201,9 @@ pub struct Version {
pub name: String,
/// The version number. Ideally will follow semantic versioning
pub version_number: String,
/// The changelog for this version of the mod.
/// The changelog for this version of the project.
pub changelog: String,
/// A link to the changelog for this version of the mod. (Deprecated), being replaced by `changelog`
/// A link to the changelog for this version of the project. (Deprecated), being replaced by `changelog`
pub changelog_url: Option<String>,
/// The date that this version was published.
pub date_published: DateTime<Utc>,
@@ -210,15 +214,15 @@ pub struct Version {
/// A list of files available for download for this version.
pub files: Vec<VersionFile>,
/// A list of mods that this version depends on.
/// A list of projects that this version depends on.
pub dependencies: Vec<Dependency>,
/// A list of versions of Minecraft that this version of the mod supports.
/// A list of versions of Minecraft that this version of the project supports.
pub game_versions: Vec<GameVersion>,
/// The loaders that this version works on
pub loaders: Vec<ModLoader>,
pub loaders: Vec<Loader>,
}
/// A single mod file, with a url for the file and the file's hash
/// A single project file, with a url for the file and the file's hash
#[derive(Serialize, Deserialize)]
pub struct VersionFile {
/// A map of hashes of the file. The key is the hashing algorithm
@@ -310,14 +314,14 @@ impl DependencyType {
}
/// A specific version of Minecraft
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, PartialEq)]
#[serde(transparent)]
pub struct GameVersion(pub String);
/// A mod loader
/// A project loader
#[derive(Serialize, Deserialize, Clone)]
#[serde(transparent)]
pub struct ModLoader(pub String);
pub struct Loader(pub String);
// These fields must always succeed parsing; deserialize errors aren't
// processed correctly (don't return JSON errors)

View File

@@ -22,7 +22,7 @@ pub struct Report {
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub enum ItemType {
Mod,
Project,
Version,
User,
Unknown,
@@ -31,7 +31,7 @@ pub enum ItemType {
impl ItemType {
pub fn as_str(&self) -> &'static str {
match self {
ItemType::Mod => "mod",
ItemType::Project => "project",
ItemType::Version => "version",
ItemType::User => "user",
ItemType::Unknown => "unknown",

View File

@@ -11,7 +11,7 @@ pub struct TeamId(pub u64);
pub const OWNER_ROLE: &str = "Owner";
// TODO: permissions, role names, etc
/// A team of users who control a mod
/// A team of users who control a project
#[derive(Serialize, Deserialize)]
pub struct Team {
/// The id of the team
@@ -31,7 +31,7 @@ bitflags::bitflags! {
const MANAGE_INVITES = 1 << 4;
const REMOVE_MEMBER = 1 << 5;
const EDIT_MEMBER = 1 << 6;
const DELETE_MOD = 1 << 7;
const DELETE_PROJECT = 1 << 7;
const ALL = 0b11111111;
}
}

View File

@@ -11,4 +11,4 @@ pub async fn index_get() -> HttpResponse {
});
HttpResponse::Ok().json(data)
}
}

View File

@@ -1,6 +1,6 @@
use crate::auth::get_user_from_headers;
use crate::database;
use crate::models::mods::ModId;
use crate::models::projects::ProjectId;
use crate::routes::ApiError;
use actix_web::{get, web, HttpRequest, HttpResponse};
use sqlx::PgPool;
@@ -55,22 +55,13 @@ pub async fn maven_metadata(
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
let id_option: Option<ModId> = serde_json::from_str(&*format!("\"{}\"", string)).ok();
let mod_data = if let Some(id) = id_option {
match database::models::Mod::get_full(id.into(), &**pool).await {
Ok(Some(data)) => Ok(Some(data)),
Ok(None) => database::models::Mod::get_full_from_slug(&string, &**pool).await,
Err(e) => Err(e),
}
} else {
database::models::Mod::get_full_from_slug(&string, &**pool).await
}
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let project_data =
database::models::Project::get_full_from_slug_or_project_id(string, &**pool).await?;
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
let data = if let Some(data) = mod_data {
let data = if let Some(data) = project_data {
data
} else {
return Ok(HttpResponse::NotFound().body(""));
@@ -85,17 +76,16 @@ pub async fn maven_metadata(
} else {
let user_id: database::models::ids::UserId = user.id.into();
let mod_exists = sqlx::query!(
let project_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)",
data.inner.team_id as database::models::ids::TeamId,
user_id as database::models::ids::UserId,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.await?
.exists;
authorized = mod_exists.unwrap_or(false);
authorized = project_exists.unwrap_or(false);
}
}
}
@@ -110,15 +100,16 @@ pub async fn maven_metadata(
LEFT JOIN release_channels ON release_channels.id = versions.release_channel
WHERE mod_id = $1
",
data.inner.id as database::models::ids::ModId
data.inner.id as database::models::ids::ProjectId
)
.fetch_all(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
let project_id: ProjectId = data.inner.id.into();
let respdata = Metadata {
group_id: "maven.modrinth".to_string(),
artifact_id: string,
artifact_id: format!("{}", project_id),
versioning: Versioning {
latest: version_names
.last()
@@ -141,7 +132,7 @@ pub async fn maven_metadata(
Ok(HttpResponse::Ok()
.content_type("text/xml")
.body(yaserde::ser::to_string(&respdata).map_err(|e| ApiError::XmlError(e))?))
.body(yaserde::ser::to_string(&respdata).map_err(ApiError::XmlError)?))
}
#[get("maven/modrinth/{id}/{versionnum}/{file}")]
@@ -150,22 +141,21 @@ pub async fn version_file(
web::Path((string, vnum, file)): web::Path<(String, String, String)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id_option: Option<ModId> = serde_json::from_str(&*format!("\"{}\"", string)).ok();
let id_option: Option<ProjectId> = serde_json::from_str(&*format!("\"{}\"", string)).ok();
let mod_data = if let Some(id) = id_option {
match database::models::Mod::get_full(id.into(), &**pool).await {
let project_data = if let Some(id) = id_option {
match database::models::Project::get_full(id.into(), &**pool).await {
Ok(Some(data)) => Ok(Some(data)),
Ok(None) => database::models::Mod::get_full_from_slug(&string, &**pool).await,
Ok(None) => database::models::Project::get_full_from_slug(&string, &**pool).await,
Err(e) => Err(e),
}
} else {
database::models::Mod::get_full_from_slug(&string, &**pool).await
}
.map_err(|e| ApiError::DatabaseError(e.into()))?;
database::models::Project::get_full_from_slug(&string, &**pool).await
}?;
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
let data = if let Some(data) = mod_data {
let data = if let Some(data) = project_data {
data
} else {
return Ok(HttpResponse::NotFound().body(""));
@@ -180,17 +170,16 @@ pub async fn version_file(
} else {
let user_id: database::models::ids::UserId = user.id.into();
let mod_exists = sqlx::query!(
let project_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)",
data.inner.team_id as database::models::ids::TeamId,
user_id as database::models::ids::UserId,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.await?
.exists;
authorized = mod_exists.unwrap_or(false);
authorized = project_exists.unwrap_or(false);
}
}
}
@@ -201,12 +190,11 @@ pub async fn version_file(
let vid = if let Some(vid) = sqlx::query!(
"SELECT id FROM versions WHERE mod_id = $1 AND version_number = $2",
data.inner.id as database::models::ids::ModId,
data.inner.id as database::models::ids::ProjectId,
vnum
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.await?
{
vid
} else {
@@ -215,8 +203,7 @@ pub async fn version_file(
let version = if let Some(version) =
database::models::Version::get_full(database::models::ids::VersionId(vid.id), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.await?
{
version
} else {
@@ -238,19 +225,17 @@ pub async fn version_file(
};
return Ok(HttpResponse::Ok()
.content_type("text/xml")
.body(yaserde::ser::to_string(&respdata).map_err(|e| ApiError::XmlError(e))?));
} else {
if let Some(selected_file) = version.files.iter().find(|x| x.filename == file) {
.body(yaserde::ser::to_string(&respdata).map_err(ApiError::XmlError)?));
} else if let Some(selected_file) = version.files.iter().find(|x| x.filename == file) {
return Ok(HttpResponse::TemporaryRedirect()
.header("Location", &*selected_file.url)
.body(""));
} else if file == format!("{}-{}.jar", &string, &version.version_number) {
if let Some(selected_file) = version.files.iter().last() {
return Ok(HttpResponse::TemporaryRedirect()
.header("Location", &*selected_file.url)
.body(""));
} else if file == format!("{}-{}.jar", &string, &version.version_number) {
if let Some(selected_file) = version.files.iter().last() {
return Ok(HttpResponse::TemporaryRedirect()
.header("Location", &*selected_file.url)
.body(""));
}
};
}
}
Ok(HttpResponse::NotFound().body(""))

View File

@@ -1,18 +1,22 @@
use actix_web::web;
mod v1;
pub use v1::v1_config;
mod auth;
mod index;
mod maven;
mod mod_creation;
mod moderation;
mod mods;
mod not_found;
mod notifications;
mod project_creation;
mod projects;
mod reports;
mod tags;
mod teams;
mod users;
mod version_creation;
mod version_file;
mod versions;
pub use auth::config as auth_config;
@@ -22,21 +26,35 @@ pub use self::index::index_get;
pub use self::not_found::not_found;
use crate::file_hosting::FileHostingError;
pub fn mods_config(cfg: &mut web::ServiceConfig) {
cfg.service(mods::mod_search);
cfg.service(mods::mods_get);
cfg.service(mod_creation::mod_create);
pub fn v2_config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/v2/")
.configure(auth_config)
.configure(tags_config)
.configure(projects_config)
.configure(versions_config)
.configure(teams_config)
.configure(users_config)
.configure(moderation_config)
.configure(reports_config)
.configure(notifications_config),
);
}
pub fn projects_config(cfg: &mut web::ServiceConfig) {
cfg.service(projects::project_search);
cfg.service(projects::projects_get);
cfg.service(project_creation::project_create);
cfg.service(
web::scope("mod")
.service(mods::mod_slug_get)
.service(mods::mod_get)
.service(mods::mod_delete)
.service(mods::mod_edit)
.service(mods::mod_icon_edit)
.service(mods::mod_follow)
.service(mods::mod_unfollow)
.service(web::scope("{mod_id}").service(versions::version_list)),
web::scope("project")
.service(projects::project_get)
.service(projects::project_delete)
.service(projects::project_edit)
.service(projects::project_icon_edit)
.service(projects::project_follow)
.service(projects::project_unfollow)
.service(web::scope("{project_id}").service(versions::version_list)),
);
}
@@ -57,9 +75,17 @@ pub fn versions_config(cfg: &mut web::ServiceConfig) {
);
cfg.service(
web::scope("version_file")
.service(versions::delete_file)
.service(versions::get_version_from_hash)
.service(versions::download_version),
.service(version_file::delete_file)
.service(version_file::get_version_from_hash)
.service(version_file::download_version)
.service(version_file::get_update_from_hash),
);
cfg.service(
web::scope("version_files")
.service(version_file::get_versions_from_hashes)
.service(version_file::download_files)
.service(version_file::update_files),
);
}
@@ -69,9 +95,8 @@ pub fn users_config(cfg: &mut web::ServiceConfig) {
cfg.service(users::users_get);
cfg.service(
web::scope("user")
.service(users::user_username_get)
.service(users::user_get)
.service(users::mods_list)
.service(users::projects_list)
.service(users::user_delete)
.service(users::user_edit)
.service(users::user_icon_edit)
@@ -102,7 +127,7 @@ pub fn notifications_config(cfg: &mut web::ServiceConfig) {
}
pub fn moderation_config(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("moderation").service(moderation::mods));
cfg.service(web::scope("moderation").service(moderation::get_projects));
}
pub fn reports_config(cfg: &mut web::ServiceConfig) {
@@ -117,8 +142,10 @@ pub enum ApiError {
EnvError(#[from] dotenv::Error),
#[error("Error while uploading file")]
FileHostingError(#[from] FileHostingError),
#[error("Internal server error: {0}")]
#[error("Database Error: {0}")]
DatabaseError(#[from] crate::database::models::DatabaseError),
#[error("Database Error: {0}")]
SqlxDatabaseError(#[from] sqlx::Error),
#[error("Internal server error: {0}")]
XmlError(String),
#[error("Deserialization error: {0}")]
@@ -129,6 +156,8 @@ pub enum ApiError {
CustomAuthenticationError(String),
#[error("Invalid Input: {0}")]
InvalidInputError(String),
#[error("Error while validating input: {0}")]
ValidationError(#[from] validator::ValidationErrors),
#[error("Search Error: {0}")]
SearchError(#[from] meilisearch_sdk::errors::Error),
#[error("Indexing Error: {0}")]
@@ -140,6 +169,7 @@ impl actix_web::ResponseError for ApiError {
match self {
ApiError::EnvError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::DatabaseError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::SqlxDatabaseError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::AuthenticationError(..) => actix_web::http::StatusCode::UNAUTHORIZED,
ApiError::CustomAuthenticationError(..) => actix_web::http::StatusCode::UNAUTHORIZED,
ApiError::XmlError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
@@ -148,6 +178,7 @@ impl actix_web::ResponseError for ApiError {
ApiError::IndexingError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::FileHostingError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::InvalidInputError(..) => actix_web::http::StatusCode::BAD_REQUEST,
ApiError::ValidationError(..) => actix_web::http::StatusCode::BAD_REQUEST,
}
}
@@ -156,6 +187,7 @@ impl actix_web::ResponseError for ApiError {
crate::models::error::ApiError {
error: match self {
ApiError::EnvError(..) => "environment_error",
ApiError::SqlxDatabaseError(..) => "database_error",
ApiError::DatabaseError(..) => "database_error",
ApiError::AuthenticationError(..) => "unauthorized",
ApiError::CustomAuthenticationError(..) => "unauthorized",
@@ -165,6 +197,7 @@ impl actix_web::ResponseError for ApiError {
ApiError::IndexingError(..) => "indexing_error",
ApiError::FileHostingError(..) => "file_hosting_error",
ApiError::InvalidInputError(..) => "invalid_input",
ApiError::ValidationError(..) => "invalid_input",
},
description: &self.to_string(),
},

View File

@@ -1,7 +1,7 @@
use super::ApiError;
use crate::auth::check_is_moderator_from_headers;
use crate::database;
use crate::models::mods::{Mod, ModStatus};
use crate::models::projects::{Project, ProjectStatus};
use actix_web::{get, web, HttpRequest, HttpResponse};
use serde::Deserialize;
use sqlx::PgPool;
@@ -9,15 +9,15 @@ use sqlx::PgPool;
#[derive(Deserialize)]
pub struct ResultCount {
#[serde(default = "default_count")]
count: i16,
pub count: i16,
}
fn default_count() -> i16 {
100
}
#[get("mods")]
pub async fn mods(
#[get("projects")]
pub async fn get_projects(
req: HttpRequest,
pool: web::Data<PgPool>,
count: web::Query<ResultCount>,
@@ -26,7 +26,7 @@ pub async fn mods(
use futures::stream::TryStreamExt;
let mod_ids = sqlx::query!(
let project_ids = sqlx::query!(
"
SELECT id FROM mods
WHERE status = (
@@ -35,21 +35,19 @@ pub async fn mods(
ORDER BY updated ASC
LIMIT $2;
",
ModStatus::Processing.as_str(),
ProjectStatus::Processing.as_str(),
count.count as i64
)
.fetch_many(&**pool)
.try_filter_map(|e| async { Ok(e.right().map(|m| database::models::ids::ModId(m.id))) })
.try_collect::<Vec<database::models::ModId>>()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.try_filter_map(|e| async { Ok(e.right().map(|m| database::models::ProjectId(m.id))) })
.try_collect::<Vec<database::models::ProjectId>>()
.await?;
let mods: Vec<Mod> = database::models::mod_item::Mod::get_many_full(mod_ids, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
let projects: Vec<Project> = database::Project::get_many_full(project_ids, &**pool)
.await?
.into_iter()
.map(super::mods::convert_mod)
.map(super::projects::convert_project)
.collect();
Ok(HttpResponse::Ok().json(mods))
Ok(HttpResponse::Ok().json(projects))
}

View File

@@ -27,8 +27,7 @@ pub async fn notifications_get(
let notifications_data =
database::models::notification_item::Notification::get_many(notification_ids, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
let mut notifications: Vec<Notification> = Vec::new();
@@ -52,9 +51,7 @@ pub async fn notification_get(
let id = info.into_inner().0;
let notification_data =
database::models::notification_item::Notification::get(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
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_mod() {
@@ -100,17 +97,13 @@ pub async fn notification_delete(
let id = info.into_inner().0;
let notification_data =
database::models::notification_item::Notification::get(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
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_mod() {
database::models::notification_item::Notification::remove(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
database::models::notification_item::Notification::remove(id.into(), &**pool).await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::CustomAuthenticationError(
"You are not authorized to delete this notification!".to_string(),

View File

@@ -2,7 +2,9 @@ use crate::auth::{get_user_from_headers, AuthenticationError};
use crate::database::models;
use crate::file_hosting::{FileHost, FileHostingError};
use crate::models::error::ApiError;
use crate::models::mods::{DonationLink, License, ModId, ModStatus, SideType, VersionId};
use crate::models::projects::{
DonationLink, License, ProjectId, ProjectStatus, SideType, VersionId,
};
use crate::models::users::UserId;
use crate::routes::version_creation::InitialVersionData;
use crate::search::indexing::{queue::CreationQueue, IndexingError};
@@ -11,16 +13,19 @@ 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;
use thiserror::Error;
use validator::Validate;
#[derive(Error, Debug)]
pub enum CreateError {
#[error("Environment Error")]
EnvError(#[from] dotenv::Error),
#[error("An unknown database error occured")]
#[error("An unknown database error occurred")]
SqlxDatabaseError(#[from] sqlx::Error),
#[error("Database Error: {0}")]
DatabaseError(#[from] models::DatabaseError),
@@ -30,11 +35,15 @@ pub enum CreateError {
MultipartError(actix_multipart::MultipartError),
#[error("Error while parsing JSON: {0}")]
SerDeError(#[from] serde_json::Error),
#[error("Error while validating input: {0}")]
ValidationError(#[from] validator::ValidationErrors),
#[error("Error while uploading file")]
FileHostingError(#[from] FileHostingError),
#[error("Error while validating uploaded file: {0}")]
FileValidationError(#[from] crate::validate::ValidationError),
#[error("{}", .0)]
MissingValueError(String),
#[error("Invalid format for mod icon: {0}")]
#[error("Invalid format for project icon: {0}")]
InvalidIconFormat(String),
#[error("Error with multipart data: {0}")]
InvalidInput(String),
@@ -46,7 +55,7 @@ pub enum CreateError {
InvalidCategory(String),
#[error("Invalid file type for version file: {0}")]
InvalidFileType(String),
#[error("Slug collides with other mod's id!")]
#[error("Slug collides with other project's id!")]
SlugCollision,
#[error("Authentication Error: {0}")]
Unauthorized(#[from] AuthenticationError),
@@ -74,6 +83,8 @@ impl actix_web::ResponseError for CreateError {
CreateError::Unauthorized(..) => StatusCode::UNAUTHORIZED,
CreateError::CustomAuthenticationError(..) => StatusCode::UNAUTHORIZED,
CreateError::SlugCollision => StatusCode::BAD_REQUEST,
CreateError::ValidationError(..) => StatusCode::BAD_REQUEST,
CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST,
}
}
@@ -97,46 +108,81 @@ impl actix_web::ResponseError for CreateError {
CreateError::Unauthorized(..) => "unauthorized",
CreateError::CustomAuthenticationError(..) => "unauthorized",
CreateError::SlugCollision => "invalid_input",
CreateError::ValidationError(..) => "invalid_input",
CreateError::FileValidationError(..) => "invalid_input",
},
description: &self.to_string(),
})
}
}
#[derive(Serialize, Deserialize, Clone)]
struct ModCreateData {
/// The title or name of the mod.
pub mod_name: String,
/// The slug of a mod, used for vanity URLs
pub mod_slug: String,
/// A short description of the mod.
pub mod_description: String,
/// A long description of the mod, in markdown.
pub mod_body: String,
/// A list of initial versions to upload with the created mod
pub initial_versions: Vec<InitialVersionData>,
/// A list of the categories that the mod is in.
pub categories: Vec<String>,
/// An optional link to where to submit bugs or issues with the mod.
pub issues_url: Option<String>,
/// An optional link to the source code for the mod.
pub source_url: Option<String>,
/// An optional link to the mod's wiki page or other relevant information.
pub wiki_url: Option<String>,
/// An optional link to the mod's license page
pub license_url: Option<String>,
/// An optional link to the mod's discord.
pub discord_url: Option<String>,
/// An optional boolean. If true, the mod will be created as a draft.
pub is_draft: Option<bool>,
/// The support range for the client mod
lazy_static! {
static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_-]*$").unwrap();
}
fn default_project_type() -> String {
"mod".to_string()
}
#[derive(Serialize, Deserialize, Validate, Clone)]
struct ProjectCreateData {
#[validate(length(min = 3, max = 256))]
#[serde(alias = "mod_name")]
/// The title or name of the project.
pub title: String,
#[validate(length(min = 1, max = 64))]
#[serde(default = "default_project_type")]
/// The project type of this mod
pub project_type: String,
#[validate(length(min = 3, max = 64), regex = "RE_URL_SAFE")]
#[serde(alias = "mod_slug")]
/// The slug of a project, used for vanity URLs
pub slug: String,
#[validate(length(min = 3, max = 2048))]
#[serde(alias = "mod_description")]
/// A short description of the project.
pub description: String,
#[validate(length(max = 65536))]
#[serde(alias = "mod_body")]
/// A long description of the project, in markdown.
pub body: String,
/// The support range for the client project
pub client_side: SideType,
/// The support range for the server mod
/// The support range for the server project
pub server_side: SideType,
/// The license id that the mod follows
pub license_id: String,
/// An optional list of all donation links the mod has
#[validate(length(max = 64))]
/// 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(url, length(max = 2048))]
/// An optional link to where to submit bugs or issues with the project.
pub issues_url: Option<String>,
#[validate(url, length(max = 2048))]
/// An optional link to the source code for the project.
pub source_url: Option<String>,
#[validate(url, length(max = 2048))]
/// An optional link to the project's wiki page or other relevant information.
pub wiki_url: Option<String>,
#[validate(url, length(max = 2048))]
/// An optional link to the project's license page
pub license_url: Option<String>,
#[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,
}
pub struct UploadedFile {
@@ -156,8 +202,8 @@ pub async fn undo_uploads(
Ok(())
}
#[post("mod")]
pub async fn mod_create(
#[post("project")]
pub async fn project_create(
req: HttpRequest,
payload: Multipart,
client: Data<PgPool>,
@@ -167,7 +213,7 @@ pub async fn mod_create(
let mut transaction = client.begin().await?;
let mut uploaded_files = Vec::new();
let result = mod_create_inner(
let result = project_create_inner(
req,
payload,
&mut transaction,
@@ -196,7 +242,7 @@ pub async fn mod_create(
/*
Mod Creation Steps:
Project Creation Steps:
Get logged in user
Must match the author in the version creation
@@ -206,12 +252,12 @@ Get logged in user
- Create versions
- Some shared logic with version creation
- Create list of VersionBuilders
- Create ModBuilder
- Create ProjectBuilder
2. Upload
- Icon: check file format & size
- Upload to backblaze & record URL
- Mod files
- Project files
- Check for matching version
- File size limits?
- Check file type
@@ -221,10 +267,10 @@ Get logged in user
3. Creation
- Database stuff
- Add mod data to indexing queue
- Add project data to indexing queue
*/
async fn mod_create_inner(
pub async fn project_create_inner(
req: HttpRequest,
mut payload: Multipart,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
@@ -238,15 +284,17 @@ async fn mod_create_inner(
// The currently logged in user
let current_user = get_user_from_headers(req.headers(), &mut *transaction).await?;
let mod_id: ModId = models::generate_mod_id(transaction).await?.into();
let project_id: ProjectId = models::generate_project_id(transaction).await?.into();
let mod_create_data;
let project_create_data;
let mut versions;
let mut versions_map = std::collections::HashMap::new();
let all_game_versions = models::categories::GameVersion::list(&mut *transaction).await?;
{
// The first multipart field must be named "data" and contain a
// JSON `ModCreateData` object.
// JSON `ProjectCreateData` object.
let mut field = payload
.next()
@@ -275,75 +323,20 @@ async fn mod_create_inner(
while let Some(chunk) = field.next().await {
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
}
let create_data: ModCreateData = serde_json::from_slice(&data)?;
let create_data: ProjectCreateData = serde_json::from_slice(&data)?;
{
// Verify the lengths of various fields in the mod create data
/*
# ModCreateData
mod_name: 3..=256
mod_description: 3..=2048,
mod_body: max of 64KiB?,
categories: Vec<String>, 1..=256
issues_url: 0..=2048, (Validate url?)
source_url: 0..=2048,
wiki_url: 0..=2048,
create_data.validate()?;
initial_versions: Vec<InitialVersionData>,
team_members: Vec<TeamMember>,
let slug_project_id_option: Option<ProjectId> =
serde_json::from_str(&*format!("\"{}\"", create_data.slug)).ok();
# TeamMember:
name: 3..=64
role: 3..=64
*/
check_length(3..=256, "mod name", &create_data.mod_name)?;
check_length(3..=2048, "mod description", &create_data.mod_description)?;
check_length(3..=64, "mod slug", &create_data.mod_slug)?;
check_length(..65536, "mod body", &create_data.mod_body)?;
if create_data.categories.len() > 3 {
return Err(CreateError::InvalidInput(
"The maximum number of categories for a mod is four.".to_string(),
));
}
create_data
.categories
.iter()
.try_for_each(|f| check_length(1..=256, "category", f))?;
if let Some(url) = &create_data.issues_url {
check_length(..=2048, "url", url)?;
}
if let Some(url) = &create_data.wiki_url {
check_length(..=2048, "url", url)?;
}
if let Some(url) = &create_data.source_url {
check_length(..=2048, "url", url)?;
}
if let Some(url) = &create_data.discord_url {
check_length(..=2048, "url", url)?;
}
if let Some(url) = &create_data.license_url {
check_length(..=2048, "url", url)?;
}
create_data
.initial_versions
.iter()
.try_for_each(|v| super::version_creation::check_version(v))?;
}
let slug_modid_option: Option<ModId> =
serde_json::from_str(&*format!("\"{}\"", create_data.mod_slug)).ok();
if let Some(slug_modid) = slug_modid_option {
let slug_modid: models::ids::ModId = slug_modid.into();
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_modid as models::ids::ModId
slug_project_id as models::ids::ProjectId
)
.fetch_one(&mut *transaction)
.await
@@ -366,13 +359,31 @@ async fn mod_create_inner(
)));
}
}
versions
.push(create_initial_version(data, mod_id, current_user.id, transaction).await?);
versions.push(
create_initial_version(
data,
project_id,
current_user.id,
&all_game_versions,
transaction,
)
.await?,
);
}
mod_create_data = create_data;
project_create_data = create_data;
}
let project_type_id =
models::ProjectTypeId::get_id(project_create_data.project_type.clone(), &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::InvalidInput(format!(
"Project Type {} does not exist.",
project_create_data.project_type.clone()
))
})?;
let mut icon_url = None;
while let Some(item) = payload.next().await {
@@ -391,14 +402,14 @@ async fn mod_create_inner(
if name == "icon" {
if icon_url.is_some() {
return Err(CreateError::InvalidInput(String::from(
"Mods can only have one icon",
"Projects can only have one icon",
)));
}
// Upload the icon to the cdn
icon_url = Some(
process_icon_upload(
uploaded_files,
mod_id,
project_id,
file_extension,
file_host,
field,
@@ -420,27 +431,33 @@ async fn mod_create_inner(
// `index` is always valid for these lists
let created_version = versions.get_mut(index).unwrap();
let version_data = mod_create_data.initial_versions.get(index).unwrap();
let version_data = project_create_data.initial_versions.get(index).unwrap();
// Upload the new jar file
let file_builder = super::version_creation::upload_file(
super::version_creation::upload_file(
&mut field,
file_host,
uploaded_files,
&mut created_version.files,
&cdn_url,
&content_disposition,
mod_id,
project_id,
&version_data.version_number,
&*project_create_data.project_type,
version_data.loaders.clone(),
version_data.game_versions.clone(),
&all_game_versions,
false,
)
.await?;
// Add the newly uploaded file to the existing or new version
created_version.files.push(file_builder);
}
{
// Check to make sure that all specified files were uploaded
for (version_data, builder) in mod_create_data.initial_versions.iter().zip(versions.iter())
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(
@@ -450,11 +467,15 @@ async fn mod_create_inner(
}
// Convert the list of category names to actual categories
let mut categories = Vec::with_capacity(mod_create_data.categories.len());
for category in &mod_create_data.categories {
let id = models::categories::Category::get_id(&category, &mut *transaction)
.await?
.ok_or_else(|| CreateError::InvalidCategory(category.clone()))?;
let mut categories = Vec::with_capacity(project_create_data.categories.len());
for category in &project_create_data.categories {
let id = models::categories::Category::get_id_project(
&category,
project_type_id,
&mut *transaction,
)
.await?
.ok_or_else(|| CreateError::InvalidCategory(category.clone()))?;
categories.push(id);
}
@@ -470,10 +491,10 @@ async fn mod_create_inner(
let team_id = team.insert(&mut *transaction).await?;
let status;
if mod_create_data.is_draft.unwrap_or(false) {
status = ModStatus::Draft;
if project_create_data.is_draft.unwrap_or(false) {
status = ProjectStatus::Draft;
} else {
status = ModStatus::Processing;
status = ProjectStatus::Processing;
}
let status_id = models::StatusId::get_id(&status, &mut *transaction)
@@ -482,7 +503,7 @@ async fn mod_create_inner(
CreateError::InvalidInput(format!("Status {} does not exist.", status.clone()))
})?;
let client_side_id =
models::SideTypeId::get_id(&mod_create_data.client_side, &mut *transaction)
models::SideTypeId::get_id(&project_create_data.client_side, &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::InvalidInput(
@@ -491,7 +512,7 @@ async fn mod_create_inner(
})?;
let server_side_id =
models::SideTypeId::get_id(&mod_create_data.server_side, &mut *transaction)
models::SideTypeId::get_id(&project_create_data.server_side, &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::InvalidInput(
@@ -500,14 +521,14 @@ async fn mod_create_inner(
})?;
let license_id =
models::categories::License::get_id(&mod_create_data.license_id, &mut *transaction)
models::categories::License::get_id(&project_create_data.license_id, &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::InvalidInput("License specified does not exist.".to_string())
})?;
let mut donation_urls = vec![];
if let Some(urls) = &mod_create_data.donation_urls {
if let Some(urls) = &project_create_data.donation_urls {
for url in urls {
let platform_id = models::DonationPlatformId::get_id(&url.id, &mut *transaction)
.await?
@@ -518,8 +539,8 @@ async fn mod_create_inner(
))
})?;
donation_urls.push(models::mod_item::DonationUrl {
mod_id: mod_id.into(),
donation_urls.push(models::project_item::DonationUrl {
project_id: project_id.into(),
platform_id,
platform_short: "".to_string(),
platform_name: "".to_string(),
@@ -528,72 +549,76 @@ async fn mod_create_inner(
}
}
let mod_builder = models::mod_item::ModBuilder {
mod_id: mod_id.into(),
let project_builder = models::project_item::ProjectBuilder {
project_id: project_id.into(),
project_type_id,
team_id,
title: mod_create_data.mod_name,
description: mod_create_data.mod_description,
body: mod_create_data.mod_body,
title: project_create_data.title,
description: project_create_data.description,
body: project_create_data.body,
icon_url,
issues_url: mod_create_data.issues_url,
source_url: mod_create_data.source_url,
wiki_url: mod_create_data.wiki_url,
issues_url: project_create_data.issues_url,
source_url: project_create_data.source_url,
wiki_url: project_create_data.wiki_url,
license_url: mod_create_data.license_url,
discord_url: mod_create_data.discord_url,
license_url: project_create_data.license_url,
discord_url: project_create_data.discord_url,
categories,
initial_versions: versions,
status: status_id,
client_side: client_side_id,
server_side: server_side_id,
license: license_id,
slug: Some(mod_create_data.mod_slug),
slug: Some(project_create_data.slug),
donation_urls,
};
let now = chrono::Utc::now();
let response = crate::models::mods::Mod {
id: mod_id,
slug: mod_builder.slug.clone(),
let response = crate::models::projects::Project {
id: project_id,
slug: project_builder.slug.clone(),
project_type: project_create_data.project_type.clone(),
team: team_id.into(),
title: mod_builder.title.clone(),
description: mod_builder.description.clone(),
body: mod_builder.body.clone(),
title: project_builder.title.clone(),
description: project_builder.description.clone(),
body: project_builder.body.clone(),
body_url: None,
published: now,
updated: now,
status: status.clone(),
license: License {
id: mod_create_data.license_id.clone(),
id: project_create_data.license_id.clone(),
name: "".to_string(),
url: mod_builder.license_url.clone(),
url: project_builder.license_url.clone(),
},
client_side: mod_create_data.client_side,
server_side: mod_create_data.server_side,
client_side: project_create_data.client_side,
server_side: project_create_data.server_side,
downloads: 0,
followers: 0,
categories: mod_create_data.categories,
versions: mod_builder
categories: project_create_data.categories,
versions: project_builder
.initial_versions
.iter()
.map(|v| v.version_id.into())
.collect::<Vec<_>>(),
icon_url: mod_builder.icon_url.clone(),
issues_url: mod_builder.issues_url.clone(),
source_url: mod_builder.source_url.clone(),
wiki_url: mod_builder.wiki_url.clone(),
discord_url: mod_builder.discord_url.clone(),
donation_urls: mod_create_data.donation_urls.clone(),
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(),
};
let _mod_id = mod_builder.insert(&mut *transaction).await?;
let _project_id = project_builder.insert(&mut *transaction).await?;
if status.is_searchable() {
let index_mod =
crate::search::indexing::local_import::query_one(mod_id.into(), &mut *transaction)
.await?;
indexing_queue.add(index_mod);
let index_project = crate::search::indexing::local_import::query_one(
project_id.into(),
&mut *transaction,
)
.await?;
indexing_queue.add(index_project);
}
Ok(HttpResponse::Ok().json(response))
@@ -602,18 +627,18 @@ async fn mod_create_inner(
async fn create_initial_version(
version_data: &InitialVersionData,
mod_id: ModId,
project_id: ProjectId,
author: UserId,
all_game_versions: &[models::categories::GameVersion],
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<models::version_item::VersionBuilder, CreateError> {
if version_data.mod_id.is_some() {
if version_data.project_id.is_some() {
return Err(CreateError::InvalidInput(String::from(
"Found mod id in initial version for new mod",
"Found project id in initial version for new project",
)));
}
check_length(3..=256, "version name", &version_data.version_title)?;
check_length(1..=32, "version number", &version_data.version_number)?;
version_data.validate()?;
// Randomly generate a new id to be used for the version
let version_id: VersionId = models::generate_version_id(transaction).await?.into();
@@ -623,13 +648,17 @@ async fn create_initial_version(
.await?
.expect("Release Channel not found in database");
let mut game_versions = Vec::with_capacity(version_data.game_versions.len());
for v in &version_data.game_versions {
let id = models::categories::GameVersion::get_id(&v.0, &mut *transaction)
.await?
.ok_or_else(|| CreateError::InvalidGameVersion(v.0.clone()))?;
game_versions.push(id);
}
let game_versions = version_data
.game_versions
.iter()
.map(|x| {
all_game_versions
.iter()
.find(|y| y.version == x.0)
.ok_or_else(|| CreateError::InvalidGameVersion(x.0.clone()))
.map(|y| y.id)
})
.collect::<Result<Vec<models::GameVersionId>, CreateError>>()?;
let mut loaders = Vec::with_capacity(version_data.loaders.len());
for l in &version_data.loaders {
@@ -647,7 +676,7 @@ async fn create_initial_version(
let version = models::version_item::VersionBuilder {
version_id: version_id.into(),
mod_id: mod_id.into(),
project_id: project_id.into(),
author_id: author.into(),
name: version_data.version_title.clone(),
version_number: version_data.version_number.clone(),
@@ -668,7 +697,7 @@ async fn create_initial_version(
async fn process_icon_upload(
uploaded_files: &mut Vec<UploadedFile>,
mod_id: ModId,
project_id: ProjectId,
file_extension: &str,
file_host: &dyn FileHost,
mut field: actix_multipart::Field,
@@ -689,7 +718,7 @@ async fn process_icon_upload(
let upload_data = file_host
.upload_file(
content_type,
&format!("data/{}/icon.{}", mod_id, file_extension),
&format!("data/{}/icon.{}", project_id, file_extension),
data,
)
.await?;
@@ -723,31 +752,3 @@ pub fn get_image_content_type(extension: &str) -> Option<&'static str> {
None
}
}
pub fn check_length(
range: impl std::ops::RangeBounds<usize> + std::fmt::Debug,
field_name: &str,
field: &str,
) -> Result<(), CreateError> {
use std::ops::Bound;
let length = field.len();
if !range.contains(&length) {
let bounds = match (range.start_bound(), range.end_bound()) {
(Bound::Included(a), Bound::Included(b)) => format!("between {} and {} bytes", a, b),
(Bound::Included(a), Bound::Excluded(b)) => {
format!("between {} and {} bytes", a, b - 1)
}
(Bound::Included(a), Bound::Unbounded) => format!("more than {} bytes", a),
(Bound::Unbounded, Bound::Included(b)) => format!("less than or equal to {} bytes", b),
(Bound::Unbounded, Bound::Excluded(b)) => format!("less than {} bytes", b),
_ => format!("{:?}", range),
};
Err(CreateError::InvalidInput(format!(
"The {} must be {}; got {}.",
field_name, bounds, length
)))
} else {
Ok(())
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::models::ids::{ModId, UserId, VersionId};
use crate::models::ids::{ProjectId, UserId, VersionId};
use crate::models::reports::{ItemType, Report};
use crate::routes::ApiError;
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
@@ -21,10 +21,7 @@ pub async fn report_create(
pool: web::Data<PgPool>,
mut body: web::Payload,
) -> Result<HttpResponse, ApiError> {
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mut transaction = pool.begin().await?;
let current_user = get_user_from_headers(req.headers(), &mut *transaction).await?;
@@ -48,7 +45,7 @@ pub async fn report_create(
let mut report = crate::database::models::report_item::Report {
id,
report_type_id: report_type,
mod_id: None,
project_id: None,
version_id: None,
user_id: None,
body: new_report.body.clone(),
@@ -57,9 +54,10 @@ pub async fn report_create(
};
match new_report.item_type {
ItemType::Mod => {
report.mod_id =
Some(serde_json::from_str::<ModId>(&*format!("\"{}\"", new_report.item_id))?.into())
ItemType::Project => {
report.project_id = Some(
serde_json::from_str::<ProjectId>(&*format!("\"{}\"", new_report.item_id))?.into(),
)
}
ItemType::Version => {
report.version_id = Some(
@@ -79,14 +77,8 @@ pub async fn report_create(
}
}
report
.insert(&mut transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
report.insert(&mut transaction).await?;
transaction.commit().await?;
Ok(HttpResponse::Ok().json(Report {
id: id.into(),
@@ -133,12 +125,10 @@ pub async fn reports(
.map(|m| crate::database::models::ids::ReportId(m.id)))
})
.try_collect::<Vec<crate::database::models::ids::ReportId>>()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
let query_reports = crate::database::models::report_item::Report::get_many(report_ids, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let query_reports =
crate::database::models::report_item::Report::get_many(report_ids, &**pool).await?;
let mut reports = Vec::new();
@@ -146,9 +136,9 @@ pub async fn reports(
let mut item_id = "".to_string();
let mut item_type = ItemType::Unknown;
if let Some(mod_id) = x.mod_id {
item_id = serde_json::to_string::<ModId>(&mod_id.into())?;
item_type = ItemType::Mod;
if let Some(project_id) = x.project_id {
item_id = serde_json::to_string::<ProjectId>(&project_id.into())?;
item_type = ItemType::Project;
} else if let Some(version_id) = x.version_id {
item_id = serde_json::to_string::<VersionId>(&version_id.into())?;
item_type = ItemType::Version;
@@ -183,11 +173,10 @@ pub async fn delete_report(
info.into_inner().0.into(),
&**pool,
)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}

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, ReportType};
use crate::database::models::categories::{DonationPlatform, License, ProjectType, ReportType};
use actix_web::{delete, get, put, web, HttpRequest, HttpResponse};
use models::categories::{Category, GameVersion, Loader};
use sqlx::PgPool;
@@ -30,27 +30,55 @@ pub fn config(cfg: &mut web::ServiceConfig) {
);
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct CategoryData {
icon: String,
name: String,
project_type: String,
}
// TODO: searching / filtering? Could be used to implement a live
// searching category list
#[get("category")]
pub async fn category_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results = Category::list(&**pool).await?;
let results = Category::list(&**pool)
.await?
.into_iter()
.map(|x| CategoryData {
icon: x.icon,
name: x.category,
project_type: x.project_type,
})
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(results))
}
#[put("category/{name}")]
#[put("category")]
pub async fn category_create(
req: HttpRequest,
pool: web::Data<PgPool>,
category: web::Path<(String,)>,
new_category: web::Json<CategoryData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = category.into_inner().0;
let project_type = crate::database::models::ProjectTypeId::get_id(
(&new_category).project_type.clone(),
&**pool,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError("Specified project type does not exist!".to_string())
})?;
let _id = Category::builder().name(&name)?.insert(&**pool).await?;
let _id = Category::builder()
.name(&new_category.name)?
.project_type(&project_type)?
.icon(&new_category.icon)?
.insert(&**pool)
.await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
#[delete("category/{name}")]
@@ -72,31 +100,56 @@ pub async fn category_delete(
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct LoaderData {
icon: String,
name: String,
supported_project_types: Vec<String>,
}
#[get("loader")]
pub async fn loader_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results = Loader::list(&**pool).await?;
let results = Loader::list(&**pool)
.await?
.into_iter()
.map(|x| LoaderData {
icon: x.icon,
name: x.loader,
supported_project_types: x.supported_project_types,
})
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(results))
}
#[put("loader/{name}")]
#[put("loader")]
pub async fn loader_create(
req: HttpRequest,
pool: web::Data<PgPool>,
loader: web::Path<(String,)>,
new_loader: web::Json<LoaderData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = loader.into_inner().0;
let mut transaction = pool.begin().await?;
let _id = Loader::builder().name(&name)?.insert(&**pool).await?;
let project_types =
ProjectType::get_many_id(&new_loader.supported_project_types, &mut *transaction).await?;
Ok(HttpResponse::Ok().body(""))
let _id = Loader::builder()
.name(&new_loader.name)?
.icon(&new_loader.icon)?
.supported_project_types(&*project_types.into_iter().map(|x| x.id).collect::<Vec<_>>())?
.insert(&mut transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}
#[delete("loader/{name}")]
@@ -118,14 +171,21 @@ pub async fn loader_delete(
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(serde::Deserialize)]
#[derive(serde::Serialize)]
pub struct GameVersionQueryData {
pub version: String,
pub version_type: String,
pub date: chrono::DateTime<chrono::Utc>,
}
#[derive(serde::Deserialize)]
pub struct GameVersionQuery {
#[serde(rename = "type")]
type_: Option<String>,
major: Option<bool>,
@@ -134,16 +194,22 @@ pub struct GameVersionQueryData {
#[get("game_version")]
pub async fn game_version_list(
pool: web::Data<PgPool>,
query: web::Query<GameVersionQueryData>,
query: web::Query<GameVersionQuery>,
) -> Result<HttpResponse, ApiError> {
if query.type_.is_some() || query.major.is_some() {
let results =
GameVersion::list_filter(query.type_.as_deref(), query.major, &**pool).await?;
Ok(HttpResponse::Ok().json(results))
let results: Vec<GameVersionQueryData> = if query.type_.is_some() || query.major.is_some() {
GameVersion::list_filter(query.type_.as_deref(), query.major, &**pool).await?
} else {
let results = GameVersion::list(&**pool).await?;
Ok(HttpResponse::Ok().json(results))
GameVersion::list(&**pool).await?
}
.into_iter()
.map(|x| GameVersionQueryData {
version: x.version,
version_type: x.version_type,
date: x.date,
})
.collect();
Ok(HttpResponse::Ok().json(results))
}
#[derive(serde::Deserialize)]
@@ -177,7 +243,7 @@ pub async fn game_version_create(
let _id = builder.insert(&**pool).await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
#[delete("game_version/{name}")]
@@ -199,7 +265,7 @@ pub async fn game_version_delete(
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
@@ -246,7 +312,7 @@ pub async fn license_create(
.insert(&**pool)
.await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
#[delete("license/{name}")]
@@ -268,7 +334,7 @@ pub async fn license_delete(
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
@@ -315,7 +381,7 @@ pub async fn donation_platform_create(
.insert(&**pool)
.await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
#[delete("donation_platform/{name}")]
@@ -337,7 +403,7 @@ pub async fn donation_platform_delete(
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
@@ -361,7 +427,7 @@ pub async fn report_type_create(
let _id = ReportType::builder().name(&name)?.insert(&**pool).await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
#[delete("report_type/{name}")]
@@ -383,7 +449,7 @@ pub async fn report_type_delete(
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}

View File

@@ -1,7 +1,7 @@
use crate::auth::get_user_from_headers;
use crate::database::models::notification_item::{NotificationActionBuilder, NotificationBuilder};
use crate::database::models::TeamMember;
use crate::models::ids::ModId;
use crate::models::ids::ProjectId;
use crate::models::teams::{Permissions, TeamId};
use crate::models::users::UserId;
use crate::routes::ApiError;
@@ -76,10 +76,7 @@ pub async fn join_team(
"You are already a member of this team".to_string(),
));
}
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mut transaction = pool.begin().await?;
// Edit Team Member to set Accepted to True
TeamMember::edit_team_member(
@@ -92,17 +89,14 @@ pub async fn join_team(
)
.await?;
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
transaction.commit().await?;
} else {
return Err(ApiError::InvalidInputError(
"There is no pending request from this team".to_string(),
));
}
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
fn default_role() -> String {
@@ -127,10 +121,7 @@ pub async fn add_team_member(
) -> Result<HttpResponse, ApiError> {
let team_id = info.into_inner().0.into();
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mut transaction = pool.begin().await?;
let current_user = get_user_from_headers(req.headers(), &**pool).await?;
let team_member =
@@ -181,8 +172,7 @@ pub async fn add_team_member(
}
crate::database::models::User::get(member.user_id, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.await?
.ok_or_else(|| ApiError::InvalidInputError("An invalid User ID specified".to_string()))?;
let new_id = crate::database::models::ids::generate_team_member_id(&mut transaction).await?;
@@ -195,8 +185,7 @@ pub async fn add_team_member(
accepted: false,
}
.insert(&mut transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
let result = sqlx::query!(
"
@@ -206,17 +195,16 @@ pub async fn add_team_member(
team_id as crate::database::models::ids::TeamId
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
let team: TeamId = team_id.into();
NotificationBuilder {
title: "You have been invited to join a team!".to_string(),
text: format!(
"Team invite from {} to join the team for mod {}",
"Team invite from {} to join the team for project {}",
current_user.username, result.title
),
link: format!("mod/{}", ModId(result.id as u64)),
link: format!("project/{}", ProjectId(result.id as u64)),
actions: vec![
NotificationActionBuilder {
title: "Accept".to_string(),
@@ -234,12 +222,9 @@ pub async fn add_team_member(
.insert(new_member.user_id.into(), &mut transaction)
.await?;
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
transaction.commit().await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
#[derive(Serialize, Deserialize, Clone)]
@@ -262,10 +247,7 @@ pub async fn edit_team_member(
let current_user = get_user_from_headers(req.headers(), &**pool).await?;
let team_member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?;
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mut transaction = pool.begin().await?;
let member = match team_member {
Some(m) => m,
@@ -306,12 +288,9 @@ pub async fn edit_team_member(
)
.await?;
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
transaction.commit().await?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
}
#[delete("{id}/members/{user_id}")]
@@ -371,7 +350,7 @@ pub async fn remove_team_member(
"You do not have permission to cancel a team invite".to_string(),
));
}
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}

View File

@@ -1,33 +1,28 @@
use crate::auth::get_user_from_headers;
use crate::database::models::User;
use crate::file_hosting::FileHost;
use crate::models::ids::ModId;
use crate::models::mods::ModStatus;
use crate::models::ids::ProjectId;
use crate::models::notifications::Notification;
use crate::models::projects::ProjectStatus;
use crate::models::users::{Role, UserId};
use crate::routes::notifications::convert_notification;
use crate::routes::ApiError;
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
use futures::StreamExt;
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
use validator::Validate;
#[get("user")]
pub async fn user_auth_get(
req: HttpRequest,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
Ok(HttpResponse::Ok().json(
get_user_from_headers(
req.headers(),
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?,
))
Ok(HttpResponse::Ok()
.json(get_user_from_headers(req.headers(), &mut *pool.acquire().await?).await?))
}
#[derive(Serialize, Deserialize)]
@@ -45,33 +40,13 @@ pub async fn users_get(
.map(|x| x.into())
.collect();
let users_data = User::get_many(user_ids, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let users_data = User::get_many(user_ids, &**pool).await?;
let users: Vec<crate::models::users::User> = users_data.into_iter().map(convert_user).collect();
Ok(HttpResponse::Ok().json(users))
}
#[get("@{id}")]
pub async fn user_username_get(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let user_data = User::get_from_username(id, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(data) = user_data {
let response = convert_user(data);
Ok(HttpResponse::Ok().json(response))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[get("{id}")]
pub async fn user_get(
info: web::Path<(String,)>,
@@ -83,19 +58,13 @@ pub async fn user_get(
let mut user_data;
if let Some(id) = id_option {
user_data = User::get(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
user_data = User::get(id.into(), &**pool).await?;
if user_data.is_none() {
user_data = User::get_from_username(string, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
user_data = User::get_from_username(string, &**pool).await?;
}
} else {
user_data = User::get_from_username(string, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
user_data = User::get_from_username(string, &**pool).await?;
}
if let Some(data) = user_data {
@@ -120,48 +89,35 @@ fn convert_user(data: crate::database::models::user_item::User) -> crate::models
}
}
#[get("{user_id}/mods")]
pub async fn mods_list(
#[get("{user_id}/projects")]
pub async fn projects_list(
req: HttpRequest,
info: web::Path<(UserId,)>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await.ok();
let id: crate::database::models::UserId = info.into_inner().0.into();
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
let user_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)",
id as crate::database::models::UserId,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.exists;
if user_exists.unwrap_or(false) {
if let Some(id) = id_option {
let user_id: UserId = id.into();
let mod_data = if let Some(current_user) = user {
let project_data = if let Some(current_user) = user {
if current_user.role.is_mod() || current_user.id == user_id {
User::get_mods_private(id, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
User::get_projects_private(id, &**pool).await?
} else {
User::get_mods(id, ModStatus::Approved.as_str(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
User::get_projects(id, ProjectStatus::Approved.as_str(), &**pool).await?
}
} else {
User::get_mods(id, ModStatus::Approved.as_str(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
User::get_projects(id, ProjectStatus::Approved.as_str(), &**pool).await?
};
let response = mod_data
let response = project_data
.into_iter()
.map(|v| v.into())
.collect::<Vec<crate::models::ids::ModId>>();
.collect::<Vec<crate::models::ids::ProjectId>>();
Ok(HttpResponse::Ok().json(response))
} else {
@@ -169,140 +125,147 @@ pub async fn mods_list(
}
}
#[derive(Serialize, Deserialize)]
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 = 255), 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 = 255), regex = "RE_URL_SAFE")]
pub name: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
#[validate(email)]
pub email: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
#[validate(length(max = 160))]
pub bio: Option<Option<String>>,
pub role: Option<String>,
pub role: Option<Role>,
}
#[patch("{id}")]
pub async fn user_edit(
req: HttpRequest,
info: web::Path<(UserId,)>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
new_user: web::Json<EditUser>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let user_id = info.into_inner().0;
let id: crate::database::models::ids::UserId = user_id.into();
new_user.validate()?;
if user.id == user_id || user.role.is_mod() {
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
if let Some(username) = &new_user.username {
sqlx::query!(
"
if let Some(id) = id_option {
let user_id: UserId = id.into();
if user.id == user_id || user.role.is_mod() {
let mut transaction = pool.begin().await?;
if let Some(username) = &new_user.username {
sqlx::query!(
"
UPDATE users
SET username = $1
WHERE (id = $2)
",
username,
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
username,
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(name) = &new_user.name {
sqlx::query!(
"
if let Some(name) = &new_user.name {
sqlx::query!(
"
UPDATE users
SET name = $1
WHERE (id = $2)
",
name.as_deref(),
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
name.as_deref(),
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(bio) = &new_user.bio {
sqlx::query!(
"
if let Some(bio) = &new_user.bio {
sqlx::query!(
"
UPDATE users
SET bio = $1
WHERE (id = $2)
",
bio.as_deref(),
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
bio.as_deref(),
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(email) = &new_user.email {
sqlx::query!(
"
if let Some(email) = &new_user.email {
sqlx::query!(
"
UPDATE users
SET email = $1
WHERE (id = $2)
",
email.as_deref(),
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(role) = &new_user.role {
if !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the role of this user!".to_string(),
));
email.as_deref(),
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
}
let role = Role::from_string(role).to_string();
if let Some(role) = &new_user.role {
if !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the role of this user!"
.to_string(),
));
}
sqlx::query!(
"
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
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
role,
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
}
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().body(""))
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::CustomAuthenticationError(
"You do not have permission to edit this user!".to_string(),
))
}
} else {
Err(ApiError::CustomAuthenticationError(
"You do not have permission to edit this user!".to_string(),
))
Ok(HttpResponse::NotFound().body(""))
}
}
@@ -315,81 +278,87 @@ pub struct Extension {
pub async fn user_icon_edit(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
info: web::Path<(UserId,)>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
mut payload: web::Payload,
) -> Result<HttpResponse, ApiError> {
if let Some(content_type) = super::mod_creation::get_image_content_type(&*ext.ext) {
if let Some(content_type) = super::project_creation::get_image_content_type(&*ext.ext) {
let cdn_url = dotenv::var("CDN_URL")?;
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
if user.id != id && !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to edit this user's icon.".to_string(),
));
}
let mut icon_url = user.avatar_url;
if user.id != id {
let new_user = User::get(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(new) = new_user {
icon_url = new.avatar_url;
} else {
return Ok(HttpResponse::NotFound().body(""));
if let Some(id) = id_option {
if user.id != id.into() && !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to edit this user's icon.".to_string(),
));
}
}
if let Some(icon) = icon_url {
if icon.starts_with(&cdn_url) {
let name = icon.split('/').next();
let mut icon_url = user.avatar_url;
if let Some(icon_path) = name {
file_host.delete_file_version("", icon_path).await?;
let user_id: UserId = id.into();
if user.id != user_id {
let new_user = User::get(id, &**pool).await?;
if let Some(new) = new_user {
icon_url = new.avatar_url;
} else {
return Ok(HttpResponse::NotFound().body(""));
}
}
}
let mut bytes = web::BytesMut::new();
while let Some(item) = payload.next().await {
bytes.extend_from_slice(&item.map_err(|_| {
ApiError::InvalidInputError("Unable to parse bytes in payload sent!".to_string())
})?);
}
if let Some(icon) = icon_url {
if icon.starts_with(&cdn_url) {
let name = icon.split('/').next();
if bytes.len() >= 262144 {
return Err(ApiError::InvalidInputError(String::from(
"Icons must be smaller than 256KiB",
)));
}
if let Some(icon_path) = name {
file_host.delete_file_version("", icon_path).await?;
}
}
}
let upload_data = file_host
.upload_file(
content_type,
&format!("user/{}/icon.{}", id, ext.ext),
bytes.to_vec(),
let mut bytes = web::BytesMut::new();
while let Some(item) = payload.next().await {
bytes.extend_from_slice(&item.map_err(|_| {
ApiError::InvalidInputError(
"Unable to parse bytes in payload sent!".to_string(),
)
})?);
}
if bytes.len() >= 262144 {
return Err(ApiError::InvalidInputError(String::from(
"Icons must be smaller than 256KiB",
)));
}
let upload_data = file_host
.upload_file(
content_type,
&format!("user/{}/icon.{}", user_id, ext.ext),
bytes.to_vec(),
)
.await?;
sqlx::query!(
"
UPDATE users
SET avatar_url = $1
WHERE (id = $2)
",
format!("{}/{}", cdn_url, upload_data.file_name),
id as crate::database::models::ids::UserId,
)
.execute(&**pool)
.await?;
let mod_id: crate::database::models::ids::UserId = id.into();
sqlx::query!(
"
UPDATE users
SET avatar_url = $1
WHERE (id = $2)
",
format!("{}/{}", cdn_url, upload_data.file_name),
mod_id as crate::database::models::ids::UserId,
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Err(ApiError::InvalidInputError(format!(
"Invalid format for user icon: {}",
@@ -411,32 +380,34 @@ fn default_removal() -> String {
#[delete("{id}")]
pub async fn user_delete(
req: HttpRequest,
info: web::Path<(UserId,)>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
removal_type: web::Query<RemovalType>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
if !user.role.is_mod() && user.id != id {
return Err(ApiError::CustomAuthenticationError(
"You do not have permission to delete this user!".to_string(),
));
}
if let Some(id) = id_option {
if !user.role.is_mod() && user.id != id.into() {
return Err(ApiError::CustomAuthenticationError(
"You do not have permission to delete this user!".to_string(),
));
}
let result;
if &*removal_type.removal_type == "full" {
result = crate::database::models::User::remove_full(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
} else {
result = crate::database::models::User::remove(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
};
let result;
if &*removal_type.removal_type == "full" {
result = crate::database::models::User::remove_full(id, &**pool).await?;
} else {
result = crate::database::models::User::remove(id, &**pool).await?;
};
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
@@ -445,59 +416,68 @@ pub async fn user_delete(
#[get("{id}/follows")]
pub async fn user_follows(
req: HttpRequest,
info: web::Path<(UserId,)>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
if !user.role.is_mod() && user.id != id {
return Err(ApiError::CustomAuthenticationError(
"You do not have permission to see the mods this user follows!".to_string(),
));
if let Some(id) = id_option {
if !user.role.is_mod() && user.id != id.into() {
return Err(ApiError::CustomAuthenticationError(
"You do not have permission to see the projects this user follows!".to_string(),
));
}
use futures::TryStreamExt;
let projects: Vec<ProjectId> = 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| ProjectId(m.mod_id as u64))) })
.try_collect::<Vec<ProjectId>>()
.await?;
Ok(HttpResponse::Ok().json(projects))
} else {
Ok(HttpResponse::NotFound().body(""))
}
use futures::TryStreamExt;
let user_id: crate::database::models::UserId = id.into();
let mods: Vec<ModId> = sqlx::query!(
"
SELECT mf.mod_id FROM mod_follows mf
WHERE mf.follower_id = $1
",
user_id as crate::database::models::ids::UserId,
)
.fetch_many(&**pool)
.try_filter_map(|e| async { Ok(e.right().map(|m| ModId(m.mod_id as u64))) })
.try_collect::<Vec<ModId>>()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().json(mods))
}
#[get("{id}/notifications")]
pub async fn user_notifications(
req: HttpRequest,
info: web::Path<(UserId,)>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
if !user.role.is_mod() && user.id != id {
return Err(ApiError::CustomAuthenticationError(
"You do not have permission to see the mods this user follows!".to_string(),
));
if let Some(id) = id_option {
if !user.role.is_mod() && user.id != id.into() {
return Err(ApiError::CustomAuthenticationError(
"You do not have permission to see the notifications of this user!".to_string(),
));
}
let notifications: Vec<Notification> =
crate::database::models::notification_item::Notification::get_many_user(id, &**pool)
.await?
.into_iter()
.map(convert_notification)
.collect();
Ok(HttpResponse::Ok().json(notifications))
} else {
Ok(HttpResponse::NotFound().body(""))
}
let notifications: Vec<Notification> =
crate::database::models::notification_item::Notification::get_many_user(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.into_iter()
.map(convert_notification)
.collect();
Ok(HttpResponse::Ok().json(notifications))
}

129
src/routes/v1/mod.rs Normal file
View File

@@ -0,0 +1,129 @@
use actix_web::web;
mod moderation;
mod mods;
mod reports;
mod tags;
mod users;
mod versions;
pub fn v1_config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/api/v1/")
.configure(super::auth_config)
.configure(tags_config)
.configure(mods_config)
.configure(versions_config)
.configure(teams_config)
.configure(users_config)
.configure(moderation_config)
.configure(reports_config)
.configure(notifications_config),
);
}
pub fn tags_config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/tag/")
.service(tags::category_list)
.service(tags::category_create)
.service(super::tags::category_delete)
.service(tags::loader_list)
.service(tags::loader_create)
.service(super::tags::loader_delete)
.service(super::tags::game_version_list)
.service(super::tags::game_version_create)
.service(super::tags::game_version_delete)
.service(super::tags::license_create)
.service(super::tags::license_delete)
.service(super::tags::license_list)
.service(super::tags::donation_platform_create)
.service(super::tags::donation_platform_list)
.service(super::tags::donation_platform_delete)
.service(super::tags::report_type_create)
.service(super::tags::report_type_delete)
.service(super::tags::report_type_list),
);
}
pub fn mods_config(cfg: &mut web::ServiceConfig) {
cfg.service(mods::mod_search);
cfg.service(mods::mods_get);
cfg.service(mods::mod_create);
cfg.service(
web::scope("mod")
.service(super::projects::project_get)
.service(super::projects::project_delete)
.service(super::projects::project_edit)
.service(super::projects::project_icon_edit)
.service(super::projects::project_follow)
.service(super::projects::project_unfollow)
.service(web::scope("{mod_id}").service(versions::version_list)),
);
}
pub fn versions_config(cfg: &mut web::ServiceConfig) {
cfg.service(versions::versions_get);
cfg.service(super::version_creation::version_create);
cfg.service(
web::scope("version")
.service(versions::version_get)
.service(super::versions::version_delete)
.service(super::version_creation::upload_file_to_version)
.service(super::versions::version_edit),
);
cfg.service(
web::scope("version_file")
.service(versions::delete_file)
.service(versions::get_version_from_hash)
.service(versions::download_version),
);
}
pub fn users_config(cfg: &mut web::ServiceConfig) {
cfg.service(super::users::user_auth_get);
cfg.service(super::users::users_get);
cfg.service(
web::scope("user")
.service(super::users::user_get)
.service(users::mods_list)
.service(super::users::user_delete)
.service(super::users::user_edit)
.service(super::users::user_icon_edit)
.service(super::users::user_notifications)
.service(super::users::user_follows),
);
}
pub fn teams_config(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("team")
.service(super::teams::team_members_get)
.service(super::teams::edit_team_member)
.service(super::teams::add_team_member)
.service(super::teams::join_team)
.service(super::teams::remove_team_member),
);
}
pub fn notifications_config(cfg: &mut web::ServiceConfig) {
cfg.service(super::notifications::notifications_get);
cfg.service(
web::scope("notification")
.service(super::notifications::notification_get)
.service(super::notifications::notification_delete),
);
}
pub fn moderation_config(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("moderation").service(moderation::get_mods));
}
pub fn reports_config(cfg: &mut web::ServiceConfig) {
cfg.service(reports::reports);
cfg.service(reports::report_create);
cfg.service(super::reports::delete_report);
}

View File

@@ -0,0 +1,44 @@
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 actix_web::web;
use actix_web::{get, HttpRequest, HttpResponse};
use sqlx::PgPool;
#[get("mods")]
pub async fn get_mods(
req: HttpRequest,
pool: web::Data<PgPool>,
count: web::Query<ResultCount>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(req.headers(), &**pool).await?;
use futures::stream::TryStreamExt;
let project_ids = sqlx::query!(
"
SELECT id FROM mods
WHERE status = (
SELECT id FROM statuses WHERE status = $1
)
ORDER BY updated 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<Project> = database::Project::get_many_full(project_ids, &**pool)
.await?
.into_iter()
.map(crate::routes::projects::convert_project)
.collect();
Ok(HttpResponse::Ok().json(projects))
}

172
src/routes/v1/mods.rs Normal file
View File

@@ -0,0 +1,172 @@
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};
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::{database, models};
use actix_multipart::Multipart;
use actix_web::web;
use actix_web::web::Data;
use actix_web::{get, post, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ResultSearchMod {
pub mod_id: String,
pub slug: Option<String>,
pub author: String,
pub title: String,
pub description: String,
pub categories: Vec<String>,
pub versions: Vec<String>,
pub downloads: i32,
pub follows: i32,
pub page_url: String,
pub icon_url: String,
pub author_url: String,
pub date_created: String,
pub date_modified: String,
pub latest_version: String,
pub license: String,
pub client_side: String,
pub server_side: String,
pub host: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct SearchResults {
pub hits: Vec<ResultSearchMod>,
pub offset: usize,
pub limit: usize,
pub total_hits: usize,
}
#[get("mod")]
pub async fn mod_search(
web::Query(info): web::Query<SearchRequest>,
config: web::Data<SearchConfig>,
) -> Result<HttpResponse, SearchError> {
let results = search_for_project(&info, &**config).await?;
Ok(HttpResponse::Ok().json(SearchResults {
hits: results
.hits
.into_iter()
.map(|x| ResultSearchMod {
mod_id: x.project_id.clone(),
slug: x.slug,
author: x.author.clone(),
title: x.title,
description: x.description,
categories: x.categories,
versions: x.versions,
downloads: x.downloads,
follows: x.follows,
page_url: format!("https://modrinth.com/mod/{}", x.project_id),
icon_url: x.icon_url,
author_url: format!("https://modrinth.com/user/{}", x.author),
date_created: x.date_created,
date_modified: x.date_modified,
latest_version: x.latest_version,
license: x.license,
client_side: x.client_side,
server_side: x.server_side,
host: "modrinth".to_string(),
})
.collect(),
offset: results.offset,
limit: results.limit,
total_hits: results.total_hits,
}))
}
#[get("mods")]
pub async fn mods_get(
req: HttpRequest,
ids: web::Query<ProjectIds>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let project_ids = serde_json::from_str::<Vec<models::ids::ProjectId>>(&*ids.ids)?
.into_iter()
.map(|x| x.into())
.collect();
let projects_data = database::models::Project::get_many_full(project_ids, &**pool).await?;
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
let mut projects = Vec::new();
for project_data in projects_data {
let mut authorized = !project_data.status.is_hidden();
if let Some(user) = &user_option {
if !authorized {
if user.role.is_mod() {
authorized = true;
} else {
let user_id: database::models::ids::UserId = user.id.into();
let project_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)",
project_data.inner.team_id as database::models::ids::TeamId,
user_id as database::models::ids::UserId,
)
.fetch_one(&**pool)
.await?
.exists;
authorized = project_exists.unwrap_or(false);
}
}
}
if authorized {
projects.push(convert_project(project_data));
}
}
Ok(HttpResponse::Ok().json(projects))
}
#[post("mod")]
pub async fn mod_create(
req: HttpRequest,
payload: Multipart,
client: Data<PgPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
indexing_queue: Data<Arc<CreationQueue>>,
) -> Result<HttpResponse, CreateError> {
let mut transaction = client.begin().await?;
let mut uploaded_files = Vec::new();
let result = project_create_inner(
req,
payload,
&mut transaction,
&***file_host,
&mut uploaded_files,
&***indexing_queue,
)
.await;
if result.is_err() {
let undo_result = undo_uploads(&***file_host, &uploaded_files).await;
let rollback_result = transaction.rollback().await;
if let Err(e) = undo_result {
return Err(e);
}
if let Err(e) = rollback_result {
return Err(e.into());
}
} else {
transaction.commit().await?;
}
result
}

195
src/routes/v1/reports.rs Normal file
View File

@@ -0,0 +1,195 @@
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 actix_web::web;
use actix_web::{get, post, HttpRequest, HttpResponse};
use chrono::{DateTime, Utc};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
#[derive(Serialize, Deserialize)]
pub struct Report {
pub id: ReportId,
pub report_type: String,
pub item_id: String,
pub item_type: ItemType,
pub reporter: UserId,
pub body: String,
pub created: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub enum ItemType {
Mod,
Version,
User,
Unknown,
}
impl ItemType {
pub fn as_str(&self) -> &'static str {
match self {
ItemType::Mod => "mod",
ItemType::Version => "version",
ItemType::User => "user",
ItemType::Unknown => "unknown",
}
}
}
#[derive(Deserialize)]
pub struct CreateReport {
pub report_type: String,
pub item_id: String,
pub item_type: ItemType,
pub body: String,
}
#[post("report")]
pub async fn report_create(
req: HttpRequest,
pool: web::Data<PgPool>,
mut body: web::Payload,
) -> Result<HttpResponse, ApiError> {
let mut transaction = pool.begin().await?;
let current_user = get_user_from_headers(req.headers(), &mut *transaction).await?;
let mut bytes = web::BytesMut::new();
while let Some(item) = body.next().await {
bytes.extend_from_slice(&item.map_err(|_| {
ApiError::InvalidInputError("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::InvalidInputError(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: chrono::Utc::now(),
};
match new_report.item_type {
ItemType::Mod => {
report.project_id = Some(
serde_json::from_str::<ProjectId>(&*format!("\"{}\"", new_report.item_id))?.into(),
)
}
ItemType::Version => {
report.version_id = Some(
serde_json::from_str::<VersionId>(&*format!("\"{}\"", new_report.item_id))?.into(),
)
}
ItemType::User => {
report.user_id = Some(
serde_json::from_str::<UserId>(&*format!("\"{}\"", new_report.item_id))?.into(),
)
}
ItemType::Unknown => {
return Err(ApiError::InvalidInputError(format!(
"Invalid report item type: {}",
new_report.item_type.as_str()
)))
}
}
report.insert(&mut transaction).await?;
transaction.commit().await?;
Ok(HttpResponse::Ok().json(Report {
id: id.into(),
report_type: new_report.report_type.clone(),
item_id: new_report.item_id.clone(),
item_type: new_report.item_type.clone(),
reporter: current_user.id,
body: new_report.body.clone(),
created: chrono::Utc::now(),
}))
}
#[derive(Deserialize)]
pub struct ResultCount {
#[serde(default = "default_count")]
count: i16,
}
fn default_count() -> i16 {
100
}
#[get("report")]
pub async fn reports(
req: HttpRequest,
pool: web::Data<PgPool>,
count: web::Query<ResultCount>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(req.headers(), &**pool).await?;
use futures::stream::TryStreamExt;
let report_ids = sqlx::query!(
"
SELECT id FROM reports
ORDER BY created ASC
LIMIT $1;
",
count.count as i64
)
.fetch_many(&**pool)
.try_filter_map(|e| async {
Ok(e.right()
.map(|m| crate::database::models::ids::ReportId(m.id)))
})
.try_collect::<Vec<crate::database::models::ids::ReportId>>()
.await?;
let query_reports =
crate::database::models::report_item::Report::get_many(report_ids, &**pool).await?;
let mut reports = Vec::new();
for x in query_reports {
let mut item_id = "".to_string();
let mut item_type = ItemType::Unknown;
if let Some(project_id) = x.project_id {
item_id = serde_json::to_string::<ProjectId>(&project_id.into())?;
item_type = ItemType::Mod;
} else if let Some(version_id) = x.version_id {
item_id = serde_json::to_string::<VersionId>(&version_id.into())?;
item_type = ItemType::Version;
} else if let Some(user_id) = x.user_id {
item_id = serde_json::to_string::<UserId>(&user_id.into())?;
item_type = ItemType::User;
}
reports.push(Report {
id: x.id.into(),
report_type: x.report_type,
item_id,
item_type,
reporter: x.reporter.into(),
body: x.body,
created: x.created,
})
}
Ok(HttpResponse::Ok().json(reports))
}

81
src/routes/v1/tags.rs Normal file
View File

@@ -0,0 +1,81 @@
use crate::auth::check_is_admin_from_headers;
use crate::database::models::categories::{Category, Loader, ProjectType};
use crate::routes::ApiError;
use actix_web::{get, put, web};
use actix_web::{HttpRequest, HttpResponse};
use sqlx::PgPool;
const DEFAULT_ICON: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>"#;
#[get("category")]
pub async fn category_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results = Category::list(&**pool)
.await?
.into_iter()
.filter(|x| &*x.project_type == "mod")
.map(|x| x.project_type)
.collect::<Vec<String>>();
Ok(HttpResponse::Ok().json(results))
}
#[put("category/{name}")]
pub async fn category_create(
req: HttpRequest,
pool: web::Data<PgPool>,
category: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = category.into_inner().0;
let project_type = crate::database::models::ProjectTypeId::get_id("mod".to_string(), &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError("Specified project type does not exist!".to_string())
})?;
let _id = Category::builder()
.name(&name)?
.icon(DEFAULT_ICON)?
.project_type(&project_type)?
.insert(&**pool)
.await?;
Ok(HttpResponse::NoContent().body(""))
}
#[get("loader")]
pub async fn loader_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results = Loader::list(&**pool)
.await?
.into_iter()
.filter(|x| x.supported_project_types.contains(&"mod".to_string()))
.map(|x| x.loader)
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(results))
}
#[put("loader/{name}")]
pub async fn loader_create(
req: HttpRequest,
pool: web::Data<PgPool>,
loader: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = loader.into_inner().0;
let mut transaction = pool.begin().await?;
let project_types =
ProjectType::get_many_id(&vec!["mod".to_string()], &mut *transaction).await?;
let _id = Loader::builder()
.name(&name)?
.icon(DEFAULT_ICON)?
.supported_project_types(&*project_types.into_iter().map(|x| x.id).collect::<Vec<_>>())?
.insert(&mut transaction)
.await?;
Ok(HttpResponse::NoContent().body(""))
}

44
src/routes/v1/users.rs Normal file
View File

@@ -0,0 +1,44 @@
use crate::auth::get_user_from_headers;
use crate::database::models::User;
use crate::models::ids::UserId;
use crate::models::projects::ProjectStatus;
use crate::routes::ApiError;
use actix_web::web;
use actix_web::{get, HttpRequest, HttpResponse};
use sqlx::PgPool;
#[get("{user_id}/mods")]
pub async fn mods_list(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await.ok();
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
if let Some(id) = id_option {
let user_id: UserId = id.into();
let project_data = if let Some(current_user) = user {
if current_user.role.is_mod() || current_user.id == user_id {
User::get_projects_private(id, &**pool).await?
} else {
User::get_projects(id, ProjectStatus::Approved.as_str(), &**pool).await?
}
} else {
User::get_projects(id, ProjectStatus::Approved.as_str(), &**pool).await?
};
let response = project_data
.into_iter()
.map(|v| v.into())
.collect::<Vec<crate::models::ids::ProjectId>>();
Ok(HttpResponse::Ok().json(response))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

416
src/routes/v1/versions.rs Normal file
View File

@@ -0,0 +1,416 @@
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::{database, models, Pepper};
use actix_web::{delete, get, web, HttpRequest, HttpResponse};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::borrow::Borrow;
use std::sync::Arc;
/// A specific version of a mod
#[derive(Serialize, Deserialize)]
pub struct LegacyVersion {
pub id: VersionId,
pub mod_id: ProjectId,
pub author_id: UserId,
pub featured: bool,
pub name: String,
pub version_number: String,
pub changelog: String,
pub changelog_url: Option<String>,
pub date_published: DateTime<Utc>,
pub downloads: u32,
pub version_type: VersionType,
pub files: Vec<VersionFile>,
pub dependencies: Vec<Dependency>,
pub game_versions: Vec<GameVersion>,
pub loaders: Vec<Loader>,
}
fn convert_to_legacy(version: Version) -> LegacyVersion {
LegacyVersion {
id: version.id,
mod_id: version.project_id,
author_id: version.author_id,
featured: version.featured,
name: version.name,
version_number: version.version_number,
changelog: version.changelog,
changelog_url: version.changelog_url,
date_published: version.date_published,
downloads: version.downloads,
version_type: version.version_type,
files: version.files,
dependencies: version.dependencies,
game_versions: version.game_versions,
loaders: version.loaders,
}
}
#[get("version")]
pub async fn version_list(
info: web::Path<(String,)>,
web::Query(filters): web::Query<VersionListFilters>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
let result = database::models::Project::get_from_slug_or_project_id(string, &**pool).await?;
if let Some(project) = result {
let id = project.id;
let version_ids = database::models::Version::get_project_versions(
id,
filters
.game_versions
.as_ref()
.map(|x| serde_json::from_str(x).unwrap_or_default()),
filters
.loaders
.as_ref()
.map(|x| serde_json::from_str(x).unwrap_or_default()),
&**pool,
)
.await?;
let mut versions = database::models::Version::get_many_full(version_ids, &**pool).await?;
let mut response = versions
.iter()
.cloned()
.filter(|version| {
filters
.featured
.map(|featured| featured == version.featured)
.unwrap_or(true)
})
.map(convert_version)
.map(convert_to_legacy)
.collect::<Vec<_>>();
versions.sort_by(|a, b| b.date_published.cmp(&a.date_published));
// Attempt to populate versions with "auto featured" versions
if response.is_empty() && !versions.is_empty() && filters.featured.unwrap_or(false) {
let loaders = database::models::categories::Loader::list(&**pool).await?;
let game_versions =
database::models::categories::GameVersion::list_filter(None, Some(true), &**pool)
.await?;
let mut joined_filters = Vec::new();
for game_version in &game_versions {
for loader in &loaders {
joined_filters.push((game_version, loader))
}
}
joined_filters.into_iter().for_each(|filter| {
versions
.iter()
.find(|version| {
version.game_versions.contains(&filter.0.version)
&& version.loaders.contains(&filter.1.loader)
})
.map(|version| {
response.push(convert_to_legacy(convert_version(version.clone())))
})
.unwrap_or(());
});
if response.is_empty() {
versions
.into_iter()
.for_each(|version| response.push(convert_to_legacy(convert_version(version))));
}
}
response.sort_by(|a, b| b.date_published.cmp(&a.date_published));
response.dedup_by(|a, b| a.id == b.id);
Ok(HttpResponse::Ok().json(response))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[get("versions")]
pub async fn versions_get(
ids: web::Query<VersionIds>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let version_ids = serde_json::from_str::<Vec<models::ids::VersionId>>(&*ids.ids)?
.into_iter()
.map(|x| x.into())
.collect();
let versions_data = database::models::Version::get_many_full(version_ids, &**pool).await?;
let mut versions = Vec::new();
for version_data in versions_data {
versions.push(convert_to_legacy(convert_version(version_data)));
}
Ok(HttpResponse::Ok().json(versions))
}
#[get("{version_id}")]
pub async fn version_get(
info: web::Path<(models::ids::VersionId,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let version_data = database::models::Version::get_full(id.into(), &**pool).await?;
if let Some(data) = version_data {
Ok(HttpResponse::Ok().json(convert_to_legacy(convert_version(data))))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Deserialize)]
pub struct Algorithm {
#[serde(default = "default_algorithm")]
algorithm: String,
}
fn default_algorithm() -> String {
"sha1".into()
}
// under /api/v1/version_file/{hash}
#[get("{version_id}")]
pub async fn get_version_from_hash(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
algorithm: web::Query<Algorithm>,
) -> Result<HttpResponse, ApiError> {
let hash = info.into_inner().0.to_lowercase();
let result = sqlx::query!(
"
SELECT f.version_id version_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await?;
if let Some(id) = result {
let version_data = database::models::Version::get_full(
database::models::VersionId(id.version_id),
&**pool,
)
.await?;
if let Some(data) = version_data {
Ok(HttpResponse::Ok().json(super::versions::convert_version(data)))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Serialize, Deserialize)]
pub struct DownloadRedirect {
pub url: String,
}
// under /api/v1/version_file/{hash}/download
#[allow(clippy::await_holding_refcell_ref)]
#[get("{version_id}/download")]
pub async fn download_version(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
algorithm: web::Query<Algorithm>,
pepper: web::Data<Pepper>,
) -> Result<HttpResponse, ApiError> {
let hash = info.into_inner().0;
let result = sqlx::query!(
"
SELECT f.url url, f.id id, f.version_id version_id, v.mod_id mod_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(id) = result {
let real_ip = req.connection_info();
let ip_option = real_ip.borrow().remote_addr();
if let Some(ip) = ip_option {
let hash = sha1::Sha1::from(format!("{}{}", ip, pepper.pepper)).hexdigest();
let download_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM downloads WHERE version_id = $1 AND date > (CURRENT_DATE - INTERVAL '30 minutes ago') AND identifier = $2)",
id.version_id,
hash,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.exists.unwrap_or(false);
if !download_exists {
sqlx::query!(
"
INSERT INTO downloads (
version_id, identifier
)
VALUES (
$1, $2
)
",
id.version_id,
hash
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
UPDATE versions
SET downloads = downloads + 1
WHERE id = $1
",
id.version_id,
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
UPDATE mods
SET downloads = downloads + 1
WHERE id = $1
",
id.mod_id,
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
}
Ok(HttpResponse::TemporaryRedirect()
.header("Location", &*id.url)
.json(DownloadRedirect { url: id.url }))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
// under /api/v1/version_file/{hash}
#[delete("{version_id}")]
pub async fn delete_file(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
algorithm: web::Query<Algorithm>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let hash = info.into_inner().0.to_lowercase();
let result = sqlx::query!(
"
SELECT f.id id, f.version_id version_id, f.filename filename, v.version_number version_number, v.mod_id project_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await
?;
if let Some(row) = result {
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id_version(
database::models::ids::VersionId(row.version_id),
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::DatabaseError)?
.ok_or_else(|| {
ApiError::CustomAuthenticationError(
"You don't have permission to delete this file!".to_string(),
)
})?;
if !team_member
.permissions
.contains(Permissions::DELETE_VERSION)
{
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to delete this file!".to_string(),
));
}
}
let mut transaction = pool.begin().await?;
sqlx::query!(
"
DELETE FROM hashes
WHERE file_id = $1
",
row.id
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
DELETE FROM files
WHERE files.id = $1
",
row.id,
)
.execute(&mut *transaction)
.await?;
let project_id: models::projects::ProjectId =
database::models::ids::ProjectId(row.project_id).into();
file_host
.delete_file_version(
"",
&format!(
"data/{}/versions/{}/{}",
project_id, row.version_number, row.filename
),
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

View File

@@ -3,29 +3,43 @@ use crate::database::models;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::version_item::{VersionBuilder, VersionFileBuilder};
use crate::file_hosting::FileHost;
use crate::models::mods::{
Dependency, GameVersion, ModId, ModLoader, Version, VersionFile, VersionId, VersionType,
use crate::models::projects::{
Dependency, GameVersion, Loader, ProjectId, Version, VersionFile, VersionId, VersionType,
};
use crate::models::teams::Permissions;
use crate::routes::mod_creation::{CreateError, UploadedFile};
use crate::routes::project_creation::{CreateError, UploadedFile};
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;
#[derive(Serialize, Deserialize, Clone)]
lazy_static! {
static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_\-.]*$").unwrap();
}
#[derive(Serialize, Deserialize, Validate, Clone)]
pub struct InitialVersionData {
pub mod_id: Option<ModId>,
#[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")]
pub version_number: String,
#[validate(length(min = 3, max = 256))]
pub version_title: String,
#[validate(length(max = 65536))]
pub version_body: Option<String>,
#[validate(length(min = 0, max = 256))]
pub dependencies: Vec<Dependency>,
pub game_versions: Vec<GameVersion>,
pub release_channel: VersionType,
pub loaders: Vec<ModLoader>,
pub loaders: Vec<Loader>,
pub featured: bool,
}
@@ -34,42 +48,6 @@ struct InitialFileData {
// TODO: hashes?
}
pub fn check_version(version: &InitialVersionData) -> Result<(), CreateError> {
/*
# InitialVersionData
file_parts: Vec<String>, 1..=256
version_number: 1..=64,
version_title: 3..=256,
version_body: max of 64KiB,
game_versions: Vec<GameVersion>, 1..=256
release_channel: VersionType,
loaders: Vec<ModLoader>, 1..=256
*/
use super::mod_creation::check_length;
version
.file_parts
.iter()
.try_for_each(|f| check_length(1..=256, "file part name", f))?;
check_length(1..=64, "version number", &version.version_number)?;
check_length(3..=256, "version title", &version.version_title)?;
if let Some(body) = &version.version_body {
check_length(..65536, "version body", body)?;
}
version
.game_versions
.iter()
.try_for_each(|v| check_length(1..=256, "game version", &v.0))?;
version
.loaders
.iter()
.try_for_each(|l| check_length(1..=256, "loader name", &l.0))?;
Ok(())
}
// under `/api/v1/version`
#[post("version")]
pub async fn version_create(
@@ -91,7 +69,8 @@ pub async fn version_create(
.await;
if result.is_err() {
let undo_result = super::mod_creation::undo_uploads(&***file_host, &uploaded_files).await;
let undo_result =
super::project_creation::undo_uploads(&***file_host, &uploaded_files).await;
let rollback_result = transaction.rollback().await;
if let Err(e) = undo_result {
@@ -119,6 +98,8 @@ async fn version_create_inner(
let mut initial_version_data = None;
let mut version_builder = None;
let all_game_versions = models::categories::GameVersion::list(&mut *transaction).await?;
let user = get_user_from_headers(req.headers(), &mut *transaction).await?;
while let Some(item) = payload.next().await {
@@ -139,34 +120,36 @@ async fn version_create_inner(
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.mod_id.is_none() {
return Err(CreateError::MissingValueError("Missing mod id".to_string()));
if version_create_data.project_id.is_none() {
return Err(CreateError::MissingValueError(
"Missing project id".to_string(),
));
}
check_version(version_create_data)?;
version_create_data.validate()?;
let mod_id: models::ModId = version_create_data.mod_id.unwrap().into();
let project_id: models::ProjectId = version_create_data.project_id.unwrap().into();
// Ensure that the mod this version is being added to exists
// Ensure that the project this version is being added to exists
let results = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)",
mod_id as models::ModId
project_id as models::ProjectId
)
.fetch_one(&mut *transaction)
.await?;
if !results.exists.unwrap_or(false) {
return Err(CreateError::InvalidInput(
"An invalid mod id was supplied".to_string(),
"An invalid project id was supplied".to_string(),
));
}
// Check whether there is already a version of this mod with the
// Check whether there is already a version of this project with the
// same version number
let results = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM versions WHERE (version_number=$1) AND (mod_id=$2))",
version_create_data.version_number,
mod_id as models::ModId,
project_id as models::ProjectId,
)
.fetch_one(&mut *transaction)
.await?;
@@ -178,15 +161,18 @@ async fn version_create_inner(
}
// Check that the user creating this version is a team member
// of the mod the version is being added to.
let team_member =
models::TeamMember::get_from_user_id_mod(mod_id, user.id.into(), &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::CustomAuthenticationError(
"You don't have permission to upload this version!".to_string(),
)
})?;
// of the project the version is being added to.
let team_member = models::TeamMember::get_from_user_id_project(
project_id,
user.id.into(),
&mut *transaction,
)
.await?
.ok_or_else(|| {
CreateError::CustomAuthenticationError(
"You don't have permission to upload this version!".to_string(),
)
})?;
if !team_member
.permissions
@@ -206,13 +192,17 @@ async fn version_create_inner(
.await?
.expect("Release channel not found in database");
let mut game_versions = Vec::with_capacity(version_create_data.game_versions.len());
for v in &version_create_data.game_versions {
let id = models::categories::GameVersion::get_id(&v.0, &mut *transaction)
.await?
.ok_or_else(|| CreateError::InvalidGameVersion(v.0.clone()))?;
game_versions.push(id);
}
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 mut loaders = Vec::with_capacity(version_create_data.loaders.len());
for l in &version_create_data.loaders {
@@ -230,7 +220,7 @@ async fn version_create_inner(
version_builder = Some(VersionBuilder {
version_id: version_id.into(),
mod_id: version_create_data.mod_id.unwrap().into(),
project_id,
author_id: user.id.into(),
name: version_create_data.version_title.clone(),
version_number: version_create_data.version_number.clone(),
@@ -253,19 +243,38 @@ async fn version_create_inner(
CreateError::InvalidInput(String::from("`data` field must come before file fields"))
})?;
let file_builder = upload_file(
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,
uploaded_files,
&mut version.files,
&cdn_url,
&content_disposition,
version.mod_id.into(),
version.project_id.into(),
&version.version_number,
&*project_type,
version_data.loaders,
version_data.game_versions,
&all_game_versions,
false,
)
.await?;
// Add the newly uploaded file to the existing or new version
version.files.push(file_builder);
}
let version_data = initial_version_data
@@ -278,7 +287,7 @@ async fn version_create_inner(
SELECT m.title FROM mods m
WHERE id = $1
",
builder.mod_id as crate::database::models::ids::ModId
builder.project_id as crate::database::models::ids::ProjectId
)
.fetch_one(&mut *transaction)
.await?;
@@ -290,7 +299,7 @@ async fn version_create_inner(
SELECT follower_id FROM mod_follows
WHERE mod_id = $1
",
builder.mod_id as crate::database::models::ids::ModId
builder.project_id as crate::database::models::ids::ProjectId
)
.fetch_many(&mut *transaction)
.try_filter_map(|e| async {
@@ -300,17 +309,17 @@ async fn version_create_inner(
.try_collect::<Vec<crate::database::models::ids::UserId>>()
.await?;
let mod_id: ModId = builder.mod_id.into();
let project_id: ProjectId = builder.project_id.into();
let version_id: VersionId = builder.version_id.into();
NotificationBuilder {
title: "A mod you followed has been updated!".to_string(),
title: "A project you followed has been updated!".to_string(),
text: format!(
"Mod {} has been updated to version {}",
"Project {} has been updated to version {}",
result.title,
version_data.version_number.clone()
),
link: format!("mod/{}/version/{}", mod_id, version_id),
link: format!("project/{}/version/{}", project_id, version_id),
actions: vec![],
}
.insert_many(users, &mut *transaction)
@@ -318,7 +327,7 @@ async fn version_create_inner(
let response = Version {
id: builder.version_id.into(),
mod_id: builder.mod_id.into(),
project_id: builder.project_id.into(),
author_id: user.id,
featured: builder.featured,
name: builder.name.clone(),
@@ -388,7 +397,8 @@ pub async fn upload_file_to_version(
.await;
if result.is_err() {
let undo_result = super::mod_creation::undo_uploads(&***file_host, &uploaded_files).await;
let undo_result =
super::project_creation::undo_uploads(&***file_host, &uploaded_files).await;
let rollback_result = transaction.rollback().await;
if let Err(e) = undo_result {
@@ -419,16 +429,7 @@ async fn upload_file_to_version_inner(
let user = get_user_from_headers(req.headers(), &mut *transaction).await?;
let result = sqlx::query!(
"
SELECT mod_id, version_number, author_id
FROM versions
WHERE id = $1
",
version_id as models::VersionId,
)
.fetch_optional(&mut *transaction)
.await?;
let result = models::Version::get_full(version_id, &mut *transaction).await?;
let version = match result {
Some(v) => v,
@@ -457,9 +458,23 @@ async fn upload_file_to_version_inner(
));
}
let mod_id = ModId(version.mod_id as u64);
let project_id = ProjectId(version.project_id.0 as u64);
let version_number = version.version_number;
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 all_game_versions = models::categories::GameVersion::list(&mut *transaction).await?;
while let Some(item) = payload.next().await {
let mut field: Field = item.map_err(CreateError::MultipartError)?;
let content_disposition = field.content_disposition().ok_or_else(|| {
@@ -485,19 +500,27 @@ async fn upload_file_to_version_inner(
CreateError::InvalidInput(String::from("`data` field must come before file fields"))
})?;
let file_builder = upload_file(
upload_file(
&mut field,
file_host,
uploaded_files,
&mut file_builders,
&cdn_url,
&content_disposition,
mod_id,
project_id,
&version_number,
&*project_type,
version.loaders.clone().into_iter().map(Loader).collect(),
version
.game_versions
.clone()
.into_iter()
.map(GameVersion)
.collect(),
&all_game_versions,
true,
)
.await?;
// TODO: Malware scan + file validation
file_builders.push(file_builder);
}
if file_builders.is_empty() {
@@ -514,19 +537,26 @@ async fn upload_file_to_version_inner(
}
// 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 mod
// 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,
uploaded_files: &mut Vec<UploadedFile>,
version_files: &mut Vec<models::version_item::VersionFileBuilder>,
cdn_url: &str,
content_disposition: &actix_web::http::header::ContentDisposition,
mod_id: crate::models::ids::ModId,
project_id: crate::models::ids::ProjectId,
version_number: &str,
) -> Result<models::version_item::VersionFileBuilder, CreateError> {
project_type: &str,
loaders: Vec<Loader>,
game_versions: Vec<GameVersion>,
all_game_versions: &[models::categories::GameVersion],
ignore_primary: bool,
) -> Result<(), CreateError> {
let (file_name, file_extension) = get_name_ext(content_disposition)?;
let content_type = mod_file_type(file_extension)
let content_type = project_file_type(file_extension)
.ok_or_else(|| CreateError::InvalidFileType(file_extension.to_string()))?;
let mut data = Vec::new();
@@ -534,20 +564,32 @@ pub async fn upload_file(
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
}
// Mod file size limit of 25MiB
// Project file size limit of 25MiB
const FILE_SIZE_CAP: usize = 25 * (2 << 30);
// TODO: override file size cap for authorized users or mods
// TODO: override file size cap for authorized users or projects
if data.len() >= FILE_SIZE_CAP {
return Err(CreateError::InvalidInput(
String::from("Mod file exceeds the maximum of 25MiB. Contact a moderator or admin to request permission to upload larger files.")
String::from("Project file exceeds the maximum of 25MiB. Contact a moderator or admin to request permission to upload larger files.")
));
}
let validation_result = validate_file(
data.as_slice(),
file_extension,
project_type,
loaders,
game_versions,
all_game_versions,
)?;
let upload_data = file_host
.upload_file(
content_type,
&format!("data/{}/versions/{}/{}", mod_id, version_number, file_name),
&format!(
"data/{}/versions/{}/{}",
project_id, version_number, file_name
),
data.to_vec(),
)
.await?;
@@ -558,7 +600,7 @@ pub async fn upload_file(
});
// TODO: Malware scan + file validation
Ok(models::version_item::VersionFileBuilder {
version_files.push(models::version_item::VersionFileBuilder {
filename: file_name.to_string(),
url: format!("{}/{}", cdn_url, upload_data.file_name),
hashes: vec![
@@ -575,12 +617,16 @@ pub async fn upload_file(
hash: upload_data.content_sha512.into_bytes(),
},
],
primary: uploaded_files.len() == 1,
})
primary: validation_result == ValidationResult::Pass
&& version_files.iter().all(|x| !x.primary)
&& !ignore_primary,
});
Ok(())
}
// Currently we only support jar mods; this may change in the future (datapacks?)
fn mod_file_type(ext: &str) -> Option<&str> {
// Currently we only support jar projects; this may change in the future (datapacks?)
fn project_file_type(ext: &str) -> Option<&str> {
match ext {
"jar" => Some("application/java-archive"),
_ => None,

519
src/routes/version_file.rs Normal file
View File

@@ -0,0 +1,519 @@
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::{database, Pepper};
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::borrow::Borrow;
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Deserialize)]
pub struct Algorithm {
#[serde(default = "default_algorithm")]
algorithm: String,
}
fn default_algorithm() -> String {
"sha1".into()
}
// under /api/v1/version_file/{hash}
#[get("{version_id}")]
pub async fn get_version_from_hash(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
algorithm: web::Query<Algorithm>,
) -> Result<HttpResponse, ApiError> {
let hash = info.into_inner().0.to_lowercase();
let result = sqlx::query!(
"
SELECT f.version_id version_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await?;
if let Some(id) = result {
let version_data = database::models::Version::get_full(
database::models::VersionId(id.version_id),
&**pool,
)
.await?;
if let Some(data) = version_data {
Ok(HttpResponse::Ok().json(super::versions::convert_version(data)))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Serialize, Deserialize)]
pub struct DownloadRedirect {
pub url: String,
}
// under /api/v1/version_file/{hash}/download
#[get("{version_id}/download")]
pub async fn download_version(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
algorithm: web::Query<Algorithm>,
pepper: web::Data<Pepper>,
) -> Result<HttpResponse, ApiError> {
let hash = info.into_inner().0.to_lowercase();
let mut transaction = pool.begin().await?;
let result = sqlx::query!(
"
SELECT f.url url, f.id id, f.version_id version_id, v.mod_id project_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&mut *transaction)
.await?;
if let Some(id) = result {
download_version_inner(
database::models::VersionId(id.version_id),
database::models::ProjectId(id.project_id),
&req,
&mut transaction,
&pepper,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::TemporaryRedirect()
.header("Location", &*id.url)
.json(DownloadRedirect { url: id.url }))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
async fn download_version_inner(
version_id: database::models::VersionId,
project_id: database::models::ProjectId,
req: &HttpRequest,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
pepper: &web::Data<Pepper>,
) -> Result<(), ApiError> {
let real_ip = req.connection_info();
let ip_option = real_ip.borrow().remote_addr();
if let Some(ip) = ip_option {
let hash = sha1::Sha1::from(format!("{}{}", ip, pepper.pepper)).hexdigest();
let download_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM downloads WHERE version_id = $1 AND date > (CURRENT_DATE - INTERVAL '30 minutes ago') AND identifier = $2)",
version_id as database::models::VersionId,
hash,
)
.fetch_one(&mut *transaction)
.await
?
.exists.unwrap_or(false);
if !download_exists {
sqlx::query!(
"
INSERT INTO downloads (
version_id, identifier
)
VALUES (
$1, $2
)
",
version_id as database::models::VersionId,
hash
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
UPDATE versions
SET downloads = downloads + 1
WHERE id = $1
",
version_id as database::models::VersionId,
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
UPDATE mods
SET downloads = downloads + 1
WHERE id = $1
",
project_id as database::models::ProjectId,
)
.execute(&mut *transaction)
.await?;
}
}
Ok(())
}
// under /api/v1/version_file/{hash}
#[delete("{version_id}")]
pub async fn delete_file(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
algorithm: web::Query<Algorithm>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let hash = info.into_inner().0.to_lowercase();
let result = sqlx::query!(
"
SELECT f.id id, f.version_id version_id, f.filename filename, v.version_number version_number, v.mod_id project_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await
?;
if let Some(row) = result {
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id_version(
database::models::ids::VersionId(row.version_id),
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::DatabaseError)?
.ok_or_else(|| {
ApiError::CustomAuthenticationError(
"You don't have permission to delete this file!".to_string(),
)
})?;
if !team_member
.permissions
.contains(Permissions::DELETE_VERSION)
{
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to delete this file!".to_string(),
));
}
}
let mut transaction = pool.begin().await?;
sqlx::query!(
"
DELETE FROM hashes
WHERE file_id = $1
",
row.id
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"
DELETE FROM files
WHERE files.id = $1
",
row.id,
)
.execute(&mut *transaction)
.await?;
let project_id: models::projects::ProjectId =
database::models::ids::ProjectId(row.project_id).into();
file_host
.delete_file_version(
"",
&format!(
"data/{}/versions/{}/{}",
project_id, row.version_number, row.filename
),
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Deserialize)]
pub struct UpdateData {
pub hash: (String, String),
pub loaders: Vec<Loader>,
pub game_versions: Vec<GameVersion>,
}
#[post("{version_id}/update")]
pub async fn get_update_from_hash(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
algorithm: web::Query<Algorithm>,
update_data: web::Json<UpdateData>,
) -> Result<HttpResponse, ApiError> {
let hash = info.into_inner().0.to_lowercase();
// get version_id from hash
// get mod_id from hash
// get latest version satisfying conditions - if not found
let result = sqlx::query!(
"
SELECT v.mod_id project_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await?;
if let Some(id) = result {
let version_ids = database::models::Version::get_project_versions(
database::models::ProjectId(id.project_id),
Some(
update_data
.game_versions
.clone()
.into_iter()
.map(|x| x.0)
.collect(),
),
Some(
update_data
.loaders
.clone()
.into_iter()
.map(|x| x.0)
.collect(),
),
&**pool,
)
.await?;
if let Some(version_id) = version_ids.last() {
let version_data = database::models::Version::get_full(*version_id, &**pool).await?;
if let Some(data) = version_data {
Ok(HttpResponse::Ok().json(super::versions::convert_version(data)))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
// Requests above with multiple versions below
#[derive(Deserialize)]
pub struct FileHashes {
pub algorithm: String,
pub hashes: Vec<String>,
}
// under /api/v2/version_files
#[post("/")]
pub async fn get_versions_from_hashes(
pool: web::Data<PgPool>,
file_data: web::Json<FileHashes>,
) -> Result<HttpResponse, ApiError> {
let hashes_parsed: Vec<Vec<u8>> = file_data
.hashes
.iter()
.map(|x| x.as_bytes().to_vec())
.collect();
let result = sqlx::query!(
"
SELECT h.hash hash, h.algorithm algorithm, f.version_id version_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
WHERE h.algorithm = $2 AND h.hash IN (SELECT * FROM UNNEST($1::bytea[]))
",
hashes_parsed.as_slice(),
file_data.algorithm
)
.fetch_all(&**pool)
.await?;
let versions_data = database::models::Version::get_many_full(
result
.iter()
.map(|x| database::models::VersionId(x.version_id))
.collect(),
&**pool,
)
.await?;
let mut response = HashMap::new();
for row in result {
if let Some(version) = versions_data.iter().find(|x| x.id.0 == row.version_id) {
response.insert(row.hash, super::versions::convert_version(version.clone()));
}
}
Ok(HttpResponse::Ok().json(response))
}
#[post("download")]
pub async fn download_files(
req: HttpRequest,
pool: web::Data<PgPool>,
file_data: web::Json<FileHashes>,
pepper: web::Data<Pepper>,
) -> Result<HttpResponse, ApiError> {
let hashes_parsed: Vec<Vec<u8>> = file_data
.hashes
.iter()
.map(|x| x.as_bytes().to_vec())
.collect();
let mut transaction = pool.begin().await?;
let result = sqlx::query!(
"
SELECT f.url url, h.hash hash, h.algorithm algorithm, f.version_id version_id, v.mod_id project_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash IN (SELECT * FROM UNNEST($1::bytea[]))
",
hashes_parsed.as_slice(),
file_data.algorithm
)
.fetch_all(&mut *transaction)
.await?;
let mut response = HashMap::new();
for row in result {
download_version_inner(
database::models::VersionId(row.version_id),
database::models::ProjectId(row.project_id),
&req,
&mut transaction,
&pepper,
)
.await?;
response.insert(row.hash, row.url);
}
Ok(HttpResponse::Ok().json(response))
}
#[derive(Deserialize)]
pub struct ManyUpdateData {
pub algorithm: String,
pub hashes: Vec<String>,
pub loaders: Vec<Loader>,
pub game_versions: Vec<GameVersion>,
}
#[post("update")]
pub async fn update_files(
pool: web::Data<PgPool>,
update_data: web::Json<ManyUpdateData>,
) -> Result<HttpResponse, ApiError> {
let hashes_parsed: Vec<Vec<u8>> = update_data
.hashes
.iter()
.map(|x| x.as_bytes().to_vec())
.collect();
let mut transaction = pool.begin().await?;
let result = sqlx::query!(
"
SELECT f.url url, h.hash hash, h.algorithm algorithm, f.version_id version_id, v.mod_id project_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash IN (SELECT * FROM UNNEST($1::bytea[]))
",
hashes_parsed.as_slice(),
update_data.algorithm
)
.fetch_all(&mut *transaction)
.await?;
let mut version_ids = Vec::new();
for row in &result {
let updated_versions = database::models::Version::get_project_versions(
database::models::ProjectId(row.project_id),
Some(
update_data
.game_versions
.clone()
.iter()
.map(|x| x.0.clone())
.collect(),
),
Some(
update_data
.loaders
.clone()
.iter()
.map(|x| x.0.clone())
.collect(),
),
&**pool,
)
.await?;
if let Some(latest_version) = updated_versions.last() {
version_ids.push(*latest_version);
}
}
let versions = database::models::Version::get_many_full(version_ids, &**pool).await?;
let mut response = HashMap::new();
for row in &result {
if let Some(version) = versions.iter().find(|x| x.id.0 == row.version_id) {
response.insert(
row.hash.clone(),
super::versions::convert_version(version.clone()),
);
}
}
Ok(HttpResponse::Ok().json(response))
}

View File

@@ -1,15 +1,15 @@
use super::ApiError;
use crate::auth::get_user_from_headers;
use crate::file_hosting::FileHost;
use crate::database;
use crate::models;
use crate::models::mods::{Dependency, DependencyType};
use crate::models::projects::{Dependency, DependencyType};
use crate::models::teams::Permissions;
use crate::{database, Pepper};
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 std::borrow::Borrow;
use std::sync::Arc;
use validator::Validate;
#[derive(Serialize, Deserialize, Clone)]
pub struct VersionListFilters {
@@ -20,23 +20,18 @@ pub struct VersionListFilters {
#[get("version")]
pub async fn version_list(
info: web::Path<(models::ids::ModId,)>,
info: web::Path<(String,)>,
web::Query(filters): web::Query<VersionListFilters>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0.into();
let string = info.into_inner().0;
let mod_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1)",
id as database::models::ModId,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.exists;
let result = database::models::Project::get_from_slug_or_project_id(string, &**pool).await?;
if mod_exists.unwrap_or(false) {
let version_ids = database::models::Version::get_mod_versions(
if let Some(project) = result {
let id = project.id;
let version_ids = database::models::Version::get_project_versions(
id,
filters
.game_versions
@@ -48,12 +43,9 @@ pub async fn version_list(
.map(|x| serde_json::from_str(x).unwrap_or_default()),
&**pool,
)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
let mut versions = database::models::Version::get_many_full(version_ids, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mut versions = database::models::Version::get_many_full(version_ids, &**pool).await?;
let mut response = versions
.iter()
@@ -87,8 +79,8 @@ pub async fn version_list(
versions
.iter()
.find(|version| {
version.game_versions.contains(&filter.0)
&& version.loaders.contains(&filter.1)
version.game_versions.contains(&filter.0.version)
&& version.loaders.contains(&filter.1.loader)
})
.map(|version| response.push(convert_version(version.clone())))
.unwrap_or(());
@@ -124,9 +116,7 @@ pub async fn versions_get(
.into_iter()
.map(|x| x.into())
.collect();
let versions_data = database::models::Version::get_many_full(version_ids, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let versions_data = database::models::Version::get_many_full(version_ids, &**pool).await?;
let mut versions = Vec::new();
@@ -143,9 +133,7 @@ pub async fn version_get(
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let version_data = database::models::Version::get_full(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let version_data = database::models::Version::get_full(id.into(), &**pool).await?;
if let Some(data) = version_data {
Ok(HttpResponse::Ok().json(convert_version(data)))
@@ -154,12 +142,14 @@ pub async fn version_get(
}
}
fn convert_version(data: database::models::version_item::QueryVersion) -> models::mods::Version {
use models::mods::VersionType;
pub fn convert_version(
data: database::models::version_item::QueryVersion,
) -> models::projects::Version {
use models::projects::VersionType;
models::mods::Version {
models::projects::Version {
id: data.id.into(),
mod_id: data.mod_id.into(),
project_id: data.project_id.into(),
author_id: data.author_id.into(),
featured: data.featured,
@@ -180,7 +170,7 @@ fn convert_version(data: database::models::version_item::QueryVersion) -> models
.files
.into_iter()
.map(|f| {
models::mods::VersionFile {
models::projects::VersionFile {
url: f.url,
filename: f.filename,
// FIXME: Hashes are currently stored as an ascii byte slice instead
@@ -206,25 +196,33 @@ fn convert_version(data: database::models::version_item::QueryVersion) -> models
game_versions: data
.game_versions
.into_iter()
.map(models::mods::GameVersion)
.map(models::projects::GameVersion)
.collect(),
loaders: data
.loaders
.into_iter()
.map(models::mods::ModLoader)
.map(models::projects::Loader)
.collect(),
}
}
#[derive(Serialize, Deserialize)]
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")]
pub version_number: Option<String>,
#[validate(length(max = 65536))]
pub changelog: Option<String>,
pub version_type: Option<models::mods::VersionType>,
pub version_type: Option<models::projects::VersionType>,
#[validate(length(min = 1, max = 256))]
pub dependencies: Option<Vec<Dependency>>,
pub game_versions: Option<Vec<models::mods::GameVersion>>,
pub loaders: Option<Vec<models::mods::ModLoader>>,
pub game_versions: Option<Vec<models::projects::GameVersion>>,
pub loaders: Option<Vec<models::projects::Loader>>,
pub featured: Option<bool>,
pub primary_file: Option<(String, String)>,
}
@@ -238,12 +236,12 @@ pub async fn version_edit(
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
new_version.validate()?;
let version_id = info.into_inner().0;
let id = version_id.into();
let result = database::models::Version::get_full(id, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let result = database::models::Version::get_full(id, &**pool).await?;
if let Some(version_item) = result {
let team_member = database::models::TeamMember::get_from_user_id_version(
@@ -269,18 +267,9 @@ pub async fn version_edit(
));
}
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mut transaction = pool.begin().await?;
if let Some(name) = &new_version.name {
if name.len() > 256 || name.len() < 3 {
return Err(ApiError::InvalidInputError(
"The version name must be within 3-256 characters!".to_string(),
));
}
sqlx::query!(
"
UPDATE versions
@@ -291,17 +280,10 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
if let Some(number) = &new_version.version_number {
if number.len() > 64 || number.is_empty() {
return Err(ApiError::InvalidInputError(
"The version number must be within 1-64 characters!".to_string(),
));
}
sqlx::query!(
"
UPDATE versions
@@ -312,8 +294,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
if let Some(version_type) = &new_version.version_type {
@@ -338,8 +319,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
if let Some(dependencies) = &new_version.dependencies {
@@ -350,8 +330,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
for dependency in dependencies {
let dependency_id: database::models::ids::VersionId =
@@ -367,8 +346,7 @@ pub async fn version_edit(
dependency.dependency_type.as_str()
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
}
@@ -380,8 +358,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
for game_version in game_versions {
let game_version_id = database::models::categories::GameVersion::get_id(
@@ -404,8 +381,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
}
@@ -417,8 +393,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
for loader in loaders {
let loader_id =
@@ -439,8 +414,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
}
@@ -455,8 +429,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
if let Some(primary_file) = &new_version.primary_file {
@@ -470,8 +443,7 @@ pub async fn version_edit(
primary_file.0
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.await?
.ok_or_else(|| {
ApiError::InvalidInputError(format!(
"Specified file with hash {} does not exist.",
@@ -488,8 +460,7 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
sqlx::query!(
"
@@ -500,18 +471,10 @@ pub async fn version_edit(
result.id,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
if let Some(body) = &new_version.changelog {
if body.len() > 65536 {
return Err(ApiError::InvalidInputError(
"The version changelog must be less than 65536 characters long!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE versions
@@ -522,15 +485,11 @@ pub async fn version_edit(
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
.await?;
}
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().body(""))
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
} else {
Err(ApiError::CustomAuthenticationError(
"You do not have permission to edit this version!".to_string(),
@@ -574,261 +533,10 @@ pub async fn version_delete(
}
}
let result = database::models::Version::remove_full(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let result = database::models::Version::remove_full(id.into(), &**pool).await?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Deserialize)]
pub struct Algorithm {
#[serde(default = "default_algorithm")]
algorithm: String,
}
fn default_algorithm() -> String {
"sha1".into()
}
// under /api/v1/version_file/{hash}
#[get("{version_id}")]
pub async fn get_version_from_hash(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
algorithm: web::Query<Algorithm>,
) -> Result<HttpResponse, ApiError> {
let hash = info.into_inner().0;
let result = sqlx::query!(
"
SELECT f.version_id version_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(id) = result {
let version_data = database::models::Version::get_full(
database::models::VersionId(id.version_id),
&**pool,
)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(data) = version_data {
Ok(HttpResponse::Ok().json(convert_version(data)))
} else {
Ok(HttpResponse::NotFound().body(""))
}
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(Serialize, Deserialize)]
pub struct DownloadRedirect {
pub url: String,
}
// under /api/v1/version_file/{hash}/download
#[allow(clippy::await_holding_refcell_ref)]
#[get("{version_id}/download")]
pub async fn download_version(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
algorithm: web::Query<Algorithm>,
pepper: web::Data<Pepper>,
) -> Result<HttpResponse, ApiError> {
let hash = info.into_inner().0;
let result = sqlx::query!(
"
SELECT f.url url, f.id id, f.version_id version_id, v.mod_id mod_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(id) = result {
let real_ip = req.connection_info();
let ip_option = real_ip.borrow().remote_addr();
if let Some(ip) = ip_option {
let hash = sha1::Sha1::from(format!("{}{}", ip, pepper.pepper)).hexdigest();
let download_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM downloads WHERE version_id = $1 AND date > (CURRENT_DATE - INTERVAL '30 minutes ago') AND identifier = $2)",
id.version_id,
hash,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.exists.unwrap_or(false);
if !download_exists {
sqlx::query!(
"
INSERT INTO downloads (
version_id, identifier
)
VALUES (
$1, $2
)
",
id.version_id,
hash
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
UPDATE versions
SET downloads = downloads + 1
WHERE id = $1
",
id.version_id,
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
UPDATE mods
SET downloads = downloads + 1
WHERE id = $1
",
id.mod_id,
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
}
Ok(HttpResponse::TemporaryRedirect()
.header("Location", &*id.url)
.json(DownloadRedirect { url: id.url }))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
// under /api/v1/version_file/{hash}
#[delete("{version_id}")]
pub async fn delete_file(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
algorithm: web::Query<Algorithm>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let hash = info.into_inner().0;
let result = sqlx::query!(
"
SELECT f.id id, f.version_id version_id, f.filename filename, v.version_number version_number, v.mod_id mod_id FROM hashes h
INNER JOIN files f ON h.file_id = f.id
INNER JOIN versions v ON v.id = f.version_id
WHERE h.algorithm = $2 AND h.hash = $1
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(row) = result {
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id_version(
database::models::ids::VersionId(row.version_id),
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::DatabaseError)?
.ok_or_else(|| {
ApiError::CustomAuthenticationError(
"You don't have permission to delete this file!".to_string(),
)
})?;
if !team_member
.permissions
.contains(Permissions::DELETE_VERSION)
{
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to delete this file!".to_string(),
));
}
}
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
DELETE FROM hashes
WHERE file_id = $1
",
row.id
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
DELETE FROM files
WHERE files.id = $1
",
row.id,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let mod_id: models::mods::ModId = database::models::ids::ModId(row.mod_id).into();
file_host
.delete_file_version(
"",
&format!(
"data/{}/versions/{}/{}",
mod_id, row.version_number, row.filename
),
)
.await?;
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().body(""))
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}

View File

@@ -2,32 +2,32 @@ use futures::{StreamExt, TryStreamExt};
use log::info;
use super::IndexingError;
use crate::models::mods::SideType;
use crate::search::UploadSearchMod;
use crate::models::projects::SideType;
use crate::search::UploadSearchProject;
use sqlx::postgres::PgPool;
use std::borrow::Cow;
// TODO: only loaders for recent versions? For mods that have moved from forge to fabric
pub async fn index_local(pool: PgPool) -> Result<Vec<UploadSearchMod>, IndexingError> {
info!("Indexing local mods!");
// TODO: only loaders for recent versions? For projects that have moved from forge to fabric
pub async fn index_local(pool: PgPool) -> Result<Vec<UploadSearchProject>, IndexingError> {
info!("Indexing local projects!");
let mut docs_to_add: Vec<UploadSearchMod> = vec![];
let mut docs_to_add: Vec<UploadSearchProject> = vec![];
let mut mods = sqlx::query!(
let mut projects = sqlx::query!(
"
SELECT m.id, m.title, m.description, m.downloads, m.follows, m.icon_url, m.body_url, m.published, m.updated, m.team_id, m.status, m.slug, m.license, m.client_side, m.server_side FROM mods m
"
).fetch(&pool);
while let Some(result) = mods.next().await {
if let Ok(mod_data) = result {
let status = crate::models::mods::ModStatus::from_str(
while let Some(result) = projects.next().await {
if let Ok(project_data) = result {
let status = crate::models::projects::ProjectStatus::from_str(
&sqlx::query!(
"
SELECT status FROM statuses
WHERE id = $1
",
mod_data.status,
SELECT status FROM statuses
WHERE id = $1
",
project_data.status,
)
.fetch_one(&pool)
.await?
@@ -46,7 +46,7 @@ pub async fn index_local(pool: PgPool) -> Result<Vec<UploadSearchMod>, IndexingE
WHERE versions.mod_id = $1
ORDER BY gv.created ASC
",
mod_data.id
project_data.id
)
.fetch_many(&pool)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.version)) })
@@ -60,7 +60,7 @@ pub async fn index_local(pool: PgPool) -> Result<Vec<UploadSearchMod>, IndexingE
INNER JOIN loaders ON loaders.id = lv.loader_id
WHERE versions.mod_id = $1
",
mod_data.id
project_data.id
)
.fetch_many(&pool)
.try_filter_map(|e| async { Ok(e.right().map(|c| Cow::Owned(c.loader))) })
@@ -74,7 +74,7 @@ pub async fn index_local(pool: PgPool) -> Result<Vec<UploadSearchMod>, IndexingE
INNER JOIN categories c ON mc.joining_category_id=c.id
WHERE mc.joining_mod_id = $1
",
mod_data.id
project_data.id
)
.fetch_many(&pool)
.try_filter_map(|e| async { Ok(e.right().map(|c| Cow::Owned(c.category))) })
@@ -90,22 +90,21 @@ pub async fn index_local(pool: PgPool) -> Result<Vec<UploadSearchMod>, IndexingE
WHERE tm.team_id = $2 AND tm.role = $1
",
crate::models::teams::OWNER_ROLE,
mod_data.team_id,
project_data.team_id,
)
.fetch_one(&pool)
.await?;
let mut icon_url = "".to_string();
if let Some(url) = mod_data.icon_url {
if let Some(url) = project_data.icon_url {
icon_url = url;
}
let mod_id = crate::models::ids::ModId(mod_data.id as u64);
let author_id = crate::models::ids::UserId(user.id as u64);
let project_id = crate::models::ids::ProjectId(project_data.id as u64);
// TODO: is this correct? This just gets the latest version of
// minecraft that this mod has a version that supports; it doesn't
// minecraft that this project has a version that supports; it doesn't
// take betas or other info into account.
let latest_version = versions
.last()
@@ -116,10 +115,10 @@ pub async fn index_local(pool: PgPool) -> Result<Vec<UploadSearchMod>, IndexingE
let client_side = SideType::from_str(
&sqlx::query!(
"
SELECT name FROM side_types
WHERE id = $1
",
mod_data.client_side,
SELECT name FROM side_types
WHERE id = $1
",
project_data.client_side,
)
.fetch_one(&pool)
.await?
@@ -129,10 +128,10 @@ pub async fn index_local(pool: PgPool) -> Result<Vec<UploadSearchMod>, IndexingE
let server_side = SideType::from_str(
&sqlx::query!(
"
SELECT name FROM side_types
WHERE id = $1
",
mod_data.server_side,
SELECT name FROM side_types
WHERE id = $1
",
project_data.server_side,
)
.fetch_one(&pool)
.await?
@@ -140,33 +139,31 @@ pub async fn index_local(pool: PgPool) -> Result<Vec<UploadSearchMod>, IndexingE
);
let license = crate::database::models::categories::License::get(
crate::database::models::LicenseId(mod_data.license),
crate::database::models::LicenseId(project_data.license),
&pool,
)
.await?;
docs_to_add.push(UploadSearchMod {
mod_id: format!("local-{}", mod_id),
title: mod_data.title,
description: mod_data.description,
docs_to_add.push(UploadSearchProject {
project_id: format!("local-{}", project_id),
title: project_data.title,
description: project_data.description,
categories,
versions,
follows: mod_data.follows,
downloads: mod_data.downloads,
page_url: format!("https://modrinth.com/mod/{}", mod_id),
follows: project_data.follows,
downloads: project_data.downloads,
icon_url,
author: user.username,
author_url: format!("https://modrinth.com/user/{}", author_id),
date_created: mod_data.published,
created_timestamp: mod_data.published.timestamp(),
date_modified: mod_data.updated,
modified_timestamp: mod_data.updated.timestamp(),
date_created: project_data.published,
created_timestamp: project_data.published.timestamp(),
date_modified: project_data.updated,
modified_timestamp: project_data.updated.timestamp(),
latest_version,
license: license.short,
client_side: client_side.to_string(),
server_side: server_side.to_string(),
host: Cow::Borrowed("modrinth"),
slug: mod_data.slug,
slug: project_data.slug,
});
}
}
@@ -175,10 +172,10 @@ pub async fn index_local(pool: PgPool) -> Result<Vec<UploadSearchMod>, IndexingE
}
pub async fn query_one(
id: crate::database::models::ModId,
id: crate::database::models::ProjectId,
exec: &mut sqlx::PgConnection,
) -> Result<UploadSearchMod, IndexingError> {
let mod_data = sqlx::query!(
) -> Result<UploadSearchProject, IndexingError> {
let project_data = sqlx::query!(
"
SELECT m.id, m.title, m.description, m.downloads, m.follows, m.icon_url, m.body_url, m.published, m.updated, m.team_id, m.slug, m.license, m.client_side, m.server_side
FROM mods m
@@ -195,7 +192,7 @@ pub async fn query_one(
WHERE versions.mod_id = $1
ORDER BY gv.created ASC
",
mod_data.id
project_data.id
)
.fetch_many(&mut *exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| c.version)) })
@@ -209,7 +206,7 @@ pub async fn query_one(
INNER JOIN loaders ON loaders.id = lv.loader_id
WHERE versions.mod_id = $1
",
mod_data.id
project_data.id
)
.fetch_many(&mut *exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| Cow::Owned(c.loader))) })
@@ -223,7 +220,7 @@ pub async fn query_one(
INNER JOIN categories c ON mc.joining_category_id=c.id
WHERE mc.joining_mod_id = $1
",
mod_data.id
project_data.id
)
.fetch_many(&mut *exec)
.try_filter_map(|e| async { Ok(e.right().map(|c| Cow::Owned(c.category))) })
@@ -239,22 +236,21 @@ pub async fn query_one(
WHERE tm.team_id = $2 AND tm.role = $1
",
crate::models::teams::OWNER_ROLE,
mod_data.team_id,
project_data.team_id,
)
.fetch_one(&mut *exec)
.await?;
let mut icon_url = "".to_string();
if let Some(url) = mod_data.icon_url {
if let Some(url) = project_data.icon_url {
icon_url = url;
}
let mod_id = crate::models::ids::ModId(mod_data.id as u64);
let author_id = crate::models::ids::UserId(user.id as u64);
let project_id = crate::models::ids::ProjectId(project_data.id as u64);
// TODO: is this correct? This just gets the latest version of
// minecraft that this mod has a version that supports; it doesn't
// minecraft that this project has a version that supports; it doesn't
// take betas or other info into account.
let latest_version = versions
.last()
@@ -265,10 +261,10 @@ pub async fn query_one(
let client_side = SideType::from_str(
&sqlx::query!(
"
SELECT name FROM side_types
WHERE id = $1
",
mod_data.client_side,
SELECT name FROM side_types
WHERE id = $1
",
project_data.client_side,
)
.fetch_one(&mut *exec)
.await?
@@ -278,10 +274,10 @@ pub async fn query_one(
let server_side = SideType::from_str(
&sqlx::query!(
"
SELECT name FROM side_types
WHERE id = $1
",
mod_data.server_side,
SELECT name FROM side_types
WHERE id = $1
",
project_data.server_side,
)
.fetch_one(&mut *exec)
.await?
@@ -289,32 +285,30 @@ pub async fn query_one(
);
let license = crate::database::models::categories::License::get(
crate::database::models::LicenseId(mod_data.license),
crate::database::models::LicenseId(project_data.license),
&mut *exec,
)
.await?;
Ok(UploadSearchMod {
mod_id: format!("local-{}", mod_id),
title: mod_data.title,
description: mod_data.description,
Ok(UploadSearchProject {
project_id: format!("local-{}", project_id),
title: project_data.title,
description: project_data.description,
categories,
versions,
follows: mod_data.follows,
downloads: mod_data.downloads,
page_url: format!("https://modrinth.com/mod/{}", mod_id),
follows: project_data.follows,
downloads: project_data.downloads,
icon_url,
author: user.username,
author_url: format!("https://modrinth.com/user/{}", author_id),
date_created: mod_data.published,
created_timestamp: mod_data.published.timestamp(),
date_modified: mod_data.updated,
modified_timestamp: mod_data.updated.timestamp(),
date_created: project_data.published,
created_timestamp: project_data.published.timestamp(),
date_modified: project_data.updated,
modified_timestamp: project_data.updated.timestamp(),
latest_version,
license: license.short,
client_side: client_side.to_string(),
server_side: server_side.to_string(),
host: Cow::Borrowed("modrinth"),
slug: mod_data.slug,
slug: project_data.slug,
})
}

View File

@@ -2,7 +2,7 @@
pub mod local_import;
pub mod queue;
use crate::search::{SearchConfig, UploadSearchMod};
use crate::search::{SearchConfig, UploadSearchProject};
use local_import::index_local;
use meilisearch_sdk::client::Client;
use meilisearch_sdk::indexes::Index;
@@ -27,14 +27,13 @@ pub enum IndexingError {
EnvError(#[from] dotenv::Error),
}
// The chunk size for adding mods to the indexing database. If the request size
// The chunk size for adding projects to the indexing database. If the request size
// is too large (>10MiB) then the request fails with an error. This chunk size
// assumes a max average size of 1KiB per mod to avoid this cap.
// assumes a max average size of 1KiB per project to avoid this cap.
const MEILISEARCH_CHUNK_SIZE: usize = 10000;
#[derive(Debug)]
pub struct IndexingSettings {
pub index_external: bool,
pub index_local: bool,
}
@@ -42,31 +41,24 @@ impl IndexingSettings {
#[allow(dead_code)]
pub fn from_env() -> Self {
let index_local = true;
let index_external = dotenv::var("INDEX_CURSEFORGE")
.ok()
.and_then(|b| b.parse::<bool>().ok())
.unwrap_or(false);
Self {
index_external,
index_local,
}
Self { index_local }
}
}
pub async fn index_mods(
pub async fn index_projects(
pool: PgPool,
settings: IndexingSettings,
config: &SearchConfig,
) -> Result<(), IndexingError> {
let mut docs_to_add: Vec<UploadSearchMod> = vec![];
let mut docs_to_add: Vec<UploadSearchProject> = vec![];
if settings.index_local {
docs_to_add.append(&mut index_local(pool.clone()).await?);
}
// Write Indices
add_mods(docs_to_add, config).await?;
add_projects(docs_to_add, config).await?;
Ok(())
}
@@ -74,12 +66,12 @@ pub async fn index_mods(
pub async fn reset_indices(config: &SearchConfig) -> Result<(), IndexingError> {
let client = Client::new(&*config.address, &*config.key);
client.delete_index("relevance_mods").await?;
client.delete_index("downloads_mods").await?;
client.delete_index("follows_mods").await?;
client.delete_index("alphabetically_mods").await?;
client.delete_index("updated_mods").await?;
client.delete_index("newest_mods").await?;
client.delete_index("relevance_projects").await?;
client.delete_index("downloads_projects").await?;
client.delete_index("follows_projects").await?;
client.delete_index("updated_projects").await?;
client.delete_index("newest_projects").await?;
client.delete_index("alphabetically_projects").await?;
Ok(())
}
@@ -87,7 +79,7 @@ pub async fn reconfigure_indices(config: &SearchConfig) -> Result<(), IndexingEr
let client = Client::new(&*config.address, &*config.key);
// Relevance Index
update_index(&client, "relevance_mods", {
update_index(&client, "relevance_projects", {
let mut relevance_rules = default_rules();
relevance_rules.push_back("desc(downloads)".to_string());
relevance_rules.into()
@@ -95,7 +87,7 @@ pub async fn reconfigure_indices(config: &SearchConfig) -> Result<(), IndexingEr
.await?;
// Downloads Index
update_index(&client, "downloads_mods", {
update_index(&client, "downloads_projects", {
let mut downloads_rules = default_rules();
downloads_rules.push_front("desc(downloads)".to_string());
downloads_rules.into()
@@ -103,7 +95,7 @@ pub async fn reconfigure_indices(config: &SearchConfig) -> Result<(), IndexingEr
.await?;
// Follows Index
update_index(&client, "follows_mods", {
update_index(&client, "follows_projects", {
let mut follows_rules = default_rules();
follows_rules.push_front("desc(follows)".to_string());
follows_rules.into()
@@ -111,15 +103,15 @@ pub async fn reconfigure_indices(config: &SearchConfig) -> Result<(), IndexingEr
.await?;
// Alphabetically Index
update_index(&client, "alphabetically_mods", {
update_index(&client, "alphabetically_projects", {
let mut alphabetically_rules = default_rules();
alphabetically_rules.push_front("desc(title)".to_string());
alphabetically_rules.into()
})
.await?;
.await?;
// Updated Index
update_index(&client, "updated_mods", {
update_index(&client, "updated_projects", {
let mut updated_rules = default_rules();
updated_rules.push_front("desc(modified_timestamp)".to_string());
updated_rules.into()
@@ -127,7 +119,7 @@ pub async fn reconfigure_indices(config: &SearchConfig) -> Result<(), IndexingEr
.await?;
// Created Index
update_index(&client, "newest_mods", {
update_index(&client, "newest_projects", {
let mut newest_rules = default_rules();
newest_rules.push_front("desc(created_timestamp)".to_string());
newest_rules.into()
@@ -147,7 +139,7 @@ async fn update_index<'a>(
Err(meilisearch_sdk::errors::Error::MeiliSearchError {
error_code: meilisearch_sdk::errors::ErrorCode::IndexNotFound,
..
}) => client.create_index(name, Some("mod_id")).await?,
}) => client.create_index(name, Some("project_id")).await?,
Err(e) => {
return Err(IndexingError::IndexDBError(e));
}
@@ -171,7 +163,7 @@ async fn create_index<'a>(
..
}) => {
// Only create index and set settings if the index doesn't already exist
let index = client.create_index(name, Some("mod_id")).await?;
let index = client.create_index(name, Some("project_id")).await?;
index
.set_settings(&default_settings().with_ranking_rules(rules()))
@@ -186,72 +178,72 @@ async fn create_index<'a>(
}
}
async fn add_to_index(index: Index<'_>, mods: &[UploadSearchMod]) -> Result<(), IndexingError> {
async fn add_to_index(index: Index<'_>, mods: &[UploadSearchProject]) -> Result<(), IndexingError> {
for chunk in mods.chunks(MEILISEARCH_CHUNK_SIZE) {
index.add_documents(chunk, Some("mod_id")).await?;
index.add_documents(chunk, Some("project_id")).await?;
}
Ok(())
}
pub async fn add_mods(
mods: Vec<UploadSearchMod>,
pub async fn add_projects(
projects: Vec<UploadSearchProject>,
config: &SearchConfig,
) -> Result<(), IndexingError> {
let client = Client::new(&*config.address, &*config.key);
// Relevance Index
let relevance_index = create_index(&client, "relevance_mods", || {
let relevance_index = create_index(&client, "relevance_projects", || {
let mut relevance_rules = default_rules();
relevance_rules.push_back("desc(downloads)".to_string());
relevance_rules.into()
})
.await?;
add_to_index(relevance_index, &mods).await?;
add_to_index(relevance_index, &projects).await?;
// Downloads Index
let downloads_index = create_index(&client, "downloads_mods", || {
let downloads_index = create_index(&client, "downloads_projects", || {
let mut downloads_rules = default_rules();
downloads_rules.push_front("desc(downloads)".to_string());
downloads_rules.into()
})
.await?;
add_to_index(downloads_index, &mods).await?;
add_to_index(downloads_index, &projects).await?;
// Follows Index
let follows_index = create_index(&client, "follows_mods", || {
let follows_index = create_index(&client, "follows_projects", || {
let mut follows_rules = default_rules();
follows_rules.push_front("desc(follows)".to_string());
follows_rules.into()
})
.await?;
add_to_index(follows_index, &mods).await?;
add_to_index(follows_index, &projects).await?;
// Alphabetically Index
let alphabetically_index = create_index(&client, "alphabetically_mods", || {
let alphabetically_index = create_index(&client, "alphabetically_projects", || {
let mut alphabetically_rules = default_rules();
alphabetically_rules.push_front("desc(title)".to_string());
alphabetically_rules.into()
})
.await?;
add_to_index(alphabetically_index, &mods).await?;
.await?;
add_to_index(alphabetically_index, &projects).await?;
// Updated Index
let updated_index = create_index(&client, "updated_mods", || {
let updated_index = create_index(&client, "updated_projects", || {
let mut updated_rules = default_rules();
updated_rules.push_front("desc(modified_timestamp)".to_string());
updated_rules.into()
})
.await?;
add_to_index(updated_index, &mods).await?;
add_to_index(updated_index, &projects).await?;
// Created Index
let newest_index = create_index(&client, "newest_mods", || {
let newest_index = create_index(&client, "newest_projects", || {
let mut newest_rules = default_rules();
newest_rules.push_front("desc(created_timestamp)".to_string());
newest_rules.into()
})
.await?;
add_to_index(newest_index, &mods).await?;
add_to_index(newest_index, &projects).await?;
Ok(())
}
@@ -271,7 +263,7 @@ fn default_rules() -> VecDeque<String> {
fn default_settings() -> Settings {
let displayed_attributes = vec![
"mod_id".to_string(),
"project_id".to_string(),
"slug".to_string(),
"author".to_string(),
"title".to_string(),
@@ -280,16 +272,13 @@ fn default_settings() -> Settings {
"versions".to_string(),
"downloads".to_string(),
"follows".to_string(),
"page_url".to_string(),
"icon_url".to_string(),
"author_url".to_string(),
"date_created".to_string(),
"date_modified".to_string(),
"latest_version".to_string(),
"license".to_string(),
"client_side".to_string(),
"server_side".to_string(),
"host".to_string(),
];
let searchable_attributes = vec![
@@ -325,7 +314,7 @@ fn default_settings() -> Settings {
// This isn't currenly used, but I wrote it and it works, so I'm
// keeping this mess in case someone needs it in the future.
#[allow(dead_code)]
pub fn sort_mods(a: &str, b: &str) -> std::cmp::Ordering {
pub fn sort_projects(a: &str, b: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
let cmp = a.contains('.').cmp(&b.contains('.'));

View File

@@ -1,4 +1,4 @@
use super::{add_mods, IndexingError, UploadSearchMod};
use super::{add_projects, IndexingError, UploadSearchProject};
use crate::search::SearchConfig;
use std::sync::Mutex;
@@ -7,7 +7,7 @@ pub struct CreationQueue {
// and I don't think this can deadlock. This queue requires fast
// writes and then a single potentially slower read/write that
// empties the queue.
queue: Mutex<Vec<UploadSearchMod>>,
queue: Mutex<Vec<UploadSearchProject>>,
}
impl CreationQueue {
@@ -17,11 +17,11 @@ impl CreationQueue {
}
}
pub fn add(&self, search_mod: UploadSearchMod) {
pub fn add(&self, search_project: UploadSearchProject) {
// Can only panic if mutex is poisoned
self.queue.lock().unwrap().push(search_mod);
self.queue.lock().unwrap().push(search_project);
}
pub fn take(&self) -> Vec<UploadSearchMod> {
pub fn take(&self) -> Vec<UploadSearchProject> {
std::mem::replace(&mut *self.queue.lock().unwrap(), Vec::with_capacity(10))
}
}
@@ -31,5 +31,5 @@ pub async fn index_queue(
config: &SearchConfig,
) -> Result<(), IndexingError> {
let queue = queue.take();
add_mods(queue, config).await
add_projects(queue, config).await
}

View File

@@ -1,5 +1,5 @@
use crate::models::error::ApiError;
use crate::models::mods::SearchRequest;
use crate::models::projects::SearchRequest;
use actix_web::http::StatusCode;
use actix_web::web::HttpResponse;
use chrono::{DateTime, Utc};
@@ -57,11 +57,11 @@ pub struct SearchConfig {
pub key: String,
}
/// A mod document used for uploading mods to meilisearch's indices.
/// A project document used for uploading projects to meilisearch's indices.
/// This contains some extra data that is not returned by search results.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UploadSearchMod {
pub mod_id: String,
pub struct UploadSearchProject {
pub project_id: String,
pub slug: Option<String>,
pub author: String,
pub title: String,
@@ -70,17 +70,15 @@ pub struct UploadSearchMod {
pub versions: Vec<String>,
pub follows: i32,
pub downloads: i32,
pub page_url: String,
pub icon_url: String,
pub author_url: String,
pub latest_version: Cow<'static, str>,
pub license: String,
pub client_side: String,
pub server_side: String,
/// RFC 3339 formatted creation date of the mod
/// RFC 3339 formatted creation date of the project
pub date_created: DateTime<Utc>,
/// Unix timestamp of the creation date of the mod
/// Unix timestamp of the creation date of the project
pub created_timestamp: i64,
/// RFC 3339 formatted date/time of last major modification (update)
pub date_modified: DateTime<Utc>,
@@ -92,15 +90,15 @@ pub struct UploadSearchMod {
#[derive(Serialize, Deserialize, Debug)]
pub struct SearchResults {
pub hits: Vec<ResultSearchMod>,
pub hits: Vec<ResultSearchProject>,
pub offset: usize,
pub limit: usize,
pub total_hits: usize,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ResultSearchMod {
pub mod_id: String,
pub struct ResultSearchProject {
pub project_id: String,
pub slug: Option<String>,
pub author: String,
pub title: String,
@@ -110,39 +108,34 @@ pub struct ResultSearchMod {
pub versions: Vec<String>,
pub downloads: i32,
pub follows: i32,
pub page_url: String,
pub icon_url: String,
pub author_url: String,
/// RFC 3339 formatted creation date of the mod
/// RFC 3339 formatted creation date of the project
pub date_created: String,
/// RFC 3339 formatted modification date of the mod
/// RFC 3339 formatted modification date of the project
pub date_modified: String,
pub latest_version: String,
pub license: String,
pub client_side: String,
pub server_side: String,
/// The host of the mod: Either `modrinth` or `curseforge`
pub host: String,
}
impl Document for UploadSearchMod {
impl Document for UploadSearchProject {
type UIDType = String;
fn get_uid(&self) -> &Self::UIDType {
&self.mod_id
&self.project_id
}
}
impl Document for ResultSearchMod {
impl Document for ResultSearchProject {
type UIDType = String;
fn get_uid(&self) -> &Self::UIDType {
&self.mod_id
&self.project_id
}
}
pub async fn search_for_mod(
pub async fn search_for_project(
info: &SearchRequest,
config: &SearchConfig,
) -> Result<SearchResults, SearchError> {
@@ -160,12 +153,12 @@ pub async fn search_for_mod(
let limit = info.limit.as_deref().unwrap_or("10").parse()?;
let index = match index {
"relevance" => "relevance_mods",
"downloads" => "downloads_mods",
"follows" => "follows_mods",
"alphabetically" => "alphabetically_mods",
"updated" => "updated_mods",
"newest" => "newest_mods",
"relevance" => "relevance_projects",
"downloads" => "downloads_projects",
"follows" => "follows_projects",
"updated" => "updated_projects",
"newest" => "newest_projects",
"alphabetically" => "alphabetically_projects",
i => return Err(SearchError::InvalidIndex(i.to_string())),
};
@@ -203,7 +196,7 @@ pub async fn search_for_mod(
query.with_facet_filters(&why_must_you_do_this);
}
let results = query.execute::<ResultSearchMod>().await?;
let results = query.execute::<ResultSearchProject>().await?;
Ok(SearchResults {
hits: results.hits.into_iter().map(|r| r.result).collect(),

48
src/validate/fabric.rs Normal file
View File

@@ -0,0 +1,48 @@
use crate::validate::{SupportedGameVersions, ValidationError, ValidationResult};
use chrono::{DateTime, NaiveDateTime, Utc};
use std::io::Cursor;
use zip::ZipArchive;
pub struct FabricValidator {}
impl super::Validator for FabricValidator {
fn get_file_extensions<'a>(&self) -> &'a [&'a str] {
&["jar", "zip"]
}
fn get_project_types<'a>(&self) -> &'a [&'a str] {
&["mod"]
}
fn get_supported_loaders<'a>(&self) -> &'a [&'a str] {
&["fabric"]
}
fn get_supported_game_versions(&self) -> SupportedGameVersions {
// Time since release of 18w49a, the first fabric version
SupportedGameVersions::PastDate(DateTime::<Utc>::from_utc(
NaiveDateTime::from_timestamp(1543969469, 0),
Utc,
))
}
fn validate(
&self,
archive: &mut ZipArchive<Cursor<&[u8]>>,
) -> Result<ValidationResult, ValidationError> {
archive.by_name("fabric.mod.json")?;
if !archive
.file_names()
.any(|name| name.ends_with("refmap.json") || name.ends_with(".class"))
{
return Ok(ValidationResult::Warning(
"Fabric mod file is a source file!".to_string(),
));
}
//TODO: Check if file is a dev JAR?
Ok(ValidationResult::Pass)
}
}

86
src/validate/forge.rs Normal file
View File

@@ -0,0 +1,86 @@
use crate::validate::{SupportedGameVersions, ValidationError, ValidationResult};
use chrono::{DateTime, NaiveDateTime, Utc};
use std::io::Cursor;
use zip::ZipArchive;
pub struct ForgeValidator {}
impl super::Validator for ForgeValidator {
fn get_file_extensions<'a>(&self) -> &'a [&'a str] {
&["jar", "zip"]
}
fn get_project_types<'a>(&self) -> &'a [&'a str] {
&["mod"]
}
fn get_supported_loaders<'a>(&self) -> &'a [&'a str] {
&["forge"]
}
fn get_supported_game_versions(&self) -> SupportedGameVersions {
// Time since release of 1.13, the first forge version which uses the new TOML system
SupportedGameVersions::PastDate(DateTime::<Utc>::from_utc(
NaiveDateTime::from_timestamp(1540122067, 0),
Utc,
))
}
fn validate(
&self,
archive: &mut ZipArchive<Cursor<&[u8]>>,
) -> Result<ValidationResult, ValidationError> {
archive.by_name("META-INF/mods.toml")?;
if !archive.file_names().any(|name| name.ends_with(".class")) {
return Ok(ValidationResult::Warning(
"Forge mod file is a source file!".to_string(),
));
}
//TODO: Check if file is a dev JAR?
Ok(ValidationResult::Pass)
}
}
pub struct LegacyForgeValidator {}
impl super::Validator for LegacyForgeValidator {
fn get_file_extensions<'a>(&self) -> &'a [&'a str] {
&["jar", "zip"]
}
fn get_project_types<'a>(&self) -> &'a [&'a str] {
&["mod"]
}
fn get_supported_loaders<'a>(&self) -> &'a [&'a str] {
&["forge"]
}
fn get_supported_game_versions(&self) -> SupportedGameVersions {
// Times between versions 1.5.2 to 1.12.2, which all use the legacy way of defining mods
SupportedGameVersions::Range(
DateTime::<Utc>::from_utc(NaiveDateTime::from_timestamp(1366818300, 0), Utc),
DateTime::<Utc>::from_utc(NaiveDateTime::from_timestamp(1505810340, 0), Utc),
)
}
fn validate(
&self,
archive: &mut ZipArchive<Cursor<&[u8]>>,
) -> Result<ValidationResult, ValidationError> {
archive.by_name("mcmod.info")?;
if !archive.file_names().any(|name| name.ends_with(".class")) {
return Ok(ValidationResult::Warning(
"Forge mod file is a source file!".to_string(),
));
}
//TODO: Check if file is a dev JAR?
Ok(ValidationResult::Pass)
}
}

117
src/validate/mod.rs Normal file
View File

@@ -0,0 +1,117 @@
use crate::models::projects::{GameVersion, Loader};
use crate::validate::fabric::FabricValidator;
use crate::validate::forge::{ForgeValidator, LegacyForgeValidator};
use crate::validate::pack::PackValidator;
use chrono::{DateTime, Utc};
use std::io::Cursor;
use thiserror::Error;
use zip::ZipArchive;
mod fabric;
mod forge;
mod pack;
#[derive(Error, Debug)]
pub enum ValidationError {
#[error("Unable to read Zip Archive: {0}")]
ZipError(#[from] zip::result::ZipError),
#[error("IO Error: {0}")]
IoError(#[from] std::io::Error),
#[error("Error while validating JSON: {0}")]
SerDeError(#[from] serde_json::Error),
#[error("Invalid Input: {0}")]
InvalidInputError(String),
}
#[derive(Eq, PartialEq)]
pub enum ValidationResult {
/// File should be marked as primary
Pass,
/// File should not be marked primary, the reason for which is inside the String
Warning(String),
}
pub enum SupportedGameVersions {
All,
PastDate(DateTime<Utc>),
Range(DateTime<Utc>, DateTime<Utc>),
Custom(Vec<GameVersion>),
}
pub trait Validator: Sync {
fn get_file_extensions<'a>(&self) -> &'a [&'a str];
fn get_project_types<'a>(&self) -> &'a [&'a str];
fn get_supported_loaders<'a>(&self) -> &'a [&'a str];
fn get_supported_game_versions(&self) -> SupportedGameVersions;
fn validate(
&self,
archive: &mut ZipArchive<Cursor<&[u8]>>,
) -> Result<ValidationResult, ValidationError>;
}
static VALIDATORS: [&dyn Validator; 4] = [
&PackValidator {},
&FabricValidator {},
&ForgeValidator {},
&LegacyForgeValidator {},
];
/// The return value is whether this file should be marked as primary or not, based on the analysis of the file
pub fn validate_file(
data: &[u8],
file_extension: &str,
project_type: &str,
loaders: Vec<Loader>,
game_versions: Vec<GameVersion>,
all_game_versions: &[crate::database::models::categories::GameVersion],
) -> Result<ValidationResult, ValidationError> {
let reader = std::io::Cursor::new(data);
let mut zip = zip::ZipArchive::new(reader)?;
for validator in &VALIDATORS {
if validator.get_file_extensions().contains(&file_extension)
&& validator.get_project_types().contains(&project_type)
&& loaders
.iter()
.any(|x| validator.get_supported_loaders().contains(&&*x.0))
&& game_version_supported(
&game_versions,
all_game_versions,
validator.get_supported_game_versions(),
)
{
return validator.validate(&mut zip);
}
}
Ok(ValidationResult::Pass)
}
fn game_version_supported(
game_versions: &[GameVersion],
all_game_versions: &[crate::database::models::categories::GameVersion],
supported_game_versions: SupportedGameVersions,
) -> bool {
match supported_game_versions {
SupportedGameVersions::All => true,
SupportedGameVersions::PastDate(date) => game_versions.iter().any(|x| {
all_game_versions
.iter()
.find(|y| y.version == x.0)
.map(|x| x.date > date)
.unwrap_or(false)
}),
SupportedGameVersions::Range(before, after) => game_versions.iter().any(|x| {
all_game_versions
.iter()
.find(|y| y.version == x.0)
.map(|x| x.date > before && x.date < after)
.unwrap_or(false)
}),
SupportedGameVersions::Custom(versions) => {
versions.iter().any(|x| game_versions.contains(x))
}
}
}
//todo: fabric/forge validators for 1.8+ respectively

97
src/validate/pack.rs Normal file
View File

@@ -0,0 +1,97 @@
use crate::models::projects::SideType;
use crate::validate::{SupportedGameVersions, ValidationError, ValidationResult};
use serde::{Deserialize, Serialize};
use std::io::{Cursor, Read};
use zip::ZipArchive;
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackFormat {
pub game: String,
pub format_version: i32,
pub version_id: String,
pub name: String,
pub summary: Option<String>,
pub dependencies: std::collections::HashMap<PackDependency, String>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackFile {
pub path: String,
pub hashes: std::collections::HashMap<String, String>,
pub env: Environment,
pub downloads: Vec<String>,
}
#[derive(Serialize, Deserialize)]
pub struct Environment {
pub client: SideType,
pub server: SideType,
}
#[derive(Serialize, Deserialize, Clone, Hash, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum PackDependency {
Forge,
FabricLoader,
Minecraft,
}
impl std::fmt::Display for PackDependency {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{}", self.as_str())
}
}
impl PackDependency {
// These are constant, so this can remove unneccessary allocations (`to_string`)
pub fn as_str(&self) -> &'static str {
match self {
PackDependency::Forge => "forge",
PackDependency::FabricLoader => "fabric-loader",
PackDependency::Minecraft => "minecraft",
}
}
}
pub struct PackValidator {}
impl super::Validator for PackValidator {
fn get_file_extensions<'a>(&self) -> &'a [&'a str] {
&["zip"]
}
fn get_project_types<'a>(&self) -> &'a [&'a str] {
&["modpack"]
}
fn get_supported_loaders<'a>(&self) -> &'a [&'a str] {
&["forge", "fabric"]
}
fn get_supported_game_versions(&self) -> SupportedGameVersions {
SupportedGameVersions::All
}
fn validate(
&self,
archive: &mut ZipArchive<Cursor<&[u8]>>,
) -> Result<ValidationResult, ValidationError> {
let mut file = archive.by_name("index.json")?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let pack: PackFormat = serde_json::from_str(&*contents)?;
if pack.game != *"minecraft" {
return Err(ValidationError::InvalidInputError(format!(
"Game {0} does not exist!",
pack.game
)));
}
Ok(ValidationResult::Pass)
}
}