Move descriptions to database, switch to SHA-512 hashes, fix declining invites not working, allow user deletion, fix broken permission checks for many things, security fixes

This commit is contained in:
Geometrically 2021-01-14 10:08:38 -07:00
parent e2183c2214
commit ec3c31a106
No known key found for this signature in database
GPG Key ID: 90C056FDC8FC9FF0
23 changed files with 1106 additions and 699 deletions

1
.env
View File

@ -22,7 +22,6 @@ S3_SECRET=none
S3_URL=none S3_URL=none
S3_REGION=none S3_REGION=none
S3_BUCKET_NAME=none S3_BUCKET_NAME=none
S3_PROVIDER=none
# 1 hour # 1 hour
LOCAL_INDEX_INTERVAL=3600 LOCAL_INDEX_INTERVAL=3600

1
Cargo.lock generated
View File

@ -1835,6 +1835,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_with", "serde_with",
"sha1", "sha1",
"sha2",
"sqlx", "sqlx",
"thiserror", "thiserror",
] ]

View File

@ -29,6 +29,7 @@ chrono = { version = "0.4", features = ["serde"] }
rand = "0.7.3" rand = "0.7.3"
base64 = "0.13.0" base64 = "0.13.0"
sha1 = { version = "0.6.0", features = ["std"] } sha1 = { version = "0.6.0", features = ["std"] }
sha2 = "0.9.2"
bitflags = "1.2.1" bitflags = "1.2.1"
gumdrop = "0.8.0" gumdrop = "0.8.0"

View File

@ -0,0 +1,15 @@
ALTER TABLE mods
ADD COLUMN body varchar(65536) NOT NULL DEFAULT '';
ALTER TABLE mods
ALTER COLUMN body_url DROP NOT NULL;
ALTER TABLE versions
ADD COLUMN changelog varchar(65536) NOT NULL DEFAULT '';
INSERT INTO users (
id, github_id, username, name, email,
avatar_url, bio, created
)
VALUES (
127155982985829, 10137, 'Ghost', NULL, NULL,
'https://avatars2.githubusercontent.com/u/10137', 'A deleted user', NOW()
);

File diff suppressed because it is too large Load Diff

View File

@ -36,7 +36,7 @@ pub struct ModBuilder {
pub team_id: TeamId, pub team_id: TeamId,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub body_url: String, pub body: String,
pub icon_url: Option<String>, pub icon_url: Option<String>,
pub issues_url: Option<String>, pub issues_url: Option<String>,
pub source_url: Option<String>, pub source_url: Option<String>,
@ -63,7 +63,8 @@ impl ModBuilder {
team_id: self.team_id, team_id: self.team_id,
title: self.title, title: self.title,
description: self.description, description: self.description,
body_url: self.body_url, body: self.body,
body_url: None,
published: chrono::Utc::now(), published: chrono::Utc::now(),
updated: chrono::Utc::now(), updated: chrono::Utc::now(),
status: self.status, status: self.status,
@ -113,7 +114,8 @@ pub struct Mod {
pub team_id: TeamId, pub team_id: TeamId,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub body_url: String, pub body: String,
pub body_url: Option<String>,
pub published: chrono::DateTime<chrono::Utc>, pub published: chrono::DateTime<chrono::Utc>,
pub updated: chrono::DateTime<chrono::Utc>, pub updated: chrono::DateTime<chrono::Utc>,
pub status: StatusId, pub status: StatusId,
@ -138,7 +140,7 @@ impl Mod {
sqlx::query!( sqlx::query!(
" "
INSERT INTO mods ( INSERT INTO mods (
id, team_id, title, description, body_url, id, team_id, title, description, body,
published, downloads, icon_url, issues_url, published, downloads, icon_url, issues_url,
source_url, wiki_url, status, discord_url, source_url, wiki_url, status, discord_url,
client_side, server_side, license_url, license, client_side, server_side, license_url, license,
@ -156,7 +158,7 @@ impl Mod {
self.team_id as TeamId, self.team_id as TeamId,
&self.title, &self.title,
&self.description, &self.description,
&self.body_url, &self.body,
self.published, self.published,
self.downloads, self.downloads,
self.icon_url.as_ref(), self.icon_url.as_ref(),
@ -184,7 +186,7 @@ impl Mod {
let result = sqlx::query!( let result = sqlx::query!(
" "
SELECT title, description, downloads, SELECT title, description, downloads,
icon_url, body_url, published, icon_url, body, body_url, published,
updated, status, updated, status,
issues_url, source_url, wiki_url, discord_url, license_url, issues_url, source_url, wiki_url, discord_url, license_url,
team_id, client_side, server_side, license, slug team_id, client_side, server_side, license, slug
@ -217,6 +219,7 @@ impl Mod {
server_side: SideTypeId(row.server_side), server_side: SideTypeId(row.server_side),
license: LicenseId(row.license), license: LicenseId(row.license),
slug: row.slug, slug: row.slug,
body: row.body,
})) }))
} else { } else {
Ok(None) Ok(None)
@ -233,7 +236,7 @@ impl Mod {
let mods = sqlx::query!( let mods = sqlx::query!(
" "
SELECT id, title, description, downloads, SELECT id, title, description, downloads,
icon_url, body_url, published, icon_url, body, body_url, published,
updated, status, updated, status,
issues_url, source_url, wiki_url, discord_url, license_url, issues_url, source_url, wiki_url, discord_url, license_url,
team_id, client_side, server_side, license, slug team_id, client_side, server_side, license, slug
@ -264,6 +267,7 @@ impl Mod {
server_side: SideTypeId(m.server_side), server_side: SideTypeId(m.server_side),
license: LicenseId(m.license), license: LicenseId(m.license),
slug: m.slug, slug: m.slug,
body: m.body,
})) }))
}) })
.try_collect::<Vec<Mod>>() .try_collect::<Vec<Mod>>()

View File

@ -411,4 +411,75 @@ impl TeamMember {
Ok(()) Ok(())
} }
pub async fn get_from_user_id_mod<'a, 'b, E>(
id: ModId,
user_id: UserId,
executor: E,
) -> Result<Option<Self>, super::DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.accepted FROM mods m
INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 AND accepted = TRUE
WHERE m.id = $1
",
id as ModId,
user_id as UserId
)
.fetch_optional(executor)
.await?;
if let Some(m) = result {
Ok(Some(TeamMember {
id: TeamMemberId(m.id),
team_id: TeamId(m.team_id),
user_id,
role: m.role,
permissions: Permissions::from_bits(m.permissions as u64)
.ok_or(super::DatabaseError::BitflagError)?,
accepted: m.accepted,
}))
} else {
Ok(None)
}
}
pub async fn get_from_user_id_version<'a, 'b, E>(
id: VersionId,
user_id: UserId,
executor: E,
) -> Result<Option<Self>, super::DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.accepted FROM versions v
INNER JOIN mods m ON m.id = v.mod_id
INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 AND tm.accepted = TRUE
WHERE v.id = $1
",
id as VersionId,
user_id as UserId
)
.fetch_optional(executor)
.await?;
if let Some(m) = result {
Ok(Some(TeamMember {
id: TeamMemberId(m.id),
team_id: TeamId(m.team_id),
user_id,
role: m.role,
permissions: Permissions::from_bits(m.permissions as u64)
.ok_or(super::DatabaseError::BitflagError)?,
accepted: m.accepted,
}))
} else {
Ok(None)
}
}
} }

