You've already forked AstralRinth
forked from didirus/AstralRinth
Refactor mod creation route, add more checks (#80)
This also removes the `team_members` field of `InitialModData`, as team members are no longer specified at mod creation.
This commit is contained in:
140
sqlx-data.json
140
sqlx-data.json
@@ -59,6 +59,46 @@
|
|||||||
"nullable": []
|
"nullable": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"0a3f99eae57c0c3d10aa0014db7fb8a33952da3e7d00949a25ade843859272cb": {
|
||||||
|
"query": "\n SELECT id\n FROM release_channels\n WHERE channel = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"0ca11a32b2860e4f5c3d20892a5be3cb419e084f42ba0f98e09b9995027fcc4e": {
|
||||||
|
"query": "\n SELECT id FROM statuses\n WHERE status = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"0da158263c6588a83421154342db2ede16b9abf9931827790b9fcaf71080c324": {
|
"0da158263c6588a83421154342db2ede16b9abf9931827790b9fcaf71080c324": {
|
||||||
"query": "\n SELECT u.id, u.username FROM users u\n INNER JOIN team_members tm ON tm.user_id = u.id\n WHERE tm.team_id = $2 AND tm.role = $1\n ",
|
"query": "\n SELECT u.id, u.username FROM users u\n INNER JOIN team_members tm ON tm.user_id = u.id\n WHERE tm.team_id = $2 AND tm.role = $1\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
@@ -86,6 +126,26 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"0ef06dd5094da2458c558b115ed272da338ade372e717d8580cdf52c0000f80c": {
|
||||||
|
"query": "\n SELECT user_id FROM team_members\n WHERE team_id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"1016a0bf55e9474357ac5ef725605ac337e82e1a2b93726ae795ec48f0d696dd": {
|
"1016a0bf55e9474357ac5ef725605ac337e82e1a2b93726ae795ec48f0d696dd": {
|
||||||
"query": "\n SELECT v.mod_id, v.author_id, v.name, v.version_number,\n v.changelog_url, v.date_published, v.downloads,\n release_channels.channel\n FROM versions v\n INNER JOIN release_channels ON v.release_channel = release_channels.id\n WHERE v.id = $1\n ",
|
"query": "\n SELECT v.mod_id, v.author_id, v.name, v.version_number,\n v.changelog_url, v.date_published, v.downloads,\n release_channels.channel\n FROM versions v\n INNER JOIN release_channels ON v.release_channel = release_channels.id\n WHERE v.id = $1\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
@@ -519,6 +579,46 @@
|
|||||||
"nullable": []
|
"nullable": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"40597b84607e77809c13ffa9c6b0b1674bd6378a4737a8f6118e91ae2ede7e4a": {
|
||||||
|
"query": "\n SELECT id\n FROM release_channels\n WHERE channel = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"42e072309779598d0c213280dd8052d1b4889cb24ef5204ca13b74f693b94328": {
|
||||||
|
"query": "\n SELECT user_id FROM team_members tm\n INNER JOIN mods ON mods.team_id = tm.team_id\n WHERE mods.id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"4411f2aefd43881450da34db81e826110ac86c3a6cef9fd6a3e9e341508d1f09": {
|
"4411f2aefd43881450da34db81e826110ac86c3a6cef9fd6a3e9e341508d1f09": {
|
||||||
"query": "\n SELECT id FROM versions\n WHERE mod_id = $1\n ",
|
"query": "\n SELECT id FROM versions\n WHERE mod_id = $1\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
@@ -554,6 +654,26 @@
|
|||||||
"nullable": []
|
"nullable": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"4c98e4441f8168d00bc7ff47951f15b44ff884cff6fc484645c74bfe3e7e7020": {
|
||||||
|
"query": "\n SELECT id\n FROM statuses\n WHERE status = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"4c99c0840159d18e88cd6094a41117258f2337346c145d926b5b610c76b5125f": {
|
"4c99c0840159d18e88cd6094a41117258f2337346c145d926b5b610c76b5125f": {
|
||||||
"query": "\n SELECT c.category\n FROM mods_categories mc\n INNER JOIN categories c ON mc.joining_category_id=c.id\n WHERE mc.joining_mod_id = $1\n ",
|
"query": "\n SELECT c.category\n FROM mods_categories mc\n INNER JOIN categories c ON mc.joining_category_id=c.id\n WHERE mc.joining_mod_id = $1\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
@@ -1406,6 +1526,26 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"cf031f19c7882833a8a30348ee90175a5d8b1fb7d9645c5deb2dc68c6eb33683": {
|
||||||
|
"query": "\n SELECT id FROM release_channels\n WHERE channel = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"d0172d12dce3d8ddc888893ec1cdd93ad232685e80f706e70dea22c85d96df63": {
|
"d0172d12dce3d8ddc888893ec1cdd93ad232685e80f706e70dea22c85d96df63": {
|
||||||
"query": "SELECT team_id FROM mods WHERE id=$1",
|
"query": "SELECT team_id FROM mods WHERE id=$1",
|
||||||
"describe": {
|
"describe": {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ generate_ids!(
|
|||||||
UserId
|
UserId
|
||||||
);
|
);
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, Type)]
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Type)]
|
||||||
#[sqlx(transparent)]
|
#[sqlx(transparent)]
|
||||||
pub struct UserId(pub i64);
|
pub struct UserId(pub i64);
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,47 @@ pub enum DatabaseError {
|
|||||||
)]
|
)]
|
||||||
InvalidIdentifier(String),
|
InvalidIdentifier(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ids::ChannelId {
|
||||||
|
pub async fn get_id<'a, E>(
|
||||||
|
channel: &str,
|
||||||
|
exec: E,
|
||||||
|
) -> Result<Option<ids::ChannelId>, DatabaseError>
|
||||||
|
where
|
||||||
|
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||||
|
{
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"
|
||||||
|
SELECT id FROM release_channels
|
||||||
|
WHERE channel = $1
|
||||||
|
",
|
||||||
|
channel
|
||||||
|
)
|
||||||
|
.fetch_optional(exec)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.map(|r| ids::ChannelId(r.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ids::StatusId {
|
||||||
|
pub async fn get_id<'a, E>(
|
||||||
|
status: &crate::models::mods::ModStatus,
|
||||||
|
exec: E,
|
||||||
|
) -> Result<Option<Self>, DatabaseError>
|
||||||
|
where
|
||||||
|
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||||
|
{
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"
|
||||||
|
SELECT id FROM statuses
|
||||||
|
WHERE status = $1
|
||||||
|
",
|
||||||
|
status.as_str()
|
||||||
|
)
|
||||||
|
.fetch_optional(exec)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.map(|r| ids::StatusId(r.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,12 +72,12 @@ pub enum ModStatus {
|
|||||||
impl std::fmt::Display for ModStatus {
|
impl std::fmt::Display for ModStatus {
|
||||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
ModStatus::Approved => write!(fmt, "release"),
|
ModStatus::Approved => write!(fmt, "approved"),
|
||||||
ModStatus::Rejected => write!(fmt, "beta"),
|
ModStatus::Rejected => write!(fmt, "rejected"),
|
||||||
ModStatus::Draft => write!(fmt, "alpha"),
|
ModStatus::Draft => write!(fmt, "draft"),
|
||||||
ModStatus::Unlisted => write!(fmt, "unlisted"),
|
ModStatus::Unlisted => write!(fmt, "unlisted"),
|
||||||
ModStatus::Processing => write!(fmt, "Processing"),
|
ModStatus::Processing => write!(fmt, "processing"),
|
||||||
ModStatus::Unknown => write!(fmt, "Unknown"),
|
ModStatus::Unknown => write!(fmt, "unknown"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,13 +86,23 @@ impl ModStatus {
|
|||||||
pub fn from_str(string: &str) -> ModStatus {
|
pub fn from_str(string: &str) -> ModStatus {
|
||||||
match string {
|
match string {
|
||||||
"processing" => ModStatus::Processing,
|
"processing" => ModStatus::Processing,
|
||||||
"rejected" => ModStatus::Processing,
|
"rejected" => ModStatus::Rejected,
|
||||||
"approved" => ModStatus::Processing,
|
"approved" => ModStatus::Approved,
|
||||||
"draft" => ModStatus::Processing,
|
"draft" => ModStatus::Draft,
|
||||||
"unlisted" => ModStatus::Processing,
|
"unlisted" => ModStatus::Unlisted,
|
||||||
_ => ModStatus::Unknown,
|
_ => ModStatus::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",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A specific version of a mod
|
/// A specific version of a mod
|
||||||
@@ -158,6 +168,17 @@ impl std::fmt::Display for VersionType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl VersionType {
|
||||||
|
// These are constant, so this can remove unneccessary allocations (`to_string`)
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
VersionType::Release => "release",
|
||||||
|
VersionType::Beta => "beta",
|
||||||
|
VersionType::Alpha => "alpha",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A specific version of Minecraft
|
/// A specific version of Minecraft
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
use crate::auth::{get_user_from_headers, AuthenticationError};
|
use crate::auth::{get_user_from_headers, AuthenticationError};
|
||||||
use crate::database::models;
|
use crate::database::models;
|
||||||
use crate::database::models::StatusId;
|
|
||||||
use crate::file_hosting::{FileHost, FileHostingError};
|
use crate::file_hosting::{FileHost, FileHostingError};
|
||||||
use crate::models::error::ApiError;
|
use crate::models::error::ApiError;
|
||||||
use crate::models::mods::{ModId, ModStatus, VersionId};
|
use crate::models::mods::{ModId, ModStatus, VersionId};
|
||||||
use crate::models::teams::TeamMember;
|
|
||||||
use crate::models::users::UserId;
|
use crate::models::users::UserId;
|
||||||
use crate::routes::version_creation::InitialVersionData;
|
use crate::routes::version_creation::InitialVersionData;
|
||||||
use crate::search::indexing::queue::CreationQueue;
|
use crate::search::indexing::queue::CreationQueue;
|
||||||
@@ -104,8 +102,6 @@ struct ModCreateData {
|
|||||||
pub mod_body: String,
|
pub mod_body: String,
|
||||||
/// A list of initial versions to upload with the created mod
|
/// A list of initial versions to upload with the created mod
|
||||||
pub initial_versions: Vec<InitialVersionData>,
|
pub initial_versions: Vec<InitialVersionData>,
|
||||||
/// The team of people that has ownership of this mod.
|
|
||||||
pub team_members: Vec<TeamMember>,
|
|
||||||
/// A list of the categories that the mod is in.
|
/// A list of the categories that the mod is in.
|
||||||
pub categories: Vec<String>,
|
pub categories: Vec<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 mod.
|
||||||
@@ -171,6 +167,36 @@ pub async fn mod_create(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
Mod Creation Steps:
|
||||||
|
Get logged in user
|
||||||
|
Must match the author in the version creation
|
||||||
|
|
||||||
|
1. Data
|
||||||
|
- Gets "data" field from multipart form; must be first
|
||||||
|
- Verification: string lengths
|
||||||
|
- Create versions
|
||||||
|
- Some shared logic with version creation
|
||||||
|
- Create list of VersionBuilders
|
||||||
|
- Create ModBuilder
|
||||||
|
|
||||||
|
2. Upload
|
||||||
|
- Icon: check file format & size
|
||||||
|
- Upload to backblaze & record URL
|
||||||
|
- Mod files
|
||||||
|
- Check for matching version
|
||||||
|
- File size limits?
|
||||||
|
- Check file type
|
||||||
|
- Eventually, malware scan
|
||||||
|
- Upload to backblaze & create VersionFileBuilder
|
||||||
|
-
|
||||||
|
|
||||||
|
3. Creation
|
||||||
|
- Database stuff
|
||||||
|
- Add mod data to indexing queue
|
||||||
|
*/
|
||||||
|
|
||||||
async fn mod_create_inner(
|
async fn mod_create_inner(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
mut payload: Multipart,
|
mut payload: Multipart,
|
||||||
@@ -179,157 +205,177 @@ async fn mod_create_inner(
|
|||||||
uploaded_files: &mut Vec<UploadedFile>,
|
uploaded_files: &mut Vec<UploadedFile>,
|
||||||
indexing_queue: &CreationQueue,
|
indexing_queue: &CreationQueue,
|
||||||
) -> Result<HttpResponse, CreateError> {
|
) -> Result<HttpResponse, CreateError> {
|
||||||
|
// The base URL for files uploaded to backblaze
|
||||||
let cdn_url = dotenv::var("CDN_URL")?;
|
let cdn_url = dotenv::var("CDN_URL")?;
|
||||||
|
|
||||||
|
// 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 mod_id: ModId = models::generate_mod_id(transaction).await?.into();
|
||||||
let user = get_user_from_headers(req.headers(), &mut *transaction).await?;
|
|
||||||
|
|
||||||
let mut created_versions: Vec<models::version_item::VersionBuilder> = vec![];
|
let mod_create_data;
|
||||||
|
let mut versions;
|
||||||
|
let mut versions_map = std::collections::HashMap::new();
|
||||||
|
|
||||||
let mut mod_create_data: Option<ModCreateData> = None;
|
{
|
||||||
let mut icon_url = "".to_string();
|
// The first multipart field must be named "data" and contain a
|
||||||
|
// JSON `ModCreateData` object.
|
||||||
|
|
||||||
|
let mut field = payload
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.map(|m| m.map_err(CreateError::MultipartError))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
Err(CreateError::MissingValueError(String::from(
|
||||||
|
"No `data` field in multipart upload",
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let content_disposition = field.content_disposition().ok_or_else(|| {
|
||||||
|
CreateError::MissingValueError(String::from("Missing content disposition"))
|
||||||
|
})?;
|
||||||
|
let name = content_disposition
|
||||||
|
.get_name()
|
||||||
|
.ok_or_else(|| CreateError::MissingValueError(String::from("Missing content name")))?;
|
||||||
|
|
||||||
|
if name != "data" {
|
||||||
|
return Err(CreateError::InvalidInput(String::from(
|
||||||
|
"`data` field must come before file fields",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut data = Vec::new();
|
||||||
|
while let Some(chunk) = field.next().await {
|
||||||
|
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
|
||||||
|
}
|
||||||
|
let create_data: ModCreateData = 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,
|
||||||
|
|
||||||
|
initial_versions: Vec<InitialVersionData>,
|
||||||
|
team_members: Vec<TeamMember>,
|
||||||
|
|
||||||
|
# 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(..65536, "mod body", &create_data.mod_body)?;
|
||||||
|
|
||||||
|
create_data
|
||||||
|
.categories
|
||||||
|
.iter()
|
||||||
|
.map(|f| check_length(1..=256, "category", f))
|
||||||
|
.collect::<Result<(), _>>()?;
|
||||||
|
|
||||||
|
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)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
create_data
|
||||||
|
.initial_versions
|
||||||
|
.iter()
|
||||||
|
.map(|v| super::version_creation::check_version(v))
|
||||||
|
.collect::<Result<(), _>>()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create VersionBuilders for the versions specified in `initial_versions`
|
||||||
|
versions = Vec::with_capacity(create_data.initial_versions.len());
|
||||||
|
for (i, data) in create_data.initial_versions.iter().enumerate() {
|
||||||
|
// Create a map of multipart field names to version indices
|
||||||
|
for name in &data.file_parts {
|
||||||
|
if versions_map.insert(name.to_owned(), i).is_some() {
|
||||||
|
// If the name is already used
|
||||||
|
return Err(CreateError::InvalidInput(String::from(
|
||||||
|
"Duplicate multipart field name",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
versions.push(
|
||||||
|
create_initial_version(
|
||||||
|
data,
|
||||||
|
mod_id,
|
||||||
|
current_user.id,
|
||||||
|
&cdn_url,
|
||||||
|
transaction,
|
||||||
|
file_host,
|
||||||
|
uploaded_files,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
mod_create_data = create_data;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut icon_url = None;
|
||||||
|
|
||||||
while let Some(item) = payload.next().await {
|
while let Some(item) = payload.next().await {
|
||||||
let mut field: Field = item.map_err(CreateError::MultipartError)?;
|
let mut field: Field = item.map_err(CreateError::MultipartError)?;
|
||||||
let content_disposition = field.content_disposition().ok_or_else(|| {
|
let content_disposition = field.content_disposition().ok_or_else(|| {
|
||||||
CreateError::MissingValueError("Missing content disposition".to_string())
|
CreateError::MissingValueError("Missing content disposition".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let name = content_disposition
|
let name = content_disposition
|
||||||
.get_name()
|
.get_name()
|
||||||
.ok_or_else(|| CreateError::MissingValueError("Missing content name".to_string()))?;
|
.ok_or_else(|| CreateError::MissingValueError("Missing content name".to_string()))?;
|
||||||
|
|
||||||
if name == "data" {
|
|
||||||
let mut data = Vec::new();
|
|
||||||
while let Some(chunk) = field.next().await {
|
|
||||||
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
|
|
||||||
}
|
|
||||||
let create_data: ModCreateData = serde_json::from_slice(&data)?;
|
|
||||||
|
|
||||||
check_length("mod_name", 3, 255, &*create_data.mod_name)?;
|
|
||||||
check_length("mod_description", 3, 2048, &*create_data.mod_description)?;
|
|
||||||
|
|
||||||
for version_data in &create_data.initial_versions {
|
|
||||||
if version_data.mod_id.is_some() {
|
|
||||||
return Err(CreateError::InvalidInput(String::from(
|
|
||||||
"Found mod id in initial version for new mod",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let version_id: VersionId = models::generate_version_id(transaction).await?.into();
|
|
||||||
|
|
||||||
let body_url = format!("data/{}/changelogs/{}/body.md", mod_id, version_id);
|
|
||||||
|
|
||||||
let uploaded_text = file_host
|
|
||||||
.upload_file(
|
|
||||||
"text/plain",
|
|
||||||
&body_url,
|
|
||||||
version_data.version_body.clone().into_bytes(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
uploaded_files.push(UploadedFile {
|
|
||||||
file_id: uploaded_text.file_id.clone(),
|
|
||||||
file_name: uploaded_text.file_name.clone(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let release_channel = models::ChannelId(
|
|
||||||
sqlx::query!(
|
|
||||||
"
|
|
||||||
SELECT id
|
|
||||||
FROM release_channels
|
|
||||||
WHERE channel = $1
|
|
||||||
",
|
|
||||||
version_data.release_channel.to_string()
|
|
||||||
)
|
|
||||||
.fetch_one(&mut *transaction)
|
|
||||||
.await?
|
|
||||||
.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
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 mut loaders = Vec::with_capacity(version_data.loaders.len());
|
|
||||||
for l in &version_data.loaders {
|
|
||||||
let id = models::categories::Loader::get_id(&l.0, &mut *transaction)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| CreateError::InvalidLoader(l.0.clone()))?;
|
|
||||||
loaders.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let version = models::version_item::VersionBuilder {
|
|
||||||
version_id: version_id.into(),
|
|
||||||
mod_id: mod_id.into(),
|
|
||||||
author_id: user.id.into(),
|
|
||||||
name: version_data.version_title.clone(),
|
|
||||||
version_number: version_data.version_number.clone(),
|
|
||||||
changelog_url: Some(format!("{}/{}", cdn_url, body_url)),
|
|
||||||
files: Vec::with_capacity(1),
|
|
||||||
dependencies: version_data
|
|
||||||
.dependencies
|
|
||||||
.iter()
|
|
||||||
.map(|x| (*x).into())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
game_versions,
|
|
||||||
loaders,
|
|
||||||
release_channel,
|
|
||||||
};
|
|
||||||
|
|
||||||
created_versions.push(version);
|
|
||||||
}
|
|
||||||
|
|
||||||
mod_create_data = Some(create_data);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let create_data = mod_create_data.as_ref().ok_or_else(|| {
|
|
||||||
CreateError::InvalidInput(String::from("`data` field must come before file fields"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let (file_name, file_extension) =
|
let (file_name, file_extension) =
|
||||||
super::version_creation::get_name_ext(&content_disposition)?;
|
super::version_creation::get_name_ext(&content_disposition)?;
|
||||||
|
|
||||||
if name == "icon" {
|
if name == "icon" {
|
||||||
icon_url = process_icon_upload(
|
if icon_url.is_some() {
|
||||||
uploaded_files,
|
return Err(CreateError::InvalidInput(String::from(
|
||||||
mod_id,
|
"Mods can only have one icon",
|
||||||
file_name,
|
)));
|
||||||
file_extension,
|
}
|
||||||
file_host,
|
// Upload the icon to the cdn
|
||||||
field,
|
icon_url = Some(
|
||||||
&cdn_url,
|
process_icon_upload(
|
||||||
)
|
uploaded_files,
|
||||||
.await?;
|
mod_id,
|
||||||
|
file_name,
|
||||||
|
file_extension,
|
||||||
|
file_host,
|
||||||
|
field,
|
||||||
|
&cdn_url,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (version_index, version_data) = create_data
|
let index = if let Some(i) = versions_map.get(name) {
|
||||||
.initial_versions
|
*i
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.find(|(_, x)| x.file_parts.iter().any(|n| n == name))
|
|
||||||
.ok_or_else(|| {
|
|
||||||
CreateError::InvalidInput(format!(
|
|
||||||
"File `{}` (field {}) isn't specified in the versions data",
|
|
||||||
file_name, name
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let created_version = if let Some(created_version) = created_versions.get_mut(version_index)
|
|
||||||
{
|
|
||||||
created_version
|
|
||||||
} else {
|
} else {
|
||||||
// This shouldn't be reachable, but better safe than sorry
|
|
||||||
return Err(CreateError::InvalidInput(format!(
|
return Err(CreateError::InvalidInput(format!(
|
||||||
"File `{}` (field {}) isn't specified in the versions data",
|
"File `{}` (field {}) isn't specified in the versions data",
|
||||||
file_name, name
|
file_name, name
|
||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `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();
|
||||||
|
|
||||||
// Upload the new jar file
|
// Upload the new jar file
|
||||||
let file_builder = super::version_creation::upload_file(
|
let file_builder = super::version_creation::upload_file(
|
||||||
&mut field,
|
&mut field,
|
||||||
@@ -346,179 +392,222 @@ async fn mod_create_inner(
|
|||||||
created_version.files.push(file_builder);
|
created_version.files.push(file_builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
let create_data = if let Some(create_data) = mod_create_data {
|
|
||||||
create_data
|
|
||||||
} else {
|
|
||||||
return Err(CreateError::MissingValueError(String::from(
|
|
||||||
"Multipart upload missing `data` field",
|
|
||||||
)));
|
|
||||||
};
|
|
||||||
|
|
||||||
for (version_data, builder) in create_data
|
|
||||||
.initial_versions
|
|
||||||
.iter()
|
|
||||||
.zip(created_versions.iter())
|
|
||||||
{
|
{
|
||||||
if version_data.file_parts.len() != builder.files.len() {
|
// Check to make sure that all specified files were uploaded
|
||||||
return Err(CreateError::InvalidInput(String::from(
|
for (version_data, builder) in mod_create_data.initial_versions.iter().zip(versions.iter())
|
||||||
"Some files were specified in initial_versions but not uploaded",
|
{
|
||||||
)));
|
if version_data.file_parts.len() != builder.files.len() {
|
||||||
|
return Err(CreateError::InvalidInput(String::from(
|
||||||
|
"Some files were specified in initial_versions but not uploaded",
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let ids: Vec<UserId> = (&create_data.team_members)
|
// Convert the list of category names to actual categories
|
||||||
.iter()
|
let mut categories = Vec::with_capacity(mod_create_data.categories.len());
|
||||||
.map(|m| m.user_id)
|
for category in &mod_create_data.categories {
|
||||||
.collect();
|
let id = models::categories::Category::get_id(&category, &mut *transaction)
|
||||||
if !ids.contains(&user.id) {
|
.await?
|
||||||
return Err(CreateError::InvalidInput(String::from(
|
.ok_or_else(|| CreateError::InvalidCategory(category.clone()))?;
|
||||||
"Team members must include yourself!",
|
categories.push(id);
|
||||||
)));
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let mut categories = Vec::with_capacity(create_data.categories.len());
|
// Upload the mod desciption markdown to the CDN
|
||||||
for category in &create_data.categories {
|
// TODO: Should we also process and upload an html version here for SSR?
|
||||||
let id = models::categories::Category::get_id(&category, &mut *transaction)
|
let body_path = format!("data/{}/body.md", mod_id);
|
||||||
|
{
|
||||||
|
let upload_data = file_host
|
||||||
|
.upload_file(
|
||||||
|
"text/plain",
|
||||||
|
&body_path,
|
||||||
|
mod_create_data.mod_body.into_bytes(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
uploaded_files.push(UploadedFile {
|
||||||
|
file_id: upload_data.file_id,
|
||||||
|
file_name: upload_data.file_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let team = models::team_item::TeamBuilder {
|
||||||
|
members: vec![models::team_item::TeamMemberBuilder {
|
||||||
|
user_id: current_user.id.into(),
|
||||||
|
name: current_user.username.clone(),
|
||||||
|
role: crate::models::teams::OWNER_ROLE.to_owned(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let team_id = team.insert(&mut *transaction).await?;
|
||||||
|
|
||||||
|
let status = ModStatus::Processing;
|
||||||
|
let status_id = models::StatusId::get_id(&status, &mut *transaction)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| CreateError::InvalidCategory(category.clone()))?;
|
.expect("No database entry found for status");
|
||||||
categories.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let body_url = format!("data/{}/body.md", mod_id);
|
let mod_builder = models::mod_item::ModBuilder {
|
||||||
|
mod_id: mod_id.into(),
|
||||||
|
team_id,
|
||||||
|
title: mod_create_data.mod_name,
|
||||||
|
description: mod_create_data.mod_description,
|
||||||
|
body_url: format!("{}/{}", cdn_url, body_path),
|
||||||
|
icon_url,
|
||||||
|
issues_url: mod_create_data.issues_url,
|
||||||
|
source_url: mod_create_data.source_url,
|
||||||
|
wiki_url: mod_create_data.wiki_url,
|
||||||
|
|
||||||
let upload_data = file_host
|
categories,
|
||||||
.upload_file("text/plain", &body_url, create_data.mod_body.into_bytes())
|
initial_versions: versions,
|
||||||
.await?;
|
status: status_id,
|
||||||
|
};
|
||||||
|
|
||||||
uploaded_files.push(UploadedFile {
|
let versions_list = mod_builder
|
||||||
file_id: upload_data.file_id.clone(),
|
|
||||||
file_name: upload_data.file_name.clone(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut author_username = None;
|
|
||||||
let mut author_id = None;
|
|
||||||
|
|
||||||
let team = models::team_item::TeamBuilder {
|
|
||||||
members: create_data
|
|
||||||
.team_members
|
|
||||||
.into_iter()
|
|
||||||
.map(|member| {
|
|
||||||
if member.role == crate::models::teams::OWNER_ROLE {
|
|
||||||
author_id = Some(member.user_id);
|
|
||||||
author_username = Some(member.name.clone());
|
|
||||||
}
|
|
||||||
models::team_item::TeamMemberBuilder {
|
|
||||||
user_id: member.user_id.into(),
|
|
||||||
name: member.name,
|
|
||||||
role: member.role,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let (author_username, author_id) = if let (Some(u), Some(id)) = (author_username, author_id) {
|
|
||||||
(u, id)
|
|
||||||
} else {
|
|
||||||
return Err(CreateError::InvalidInput(String::from(
|
|
||||||
"A mod must have an author",
|
|
||||||
)));
|
|
||||||
};
|
|
||||||
|
|
||||||
let team_id = team.insert(&mut *transaction).await?;
|
|
||||||
|
|
||||||
let status = ModStatus::Processing;
|
|
||||||
let status_id = sqlx::query!(
|
|
||||||
"
|
|
||||||
SELECT id
|
|
||||||
FROM statuses
|
|
||||||
WHERE status = $1
|
|
||||||
",
|
|
||||||
status.to_string()
|
|
||||||
)
|
|
||||||
.fetch_one(&mut *transaction)
|
|
||||||
.await?
|
|
||||||
.id;
|
|
||||||
|
|
||||||
let mod_builder = models::mod_item::ModBuilder {
|
|
||||||
mod_id: mod_id.into(),
|
|
||||||
team_id,
|
|
||||||
title: create_data.mod_name,
|
|
||||||
description: create_data.mod_description,
|
|
||||||
body_url: format!("{}/{}", cdn_url, body_url),
|
|
||||||
icon_url: Some(icon_url),
|
|
||||||
issues_url: create_data.issues_url,
|
|
||||||
source_url: create_data.source_url,
|
|
||||||
wiki_url: create_data.wiki_url,
|
|
||||||
|
|
||||||
categories,
|
|
||||||
initial_versions: created_versions,
|
|
||||||
status: StatusId(status_id),
|
|
||||||
};
|
|
||||||
|
|
||||||
let versions_list = mod_builder
|
|
||||||
.initial_versions
|
|
||||||
.iter()
|
|
||||||
.flat_map(|v| {
|
|
||||||
v.game_versions.iter().map(|id| id.0.to_string())
|
|
||||||
// TODO: proper version identifiers, once game versions
|
|
||||||
// have been implemented
|
|
||||||
})
|
|
||||||
.collect::<std::collections::HashSet<String>>()
|
|
||||||
.into_iter()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let timestamp = now.timestamp();
|
|
||||||
|
|
||||||
let index_mod = crate::search::UploadSearchMod {
|
|
||||||
mod_id: format!("local-{}", mod_id),
|
|
||||||
title: mod_builder.title.clone(),
|
|
||||||
description: mod_builder.description.clone(),
|
|
||||||
categories: create_data.categories.clone(),
|
|
||||||
versions: versions_list,
|
|
||||||
page_url: format!("https://modrinth.com/mod/{}", mod_id),
|
|
||||||
icon_url: mod_builder.icon_url.clone().unwrap(),
|
|
||||||
author: author_username,
|
|
||||||
author_url: format!("https://modrinth.com/user/{}", author_id),
|
|
||||||
// TODO: latest version info
|
|
||||||
latest_version: String::new(),
|
|
||||||
downloads: 0,
|
|
||||||
date_created: now,
|
|
||||||
created_timestamp: timestamp,
|
|
||||||
date_modified: now,
|
|
||||||
modified_timestamp: timestamp,
|
|
||||||
host: Cow::Borrowed("modrinth"),
|
|
||||||
empty: Cow::Borrowed("{}{}{}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
indexing_queue.add(index_mod);
|
|
||||||
|
|
||||||
let response = crate::models::mods::Mod {
|
|
||||||
id: mod_id,
|
|
||||||
team: team_id.into(),
|
|
||||||
title: mod_builder.title.clone(),
|
|
||||||
description: mod_builder.description.clone(),
|
|
||||||
body_url: mod_builder.body_url.clone(),
|
|
||||||
published: now,
|
|
||||||
updated: now,
|
|
||||||
status,
|
|
||||||
downloads: 0,
|
|
||||||
categories: create_data.categories.clone(),
|
|
||||||
versions: mod_builder
|
|
||||||
.initial_versions
|
.initial_versions
|
||||||
.iter()
|
.iter()
|
||||||
.map(|v| v.version_id.into())
|
.flat_map(|v| v.game_versions.iter().map(|id| id.0.to_string()))
|
||||||
.collect::<Vec<_>>(),
|
.collect::<std::collections::HashSet<String>>()
|
||||||
icon_url: mod_builder.icon_url.clone(),
|
.into_iter()
|
||||||
issues_url: mod_builder.issues_url.clone(),
|
.collect::<Vec<_>>();
|
||||||
source_url: mod_builder.source_url.clone(),
|
|
||||||
wiki_url: mod_builder.wiki_url.clone(),
|
let now = chrono::Utc::now();
|
||||||
|
let timestamp = now.timestamp();
|
||||||
|
|
||||||
|
let index_mod = crate::search::UploadSearchMod {
|
||||||
|
mod_id: format!("local-{}", mod_id),
|
||||||
|
title: mod_builder.title.clone(),
|
||||||
|
description: mod_builder.description.clone(),
|
||||||
|
categories: mod_create_data.categories.clone(),
|
||||||
|
versions: versions_list,
|
||||||
|
page_url: format!("https://modrinth.com/mod/{}", mod_id),
|
||||||
|
// This should really be optional in the index
|
||||||
|
icon_url: mod_builder.icon_url.clone().unwrap_or_else(String::new),
|
||||||
|
author: current_user.username.clone(),
|
||||||
|
author_url: format!("https://modrinth.com/user/{}", current_user.id),
|
||||||
|
// TODO: latest version info
|
||||||
|
latest_version: String::new(),
|
||||||
|
downloads: 0,
|
||||||
|
date_created: now,
|
||||||
|
created_timestamp: timestamp,
|
||||||
|
date_modified: now,
|
||||||
|
modified_timestamp: timestamp,
|
||||||
|
host: Cow::Borrowed("modrinth"),
|
||||||
|
empty: Cow::Borrowed("{}{}{}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
indexing_queue.add(index_mod);
|
||||||
|
|
||||||
|
let response = crate::models::mods::Mod {
|
||||||
|
id: mod_id,
|
||||||
|
team: team_id.into(),
|
||||||
|
title: mod_builder.title.clone(),
|
||||||
|
description: mod_builder.description.clone(),
|
||||||
|
body_url: mod_builder.body_url.clone(),
|
||||||
|
published: now,
|
||||||
|
updated: now,
|
||||||
|
status,
|
||||||
|
downloads: 0,
|
||||||
|
categories: mod_create_data.categories.clone(),
|
||||||
|
versions: mod_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(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _mod_id = mod_builder.insert(&mut *transaction).await?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_initial_version(
|
||||||
|
version_data: &InitialVersionData,
|
||||||
|
mod_id: ModId,
|
||||||
|
author: UserId,
|
||||||
|
cdn_url: &str,
|
||||||
|
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
file_host: &dyn FileHost,
|
||||||
|
uploaded_files: &mut Vec<UploadedFile>,
|
||||||
|
) -> Result<models::version_item::VersionBuilder, CreateError> {
|
||||||
|
if version_data.mod_id.is_some() {
|
||||||
|
return Err(CreateError::InvalidInput(String::from(
|
||||||
|
"Found mod id in initial version for new mod",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
check_length(3..=256, "version name", &version_data.version_title)?;
|
||||||
|
check_length(1..=32, "version number", &version_data.version_number)?;
|
||||||
|
|
||||||
|
// Randomly generate a new id to be used for the version
|
||||||
|
let version_id: VersionId = models::generate_version_id(transaction).await?.into();
|
||||||
|
|
||||||
|
// Upload the version's changelog to the CDN
|
||||||
|
let changelog_path = if let Some(changelog) = &version_data.version_body {
|
||||||
|
let changelog_path = format!("data/{}/changelogs/{}/body.md", mod_id, version_id);
|
||||||
|
|
||||||
|
let uploaded_text = file_host
|
||||||
|
.upload_file(
|
||||||
|
"text/plain",
|
||||||
|
&changelog_path,
|
||||||
|
changelog.clone().into_bytes(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
uploaded_files.push(UploadedFile {
|
||||||
|
file_id: uploaded_text.file_id,
|
||||||
|
file_name: uploaded_text.file_name,
|
||||||
|
});
|
||||||
|
Some(changelog_path)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let _mod_id = mod_builder.insert(&mut *transaction).await?;
|
let release_channel =
|
||||||
|
models::ChannelId::get_id(version_data.release_channel.as_str(), &mut *transaction)
|
||||||
|
.await?
|
||||||
|
.expect("Release Channel not found in database");
|
||||||
|
|
||||||
// TODO: respond with the new mod info, or with just the new mod id.
|
let mut game_versions = Vec::with_capacity(version_data.game_versions.len());
|
||||||
Ok(HttpResponse::Ok().json(response))
|
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 mut loaders = Vec::with_capacity(version_data.loaders.len());
|
||||||
|
for l in &version_data.loaders {
|
||||||
|
let id = models::categories::Loader::get_id(&l.0, &mut *transaction)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| CreateError::InvalidLoader(l.0.clone()))?;
|
||||||
|
loaders.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dependencies = version_data
|
||||||
|
.dependencies
|
||||||
|
.iter()
|
||||||
|
.map(|x| (*x).into())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let version = models::version_item::VersionBuilder {
|
||||||
|
version_id: version_id.into(),
|
||||||
|
mod_id: mod_id.into(),
|
||||||
|
author_id: author.into(),
|
||||||
|
name: version_data.version_title.clone(),
|
||||||
|
version_number: version_data.version_number.clone(),
|
||||||
|
changelog_url: changelog_path.map(|path| format!("{}/{}", cdn_url, path)),
|
||||||
|
files: Vec::new(),
|
||||||
|
dependencies,
|
||||||
|
game_versions,
|
||||||
|
loaders,
|
||||||
|
release_channel,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(version)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn process_icon_upload(
|
async fn process_icon_upload(
|
||||||
@@ -536,6 +625,12 @@ async fn process_icon_upload(
|
|||||||
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
|
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.len() >= 16384 {
|
||||||
|
return Err(CreateError::InvalidInput(String::from(
|
||||||
|
"Icons must be smaller than 16KiB",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
let upload_data = file_host
|
let upload_data = file_host
|
||||||
.upload_file(
|
.upload_file(
|
||||||
content_type,
|
content_type,
|
||||||
@@ -574,17 +669,28 @@ fn get_image_content_type(extension: &str) -> Option<&'static str> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_length(
|
pub fn check_length(
|
||||||
var_name: &str,
|
range: impl std::ops::RangeBounds<usize> + std::fmt::Debug,
|
||||||
min_length: usize,
|
field_name: &str,
|
||||||
max_length: usize,
|
field: &str,
|
||||||
string: &str,
|
|
||||||
) -> Result<(), CreateError> {
|
) -> Result<(), CreateError> {
|
||||||
let length = string.len();
|
use std::ops::Bound;
|
||||||
if length > max_length || length < min_length {
|
|
||||||
|
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!(
|
Err(CreateError::InvalidInput(format!(
|
||||||
"The {} must be between {} and {} characters; got {}.",
|
"The {} must be {}; got {}.",
|
||||||
var_name, min_length, max_length, length
|
field_name, bounds, length
|
||||||
)))
|
)))
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ pub struct InitialVersionData {
|
|||||||
pub file_parts: Vec<String>,
|
pub file_parts: Vec<String>,
|
||||||
pub version_number: String,
|
pub version_number: String,
|
||||||
pub version_title: String,
|
pub version_title: String,
|
||||||
pub version_body: String,
|
pub version_body: Option<String>,
|
||||||
pub dependencies: Vec<VersionId>,
|
pub dependencies: Vec<VersionId>,
|
||||||
pub game_versions: Vec<GameVersion>,
|
pub game_versions: Vec<GameVersion>,
|
||||||
pub release_channel: VersionType,
|
pub release_channel: VersionType,
|
||||||
@@ -31,6 +31,45 @@ struct InitialFileData {
|
|||||||
// TODO: hashes?
|
// 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()
|
||||||
|
.map(|f| check_length(1..=256, "file part name", f))
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
|
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()
|
||||||
|
.map(|v| check_length(1..=256, "game version", &v.0))
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
version
|
||||||
|
.loaders
|
||||||
|
.iter()
|
||||||
|
.map(|l| check_length(1..=256, "loader name", &l.0))
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// under `/api/v1/mod/{mod_id}`
|
// under `/api/v1/mod/{mod_id}`
|
||||||
#[post("version")]
|
#[post("version")]
|
||||||
pub async fn version_create(
|
pub async fn version_create(
|
||||||
@@ -105,8 +144,11 @@ async fn version_create_inner(
|
|||||||
return Err(CreateError::MissingValueError("Missing mod id".to_string()));
|
return Err(CreateError::MissingValueError("Missing mod id".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_version(version_create_data)?;
|
||||||
|
|
||||||
let mod_id: models::ModId = version_create_data.mod_id.unwrap().into();
|
let mod_id: models::ModId = version_create_data.mod_id.unwrap().into();
|
||||||
|
|
||||||
|
// Ensure that the mod this version is being added to exists
|
||||||
let results = sqlx::query!(
|
let results = sqlx::query!(
|
||||||
"SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)",
|
"SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)",
|
||||||
mod_id as models::ModId
|
mod_id as models::ModId
|
||||||
@@ -120,6 +162,8 @@ async fn version_create_inner(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check whether there is already a version of this mod with the
|
||||||
|
// same version number
|
||||||
let results = sqlx::query!(
|
let results = sqlx::query!(
|
||||||
"SELECT EXISTS(SELECT 1 FROM versions WHERE (version_number=$1) AND (mod_id=$2))",
|
"SELECT EXISTS(SELECT 1 FROM versions WHERE (version_number=$1) AND (mod_id=$2))",
|
||||||
version_create_data.version_number,
|
version_create_data.version_number,
|
||||||
@@ -134,58 +178,61 @@ async fn version_create_inner(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let team_id = sqlx::query!(
|
// Check that the user creating this version is a team member
|
||||||
"SELECT team_id FROM mods WHERE id=$1",
|
// of the mod the version is being added to.
|
||||||
|
let member_ids = sqlx::query!(
|
||||||
|
"
|
||||||
|
SELECT user_id FROM team_members tm
|
||||||
|
INNER JOIN mods ON mods.team_id = tm.team_id
|
||||||
|
WHERE mods.id = $1
|
||||||
|
",
|
||||||
mod_id as models::ModId,
|
mod_id as models::ModId,
|
||||||
)
|
)
|
||||||
.fetch_one(&mut *transaction)
|
.fetch_all(&mut *transaction)
|
||||||
.await?
|
.await?;
|
||||||
.team_id;
|
|
||||||
|
|
||||||
let member_ids_rows =
|
let member_ids: Vec<models::UserId> = member_ids
|
||||||
sqlx::query!("SELECT user_id FROM team_members WHERE team_id=$1", team_id,)
|
.iter()
|
||||||
.fetch_all(&mut *transaction)
|
.map(|m| models::UserId(m.user_id))
|
||||||
.await?;
|
.collect();
|
||||||
|
|
||||||
let member_ids: Vec<i64> = member_ids_rows.iter().map(|m| m.user_id).collect();
|
if !member_ids.contains(&user.id.into()) {
|
||||||
|
// TODO: Some team members may not have the permissions
|
||||||
if !member_ids.contains(&(user.id.0 as i64)) {
|
// to upload mods; We need a more in depth permissions
|
||||||
|
// system.
|
||||||
return Err(CreateError::InvalidInput("Unauthorized".to_string()));
|
return Err(CreateError::InvalidInput("Unauthorized".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let version_id: VersionId = models::generate_version_id(transaction).await?.into();
|
let version_id: VersionId = models::generate_version_id(transaction).await?.into();
|
||||||
let body_url = format!(
|
|
||||||
"data/{}/changelogs/{}/body.md",
|
|
||||||
version_create_data.mod_id.unwrap(),
|
|
||||||
version_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let uploaded_text = file_host
|
let body_path;
|
||||||
.upload_file(
|
|
||||||
"text/plain",
|
|
||||||
&body_url,
|
|
||||||
version_create_data.version_body.clone().into_bytes(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
uploaded_files.push(UploadedFile {
|
if let Some(body) = &version_create_data.version_body {
|
||||||
file_id: uploaded_text.file_id.clone(),
|
let path = format!(
|
||||||
file_name: uploaded_text.file_name.clone(),
|
"data/{}/changelogs/{}/body.md",
|
||||||
});
|
version_create_data.mod_id.unwrap(),
|
||||||
|
version_id
|
||||||
|
);
|
||||||
|
|
||||||
let release_channel = models::ChannelId(
|
let uploaded_text = file_host
|
||||||
sqlx::query!(
|
.upload_file("text/plain", &path, body.clone().into_bytes())
|
||||||
"
|
.await?;
|
||||||
SELECT id
|
|
||||||
FROM release_channels
|
uploaded_files.push(UploadedFile {
|
||||||
WHERE channel = $1
|
file_id: uploaded_text.file_id.clone(),
|
||||||
",
|
file_name: uploaded_text.file_name.clone(),
|
||||||
version_create_data.release_channel.to_string()
|
});
|
||||||
)
|
body_path = Some(path);
|
||||||
.fetch_one(&mut *transaction)
|
} else {
|
||||||
.await?
|
body_path = None;
|
||||||
.id,
|
}
|
||||||
);
|
|
||||||
|
let release_channel = models::ChannelId::get_id(
|
||||||
|
version_create_data.release_channel.as_str(),
|
||||||
|
&mut *transaction,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.expect("Release channel not found in database");
|
||||||
|
|
||||||
let mut game_versions = Vec::with_capacity(version_create_data.game_versions.len());
|
let mut game_versions = Vec::with_capacity(version_create_data.game_versions.len());
|
||||||
for v in &version_create_data.game_versions {
|
for v in &version_create_data.game_versions {
|
||||||
@@ -209,8 +256,8 @@ async fn version_create_inner(
|
|||||||
author_id: user.id.into(),
|
author_id: user.id.into(),
|
||||||
name: version_create_data.version_title.clone(),
|
name: version_create_data.version_title.clone(),
|
||||||
version_number: version_create_data.version_number.clone(),
|
version_number: version_create_data.version_number.clone(),
|
||||||
changelog_url: Some(format!("{}/{}", cdn_url, body_url)),
|
changelog_url: body_path.map(|path| format!("{}/{}", cdn_url, path)),
|
||||||
files: Vec::with_capacity(1),
|
files: Vec::new(),
|
||||||
dependencies: version_create_data
|
dependencies: version_create_data
|
||||||
.dependencies
|
.dependencies
|
||||||
.iter()
|
.iter()
|
||||||
@@ -243,22 +290,22 @@ async fn version_create_inner(
|
|||||||
version.files.push(file_builder);
|
version.files.push(file_builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
let version_data_safe = initial_version_data
|
let version_data = initial_version_data
|
||||||
.ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?;
|
.ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?;
|
||||||
let version_builder_safe = version_builder
|
let builder = version_builder
|
||||||
.ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?;
|
.ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?;
|
||||||
|
|
||||||
let response = Version {
|
let response = Version {
|
||||||
id: version_builder_safe.version_id.into(),
|
id: builder.version_id.into(),
|
||||||
mod_id: version_builder_safe.mod_id.into(),
|
mod_id: builder.mod_id.into(),
|
||||||
author_id: user.id,
|
author_id: user.id,
|
||||||
name: version_builder_safe.name.clone(),
|
name: builder.name.clone(),
|
||||||
version_number: version_builder_safe.version_number.clone(),
|
version_number: builder.version_number.clone(),
|
||||||
changelog_url: version_builder_safe.changelog_url.clone(),
|
changelog_url: builder.changelog_url.clone(),
|
||||||
date_published: chrono::Utc::now(),
|
date_published: chrono::Utc::now(),
|
||||||
downloads: 0,
|
downloads: 0,
|
||||||
version_type: version_data_safe.release_channel,
|
version_type: version_data.release_channel,
|
||||||
files: version_builder_safe
|
files: builder
|
||||||
.files
|
.files
|
||||||
.iter()
|
.iter()
|
||||||
.map(|file| VersionFile {
|
.map(|file| VersionFile {
|
||||||
@@ -280,12 +327,12 @@ async fn version_create_inner(
|
|||||||
filename: file.filename.clone(),
|
filename: file.filename.clone(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
dependencies: version_data_safe.dependencies,
|
dependencies: version_data.dependencies,
|
||||||
game_versions: version_data_safe.game_versions,
|
game_versions: version_data.game_versions,
|
||||||
loaders: version_data_safe.loaders,
|
loaders: version_data.loaders,
|
||||||
};
|
};
|
||||||
|
|
||||||
version_builder_safe.insert(transaction).await?;
|
builder.insert(transaction).await?;
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(response))
|
Ok(HttpResponse::Ok().json(response))
|
||||||
}
|
}
|
||||||
@@ -449,6 +496,16 @@ pub async fn upload_file(
|
|||||||
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
|
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mod file size limit of 25MiB
|
||||||
|
const FILE_SIZE_CAP: usize = 25 * (2 << 30);
|
||||||
|
|
||||||
|
// TODO: override file size cap for authorized users or mods
|
||||||
|
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.")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let upload_data = file_host
|
let upload_data = file_host
|
||||||
.upload_file(
|
.upload_file(
|
||||||
content_type,
|
content_type,
|
||||||
|
|||||||
Reference in New Issue
Block a user