More mod info (#104)

* More mod info

* Downloading mods

* Run prepare

* User editing + icon editing

* Finish

* Some fixes

* Fix clippy errors
This commit is contained in:
Geometrically 2020-11-27 10:57:04 -07:00 committed by GitHub
parent 92e1847c59
commit 1da5357df6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 3287 additions and 604 deletions

View File

@ -0,0 +1,63 @@
CREATE TABLE donation_platforms (
id serial PRIMARY KEY,
short varchar(100) UNIQUE NOT NULL,
name varchar(500) UNIQUE NOT NULL
);
INSERT INTO donation_platforms (short, name) VALUES ('patreon', 'Patreon');
INSERT INTO donation_platforms (short, name) VALUES ('bmac', 'Buy Me a Coffee');
INSERT INTO donation_platforms (short, name) VALUES ('paypal', 'PayPal');
INSERT INTO donation_platforms (short, name) VALUES ('github', 'GitHub Sponsors');
INSERT INTO donation_platforms (short, name) VALUES ('ko-fi', 'Ko-fi');
INSERT INTO donation_platforms (short, name) VALUES ('other', 'Other');
CREATE TABLE mods_donations (
joining_mod_id bigint REFERENCES mods ON UPDATE CASCADE NOT NULL,
joining_platform_id int REFERENCES donation_platforms ON UPDATE CASCADE NOT NULL,
url varchar(2048) NOT NULL,
PRIMARY KEY (joining_mod_id, joining_platform_id)
);
CREATE TABLE side_types (
id serial PRIMARY KEY,
name varchar(64) UNIQUE NOT NULL
);
INSERT INTO side_types (name) VALUES ('required');
INSERT INTO side_types (name) VALUES ('no-functionality');
INSERT INTO side_types (name) VALUES ('unsupported');
INSERT INTO side_types (name) VALUES ('unknown');
CREATE TABLE licenses (
id serial PRIMARY KEY,
short varchar(60) UNIQUE NOT NULL,
name varchar(1000) UNIQUE NOT NULL
);
INSERT INTO licenses (short, name) VALUES ('custom', 'Custom License');
ALTER TABLE versions
ADD COLUMN featured BOOLEAN NOT NULL default FALSE;
ALTER TABLE files
ADD COLUMN is_primary BOOLEAN NOT NULL default FALSE;
ALTER TABLE mods
ADD COLUMN license integer REFERENCES licenses NOT NULL default 1;
ALTER TABLE mods
ADD COLUMN license_url varchar(1000) NULL;
ALTER TABLE mods
ADD COLUMN client_side integer REFERENCES side_types NOT NULL default 4;
ALTER TABLE mods
ADD COLUMN server_side integer REFERENCES side_types NOT NULL default 4;
ALTER TABLE mods
ADD COLUMN discord_url varchar(255) NULL;
ALTER TABLE mods
ADD COLUMN slug varchar(255) NULL UNIQUE;
CREATE TABLE downloads (
id serial PRIMARY KEY,
version_id bigint REFERENCES versions ON UPDATE CASCADE NOT NULL,
date timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
-- A SHA1 hash of the downloader IP address
identifier varchar(40) NOT NULL
);

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,18 @@ pub struct Category {
pub category: String,
}
pub struct License {
pub id: LicenseId,
pub short: String,
pub name: String,
}
pub struct DonationPlatform {
pub id: DonationPlatformId,
pub short: String,
pub name: String,
}
pub struct CategoryBuilder<'a> {
pub name: Option<&'a str>,
}
@ -453,3 +465,293 @@ impl<'a> GameVersionBuilder<'a> {
Ok(GameVersionId(result.id))
}
}
#[derive(Default)]
pub struct LicenseBuilder<'a> {
pub short: Option<&'a str>,
pub name: Option<&'a str>,
}
impl License {
pub fn builder() -> LicenseBuilder<'static> {
LicenseBuilder::default()
}
pub async fn get_id<'a, E>(id: &str, exec: E) -> Result<Option<LicenseId>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT id FROM licenses
WHERE short = $1
",
id
)
.fetch_optional(exec)
.await?;
Ok(result.map(|r| LicenseId(r.id)))
}
pub async fn get<'a, E>(id: LicenseId, exec: E) -> Result<License, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT short, name FROM licenses
WHERE id = $1
",
id as LicenseId
)
.fetch_one(exec)
.await?;
Ok(License {
id,
short: result.short,
name: result.name,
})
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<License>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT id, short, name FROM licenses
"
)
.fetch_many(exec)
.try_filter_map(|e| async {
Ok(e.right().map(|c| License {
id: LicenseId(c.id),
short: c.short,
name: c.name,
}))
})
.try_collect::<Vec<License>>()
.await?;
Ok(result)
}
pub async fn remove<'a, E>(short: &str, exec: E) -> Result<Option<()>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
use sqlx::Done;
let result = sqlx::query!(
"
DELETE FROM licenses
WHERE short = $1
",
short
)
.execute(exec)
.await?;
if result.rows_affected() == 0 {
// Nothing was deleted
Ok(None)
} else {
Ok(Some(()))
}
}
}
impl<'a> LicenseBuilder<'a> {
/// The license's short name/abbreviation. Spaces must be replaced with '_' for it to be valid
pub fn short(self, short: &'a str) -> Result<LicenseBuilder<'a>, DatabaseError> {
if short
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
{
Ok(Self {
short: Some(short),
..self
})
} else {
Err(DatabaseError::InvalidIdentifier(short.to_string()))
}
}
/// The license's long name
pub fn name(self, name: &'a str) -> Result<LicenseBuilder<'a>, DatabaseError> {
Ok(Self {
name: Some(name),
..self
})
}
pub async fn insert<'b, E>(self, exec: E) -> Result<LicenseId, DatabaseError>
where
E: sqlx::Executor<'b, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
INSERT INTO licenses (short, name)
VALUES ($1, $2)
ON CONFLICT (short) DO NOTHING
RETURNING id
",
self.short,
self.name,
)
.fetch_one(exec)
.await?;
Ok(LicenseId(result.id))
}
}
#[derive(Default)]
pub struct DonationPlatformBuilder<'a> {
pub short: Option<&'a str>,
pub name: Option<&'a str>,
}
impl DonationPlatform {
pub fn builder() -> DonationPlatformBuilder<'static> {
DonationPlatformBuilder::default()
}
pub async fn get_id<'a, E>(
id: &str,
exec: E,
) -> Result<Option<DonationPlatformId>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT id FROM donation_platforms
WHERE short = $1
",
id
)
.fetch_optional(exec)
.await?;
Ok(result.map(|r| DonationPlatformId(r.id)))
}
pub async fn get<'a, E>(
id: DonationPlatformId,
exec: E,
) -> Result<DonationPlatform, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT short, name FROM donation_platforms
WHERE id = $1
",
id as DonationPlatformId
)
.fetch_one(exec)
.await?;
Ok(DonationPlatform {
id,
short: result.short,
name: result.name,
})
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<DonationPlatform>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT id, short, name FROM donation_platforms
"
)
.fetch_many(exec)
.try_filter_map(|e| async {
Ok(e.right().map(|c| DonationPlatform {
id: DonationPlatformId(c.id),
short: c.short,
name: c.name,
}))
})
.try_collect::<Vec<DonationPlatform>>()
.await?;
Ok(result)
}
pub async fn remove<'a, E>(short: &str, exec: E) -> Result<Option<()>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
use sqlx::Done;
let result = sqlx::query!(
"
DELETE FROM donation_platforms
WHERE short = $1
",
short
)
.execute(exec)
.await?;
if result.rows_affected() == 0 {
// Nothing was deleted
Ok(None)
} else {
Ok(Some(()))
}
}
}
impl<'a> DonationPlatformBuilder<'a> {
/// The donation platform short name. Spaces must be replaced with '_' for it to be valid
pub fn short(self, short: &'a str) -> Result<DonationPlatformBuilder<'a>, DatabaseError> {
if short
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
{
Ok(Self {
short: Some(short),
..self
})
} else {
Err(DatabaseError::InvalidIdentifier(short.to_string()))
}
}
/// The donation platform long name
pub fn name(self, name: &'a str) -> Result<DonationPlatformBuilder<'a>, DatabaseError> {
Ok(Self {
name: Some(name),
..self
})
}
pub async fn insert<'b, E>(self, exec: E) -> Result<DonationPlatformId, DatabaseError>
where
E: sqlx::Executor<'b, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
INSERT INTO donation_platforms (short, name)
VALUES ($1, $2)
ON CONFLICT (short) DO NOTHING
RETURNING id
",
self.short,
self.name,
)
.fetch_one(exec)
.await?;
Ok(DonationPlatformId(result.id))
}
}