View File

@ -207,4 +207,121 @@ impl User {
Ok(mods) Ok(mods)
} }
pub async fn remove<'a, 'b, E>(id: UserId, exec: E) -> Result<Option<()>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let deleted_user: UserId = crate::models::users::DELETED_USER.into();
sqlx::query!(
"
UPDATE team_members
SET user_id = $1
WHERE (user_id = $2 AND role = $3)
",
deleted_user as UserId,
id as UserId,
crate::models::teams::OWNER_ROLE
)
.execute(exec)
.await?;
sqlx::query!(
"
UPDATE versions
SET author_id = $1
WHERE (author_id = $2)
",
deleted_user as UserId,
id as UserId,
)
.execute(exec)
.await?;
sqlx::query!(
"
DELETE FROM team_members
WHERE user_id = $1
",
id as UserId,
)
.execute(exec)
.await?;
sqlx::query!(
"
DELETE FROM users
WHERE id = $1
",
id as UserId,
)
.execute(exec)
.await?;
Ok(Some(()))
}
pub async fn remove_full<'a, 'b, E>(
id: UserId,
exec: E,
) -> Result<Option<()>, sqlx::error::Error>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
use futures::TryStreamExt;
let mods: Vec<ModId> = sqlx::query!(
"
SELECT m.id FROM mods m
INNER JOIN team_members tm ON tm.team_id = m.team_id
WHERE tm.user_id = $1 AND tm.role = $2
",
id as UserId,
crate::models::teams::OWNER_ROLE
)
.fetch_many(exec)
.try_filter_map(|e| async { Ok(e.right().map(|m| ModId(m.id))) })
.try_collect::<Vec<ModId>>()
.await?;
for mod_id in mods {
let _result = super::mod_item::Mod::remove_full(mod_id, exec).await?;
}
let deleted_user: UserId = crate::models::users::DELETED_USER.into();
sqlx::query!(
"
UPDATE versions
SET author_id = $1
WHERE (author_id = $2)
",
deleted_user as UserId,
id as UserId,
)
.execute(exec)
.await?;
sqlx::query!(
"
DELETE FROM team_members
WHERE user_id = $1
",
id as UserId,
)
.execute(exec)
.await?;
sqlx::query!(
"
DELETE FROM users
WHERE id = $1
",
id as UserId,
)
.execute(exec)
.await?;
Ok(Some(()))
}
} }

View File

