Allow gallery featuring, add gallery images to search, rename rejection reasons, transfer ownership route (#226)

This commit is contained in:
Geometrically
2021-07-27 16:50:07 -07:00
committed by GitHub
parent bc983162f3
commit 76b62eda3a
16 changed files with 1414 additions and 1130 deletions

View File

@@ -181,19 +181,48 @@ pub async fn auth_callback(
None => {
let user_id = crate::database::models::generate_user_id(&mut transaction).await?;
User {
id: user_id,
github_id: Some(user.id as i64),
username: user.login,
name: user.name,
email: user.email,
avatar_url: Some(user.avatar_url),
bio: user.bio,
created: Utc::now(),
role: Role::Developer.to_string(),
let mut username_increment: i32 = 0;
let mut username = None;
while username.is_none() {
let test_username = format!(
"{}{}",
&*user.login,
if username_increment > 0 {
username_increment.to_string()
} else {
"".to_string()
}
);
let new_id = crate::database::models::User::get_id_from_username_or_id(
&*test_username,
&**client,
)
.await?;
if new_id.is_none() {
username = Some(test_username);
} else {
username_increment += 1;
}
}
if let Some(username) = username {
User {
id: user_id,
github_id: Some(user.id as i64),
username,
name: user.name,
email: user.email,
avatar_url: Some(user.avatar_url),
bio: user.bio,
created: Utc::now(),
role: Role::Developer.to_string(),
}
.insert(&mut transaction)
.await?;
}
.insert(&mut transaction)
.await?;
}
}

View File

@@ -52,11 +52,18 @@ pub fn projects_config(cfg: &mut web::ServiceConfig) {
.service(projects::project_delete)
.service(projects::project_edit)
.service(projects::project_icon_edit)
.service(projects::delete_project_icon)
.service(projects::add_gallery_item)
.service(projects::edit_gallery_item)
.service(projects::delete_gallery_item)
.service(projects::project_follow)
.service(projects::project_unfollow)
.service(teams::team_members_get_project)
.service(web::scope("{project_id}").service(versions::version_list))
.service(projects::dependency_list),
.service(
web::scope("{project_id}")
.service(versions::version_list)
.service(projects::dependency_list),
),
);
}
@@ -112,6 +119,7 @@ pub fn teams_config(cfg: &mut web::ServiceConfig) {
web::scope("team")
.service(teams::team_members_get)
.service(teams::edit_team_member)
.service(teams::transfer_ownership)
.service(teams::add_team_member)
.service(teams::join_team)
.service(teams::remove_team_member),

View File

@@ -185,7 +185,15 @@ struct ProjectCreateData {
#[validate(length(max = 64))]
/// The multipart names of the gallery items to upload
pub gallery_items: Option<Vec<String>>,
pub gallery_items: Option<Vec<NewGalleryItem>>,
}
#[derive(Serialize, Deserialize, Validate, Clone)]
pub struct NewGalleryItem {
/// The name of the multipart item where the gallery media is located
pub item: String,
/// Whether the gallery item should show in search or not
pub featured: bool,
}
pub struct UploadedFile {
@@ -427,27 +435,23 @@ pub async fn project_create_inner(
}
if let Some(gallery_items) = &project_create_data.gallery_items {
if
gallery_items
.iter()
.find(|x| *x == name)
.is_some()
{
if let Some(item) = gallery_items.iter().find(|x| x.item == name) {
let mut data = Vec::new();
while let Some(chunk) = field.next().await {
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
}
const FILE_SIZE_CAP: usize = 5 * (1 << 20);
const FILE_SIZE_CAP: usize = 5 * (1 << 20);
if data.len() >= FILE_SIZE_CAP {
return Err(CreateError::InvalidInput(String::from(
"Gallery image exceeds the maximum of 5MiB.",
)));
if data.len() >= FILE_SIZE_CAP {
return Err(CreateError::InvalidInput(String::from(
"Gallery image exceeds the maximum of 5MiB.",
)));
} else {
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
}
}
let hash = sha1::Sha1::from(&data).hexdigest();
let (_, file_extension) = super::version_creation::get_name_ext(&content_disposition)?;
let (_, file_extension) =
super::version_creation::get_name_ext(&content_disposition)?;
let content_type = crate::util::ext::get_image_content_type(file_extension)
.ok_or_else(|| CreateError::InvalidIconFormat(file_extension.to_string()))?;
@@ -461,7 +465,10 @@ pub async fn project_create_inner(
file_name: upload_data.file_name.clone(),
});
gallery_urls.push(format!("{}/{}", cdn_url, url));
gallery_urls.push(crate::models::projects::GalleryItem {
url,
featured: item.featured,
});
continue;
}
@@ -628,7 +635,8 @@ pub async fn project_create_inner(
.iter()
.map(|x| models::project_item::GalleryItem {
project_id: project_id.into(),
image_url: x.to_string(),
image_url: x.url.clone(),
featured: x.featured,
})
.collect(),
};
@@ -647,7 +655,7 @@ pub async fn project_create_inner(
published: now,
updated: now,
status: status.clone(),
rejection_data: None,
moderator_message: None,
license: License {
id: project_create_data.license_id.clone(),
name: "".to_string(),
@@ -783,13 +791,13 @@ async fn process_icon_upload(
if let Some(content_type) = crate::util::ext::get_image_content_type(file_extension) {
let mut data = Vec::new();
while let Some(chunk) = field.next().await {
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
}
if data.len() >= 262144 {
return Err(CreateError::InvalidInput(String::from(
"Icons must be smaller than 256KiB",
)));
if data.len() >= 262144 {
return Err(CreateError::InvalidInput(String::from(
"Icons must be smaller than 256KiB",
)));
} else {
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
}
}
let upload_data = file_host

View File

@@ -2,7 +2,8 @@ use crate::database;
use crate::file_hosting::FileHost;
use crate::models;
use crate::models::projects::{
DonationLink, License, ProjectId, ProjectStatus, RejectionReason, SearchRequest, SideType,
DonationLink, GalleryItem, License, ModeratorMessage, ProjectId, ProjectStatus, SearchRequest,
SideType,
};
use crate::models::teams::Permissions;
use crate::routes::ApiError;
@@ -235,10 +236,10 @@ pub fn convert_project(
published: m.published,
updated: m.updated,
status: data.status,
rejection_data: if let Some(reason) = m.rejection_reason {
Some(RejectionReason {
reason,
body: m.rejection_body,
moderator_message: if let Some(message) = m.moderation_message {
Some(ModeratorMessage {
message,
body: m.moderation_message_body,
})
} else {
None
@@ -272,7 +273,10 @@ pub fn convert_project(
gallery: data
.gallery_items
.into_iter()
.map(|x| x.image_url)
.map(|x| GalleryItem {
url: x.image_url,
featured: x.featured,
})
.collect(),
}
}
@@ -345,14 +349,14 @@ pub struct EditProject {
with = "::serde_with::rust::double_option"
)]
#[validate(length(max = 2000))]
pub rejection_reason: Option<Option<String>>,
pub moderation_message: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "::serde_with::rust::double_option"
)]
#[validate(length(max = 65536))]
pub rejection_body: Option<Option<String>>,
pub moderation_message_body: Option<Option<String>>,
}
#[patch("{id}")]
@@ -465,7 +469,7 @@ pub async fn project_edit(
sqlx::query!(
"
UPDATE mods
SET rejection_reason = NULL
SET moderation_message = NULL
WHERE (id = $1)
",
id as database::models::ids::ProjectId,
@@ -476,7 +480,7 @@ pub async fn project_edit(
sqlx::query!(
"
UPDATE mods
SET rejection_body = NULL
SET moderation_message_body = NULL
WHERE (id = $1)
",
id as database::models::ids::ProjectId,
@@ -841,10 +845,10 @@ pub async fn project_edit(
}
}
if let Some(rejection_reason) = &new_project.rejection_reason {
if !user.role.is_mod() {
if let Some(moderation_message) = &new_project.moderation_message {
if !user.role.is_mod() && project_item.status != ProjectStatus::Approved {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the rejection reason of this project!"
"You do not have the permissions to edit the moderation message of this project!"
.to_string(),
));
}
@@ -852,20 +856,20 @@ pub async fn project_edit(
sqlx::query!(
"
UPDATE mods
SET rejection_reason = $1
SET moderation_message = $1
WHERE (id = $2)
",
rejection_reason.as_deref(),
moderation_message.as_deref(),
id as database::models::ids::ProjectId,
)
.execute(&mut *transaction)
.await?;
}
if let Some(rejection_body) = &new_project.rejection_body {
if !user.role.is_mod() {
if let Some(moderation_message_body) = &new_project.moderation_message_body {
if !user.role.is_mod() && project_item.status != ProjectStatus::Approved {
return Err(ApiError::CustomAuthenticationError(
"You do not have the permissions to edit the rejection body of this project!"
"You do not have the permissions to edit the moderation message body of this project!"
.to_string(),
));
}
@@ -873,10 +877,10 @@ pub async fn project_edit(
sqlx::query!(
"
UPDATE mods
SET rejection_body = $1
SET moderation_message_body = $1
WHERE (id = $2)
",
rejection_body.as_deref(),
moderation_message_body.as_deref(),
id as database::models::ids::ProjectId,
)
.execute(&mut *transaction)
@@ -971,15 +975,17 @@ pub async fn project_icon_edit(
let mut bytes = web::BytesMut::new();
while let Some(item) = payload.next().await {
bytes.extend_from_slice(&item.map_err(|_| {
ApiError::InvalidInputError("Unable to parse bytes in payload sent!".to_string())
})?);
}
if bytes.len() >= 262144 {
return Err(ApiError::InvalidInputError(String::from(
"Icons must be smaller than 256KiB",
)));
if bytes.len() >= 262144 {
return Err(ApiError::InvalidInputError(String::from(
"Icons must be smaller than 256KiB",
)));
} else {
bytes.extend_from_slice(&item.map_err(|_| {
ApiError::InvalidInputError(
"Unable to parse bytes in payload sent!".to_string(),
)
})?);
}
}
let hash = sha1::Sha1::from(&bytes).hexdigest();
@@ -1081,10 +1087,16 @@ pub async fn delete_project_icon(
Ok(HttpResponse::NoContent().body(""))
}
#[derive(Serialize, Deserialize)]
pub struct GalleryCreateQuery {
pub featured: bool,
}
#[post("{id}/gallery")]
pub async fn add_gallery_item(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
web::Query(item): web::Query<GalleryCreateQuery>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
@@ -1123,17 +1135,19 @@ pub async fn add_gallery_item(
let mut bytes = web::BytesMut::new();
while let Some(item) = payload.next().await {
bytes.extend_from_slice(&item.map_err(|_| {
ApiError::InvalidInputError("Unable to parse bytes in payload sent!".to_string())
})?);
}
const FILE_SIZE_CAP: usize = 5 * (1 << 20);
const FILE_SIZE_CAP: usize = 5 * (1 << 20);
if bytes.len() >= FILE_SIZE_CAP {
return Err(ApiError::InvalidInputError(String::from(
"Gallery image exceeds the maximum of 5MiB.",
)));
if bytes.len() >= FILE_SIZE_CAP {
return Err(ApiError::InvalidInputError(String::from(
"Gallery image exceeds the maximum of 5MiB.",
)));
} else {
bytes.extend_from_slice(&item.map_err(|_| {
ApiError::InvalidInputError(
"Unable to parse bytes in payload sent!".to_string(),
)
})?);
}
}
let hash = sha1::Sha1::from(&bytes).hexdigest();
@@ -1149,6 +1163,7 @@ pub async fn add_gallery_item(
database::models::project_item::GalleryItem {
project_id: project_item.id,
image_url: format!("{}/{}", cdn_url, url),
featured: item.featured,
}
.insert(&mut transaction)
.await?;
@@ -1163,14 +1178,93 @@ pub async fn add_gallery_item(
}
#[derive(Serialize, Deserialize)]
pub struct GalleryItem {
pub item: String,
pub struct GalleryEditQuery {
pub url: String,
pub featured: bool,
}
#[patch("{id}/gallery")]
pub async fn edit_gallery_item(
req: HttpRequest,
web::Query(item): web::Query<GalleryEditQuery>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let string = info.into_inner().0;
let project_item =
database::models::Project::get_from_slug_or_project_id(string.clone(), &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError("The specified project does not exist!".to_string())
})?;
if !user.role.is_mod() {
let team_member = database::models::TeamMember::get_from_user_id(
project_item.team_id,
user.id.into(),
&**pool,
)
.await
.map_err(ApiError::DatabaseError)?
.ok_or_else(|| {
ApiError::InvalidInputError("The specified project does not exist!".to_string())
})?;
if !team_member.permissions.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to edit this project's gallery.".to_string(),
));
}
}
let mut transaction = pool.begin().await?;
let id = sqlx::query!(
"
SELECT id FROM mods_gallery
WHERE image_url = $1
",
item.url
)
.fetch_optional(&mut *transaction)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError(format!(
"Gallery item at URL {} is not part of the project's gallery.",
item.url
))
})?
.id;
let mut transaction = pool.begin().await?;
sqlx::query!(
"
UPDATE mods_gallery
SET featured = $2
WHERE id = $1
",
id,
item.featured
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}
#[derive(Serialize, Deserialize)]
pub struct GalleryDeleteQuery {
pub url: String,
}
#[delete("{id}/gallery")]
pub async fn delete_gallery_item(
req: HttpRequest,
web::Query(item): web::Query<GalleryItem>,
web::Query(item): web::Query<GalleryDeleteQuery>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
@@ -1199,7 +1293,7 @@ pub async fn delete_gallery_item(
if !team_member.permissions.contains(Permissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to edit this project's icon.".to_string(),
"You don't have permission to edit this project's gallery.".to_string(),
));
}
}
@@ -1210,19 +1304,19 @@ pub async fn delete_gallery_item(
SELECT id FROM mods_gallery
WHERE image_url = $1
",
item.item
item.url
)
.fetch_optional(&mut *transaction)
.await?
.ok_or_else(|| {
ApiError::InvalidInputError(format!(
"Gallery item at URL {} is not part of the project's gallery.",
item.item
item.url
))
})?
.id;
let name = item.item.split('/').next();
let name = item.url.split('/').next();
if let Some(item_path) = name {
file_host.delete_file_version("", item_path).await?;

View File

@@ -341,6 +341,66 @@ pub async fn edit_team_member(
Ok(HttpResponse::NoContent().body(""))
}
#[derive(Deserialize)]
pub struct TransferOwnership {
pub user_id: UserId,
}
#[patch("{id}/owner")]
pub async fn transfer_ownership(
req: HttpRequest,
info: web::Path<(TeamId,)>,
pool: web::Data<PgPool>,
new_owner: web::Json<TransferOwnership>,
) -> Result<HttpResponse, ApiError> {
let id = info.into_inner().0;
let current_user = get_user_from_headers(req.headers(), &**pool).await?;
let team_member =
TeamMember::get_from_user_id(id.into(), current_user.id.into(), &**pool).await?;
let member = match team_member {
Some(m) => m,
None => {
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to edit members of this team".to_string(),
))
}
};
if member.role != crate::models::teams::OWNER_ROLE {
return Err(ApiError::CustomAuthenticationError(
"You don't have permission to edit the ownership of this team".to_string(),
));
}
let mut transaction = pool.begin().await?;
TeamMember::edit_team_member(
id.into(),
current_user.id.into(),
None,
Some(crate::models::teams::DEFAULT_ROLE.to_string()),
None,
&mut transaction,
)
.await?;
TeamMember::edit_team_member(
id.into(),
new_owner.user_id.into(),
None,
Some(crate::models::teams::OWNER_ROLE.to_string()),
None,
&mut transaction,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
}
#[delete("{id}/members/{user_id}")]
pub async fn remove_team_member(
req: HttpRequest,

View File

@@ -98,7 +98,7 @@ pub async fn projects_list(
let user = get_user_from_headers(req.headers(), &**pool).await.ok();
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
crate::database::models::User::get_id_from_username_or_id(&*info.into_inner().0, &**pool)
.await?;
if let Some(id) = id_option {
@@ -172,7 +172,7 @@ pub async fn user_edit(
.map_err(|err| ApiError::ValidationError(validation_errors_to_string(err, None)))?;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
crate::database::models::User::get_id_from_username_or_id(&*info.into_inner().0, &**pool)
.await?;
if let Some(id) = id_option {
@@ -182,17 +182,28 @@ pub async fn user_edit(
let mut transaction = pool.begin().await?;
if let Some(username) = &new_user.username {
sqlx::query!(
"
let user_option =
crate::database::models::User::get_id_from_username_or_id(username, &**pool)
.await?;
if user_option.is_none() {
sqlx::query!(
"
UPDATE users
SET username = $1
WHERE (id = $2)
",
username,
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
username,
id as crate::database::models::ids::UserId,
)
.execute(&mut *transaction)
.await?;
} else {
return Err(ApiError::InvalidInputError(format!(
"Username {} is taken!",
username
)));
}
}
if let Some(name) = &new_user.name {
@@ -289,9 +300,11 @@ pub async fn user_icon_edit(
if let Some(content_type) = crate::util::ext::get_image_content_type(&*ext.ext) {
let cdn_url = dotenv::var("CDN_URL")?;
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
.await?;
let id_option = crate::database::models::User::get_id_from_username_or_id(
&*info.into_inner().0,
&**pool,
)
.await?;
if let Some(id) = id_option {
if user.id != id.into() && !user.role.is_mod() {
@@ -326,17 +339,17 @@ pub async fn user_icon_edit(
let mut bytes = web::BytesMut::new();
while let Some(item) = payload.next().await {
bytes.extend_from_slice(&item.map_err(|_| {
ApiError::InvalidInputError(
"Unable to parse bytes in payload sent!".to_string(),
)
})?);
}
if bytes.len() >= 262144 {
return Err(ApiError::InvalidInputError(String::from(
"Icons must be smaller than 256KiB",
)));
if bytes.len() >= 262144 {
return Err(ApiError::InvalidInputError(String::from(
"Icons must be smaller than 256KiB",
)));
} else {
bytes.extend_from_slice(&item.map_err(|_| {
ApiError::InvalidInputError(
"Unable to parse bytes in payload sent!".to_string(),
)
})?);
}
}
let upload_data = file_host
@@ -389,7 +402,7 @@ pub async fn user_delete(
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
crate::database::models::User::get_id_from_username_or_id(&*info.into_inner().0, &**pool)
.await?;
if let Some(id) = id_option {
@@ -428,7 +441,7 @@ pub async fn user_follows(
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
crate::database::models::User::get_id_from_username_or_id(&*info.into_inner().0, &**pool)
.await?;
if let Some(id) = id_option {
@@ -475,7 +488,7 @@ pub async fn user_notifications(
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
crate::database::models::User::get_id_from_username_or_id(&*info.into_inner().0, &**pool)
.await?;
if let Some(id) = id_option {

View File

@@ -16,7 +16,7 @@ pub async fn mods_list(
let user = get_user_from_headers(req.headers(), &**pool).await.ok();
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
crate::database::models::User::get_id_from_username_or_id(&*info.into_inner().0, &**pool)
.await?;
if let Some(id) = id_option {
@@ -51,7 +51,7 @@ pub async fn user_follows(
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(req.headers(), &**pool).await?;
let id_option =
crate::database::models::User::get_id_from_username_or_id(info.into_inner().0, &**pool)
crate::database::models::User::get_id_from_username_or_id(&*info.into_inner().0, &**pool)
.await?;
if let Some(id) = id_option {

View File

@@ -585,17 +585,16 @@ pub async fn upload_file(
let mut data = Vec::new();
while let Some(chunk) = field.next().await {
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
}
// Project file size limit of 100MiB
const FILE_SIZE_CAP: usize = 100 * (1 << 20);
// Project file size limit of 100MiB
const FILE_SIZE_CAP: usize = 100 * (1 << 20);
// TODO: override file size cap for authorized users or projects
if data.len() >= FILE_SIZE_CAP {
return Err(CreateError::InvalidInput(
String::from("Project file exceeds the maximum of 100MiB. Contact a moderator or admin to request permission to upload larger files.")
));
if data.len() >= FILE_SIZE_CAP {
return Err(CreateError::InvalidInput(
String::from("Project file exceeds the maximum of 100MiB. Contact a moderator or admin to request permission to upload larger files.")
));
} else {
data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?);
}
}
let validation_result = validate_file(