View File

@ -104,6 +104,15 @@ pub struct ModId(pub i64);
#[derive(Copy, Clone, Debug, Type)]
#[sqlx(transparent)]
pub struct StatusId(pub i32);
#[derive(Copy, Clone, Debug, Type)]
#[sqlx(transparent)]
pub struct SideTypeId(pub i32);
#[derive(Copy, Clone, Debug, Type)]
#[sqlx(transparent)]
pub struct LicenseId(pub i32);
#[derive(Copy, Clone, Debug, Type)]
#[sqlx(transparent)]
pub struct DonationPlatformId(pub i32);
#[derive(Copy, Clone, Debug, Type)]
#[sqlx(transparent)]

View File

@ -79,3 +79,44 @@ impl ids::StatusId {
Ok(result.map(|r| ids::StatusId(r.id)))
}
}
impl ids::SideTypeId {
pub async fn get_id<'a, E>(
side: &crate::models::mods::SideType,
exec: E,
) -> Result<Option<Self>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT id FROM side_types
WHERE name = $1
",
side.as_str()
)
.fetch_optional(exec)
.await?;
Ok(result.map(|r| ids::SideTypeId(r.id)))
}
}
impl ids::DonationPlatformId {
pub async fn get_id<'a, E>(id: &str, exec: E) -> Result<Option<Self>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT id FROM donation_platforms
WHERE short = $1
",
id
)
.fetch_optional(exec)
.await?;
Ok(result.map(|r| ids::DonationPlatformId(r.id)))
}
}

View File