@ -7,7 +7,7 @@ pub struct VersionBuilder {
pub author_id: UserId, pub author_id: UserId,
pub name: String, pub name: String,
pub version_number: String, pub version_number: String,
pub changelog_url: Option<String>, pub changelog: String,
pub files: Vec<VersionFileBuilder>, pub files: Vec<VersionFileBuilder>,
pub dependencies: Vec<VersionId>, pub dependencies: Vec<VersionId>,
pub game_versions: Vec<GameVersionId>, pub game_versions: Vec<GameVersionId>,
@ -78,7 +78,8 @@ impl VersionBuilder {
author_id: self.author_id, author_id: self.author_id,
name: self.name, name: self.name,
version_number: self.version_number, version_number: self.version_number,
changelog_url: self.changelog_url, changelog: self.changelog,
changelog_url: None,
date_published: chrono::Utc::now(), date_published: chrono::Utc::now(),
downloads: 0, downloads: 0,
release_channel: self.release_channel, release_channel: self.release_channel,
@ -152,6 +153,7 @@ pub struct Version {
pub author_id: UserId, pub author_id: UserId,
pub name: String, pub name: String,
pub version_number: String, pub version_number: String,
pub changelog: String,
pub changelog_url: Option<String>, pub changelog_url: Option<String>,
pub date_published: chrono::DateTime<chrono::Utc>, pub date_published: chrono::DateTime<chrono::Utc>,
pub downloads: i32, pub downloads: i32,
@ -382,7 +384,7 @@ impl Version {
let result = sqlx::query!( let result = sqlx::query!(
" "
SELECT v.mod_id, v.author_id, v.name, v.version_number, SELECT v.mod_id, v.author_id, v.name, v.version_number,
v.changelog_url, v.date_published, v.downloads, v.changelog, v.changelog_url, v.date_published, v.downloads,
v.release_channel, v.accepted, v.featured v.release_channel, v.accepted, v.featured
FROM versions v FROM versions v
WHERE v.id = $1 WHERE v.id = $1
@ -399,6 +401,7 @@ impl Version {
author_id: UserId(row.author_id), author_id: UserId(row.author_id),
name: row.name, name: row.name,
version_number: row.version_number, version_number: row.version_number,
changelog: row.changelog,
changelog_url: row.changelog_url, changelog_url: row.changelog_url,
date_published: row.date_published, date_published: row.date_published,
downloads: row.downloads, downloads: row.downloads,
@ -424,7 +427,7 @@ impl Version {
let versions = sqlx::query!( let versions = sqlx::query!(
" "
SELECT v.id, v.mod_id, v.author_id, v.name, v.version_number, SELECT v.id, v.mod_id, v.author_id, v.name, v.version_number,
v.changelog_url, v.date_published, v.downloads, v.changelog, v.changelog_url, v.date_published, v.downloads,
v.release_channel, v.accepted, v.featured v.release_channel, v.accepted, v.featured
FROM versions v FROM versions v
WHERE v.id IN (SELECT * FROM UNNEST($1::bigint[])) WHERE v.id IN (SELECT * FROM UNNEST($1::bigint[]))
@ -439,6 +442,7 @@ impl Version {
author_id: UserId(v.author_id), author_id: UserId(v.author_id),
name: v.name, name: v.name,
version_number: v.version_number, version_number: v.version_number,
changelog: v.changelog,
changelog_url: v.changelog_url, changelog_url: v.changelog_url,
date_published: v.date_published, date_published: v.date_published,
downloads: v.downloads, downloads: v.downloads,
@ -463,7 +467,7 @@ impl Version {
let result = sqlx::query!( let result = sqlx::query!(
" "
SELECT v.mod_id, v.author_id, v.name, v.version_number, SELECT v.mod_id, v.author_id, v.name, v.version_number,
v.changelog_url, v.date_published, v.downloads, v.changelog, v.changelog_url, v.date_published, v.downloads,
release_channels.channel, v.accepted, v.featured release_channels.channel, v.accepted, v.featured
FROM versions v FROM versions v
INNER JOIN release_channels ON v.release_channel = release_channels.id INNER JOIN release_channels ON v.release_channel = release_channels.id
@ -545,6 +549,7 @@ impl Version {
author_id: UserId(row.author_id), author_id: UserId(row.author_id),
name: row.name, name: row.name,
version_number: row.version_number, version_number: row.version_number,
changelog: row.changelog,
changelog_url: row.changelog_url, changelog_url: row.changelog_url,
date_published: row.date_published, date_published: row.date_published,
downloads: row.downloads, downloads: row.downloads,
@ -599,6 +604,7 @@ pub struct QueryVersion {
pub author_id: UserId, pub author_id: UserId,
pub name: String, pub name: String,
pub version_number: String, pub version_number: String,
pub changelog: String,
pub changelog_url: Option<String>, pub changelog_url: Option<String>,
pub date_published: chrono::DateTime<chrono::Utc>, pub date_published: chrono::DateTime<chrono::Utc>,
pub downloads: i32, pub downloads: i32,

View File

@ -1,5 +1,6 @@
use super::{DeleteFileData, FileHost, FileHostingError, UploadFileData}; use super::{DeleteFileData, FileHost, FileHostingError, UploadFileData};
use async_trait::async_trait; use async_trait::async_trait;
use sha2::Digest;
mod authorization; mod authorization;
mod delete; mod delete;
@ -32,12 +33,15 @@ impl FileHost for BackblazeHost {
file_name: &str, file_name: &str,
file_bytes: Vec<u8>, file_bytes: Vec<u8>,
) -> Result<UploadFileData, FileHostingError> { ) -> Result<UploadFileData, FileHostingError> {
let content_sha512 = format!("{:x}", sha2::Sha512::digest(&file_bytes));
let upload_data = let upload_data =
upload::upload_file(&self.upload_url_data, content_type, file_name, file_bytes).await?; upload::upload_file(&self.upload_url_data, content_type, file_name, file_bytes).await?;
Ok(UploadFileData { Ok(UploadFileData {
file_id: upload_data.file_id, file_id: upload_data.file_id,
file_name: upload_data.file_name, file_name: upload_data.file_name,
content_length: upload_data.content_length, content_length: upload_data.content_length,
content_sha512,
content_sha1: upload_data.content_sha1, content_sha1: upload_data.content_sha1,
content_md5: upload_data.content_md5, content_md5: upload_data.content_md5,
content_type: upload_data.content_type, content_type: upload_data.content_type,

View File

@ -1,5 +1,6 @@
use super::{DeleteFileData, FileHost, FileHostingError, UploadFileData}; use super::{DeleteFileData, FileHost, FileHostingError, UploadFileData};
use async_trait::async_trait; use async_trait::async_trait;
use sha2::Digest;
pub struct MockHost(()); pub struct MockHost(());
@ -21,12 +22,14 @@ impl FileHost for MockHost {
.join(file_name.replace("../", "")); .join(file_name.replace("../", ""));
std::fs::create_dir_all(path.parent().ok_or(FileHostingError::InvalidFilename)?)?; std::fs::create_dir_all(path.parent().ok_or(FileHostingError::InvalidFilename)?)?;
let content_sha1 = sha1::Sha1::from(&file_bytes).hexdigest(); let content_sha1 = sha1::Sha1::from(&file_bytes).hexdigest();
let content_sha512 = format!("{:x}", sha2::Sha512::digest(&file_bytes));
std::fs::write(path, &file_bytes)?; std::fs::write(path, &file_bytes)?;
Ok(UploadFileData { Ok(UploadFileData {
file_id: String::from("MOCK_FILE_ID"), file_id: String::from("MOCK_FILE_ID"),
file_name: file_name.to_string(), file_name: file_name.to_string(),
content_length: file_bytes.len() as u32, content_length: file_bytes.len() as u32,
content_sha512,
content_sha1, content_sha1,
content_md5: None, content_md5: None,
content_type: content_type.to_string(), content_type: content_type.to_string(),

View File

@ -32,6 +32,7 @@ pub struct UploadFileData {
pub file_id: String, pub file_id: String,
pub file_name: String, pub file_name: String,
pub content_length: u32, pub content_length: u32,
pub content_sha512: String,
pub content_sha1: String, pub content_sha1: String,
pub content_md5: Option<String>, pub content_md5: Option<String>,
pub content_type: String, pub content_type: String,

View File

@ -3,6 +3,7 @@ use async_trait::async_trait;
use s3::bucket::Bucket; use s3::bucket::Bucket;
use s3::creds::Credentials; use s3::creds::Credentials;
use s3::region::Region; use s3::region::Region;
use sha2::Digest;
pub struct S3Host { pub struct S3Host {
bucket: Bucket, bucket: Bucket,
@ -40,6 +41,7 @@ impl FileHost for S3Host {
file_bytes: Vec<u8>, file_bytes: Vec<u8>,
) -> Result<UploadFileData, FileHostingError> { ) -> Result<UploadFileData, FileHostingError> {
let content_sha1 = sha1::Sha1::from(&file_bytes).hexdigest(); let content_sha1 = sha1::Sha1::from(&file_bytes).hexdigest();
let content_sha512 = format!("{:x}", sha2::Sha512::digest(&file_bytes));
self.bucket self.bucket
.put_object_with_content_type( .put_object_with_content_type(
@ -49,37 +51,11 @@ impl FileHost for S3Host {
) )
.await?; .await?;
let provider = &*dotenv::var("S3_PROVIDER").unwrap();
if provider == "do" {
reqwest::Client::new()
.delete(&*format!(
"https://api.digitalocean.com/v2/cdn/endpoints/{}/cache",
self.bucket.name
))
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(
reqwest::header::AUTHORIZATION,
self.bucket
.credentials
.secret_key
.clone()
.unwrap_or_else(|| "".to_string()),
)
.body(
serde_json::json!({
"files": vec![file_name],
})
.to_string(),
)
.send()
.await?;
}
Ok(UploadFileData { Ok(UploadFileData {
file_id: file_name.to_string(), file_id: file_name.to_string(),
file_name: file_name.to_string(), file_name: file_name.to_string(),
content_length: file_bytes.len() as u32, content_length: file_bytes.len() as u32,
content_sha512,
content_sha1, content_sha1,
content_md5: None, content_md5: None,
content_type: content_type.to_string(), content_type: content_type.to_string(),

View File

@ -356,7 +356,6 @@ fn check_env_vars() -> bool {
failed |= check_var::<String>("S3_URL"); failed |= check_var::<String>("S3_URL");
failed |= check_var::<String>("S3_REGION"); failed |= check_var::<String>("S3_REGION");
failed |= check_var::<String>("S3_BUCKET_NAME"); failed |= check_var::<String>("S3_BUCKET_NAME");
failed |= check_var::<String>("S3_PROVIDER");
} else if storage_backend.as_deref() == Some("local") { } else if storage_backend.as_deref() == Some("local") {
failed |= check_var::<String>("MOCK_FILE_PATH"); failed |= check_var::<String>("MOCK_FILE_PATH");
} else if let Some(backend) = storage_backend { } else if let Some(backend) = storage_backend {

View File

@ -29,8 +29,10 @@ pub struct Mod {
pub title: String, pub title: String,
/// A short description of the mod. /// A short description of the mod.
pub description: String, pub description: String,
/// The link to the long description of the mod. /// A long form description of the mod.
pub body_url: String, pub body: String,
/// The link to the long description of the mod. (Deprecated), being replaced by `body`
pub body_url: Option<String>,
/// The date at which the mod was first published. /// The date at which the mod was first published.
pub published: DateTime<Utc>, pub published: DateTime<Utc>,
/// The date at which the mod was first published. /// The date at which the mod was first published.
@ -192,7 +194,9 @@ pub struct Version {
pub name: String, pub name: String,
/// The version number. Ideally will follow semantic versioning /// The version number. Ideally will follow semantic versioning
pub version_number: String, pub version_number: String,
/// A link to the changelog for this version of the mod. /// The changelog for this version of the mod.
pub changelog: String,
/// A link to the changelog for this version of the mod. (Deprecated), being replaced by `changelog`
pub changelog_url: Option<String>, pub changelog_url: Option<String>,
/// The date that this version was published. /// The date that this version was published.
pub date_published: DateTime<Utc>, pub date_published: DateTime<Utc>,

View File

@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize};
#[serde(into = "Base62Id")] #[serde(into = "Base62Id")]
pub struct UserId(pub u64); pub struct UserId(pub u64);
pub const DELETED_USER: UserId = UserId(127155982985829);
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct User { pub struct User {
pub id: UserId, pub id: UserId,

View File

@ -48,6 +48,8 @@ pub enum CreateError {
InvalidFileType(String), InvalidFileType(String),
#[error("Authentication Error: {0}")] #[error("Authentication Error: {0}")]
Unauthorized(#[from] AuthenticationError), Unauthorized(#[from] AuthenticationError),
#[error("Authentication Error: {0}")]
CustomAuthenticationError(String),
} }
impl actix_web::ResponseError for CreateError { impl actix_web::ResponseError for CreateError {
@ -68,6 +70,7 @@ impl actix_web::ResponseError for CreateError {
CreateError::InvalidCategory(..) => StatusCode::BAD_REQUEST, CreateError::InvalidCategory(..) => StatusCode::BAD_REQUEST,
CreateError::InvalidFileType(..) => StatusCode::BAD_REQUEST, CreateError::InvalidFileType(..) => StatusCode::BAD_REQUEST,
CreateError::Unauthorized(..) => StatusCode::UNAUTHORIZED, CreateError::Unauthorized(..) => StatusCode::UNAUTHORIZED,
CreateError::CustomAuthenticationError(..) => StatusCode::UNAUTHORIZED,
} }
} }
@ -89,6 +92,7 @@ impl actix_web::ResponseError for CreateError {
CreateError::InvalidCategory(..) => "invalid_input", CreateError::InvalidCategory(..) => "invalid_input",
CreateError::InvalidFileType(..) => "invalid_input", CreateError::InvalidFileType(..) => "invalid_input",
CreateError::Unauthorized(..) => "unauthorized", CreateError::Unauthorized(..) => "unauthorized",
CreateError::CustomAuthenticationError(..) => "unauthorized",
}, },
description: &self.to_string(), description: &self.to_string(),
}) })
@ -334,18 +338,8 @@ async fn mod_create_inner(
))); )));
} }
} }
versions.push( versions
create_initial_version( .push(create_initial_version(data, mod_id, current_user.id, transaction).await?);
data,
mod_id,
current_user.id,
&cdn_url,
transaction,
file_host,
uploaded_files,
)
.await?,
);
} }
mod_create_data = create_data; mod_create_data = create_data;
@ -436,24 +430,6 @@ async fn mod_create_inner(
categories.push(id); categories.push(id);
} }
// Upload the mod desciption markdown to the CDN
// TODO: Should we also process and upload an html version here for SSR?
let body_path = format!("data/{}/description.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 { let team = models::team_item::TeamBuilder {
members: vec![models::team_item::TeamMemberBuilder { members: vec![models::team_item::TeamMemberBuilder {
user_id: current_user.id.into(), user_id: current_user.id.into(),
@ -527,7 +503,7 @@ async fn mod_create_inner(
team_id, team_id,
title: mod_create_data.mod_name, title: mod_create_data.mod_name,
description: mod_create_data.mod_description, description: mod_create_data.mod_description,
body_url: format!("{}/{}", cdn_url, body_path), body: mod_create_data.mod_body,
icon_url, icon_url,
issues_url: mod_create_data.issues_url, issues_url: mod_create_data.issues_url,
source_url: mod_create_data.source_url, source_url: mod_create_data.source_url,
@ -553,7 +529,8 @@ async fn mod_create_inner(
team: team_id.into(), team: team_id.into(),
title: mod_builder.title.clone(), title: mod_builder.title.clone(),
description: mod_builder.description.clone(), description: mod_builder.description.clone(),
body_url: mod_builder.body_url.clone(), body: mod_builder.body.clone(),
body_url: None,
published: now, published: now,
updated: now, updated: now,
status: status.clone(), status: status.clone(),
@ -596,10 +573,7 @@ async fn create_initial_version(
version_data: &InitialVersionData, version_data: &InitialVersionData,
mod_id: ModId, mod_id: ModId,
author: UserId, author: UserId,
cdn_url: &str,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
file_host: &dyn FileHost,
uploaded_files: &mut Vec<UploadedFile>,
) -> Result<models::version_item::VersionBuilder, CreateError> { ) -> Result<models::version_item::VersionBuilder, CreateError> {
if version_data.mod_id.is_some() { if version_data.mod_id.is_some() {
return Err(CreateError::InvalidInput(String::from( return Err(CreateError::InvalidInput(String::from(
@ -613,30 +587,6 @@ async fn create_initial_version(
// Randomly generate a new id to be used for the version // Randomly generate a new id to be used for the version
let version_id: VersionId = models::generate_version_id(transaction).await?.into(); 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/{}/versions/{}/changelog.md",
mod_id, version_data.version_number
);
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 release_channel = let release_channel =
models::ChannelId::get_id(version_data.release_channel.as_str(), &mut *transaction) models::ChannelId::get_id(version_data.release_channel.as_str(), &mut *transaction)
.await? .await?
@ -670,7 +620,10 @@ async fn create_initial_version(
author_id: author.into(), author_id: author.into(),
name: version_data.version_title.clone(), name: version_data.version_title.clone(),
version_number: version_data.version_number.clone(), version_number: version_data.version_number.clone(),
changelog_url: changelog_path.map(|path| format!("{}/{}", cdn_url, path)), changelog: version_data
.version_body
.clone()
.unwrap_or_else(|| "".to_string()),
files: Vec::new(), files: Vec::new(),
dependencies, dependencies,
game_versions, game_versions,

View File

@ -32,8 +32,8 @@ pub struct ModerationMod {
pub title: String, pub title: String,
/// A short description of the mod. /// A short description of the mod.
pub description: String, pub description: String,
/// The link to the long description of the mod. /// The long description of the mod.
pub body_url: String, pub body: String,
/// The date at which the mod was first published. /// The date at which the mod was first published.
pub published: DateTime<Utc>, pub published: DateTime<Utc>,
/// The date at which the mod was first published. /// The date at which the mod was first published.
@ -85,7 +85,7 @@ pub async fn mods(
team: database::models::ids::TeamId(m.team_id).into(), team: database::models::ids::TeamId(m.team_id).into(),
title: m.title, title: m.title,
description: m.description, description: m.description,
body_url: m.body_url, body: m.body,
published: m.published, published: m.published,
icon_url: m.icon_url, icon_url: m.icon_url,
issues_url: m.issues_url, issues_url: m.issues_url,
@ -133,6 +133,7 @@ pub async fn versions(
featured: m.featured, featured: m.featured,
name: m.name, name: m.name,
version_number: m.version_number, version_number: m.version_number,
changelog: m.changelog,
changelog_url: m.changelog_url, changelog_url: m.changelog_url,
date_published: m.date_published, date_published: m.date_published,
downloads: m.downloads as u32, downloads: m.downloads as u32,

View File

@ -203,6 +203,7 @@ fn convert_mod(data: database::models::mod_item::QueryMod) -> models::mods::Mod
team: m.team_id.into(), team: m.team_id.into(),
title: m.title, title: m.title,
description: m.description, description: m.description,
body: m.body,
body_url: m.body_url, body_url: m.body_url,
published: m.published, published: m.published,
updated: m.updated, updated: m.updated,
@ -282,7 +283,6 @@ pub async fn mod_edit(
info: web::Path<(models::ids::ModId,)>, info: web::Path<(models::ids::ModId,)>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
config: web::Data<SearchConfig>, config: web::Data<SearchConfig>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
new_mod: web::Json<EditMod>, new_mod: web::Json<EditMod>,
indexing_queue: Data<Arc<CreationQueue>>, indexing_queue: Data<Arc<CreationQueue>>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
@ -729,13 +729,18 @@ pub async fn mod_edit(
)); ));
} }
let body_path = format!("data/{}/description.md", mod_id); sqlx::query!(
"
file_host.delete_file_version("", &*body_path).await?; UPDATE mods
SET body = $1
file_host WHERE (id = $2)
.upload_file("text/plain", &body_path, body.clone().into_bytes()) ",
.await?; body,
id as database::models::ids::ModId,
)
.execute(&mut *transaction)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
} }
transaction transaction
@ -857,18 +862,13 @@ pub async fn mod_delete(
let id = info.into_inner().0; let id = info.into_inner().0;
if !user.role.is_mod() { if !user.role.is_mod() {
let mod_item = database::models::Mod::get(id.into(), &**pool) let team_member =
.await database::models::TeamMember::get_from_user_id_mod(id.into(), user.id.into(), &**pool)
.map_err(|e| ApiError::DatabaseError(e.into()))? .await
.ok_or_else(|| ApiError::InvalidInputError("Invalid Mod ID specified!".to_string()))?; .map_err(ApiError::DatabaseError)?
let team_member = database::models::TeamMember::get_from_user_id( .ok_or_else(|| {
mod_item.team_id, ApiError::InvalidInputError("Invalid Mod ID specified!".to_string())
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::DELETE_MOD) { if !team_member.permissions.contains(Permissions::DELETE_MOD) {
return Err(ApiError::CustomAuthenticationError( return Err(ApiError::CustomAuthenticationError(

View File

@ -287,7 +287,8 @@ pub async fn remove_team_member(
let user_id = ids.1.into(); let user_id = ids.1.into();
let current_user = get_user_from_headers(req.headers(), &**pool).await?; let current_user = get_user_from_headers(req.headers(), &**pool).await?;
let team_member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?; let team_member =
TeamMember::get_from_user_id_pending(id, current_user.id.into(), &**pool).await?;
let member = match team_member { let member = match team_member {
Some(m) => m, Some(m) => m,
@ -312,7 +313,7 @@ pub async fn remove_team_member(
// Members other than the owner can either leave the team, or be // Members other than the owner can either leave the team, or be
// removed by a member with the REMOVE_MEMBER permission. // removed by a member with the REMOVE_MEMBER permission.
if delete_member.user_id == member.user_id if delete_member.user_id == member.user_id
|| member.permissions.contains(Permissions::REMOVE_MEMBER) || (member.permissions.contains(Permissions::REMOVE_MEMBER) && member.accepted)
{ {
TeamMember::delete(id, user_id, &**pool).await?; TeamMember::delete(id, user_id, &**pool).await?;
} else { } else {
@ -321,7 +322,7 @@ pub async fn remove_team_member(
)); ));
} }
} else if delete_member.user_id == member.user_id } else if delete_member.user_id == member.user_id
|| member.permissions.contains(Permissions::MANAGE_INVITES) || (member.permissions.contains(Permissions::MANAGE_INVITES) && member.accepted)
{ {
// This is a pending invite rather than a member, so the // This is a pending invite rather than a member, so the
// user being invited or team members with the MANAGE_INVITES // user being invited or team members with the MANAGE_INVITES

View File

@ -1,4 +1,4 @@
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers}; use crate::auth::get_user_from_headers;
use crate::database::models::{TeamMember, User}; use crate::database::models::{TeamMember, User};
use crate::file_hosting::FileHost; use crate::file_hosting::FileHost;
use crate::models::users::{Role, UserId}; use crate::models::users::{Role, UserId};
@ -420,24 +420,42 @@ pub async fn user_icon_edit(
} }
} }
// TODO: Make this actually do stuff #[derive(Deserialize)]
pub struct RemovalType {
#[serde(default = "default_removal")]
removal_type: String,
}
fn default_removal() -> String {
"partial".into()
}
#[delete("{id}")] #[delete("{id}")]
pub async fn user_delete( pub async fn user_delete(
req: HttpRequest, req: HttpRequest,
info: web::Path<(UserId,)>, info: web::Path<(UserId,)>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
removal_type: web::Query<RemovalType>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers( let user = get_user_from_headers(req.headers(), &**pool).await?;
req.headers(), let id = info.into_inner().0;
&mut *pool
.acquire()
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?,
)
.await?;
let _id = info.0; if !user.role.is_mod() && user.id == user.id {
let result = Some(()); return Err(ApiError::CustomAuthenticationError(
"You do not have permission to delete this user!".to_string(),
));
}
let result;
if removal_type.removal_type == "full".to_string() {
result = crate::database::models::User::remove_full(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
} else {
result = crate::database::models::User::remove(id.into(), &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
};
if result.is_some() { if result.is_some() {
Ok(HttpResponse::Ok().body("")) Ok(HttpResponse::Ok().body(""))

View File

@ -5,6 +5,7 @@ use crate::file_hosting::FileHost;
use crate::models::mods::{ use crate::models::mods::{
GameVersion, ModId, ModLoader, Version, VersionFile, VersionId, VersionType, GameVersion, ModId, ModLoader, Version, VersionFile, VersionId, VersionType,
}; };
use crate::models::teams::Permissions;
use crate::routes::mod_creation::{CreateError, UploadedFile}; use crate::routes::mod_creation::{CreateError, UploadedFile};
use actix_multipart::{Field, Multipart}; use actix_multipart::{Field, Multipart};
use actix_web::web::Data; use actix_web::web::Data;
@ -180,53 +181,29 @@ async fn version_create_inner(
// Check that the user creating this version is a team member // Check that the user creating this version is a team member
// of the mod the version is being added to. // of the mod the version is being added to.
let member_ids = sqlx::query!( let team_member = models::TeamMember::get_from_user_id_mod(
" mod_id.into(),
SELECT user_id FROM team_members tm user.id.into(),
INNER JOIN mods ON mods.team_id = tm.team_id &mut *transaction,
WHERE mods.id = $1
",
mod_id as models::ModId,
) )
.fetch_all(&mut *transaction) .await?
.await?; .ok_or_else(|| {
CreateError::CustomAuthenticationError(
"You don't have permission to upload this version!".to_string(),
)
})?;
let member_ids: Vec<models::UserId> = member_ids if !team_member
.iter() .permissions
.map(|m| models::UserId(m.user_id)) .contains(Permissions::UPLOAD_VERSION)
.collect(); {
return Err(CreateError::CustomAuthenticationError(
if !member_ids.contains(&user.id.into()) { "You don't have permission to upload this version!".to_string(),
// TODO: Some team members may not have the permissions ));
// to upload mods; We need a more in depth permissions
// system.
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_path;
if let Some(body) = &version_create_data.version_body {
let path = format!(
"data/{}/versions/{}/changelog.md",
version_create_data.mod_id.unwrap(),
version_create_data.version_number
);
let uploaded_text = file_host
.upload_file("text/plain", &path, body.clone().into_bytes())
.await?;
uploaded_files.push(UploadedFile {
file_id: uploaded_text.file_id,
file_name: uploaded_text.file_name.clone(),
});
body_path = Some(path);
} else {
body_path = None;
}
let release_channel = models::ChannelId::get_id( let release_channel = models::ChannelId::get_id(
version_create_data.release_channel.as_str(), version_create_data.release_channel.as_str(),
&mut *transaction, &mut *transaction,
@ -256,7 +233,10 @@ 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: body_path.map(|path| format!("{}/{}", cdn_url, path)), changelog: version_create_data
.version_body
.clone()
.unwrap_or_else(|| "".to_string()),
files: Vec::new(), files: Vec::new(),
dependencies: version_create_data dependencies: version_create_data
.dependencies .dependencies
@ -303,7 +283,8 @@ async fn version_create_inner(
featured: builder.featured, featured: builder.featured,
name: builder.name.clone(), name: builder.name.clone(),
version_number: builder.version_number.clone(), version_number: builder.version_number.clone(),
changelog_url: builder.changelog_url.clone(), changelog: builder.changelog.clone(),
changelog_url: None,
date_published: chrono::Utc::now(), date_published: chrono::Utc::now(),
downloads: 0, downloads: 0,
version_type: version_data.release_channel, version_type: version_data.release_channel,
@ -418,8 +399,25 @@ async fn upload_file_to_version_inner(
} }
}; };
if version.author_id as u64 != user.id.0 { let team_member = models::TeamMember::get_from_user_id_version(
return Err(CreateError::InvalidInput("Unauthorized".to_string())); version_id.into(),
user.id.into(),
&mut *transaction,
)
.await?
.ok_or_else(|| {
CreateError::CustomAuthenticationError(
"You don't have permission to upload files to this version!".to_string(),
)
})?;
if team_member
.permissions
.contains(Permissions::UPLOAD_VERSION)
{
return Err(CreateError::CustomAuthenticationError(
"You don't have permission to upload files to this version!".to_string(),
));
} }
let mod_id = ModId(version.mod_id as u64); let mod_id = ModId(version.mod_id as u64);
@ -526,12 +524,20 @@ pub async fn upload_file(
Ok(models::version_item::VersionFileBuilder { Ok(models::version_item::VersionFileBuilder {
filename: file_name.to_string(), filename: file_name.to_string(),
url: format!("{}/{}", cdn_url, upload_data.file_name), url: format!("{}/{}", cdn_url, upload_data.file_name),
hashes: vec![models::version_item::HashBuilder { hashes: vec![
algorithm: "sha1".to_string(), models::version_item::HashBuilder {
// This is an invalid cast - the database expects the hash's algorithm: "sha1".to_string(),
// bytes, but this is the string version. // This is an invalid cast - the database expects the hash's
hash: upload_data.content_sha1.into_bytes(), // bytes, but this is the string version.
}], hash: upload_data.content_sha1.into_bytes(),
},
models::version_item::HashBuilder {
algorithm: "sha512".to_string(),
// This is an invalid cast - the database expects the hash's
// bytes, but this is the string version.
hash: upload_data.content_sha512.into_bytes(),
},
],
primary: uploaded_files.len() == 1, primary: uploaded_files.len() == 1,
}) })
} }

View File

@ -1,5 +1,5 @@
use super::ApiError; use super::ApiError;
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers}; use crate::auth::get_user_from_headers;
use crate::file_hosting::FileHost; use crate::file_hosting::FileHost;
use crate::models; use crate::models;
use crate::models::teams::Permissions; use crate::models::teams::Permissions;
@ -159,6 +159,7 @@ fn convert_version(data: database::models::version_item::QueryVersion) -> models
featured: data.featured, featured: data.featured,
name: data.name, name: data.name,
version_number: data.version_number, version_number: data.version_number,
changelog: data.changelog,
changelog_url: data.changelog_url, changelog_url: data.changelog_url,
date_published: data.date_published, date_published: data.date_published,
downloads: data.downloads as u32, downloads: data.downloads as u32,
@ -220,7 +221,6 @@ pub async fn version_edit(
req: HttpRequest, req: HttpRequest,
info: web::Path<(models::ids::VersionId,)>, info: web::Path<(models::ids::VersionId,)>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
new_version: web::Json<EditVersion>, new_version: web::Json<EditVersion>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?; let user = get_user_from_headers(req.headers(), &**pool).await?;
@ -233,18 +233,8 @@ pub async fn version_edit(
.map_err(|e| ApiError::DatabaseError(e.into()))?; .map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(version_item) = result { if let Some(version_item) = result {
let mod_item = database::models::Mod::get(version_item.mod_id, &**pool) let team_member = database::models::TeamMember::get_from_user_id_version(
.await version_item.id,
.map_err(|e| ApiError::DatabaseError(e.into()))?
.ok_or_else(|| {
ApiError::InvalidInputError(
"Attempted to edit version not attached to mod. How did this happen?"
.to_string(),
)
})?;
let team_member = database::models::TeamMember::get_from_user_id(
mod_item.team_id,
user.id.into(), user.id.into(),
&**pool, &**pool,
) )
@ -494,17 +484,18 @@ pub async fn version_edit(
} }
if let Some(body) = &new_version.changelog { if let Some(body) = &new_version.changelog {
let mod_id: models::mods::ModId = version_item.mod_id.into(); sqlx::query!(
let body_path = format!( "
"data/{}/versions/{}/changelog.md", UPDATE versions
mod_id, version_item.version_number SET changelog = $1
); WHERE (id = $2)
",
file_host.delete_file_version("", &*body_path).await?; body,
id as database::models::ids::VersionId,
file_host )
.upload_file("text/plain", &body_path, body.clone().into_bytes()) .execute(&mut *transaction)
.await?; .await
.map_err(|e| ApiError::DatabaseError(e.into()))?;
} }
transaction transaction
@ -532,20 +523,8 @@ pub async fn version_delete(
let id = info.into_inner().0; let id = info.into_inner().0;
if !user.role.is_mod() { if !user.role.is_mod() {
let version = database::models::Version::get(id.into(), &**pool) let team_member = database::models::TeamMember::get_from_user_id_version(
.await id.into(),
.map_err(|e| ApiError::DatabaseError(e.into()))?
.ok_or_else(|| {
ApiError::InvalidInputError("An invalid version ID was specified".to_string())
})?;
let mod_item = database::models::Mod::get(version.mod_id, &**pool)
.await
.map_err(|e| ApiError::DatabaseError(e.into()))?
.ok_or_else(|| {
ApiError::InvalidInputError("The version is not attached to a mod".to_string())
})?;
let team_member = database::models::TeamMember::get_from_user_id(
mod_item.team_id,
user.id.into(), user.id.into(),
&**pool, &**pool,
) )
@ -735,7 +714,7 @@ pub async fn delete_file(
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>, file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
algorithm: web::Query<Algorithm>, algorithm: web::Query<Algorithm>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(req.headers(), &**pool).await?; let user = get_user_from_headers(req.headers(), &**pool).await?;
let hash = info.into_inner().0; let hash = info.into_inner().0;
@ -754,6 +733,30 @@ pub async fn delete_file(
.map_err(|e| ApiError::DatabaseError(e.into()))?; .map_err(|e| ApiError::DatabaseError(e.into()))?;
if let Some(row) = result { if let Some(row) = result {
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id_version(
database::models::ids::VersionId(row.version_id),
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::DatabaseError)?
.ok_or_else(|| {
ApiError::CustomAuthenticationError(
"You don't have permission to delete this file!".to_string(),
)
})?;
if !team_member
.permissions
.contains(Permissions::DELETE_VERSION)
{
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to delete this file!".to_string(),
));
}
}
let mut transaction = pool let mut transaction = pool
.begin() .begin()
.await .await