@ -1,5 +1,36 @@
use super::ids::*;
pub struct DonationUrl {
pub mod_id: ModId,
pub platform_id: DonationPlatformId,
pub url: String,
}
impl DonationUrl {
pub async fn insert(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), sqlx::error::Error> {
sqlx::query!(
"
INSERT INTO mods_donations (
joining_mod_id, joining_platform_id, url
)
VALUES (
$1, $2, $3
)
",
self.mod_id as ModId,
self.platform_id as DonationPlatformId,
self.url,
)
.execute(&mut *transaction)
.await?;
Ok(())
}
}
pub struct ModBuilder {
pub mod_id: ModId,
pub team_id: TeamId,
@ -10,9 +41,16 @@ pub struct ModBuilder {
pub issues_url: Option<String>,
pub source_url: Option<String>,
pub wiki_url: Option<String>,
pub license_url: Option<String>,
pub discord_url: Option<String>,
pub categories: Vec<CategoryId>,
pub initial_versions: Vec<super::version_item::VersionBuilder>,
pub status: StatusId,
pub client_side: SideTypeId,
pub server_side: SideTypeId,
pub license: LicenseId,
pub slug: Option<String>,
pub donation_urls: Vec<DonationUrl>,
}
impl ModBuilder {
@ -34,6 +72,12 @@ impl ModBuilder {
issues_url: self.issues_url,
source_url: self.source_url,
wiki_url: self.wiki_url,
license_url: self.license_url,
discord_url: self.discord_url,
client_side: self.client_side,
server_side: self.server_side,
license: self.license,
slug: self.slug,
};
mod_struct.insert(&mut *transaction).await?;
@ -42,6 +86,11 @@ impl ModBuilder {
version.insert(&mut *transaction).await?;
}
for mut donation in self.donation_urls {
donation.mod_id = self.mod_id;
donation.insert(&mut *transaction).await?;
}
for category in self.categories {
sqlx::query!(
"
@ -73,6 +122,12 @@ pub struct Mod {
pub issues_url: Option<String>,
pub source_url: Option<String>,
pub wiki_url: Option<String>,
pub license_url: Option<String>,
pub discord_url: Option<String>,
pub client_side: SideTypeId,
pub server_side: SideTypeId,
pub license: LicenseId,
pub slug: Option<String>,
}
impl Mod {
@ -85,12 +140,16 @@ impl Mod {
INSERT INTO mods (
id, team_id, title, description, body_url,
published, downloads, icon_url, issues_url,
source_url, wiki_url, status
source_url, wiki_url, status, discord_url,
client_side, server_side, license_url, license,
slug
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11, $12
$10, $11, $12, $13,
$14, $15, $16, $17,
$18
)
",
self.id as ModId,
@ -104,7 +163,13 @@ impl Mod {
self.issues_url.as_ref(),
self.source_url.as_ref(),
self.wiki_url.as_ref(),
self.status.0
self.status.0,
self.discord_url.as_ref(),
self.client_side as SideTypeId,
self.server_side as SideTypeId,
self.license_url.as_ref(),
self.license as LicenseId,
self.slug.as_ref()
)
.execute(&mut *transaction)
.await?;
@ -121,8 +186,8 @@ impl Mod {
SELECT title, description, downloads,
icon_url, body_url, published,
updated, status,
issues_url, source_url, wiki_url,
team_id
issues_url, source_url, wiki_url, discord_url, license_url,
team_id, client_side, server_side, license, slug
FROM mods
WHERE id = $1
",
@ -145,7 +210,13 @@ impl Mod {
issues_url: row.issues_url,
source_url: row.source_url,
wiki_url: row.wiki_url,
license_url: row.license_url,
discord_url: row.discord_url,
client_side: SideTypeId(row.client_side),
status: StatusId(row.status),
server_side: SideTypeId(row.server_side),
license: LicenseId(row.license),
slug: row.slug,
}))
} else {
Ok(None)
@ -164,8 +235,8 @@ impl Mod {
SELECT id, title, description, downloads,
icon_url, body_url, published,
updated, status,
issues_url, source_url, wiki_url,
team_id
issues_url, source_url, wiki_url, discord_url, license_url,
team_id, client_side, server_side, license, slug
FROM mods
WHERE id IN (SELECT * FROM UNNEST($1::bigint[]))
",
@ -186,7 +257,13 @@ impl Mod {
issues_url: m.issues_url,
source_url: m.source_url,
wiki_url: m.wiki_url,
license_url: m.license_url,
discord_url: m.discord_url,
client_side: SideTypeId(m.client_side),
status: StatusId(m.status),
server_side: SideTypeId(m.server_side),
license: LicenseId(m.license),
slug: m.slug,
}))
})
.try_collect::<Vec<Mod>>()
@ -227,6 +304,16 @@ impl Mod {
.execute(exec)
.await?;
sqlx::query!(
"
DELETE FROM mods_donations
WHERE joining_mod_id = $1
",
id as ModId,
)
.execute(exec)
.await?;
use futures::TryStreamExt;
let versions: Vec<VersionId> = sqlx::query!(
"
@ -277,6 +364,30 @@ impl Mod {
Ok(Some(()))
}
pub async fn get_full_from_slug<'a, 'b, E>(
slug: String,
executor: E,
) -> Result<Option<QueryMod>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let id = sqlx::query!(
"
SELECT id FROM mods
WHERE slug = $1
",
slug
)
.fetch_optional(executor)
.await?;
if let Some(mod_id) = id {
Mod::get_full(ModId(mod_id.id), executor).await
} else {
Ok(None)
}
}
pub async fn get_full<'a, 'b, E>(
id: ModId,
executor: E,
@ -312,6 +423,24 @@ impl Mod {
.try_collect::<Vec<VersionId>>()
.await?;
let donations: Vec<DonationUrl> = sqlx::query!(
"
SELECT joining_platform_id, url FROM mods_donations
WHERE joining_mod_id = $1
",
id as ModId,
)
.fetch_many(executor)
.try_filter_map(|e| async {
Ok(e.right().map(|c| DonationUrl {
mod_id: id,
platform_id: DonationPlatformId(c.joining_platform_id),
url: c.url,
}))
})
.try_collect::<Vec<DonationUrl>>()
.await?;
let status = sqlx::query!(
"
SELECT status FROM statuses
@ -323,11 +452,48 @@ impl Mod {
.await?
.status;
let client_side = sqlx::query!(
"
SELECT name FROM side_types
WHERE id = $1
",
inner.client_side.0,
)
.fetch_one(executor)
.await?
.name;
let server_side = sqlx::query!(
"
SELECT name FROM side_types
WHERE id = $1
",
inner.server_side.0,
)
.fetch_one(executor)
.await?
.name;
let license = sqlx::query!(
"
SELECT short, name FROM licenses
WHERE id = $1
",
inner.license.0,
)
.fetch_one(executor)
.await?;
Ok(Some(QueryMod {
inner,
categories,
versions,
donation_urls: donations,
status: crate::models::mods::ModStatus::from_str(&status),
license_id: license.short,
license_name: license.name,
client_side: crate::models::mods::SideType::from_str(&client_side),
server_side: crate::models::mods::SideType::from_str(&server_side)
}))
} else {
Ok(None)
@ -351,5 +517,10 @@ pub struct QueryMod {
pub categories: Vec<String>,
pub versions: Vec<VersionId>,
pub donation_urls: Vec<DonationUrl>,
pub status: crate::models::mods::ModStatus,
pub license_id: String,
pub license_name: String,
pub client_side: crate::models::mods::SideType,
pub server_side: crate::models::mods::SideType,
}

View File

@ -260,7 +260,7 @@ impl TeamMember {
name: m.member_name,
role: m.role,
permissions: Permissions::from_bits(m.permissions as u64)
.ok_or_else(|| super::DatabaseError::BitflagError)?,
.ok_or(super::DatabaseError::BitflagError)?,
accepted: m.accepted,
}))
} else {
@ -297,7 +297,7 @@ impl TeamMember {
name: m.member_name,
role: m.role,
permissions: Permissions::from_bits(m.permissions as u64)
.ok_or_else(|| super::DatabaseError::BitflagError)?,
.ok_or(super::DatabaseError::BitflagError)?,
accepted: m.accepted,
}))
} else {

View File

@ -113,6 +113,43 @@ impl User {
}
}
pub async fn get_from_username<'a, 'b, E>(
username: String,
executor: E,
) -> Result<Option<Self>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT u.id, u.github_id, u.name, u.email,
u.avatar_url, u.bio,
u.created, u.role
FROM users u
WHERE u.username = $1
",
username
)
.fetch_optional(executor)
.await?;
if let Some(row) = result {
Ok(Some(User {
id: UserId(row.id),
github_id: row.github_id,
name: row.name,
email: row.email,
avatar_url: row.avatar_url,
username,
bio: row.bio,
created: row.created,
role: row.role,
}))
} else {
Ok(None)
}
}
pub async fn get_many<'a, E>(user_ids: Vec<UserId>, exec: E) -> Result<Vec<User>, sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,

View File

@ -13,12 +13,14 @@ pub struct VersionBuilder {
pub game_versions: Vec<GameVersionId>,
pub loaders: Vec<LoaderId>,
pub release_channel: ChannelId,
pub featured: bool,
}
pub struct VersionFileBuilder {
pub url: String,
pub filename: String,
pub hashes: Vec<HashBuilder>,
pub primary: bool,
}
impl VersionFileBuilder {
@ -81,6 +83,7 @@ impl VersionBuilder {
downloads: 0,
release_channel: self.release_channel,
accepted: false,
featured: self.featured,
};
version.insert(&mut *transaction).await?;
@ -154,6 +157,7 @@ pub struct Version {
pub downloads: i32,
pub release_channel: ChannelId,
pub accepted: bool,
pub featured: bool,
}
impl Version {
@ -166,13 +170,13 @@ impl Version {
INSERT INTO versions (
id, mod_id, author_id, name, version_number,
changelog_url, date_published,
downloads, release_channel, accepted
downloads, release_channel, accepted, featured
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7,
$8, $9,
$10
$10, $11
)
",
self.id as VersionId,
@ -184,7 +188,8 @@ impl Version {
self.date_published,
self.downloads,
self.release_channel as ChannelId,
self.accepted
self.accepted,
self.featured
)
.execute(&mut *transaction)
.await?;
@ -234,7 +239,7 @@ impl Version {
let files = sqlx::query!(
"
SELECT files.id, files.url, files.filename FROM files
SELECT files.id, files.url, files.filename, files.is_primary FROM files
WHERE files.version_id = $1
",
id as VersionId,
@ -246,6 +251,7 @@ impl Version {
version_id: id,
url: c.url,
filename: c.filename,
primary: c.is_primary,
}))
})
.try_collect::<Vec<VersionFile>>()
@ -367,7 +373,7 @@ impl Version {
"
SELECT v.mod_id, v.author_id, v.name, v.version_number,
v.changelog_url, v.date_published, v.downloads,
v.release_channel, v.accepted
v.release_channel, v.accepted, v.featured
FROM versions v
WHERE v.id = $1
",
@ -388,6 +394,7 @@ impl Version {
downloads: row.downloads,
release_channel: ChannelId(row.release_channel),
accepted: row.accepted,
featured: row.featured,
}))
} else {
Ok(None)
@ -408,7 +415,7 @@ impl Version {
"
SELECT v.id, v.mod_id, v.author_id, v.name, v.version_number,
v.changelog_url, v.date_published, v.downloads,
v.release_channel, accepted
v.release_channel, v.accepted, v.featured
FROM versions v
WHERE v.id IN (SELECT * FROM UNNEST($1::bigint[]))
",
@ -427,6 +434,7 @@ impl Version {
downloads: v.downloads,
release_channel: ChannelId(v.release_channel),
accepted: v.accepted,
featured: v.featured,
}))
})
.try_collect::<Vec<Version>>()
@ -446,7 +454,7 @@ impl Version {
"
SELECT v.mod_id, v.author_id, v.name, v.version_number,
v.changelog_url, v.date_published, v.downloads,
release_channels.channel, v.accepted
release_channels.channel, v.accepted, v.featured
FROM versions v
INNER JOIN release_channels ON v.release_channel = release_channels.id
WHERE v.id = $1
@ -487,7 +495,7 @@ impl Version {
let mut files = sqlx::query!(
"
SELECT files.id, files.url, files.filename FROM files
SELECT files.id, files.url, files.filename, files.is_primary FROM files
WHERE files.version_id = $1
",
id as VersionId,
@ -499,6 +507,7 @@ impl Version {
url: c.url,
filename: c.filename,
hashes: std::collections::HashMap::new(),
primary: c.is_primary,
}))
})
.try_collect::<Vec<QueryFile>>()
@ -535,6 +544,7 @@ impl Version {
loaders,
game_versions,
accepted: row.accepted,
featured: row.featured,
}))
} else {
Ok(None)
@ -564,6 +574,7 @@ pub struct VersionFile {
pub version_id: VersionId,
pub url: String,
pub filename: String,
pub primary: bool,
}
pub struct FileHash {
@ -587,6 +598,7 @@ pub struct QueryVersion {
pub game_versions: Vec<String>,
pub loaders: Vec<String>,
pub accepted: bool,
pub featured: bool,
}
pub struct QueryFile {
@ -594,4 +606,5 @@ pub struct QueryFile {
pub url: String,
pub filename: String,
pub hashes: std::collections::HashMap<String, Vec<u8>>,
pub primary: bool,
}

View File

@ -36,6 +36,11 @@ struct Config {
allow_missing_vars: bool,
}
#[derive(Clone)]
pub struct Pepper {
pub pepper: String,
}
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
dotenv::dotenv().ok();
@ -155,6 +160,44 @@ async fn main() -> std::io::Result<()> {
}
});
let pool_ref = pool.clone();
scheduler.run(std::time::Duration::from_secs(15 * 60), move || {
let pool_ref = pool_ref.clone();
// Use sqlx to delete records more than an hour old
info!("Deleting old records from temporary tables");
async move {
let downloads_result = sqlx::query!(
"
DELETE FROM downloads
WHERE date < (CURRENT_DATE - INTERVAL '30 minutes ago')
"
)
.execute(&pool_ref)
.await;
if let Err(e) = downloads_result {
warn!("Deleting old records from temporary table downloads failed: {:?}", e);
}
let states_result = sqlx::query!(
"
DELETE FROM states
WHERE expires < CURRENT_DATE
"
)
.execute(&pool_ref)
.await;
if let Err(e) = states_result {
warn!("Deleting old records from temporary table states failed: {:?}", e);
}
info!("Finished deleting old records from temporary tables");
}
});
let indexing_queue = Arc::new(search::indexing::queue::CreationQueue::new());
let queue_ref = indexing_queue.clone();
@ -216,6 +259,10 @@ async fn main() -> std::io::Result<()> {
scheduler::schedule_versions(&mut scheduler, pool.clone(), skip_initial);
let ip_salt = Pepper {
pepper: crate::models::ids::Base62Id(crate::models::ids::random_base62(11)).to_string()
};
let allowed_origins = dotenv::var("CORS_ORIGINS")
.ok()
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
@ -247,6 +294,7 @@ async fn main() -> std::io::Result<()> {
.data(file_host.clone())
.data(indexing_queue.clone())
.data(search_config.clone())
.data(ip_salt.clone())
.service(routes::index_get)
.service(
web::scope("/api/v1/")

View File

@ -21,6 +21,8 @@ pub struct VersionId(pub u64);
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 slug: Option<String>,
/// The team of people that has ownership of this mod.
pub team: TeamId,
/// The title or name of the mod.
@ -35,6 +37,13 @@ pub struct Mod {
pub updated: DateTime<Utc>,
/// The status of the mod
pub status: ModStatus,
/// The license of this mod
pub license: License,
/// The support range for the client mod
pub client_side: SideType,
/// The support range for the server mod
pub server_side: SideType,
/// The total number of downloads the mod has had.
pub downloads: u32,
@ -42,7 +51,7 @@ pub struct Mod {
pub categories: Vec<String>,
/// A list of ids for versions of the mod.
pub versions: Vec<VersionId>,
///The URL of the icon of the mod
/// The URL of the icon of the mod
pub icon_url: Option<String>,
/// An optional link to where to submit bugs or issues with the mod.
pub issues_url: Option<String>,
@ -50,6 +59,60 @@ pub struct 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 discord
pub discord_url: Option<String>,
/// An optional list of all donation links the mod has
pub donation_urls: Option<Vec<DonationLink>>,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub enum SideType {
Required,
NoFunctionality,
Unsupported,
Unknown,
}
impl std::fmt::Display for SideType {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{}", self.as_str())
}
}
impl SideType {
// These are constant, so this can remove unneccessary allocations (`to_string`)
pub fn as_str(&self) -> &'static str {
match self {
SideType::Required => "required",
SideType::NoFunctionality => "no-functionality",
SideType::Unsupported => "unsupported",
SideType::Unknown => "unknown",
}
}
pub fn from_str(string: &str) -> SideType {
match string {
"required" => SideType::Required,
"no-functionality" => SideType::NoFunctionality,
"unsupported" => SideType::Unsupported,
_ => SideType::Unknown,
}
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct License {
pub id: String,
pub name: String,
pub url: Option<String>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct DonationLink {
pub id: String,
pub platform: String,
pub url: String,
}
/// A status decides the visbility of a mod in search, URLs, and the whole site itself.
@ -71,14 +134,7 @@ pub enum ModStatus {
impl std::fmt::Display for ModStatus {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
ModStatus::Approved => write!(fmt, "approved"),
ModStatus::Rejected => write!(fmt, "rejected"),
ModStatus::Draft => write!(fmt, "draft"),
ModStatus::Unlisted => write!(fmt, "unlisted"),
ModStatus::Processing => write!(fmt, "processing"),
ModStatus::Unknown => write!(fmt, "unknown"),
}
write!(fmt, "{}", self.as_str())
}
}
@ -116,10 +172,7 @@ impl ModStatus {
}
pub fn is_searchable(&self) -> bool {
match self {
ModStatus::Approved => true,
_ => false,
}
matches!(self, ModStatus::Approved)
}
}
@ -132,6 +185,8 @@ pub struct Version {
pub mod_id: ModId,
/// The ID of the author who published this version
pub author_id: UserId,
/// Whether the version is featured or not
pub featured: bool,
/// The name of this version
pub name: String,
@ -166,6 +221,8 @@ pub struct VersionFile {
pub url: String,
/// The filename of the file.
pub filename: String,
/// Whether the file is the primary file of a version
pub primary: bool,
}
#[derive(Serialize, Deserialize, Clone)]

View File

@ -26,9 +26,11 @@ pub fn mods_config(cfg: &mut web::ServiceConfig) {
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(web::scope("{mod_id}").service(versions::version_list)),
);
}
@ -46,7 +48,8 @@ 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::get_version_from_hash)
.service(versions::download_version),
);
}
@ -56,9 +59,12 @@ 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::user_delete)
.service(users::user_edit)
.service(users::user_icon_edit)
.service(users::teams),
);
}
@ -84,6 +90,8 @@ pub fn moderation_config(cfg: &mut web::ServiceConfig) {
#[derive(thiserror::Error, Debug)]
pub enum ApiError {
#[error("Environment Error")]
EnvError(#[from] dotenv::Error),
#[error("Error while uploading file")]
FileHostingError(#[from] FileHostingError),
#[error("Internal server error")]
@ -103,6 +111,7 @@ pub enum ApiError {
impl actix_web::ResponseError for ApiError {
fn status_code(&self) -> actix_web::http::StatusCode {
match self {
ApiError::EnvError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::DatabaseError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
ApiError::AuthenticationError(..) => actix_web::http::StatusCode::UNAUTHORIZED,
ApiError::CustomAuthenticationError(..) => actix_web::http::StatusCode::UNAUTHORIZED,
@ -117,6 +126,7 @@ impl actix_web::ResponseError for ApiError {
actix_web::web::HttpResponse::build(self.status_code()).json(
crate::models::error::ApiError {
error: match self {
ApiError::EnvError(..) => "environment_error",
ApiError::DatabaseError(..) => "database_error",
ApiError::AuthenticationError(..) => "unauthorized",
ApiError::CustomAuthenticationError(..) => "unauthorized",

View File

@ -2,7 +2,7 @@ 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::{ModId, ModStatus, VersionId};
use crate::models::mods::{DonationLink, License, ModId, ModStatus, SideType, VersionId};
use crate::models::users::UserId;
use crate::routes::version_creation::InitialVersionData;
use crate::search::indexing::{queue::CreationQueue, IndexingError};
@ -99,6 +99,8 @@ impl actix_web::ResponseError for CreateError {
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: Option<String>,
/// A short description of the mod.
pub mod_description: String,
/// A long description of the mod, in markdown.
@ -113,8 +115,20 @@ struct ModCreateData {
pub source_url: Option<String>,
/// An optional link to the mod's wiki page or other relevant information.
pub wiki_url: Option<String>,
/// An optional 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
pub client_side: SideType,
/// The support range for the server mod
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
pub donation_urls: Option<Vec<DonationLink>>,
}
pub struct UploadedFile {
@ -461,7 +475,53 @@ async fn mod_create_inner(
let status_id = models::StatusId::get_id(&status, &mut *transaction)
.await?
.expect("No database entry found for status");
.ok_or_else(|| {
CreateError::InvalidInput(format!("Status {} does not exist.", status.clone()))
})?;
let client_side_id =
models::SideTypeId::get_id(&mod_create_data.client_side, &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::InvalidInput(
"Client side type specified does not exist.".to_string(),
)
})?;
let server_side_id =
models::SideTypeId::get_id(&mod_create_data.server_side, &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::InvalidInput(
"Server side type specified does not exist.".to_string(),
)
})?;
let license_id =
models::categories::License::get_id(&mod_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 {
for url in urls {
let platform_id = models::DonationPlatformId::get_id(&url.id, &mut *transaction)
.await?
.ok_or_else(|| {
CreateError::InvalidInput(format!(
"Donation platform {} does not exist.",
url.id.clone()
))
})?;
donation_urls.push(models::mod_item::DonationUrl {
mod_id: mod_id.into(),
platform_id,
url: url.url.clone(),
})
}
}
let mod_builder = models::mod_item::ModBuilder {
mod_id: mod_id.into(),
@ -474,15 +534,23 @@ async fn mod_create_inner(
source_url: mod_create_data.source_url,
wiki_url: mod_create_data.wiki_url,
license_url: mod_create_data.license_url,
discord_url: mod_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: mod_create_data.mod_slug,
donation_urls,
};
let now = chrono::Utc::now();
let response = crate::models::mods::Mod {
id: mod_id,
slug: mod_builder.slug.clone(),
team: team_id.into(),
title: mod_builder.title.clone(),
description: mod_builder.description.clone(),
@ -490,6 +558,13 @@ async fn mod_create_inner(
published: now,
updated: now,
status,
license: License {
id: mod_create_data.license_id.clone(),
name: "".to_string(),
url: mod_builder.license_url.clone(),
},
client_side: mod_create_data.client_side,
server_side: mod_create_data.server_side,
downloads: 0,
categories: mod_create_data.categories,
versions: mod_builder
@ -501,6 +576,8 @@ async fn mod_create_inner(
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(),
};
let _mod_id = mod_builder.insert(&mut *transaction).await?;
@ -598,6 +675,7 @@ async fn create_initial_version(
game_versions,
loaders,
release_channel,
featured: version_data.featured,
};
Ok(version)
@ -642,7 +720,7 @@ async fn process_icon_upload(
}
}
fn get_image_content_type(extension: &str) -> Option<&'static str> {
pub fn get_image_content_type(extension: &str) -> Option<&'static str> {
let content_type = match &*extension {
"bmp" => "image/bmp",
"gif" => "image/gif",

View File

@ -2,10 +2,12 @@ use super::ApiError;
use crate::auth::check_is_moderator_from_headers;
use crate::database;
use crate::models;
use crate::models::mods::{ModStatus, VersionType};
use crate::models::mods::{ModStatus, VersionType, ModId};
use actix_web::{get, web, HttpRequest, HttpResponse};
use serde::Deserialize;
use serde::{Serialize, Deserialize};
use sqlx::PgPool;
use sqlx::types::chrono::{DateTime, Utc};
use crate::models::teams::TeamId;
#[derive(Deserialize)]
pub struct ResultCount {
@ -17,6 +19,42 @@ fn default_count() -> i16 {
100
}
/// A mod returned from the API moderation routes
#[derive(Serialize)]
pub struct ModerationMod {
/// The ID of the mod, encoded as a base62 string.
pub id: ModId,
/// The slug of a mod, used for vanity URLs
pub slug: Option<String>,
/// The team of people that has ownership of this mod.
pub team: TeamId,
/// The title or name of the mod.
pub title: String,
/// A short description of the mod.
pub description: String,
/// The link to the long description of the mod.
pub body_url: String,
/// The date at which the mod was first published.
pub published: DateTime<Utc>,
/// The date at which the mod was first published.
pub updated: DateTime<Utc>,
/// The status of the mod
pub status: ModStatus,
/// The total number of downloads the mod has had.
pub downloads: u32,
/// The URL of the icon of the mod
pub icon_url: Option<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 discord
pub discord_url: Option<String>,
}
#[get("mods")]
pub async fn mods(
req: HttpRequest,
@ -41,15 +79,14 @@ pub async fn mods(
)
.fetch_many(&**pool)
.try_filter_map(|e| async {
Ok(e.right().map(|m| models::mods::Mod {
Ok(e.right().map(|m| ModerationMod {
id: database::models::ids::ModId(m.id).into(),
slug: m.slug,
team: database::models::ids::TeamId(m.team_id).into(),
title: m.title,
description: m.description,
body_url: m.body_url,
published: m.published,
categories: vec![],
versions: vec![],
icon_url: m.icon_url,
issues_url: m.issues_url,
source_url: m.source_url,
@ -57,9 +94,10 @@ pub async fn mods(
updated: m.updated,
downloads: m.downloads as u32,
wiki_url: m.wiki_url,
discord_url: m.discord_url,
}))
})
.try_collect::<Vec<models::mods::Mod>>()
.try_collect::<Vec<ModerationMod>>()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
@ -92,6 +130,7 @@ pub async fn versions(
id: database::models::ids::VersionId(m.id).into(),
mod_id: database::models::ids::ModId(m.mod_id).into(),
author_id: database::models::ids::UserId(m.author_id).into(),
featured: m.featured,
name: m.name,
version_number: m.version_number,
changelog_url: m.changelog_url,

View File

@ -3,10 +3,11 @@ use crate::auth::get_user_from_headers;
use crate::database;
use crate::file_hosting::FileHost;
use crate::models;
use crate::models::mods::{ModStatus, SearchRequest};
use crate::models::mods::{DonationLink, License, ModStatus, SearchRequest, SideType};
use crate::models::teams::Permissions;
use crate::search::{search_for_mod, SearchConfig, SearchError};
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
@ -80,6 +81,53 @@ pub async fn mods_get(
Ok(HttpResponse::Ok().json(mods))
}
#[get("@{id}")]
pub async fn mod_slug_get(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let mod_data = database::models::Mod::get_full_from_slug(id, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
let user_option = get_user_from_headers(req.headers(), &**pool).await.ok();
if let Some(data) = mod_data {
let mut authorized = !data.status.is_hidden();
if let Some(user) = user_option {
if !authorized {
if user.role.is_mod() {
authorized = true;
} else {
let user_id: database::models::ids::UserId = user.id.into();
let mod_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM team_members WHERE id = $1 AND user_id = $2)",
data.inner.team_id as database::models::ids::TeamId,
user_id as database::models::ids::UserId,
)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.exists;
authorized = mod_exists.unwrap_or(false);
}
}
}
if authorized {
return Ok(HttpResponse::Ok().json(convert_mod(data)));
}
Ok(HttpResponse::NotFound().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[get("{id}")]
pub async fn mod_get(
req: HttpRequest,
@ -132,6 +180,7 @@ fn convert_mod(data: database::models::mod_item::QueryMod) -> models::mods::Mod
models::mods::Mod {
id: m.id.into(),
slug: m.slug,
team: m.team_id.into(),
title: m.title,
description: m.description,
@ -139,6 +188,13 @@ fn convert_mod(data: database::models::mod_item::QueryMod) -> models::mods::Mod
published: m.published,
updated: m.updated,
status: data.status,
license: License {
id: data.license_id,
name: data.license_name,
url: m.license_url,
},
client_side: data.client_side,
server_side: data.server_side,
downloads: m.downloads as u32,
categories: data.categories,
versions: data.versions.into_iter().map(|v| v.into()).collect(),
@ -146,6 +202,8 @@ fn convert_mod(data: database::models::mod_item::QueryMod) -> models::mods::Mod
issues_url: m.issues_url,
source_url: m.source_url,
wiki_url: m.wiki_url,
discord_url: m.discord_url,
donation_urls: None,
}
}
@ -175,6 +233,28 @@ pub struct EditMod {
with = "::serde_with::rust::double_option"
)]
pub wiki_url: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub license_url: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub discord_url: Option<Option<String>>,
pub donation_urls: Option<Vec<DonationLink>>,
pub license_id: Option<String>,
pub client_side: Option<SideType>,
pub server_side: Option<SideType>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub slug: Option<Option<String>>,
}
#[patch("{id}")]
@ -270,12 +350,10 @@ pub async fn mod_edit(
));
}
if status == &ModStatus::Rejected || status == &ModStatus::Approved {
if !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to set this status".to_string(),
));
}
if (status == &ModStatus::Rejected || status == &ModStatus::Approved) && !user.role.is_mod() {
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to set this status".to_string(),
));
}
let status_id = database::models::StatusId::get_id(&status, &mut *transaction)
@ -421,6 +499,199 @@ pub async fn mod_edit(
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(license_url) = &new_mod.license_url {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the license URL of this mod!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE mods
SET license_url = $1
WHERE (id = $2)
",
license_url.as_deref(),
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(discord_url) = &new_mod.discord_url {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the discord URL of this mod!"
.to_string(),
));
}
sqlx::query!(
"
UPDATE mods
SET discord_url = $1
WHERE (id = $2)
",
discord_url.as_deref(),
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(slug) = &new_mod.slug {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the slug of this mod!".to_string(),
));
}
sqlx::query!(
"
UPDATE mods
SET slug = $1
WHERE (id = $2)
",
slug.as_deref(),
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(new_side) = &new_mod.client_side {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the side type of this mod!"
.to_string(),
));
}
let side_type_id =
database::models::SideTypeId::get_id(new_side, &mut *transaction)
.await?
.expect("No database entry found for side type");
sqlx::query!(
"
UPDATE mods
SET client_side = $1
WHERE (id = $2)
",
side_type_id as database::models::SideTypeId,
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(new_side) = &new_mod.server_side {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the side type of this mod!"
.to_string(),
));
}
let side_type_id =
database::models::SideTypeId::get_id(new_side, &mut *transaction)
.await?
.expect("No database entry found for side type");
sqlx::query!(
"
UPDATE mods
SET server_side = $1
WHERE (id = $2)
",
side_type_id as database::models::SideTypeId,
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(license) = &new_mod.license_id {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the license of this mod!"
.to_string(),
));
}
let license_id =
database::models::categories::License::get_id(license, &mut *transaction)
.await?
.expect("No database entry found for license");
sqlx::query!(
"
UPDATE mods
SET license = $1
WHERE (id = $2)
",
license_id as database::models::LicenseId,
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(donations) = &new_mod.donation_urls {
if !perms.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the donation links of this mod!"
.to_string(),
));
}
sqlx::query!(
"
DELETE FROM mods_donations
WHERE joining_mod_id = $1
",
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
for donation in donations {
let platform_id = database::models::DonationPlatformId::get_id(
&donation.id,
&mut *transaction,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError(format!(
"Platform {} does not exist.",
donation.id.clone()
))
})?;
sqlx::query!(
"
INSERT INTO mods_donations (joining_mod_id, joining_platform_id, url)
VALUES ($1, $2, $3)
",
id as database::models::ids::ModId,
platform_id as database::models::ids::DonationPlatformId,
donation.url
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
}
if let Some(body) = &new_mod.body {
if !perms.contains(Permissions::EDIT_BODY) {
return Err(ApiError::CustomAuthenticationError(
@ -452,6 +723,99 @@ pub async fn mod_edit(
}
}
#[derive(Serialize, Deserialize)]
pub struct Extension {
pub ext: String,
}
#[patch("{id}/icon")]
pub async fn mod_icon_edit(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
info: web::Path<(models::ids::ModId,)>,
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) {
let cdn_url = dotenv::var("CDN_URL")?;
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
let mod_item = database::models::Mod::get(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.ok_or_else(|| ApiError::InvalidInputError("Invalid Mod ID specified!".to_string()))?;
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id(
mod_item.team_id,
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::DatabaseError)?
.ok_or_else(|| ApiError::InvalidInputError("Invalid Mod ID specified!".to_string()))?;
if !team_member.permissions.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to edit this mod's icon.".to_string(),
));
}
}
if let Some(icon) = mod_item.icon_url {
let name = icon.split('/').next();
if let Some(icon_path) = name {
file_host.delete_file_version("", icon_path).await?;
}
}
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!("data/{}/icon.{}", id, ext.ext),
bytes.to_vec(),
)
.await?;
let mod_id: database::models::ids::ModId = id.into();
sqlx::query!(
"
UPDATE mods
SET icon_url = $1
WHERE (id = $2)
",
format!("{}/{}", cdn_url, upload_data.file_name),
mod_id as database::models::ids::ModId,
)
.execute(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().body(""))
} else {
Err(ApiError::InvalidInputError(format!(
"Invalid format for mod icon: {}",
ext.ext
)))
}
}
#[delete("{id}")]
pub async fn mod_delete(
req: HttpRequest,

View File

@ -1,6 +1,7 @@
use super::ApiError;
use crate::auth::check_is_admin_from_headers;
use crate::database::models;
use crate::database::models::categories::{DonationPlatform, License};
use actix_web::{delete, get, put, web, HttpRequest, HttpResponse};
use models::categories::{Category, GameVersion, Loader};
use sqlx::PgPool;
@ -16,7 +17,13 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(loader_delete)
.service(game_version_list)
.service(game_version_create)
.service(game_version_delete),
.service(game_version_delete)
.service(license_create)
.service(license_delete)
.service(license_list)
.service(donation_platform_create)
.service(donation_platform_list)
.service(donation_platform_delete),
);
}
@ -34,14 +41,7 @@ pub async fn category_create(
pool: web::Data<PgPool>,
category: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(
req.headers(),
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?;
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = category.into_inner().0;
@ -56,14 +56,7 @@ pub async fn category_delete(
pool: web::Data<PgPool>,
category: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(
req.headers(),
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?;
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = category.into_inner().0;
let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?;
@ -94,14 +87,7 @@ pub async fn loader_create(
pool: web::Data<PgPool>,
loader: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(
req.headers(),
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?;
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = loader.into_inner().0;
@ -116,14 +102,7 @@ pub async fn loader_delete(
pool: web::Data<PgPool>,
loader: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(
req.headers(),
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?;
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = loader.into_inner().0;
let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?;
@ -176,14 +155,7 @@ pub async fn game_version_create(
game_version: web::Path<(String,)>,
version_data: web::Json<GameVersionData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(
req.headers(),
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?;
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = game_version.into_inner().0;
@ -209,14 +181,7 @@ pub async fn game_version_delete(
pool: web::Data<PgPool>,
game_version: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(
req.headers(),
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?;
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = game_version.into_inner().0;
let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?;
@ -234,3 +199,141 @@ pub async fn game_version_delete(
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(serde::Serialize)]
pub struct LicenseQueryData {
short: String,
name: String,
}
#[get("license")]
pub async fn license_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results: Vec<LicenseQueryData> = License::list(&**pool)
.await?
.into_iter()
.map(|x| LicenseQueryData {
short: x.short,
name: x.name,
})
.collect();
Ok(HttpResponse::Ok().json(results))
}
#[derive(serde::Deserialize)]
pub struct LicenseData {
name: String,
}
#[put("license/{name}")]
pub async fn license_create(
req: HttpRequest,
pool: web::Data<PgPool>,
license: web::Path<(String,)>,
license_data: web::Json<LicenseData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let short = license.into_inner().0;
let _id = License::builder()
.short(&short)?
.name(&license_data.name)?
.insert(&**pool)
.await?;
Ok(HttpResponse::Ok().body(""))
}
#[delete("license/{name}")]
pub async fn license_delete(
req: HttpRequest,
pool: web::Data<PgPool>,
license: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = license.into_inner().0;
let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?;
let result = License::remove(&name, &mut transaction).await?;
transaction
.commit()
.await
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
#[derive(serde::Serialize)]
pub struct DonationPlatformQueryData {
short: String,
name: String,
}
#[get("donation_platform")]
pub async fn donation_platform_list(pool: web::Data<PgPool>) -> Result<HttpResponse, ApiError> {
let results: Vec<DonationPlatformQueryData> = DonationPlatform::list(&**pool)
.await?
.into_iter()
.map(|x| DonationPlatformQueryData {
short: x.short,
name: x.name,
})
.collect();
Ok(HttpResponse::Ok().json(results))
}
#[derive(serde::Deserialize)]
pub struct DonationPlatformData {
name: String,
}
#[put("donation_platform/{name}")]
pub async fn donation_platform_create(
req: HttpRequest,
pool: web::Data<PgPool>,
license: web::Path<(String,)>,
license_data: web::Json<DonationPlatformData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let short = license.into_inner().0;
let _id = DonationPlatform::builder()
.short(&short)?
.name(&license_data.name)?
.insert(&**pool)
.await?;
Ok(HttpResponse::Ok().body(""))
}
#[delete("donation_platform/{name}")]
pub async fn donation_platform_delete(
req: HttpRequest,
pool: web::Data<PgPool>,
loader: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = loader.into_inner().0;
let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?;
let result = DonationPlatform::remove(&name, &mut transaction).await?;
transaction
.commit()
.await
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::Ok().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}

View File

@ -1,10 +1,13 @@
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::database::models::{TeamMember, User};
use crate::file_hosting::FileHost;
use crate::models::users::{Role, UserId};
use crate::routes::ApiError;
use actix_web::{delete, get, web, HttpRequest, HttpResponse};
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
#[get("user")]
pub async fn user_auth_get(
@ -44,22 +47,30 @@ pub async fn users_get(
let users: Vec<crate::models::users::User> = users_data
.into_iter()
.map(|data| crate::models::users::User {
id: data.id.into(),
github_id: data.github_id.map(|i| i as u64),
username: data.username,
name: data.name,
email: None,
avatar_url: data.avatar_url,
bio: data.bio,
created: data.created,
role: Role::from_string(&*data.role),
})
.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<(UserId,)>,
@ -71,23 +82,27 @@ pub async fn user_get(
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(data) = user_data {
let response = crate::models::users::User {
id: data.id.into(),
github_id: data.github_id.map(|i| i as u64),
username: data.username,
name: data.name,
email: None,
avatar_url: data.avatar_url,
bio: data.bio,
created: data.created,
role: Role::from_string(&*data.role),
};
let response = convert_user(data);
Ok(HttpResponse::Ok().json(response))
} else {
Ok(HttpResponse::NotFound().body(""))
}
}
fn convert_user(data: crate::database::models::user_item::User) -> crate::models::users::User {
crate::models::users::User {
id: data.id.into(),
github_id: data.github_id.map(|i| i as u64),
username: data.username,
name: data.name,
email: None,
avatar_url: data.avatar_url,
bio: data.bio,
created: data.created,
role: Role::from_string(&*data.role),
}
}
#[get("{user_id}/mods")]
pub async fn mods_list(
info: web::Path<(UserId,)>,
@ -161,6 +176,236 @@ pub async fn teams(
Ok(HttpResponse::Ok().json(team_members))
}
#[derive(Serialize, Deserialize)]
pub struct EditUser {
pub username: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub name: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub email: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
pub bio: Option<Option<String>>,
pub role: Option<String>,
}
#[patch("{id}")]
pub async fn user_edit(
req: HttpRequest,
info: web::Path<(UserId,)>,
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();
if user.id == user_id || user.role.is_mod() {
let mut transaction = pool
.begin()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
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()))?;
}
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()))?;
}
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()))?;
}
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(),
));
}
let role = Role::from_string(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()))?;
}
transaction
.commit()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
Ok(HttpResponse::Ok().body(""))
} else {
Err(ApiError::CustomAuthenticationError(
"You do not have permission to edit this user!".to_string(),
))
}
}
#[derive(Serialize, Deserialize)]
pub struct Extension {
pub ext: String,
}
#[patch("{id}/icon")]
pub async fn user_icon_edit(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
info: web::Path<(UserId,)>,
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) {
let cdn_url = dotenv::var("CDN_URL")?;
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id = info.into_inner().0;
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(icon) = icon_url {
if icon.starts_with(&cdn_url) {
let name = icon.split('/').next();
if let Some(icon_path) = name {
file_host.delete_file_version("", icon_path).await?;
}
}
}
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.{}", id, ext.ext),
bytes.to_vec(),
)
.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(""))
} else {
Err(ApiError::InvalidInputError(format!(
"Invalid format for user icon: {}",
ext.ext
)))
}
}
// TODO: Make this actually do stuff
#[delete("{id}")]
pub async fn user_delete(

View File

@ -24,6 +24,7 @@ pub struct InitialVersionData {
pub game_versions: Vec<GameVersion>,
pub release_channel: VersionType,
pub loaders: Vec<ModLoader>,
pub featured: bool,
}
#[derive(Serialize, Deserialize, Clone)]
@ -265,6 +266,7 @@ async fn version_create_inner(
game_versions,
loaders,
release_channel,
featured: version_create_data.featured,
});
continue;
@ -298,6 +300,7 @@ async fn version_create_inner(
id: builder.version_id.into(),
mod_id: builder.mod_id.into(),
author_id: user.id,
featured: builder.featured,
name: builder.name.clone(),
version_number: builder.version_number.clone(),
changelog_url: builder.changelog_url.clone(),
@ -324,6 +327,7 @@ async fn version_create_inner(
.collect(),
url: file.url.clone(),
filename: file.filename.clone(),
primary: file.primary,
})
.collect::<Vec<_>>(),
dependencies: version_data.dependencies,
@ -528,6 +532,7 @@ pub async fn upload_file(
// bytes, but this is the string version.
hash: upload_data.content_sha1.into_bytes(),
}],
primary: uploaded_files.len() == 1,
})
}

View File

@ -1,6 +1,6 @@
use super::ApiError;
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::database;
use crate::{database, Pepper};
use crate::file_hosting::FileHost;
use crate::models;
use crate::models::teams::Permissions;
@ -151,6 +151,7 @@ fn convert_version(data: database::models::version_item::QueryVersion) -> models
mod_id: data.mod_id.into(),
author_id: data.author_id.into(),
featured: data.featured,
name: data.name,
version_number: data.version_number,
changelog_url: data.changelog_url,
@ -178,6 +179,7 @@ fn convert_version(data: database::models::version_item::QueryVersion) -> models
.map(|(k, v)| Some((k, String::from_utf8(v).ok()?)))
.collect::<Option<_>>()
.unwrap_or_else(Default::default),
primary: f.primary,
}
})
.collect(),
@ -204,6 +206,8 @@ pub struct EditVersion {
pub game_versions: Option<Vec<models::mods::GameVersion>>,
pub loaders: Option<Vec<models::mods::ModLoader>>,
pub accepted: Option<bool>,
pub featured: Option<bool>,
pub primary_file: Option<(String, String)>,
}
#[patch("{id}")]
@ -388,6 +392,65 @@ pub async fn version_edit(
}
}
if let Some(featured) = &new_version.featured {
sqlx::query!(
"
UPDATE versions
SET featured = $1
WHERE (id = $2)
",
featured,
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(primary_file) = &new_version.primary_file {
let result = sqlx::query!(
"
SELECT id FROM files
INNER JOIN hashes ON hash = $1 AND algorithm = $2
",
primary_file.1.as_bytes(),
primary_file.0
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.ok_or_else(|| {
ApiError::InvalidInputError(format!(
"Specified file with hash {} does not exist.",
primary_file.1.clone()
))
})?;
sqlx::query!(
"
UPDATE files
SET is_primary = FALSE
WHERE (version_id = $1)
",
id as database::models::ids::VersionId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
sqlx::query!(
"
UPDATE files
SET is_primary = TRUE
WHERE (id = $1)
",
result.id,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
}
if let Some(body) = &new_version.changelog {
let mod_id: models::mods::ModId = version_item.mod_id.into();
let body_path = format!(
@ -518,6 +581,102 @@ pub async fn get_version_from_hash(
}
}
#[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;
let result = sqlx::query!(
"
SELECT f.url url, f.id id, f.version_id version_id, v.mod_id mod_id FROM files f
INNER JOIN versions v ON v.id = f.version_id
INNER JOIN hashes ON hash = $1 AND algorithm = $2
",
hash.as_bytes(),
algorithm.algorithm
)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(id) = result {
let real_ip = req.connection_info();
let ip_option = real_ip.realip_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(