Project Colors (#512)

* Inital tests

* Finish project colors

* Run fmt + clippy + prepare

* Fix dp+rp fmting
This commit is contained in:
Geometrically
2022-12-29 17:20:50 -07:00
committed by GitHub
parent 60bb6f105d
commit 5bb188a822
16 changed files with 1422 additions and 1062 deletions

View File

@@ -100,6 +100,7 @@ pub struct ProjectBuilder {
pub slug: Option<String>,
pub donation_urls: Vec<DonationUrl>,
pub gallery_items: Vec<GalleryItem>,
pub color: Option<u32>,
}
impl ProjectBuilder {
@@ -137,6 +138,7 @@ impl ProjectBuilder {
flame_anvil_project: None,
flame_anvil_user: None,
webhook_sent: false,
color: self.color,
};
project_struct.insert(&mut *transaction).await?;
@@ -213,6 +215,7 @@ pub struct Project {
pub flame_anvil_project: Option<i32>,
pub flame_anvil_user: Option<UserId>,
pub webhook_sent: bool,
pub color: Option<u32>,
}
impl Project {
@@ -227,14 +230,14 @@ impl Project {
published, downloads, icon_url, issues_url,
source_url, wiki_url, status, requested_status, discord_url,
client_side, server_side, license_url, license,
slug, project_type
slug, project_type, color
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11, $12, $13, $14,
$15, $16, $17, $18,
LOWER($19), $20
LOWER($19), $20, $21
)
",
self.id as ProjectId,
@@ -256,7 +259,8 @@ impl Project {
self.license_url.as_ref(),
&self.license,
self.slug.as_ref(),
self.project_type as ProjectTypeId
self.project_type as ProjectTypeId,
self.color.map(|x| x as i32)
)
.execute(&mut *transaction)
.await?;
@@ -279,7 +283,7 @@ impl Project {
issues_url, source_url, wiki_url, discord_url, license_url,
team_id, client_side, server_side, license, slug,
moderation_message, moderation_message_body, flame_anvil_project,
flame_anvil_user, webhook_sent
flame_anvil_user, webhook_sent, color
FROM mods
WHERE id = $1
",
@@ -321,6 +325,7 @@ impl Project {
flame_anvil_project: row.flame_anvil_project,
flame_anvil_user: row.flame_anvil_user.map(UserId),
webhook_sent: row.webhook_sent,
color: row.color.map(|x| x as u32),
}))
} else {
Ok(None)
@@ -346,7 +351,7 @@ impl Project {
issues_url, source_url, wiki_url, discord_url, license_url,
team_id, client_side, server_side, license, slug,
moderation_message, moderation_message_body, flame_anvil_project,
flame_anvil_user, webhook_sent
flame_anvil_user, webhook_sent, color
FROM mods
WHERE id = ANY($1)
",
@@ -388,6 +393,7 @@ impl Project {
flame_anvil_project: m.flame_anvil_project,
flame_anvil_user: m.flame_anvil_user.map(UserId),
webhook_sent: m.webhook_sent,
color: m.color.map(|x| x as u32),
}))
})
.try_collect::<Vec<Project>>()
@@ -666,7 +672,7 @@ impl Project {
m.updated updated, m.approved approved, m.status status, m.requested_status requested_status,
m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url,
m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,
cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.flame_anvil_project flame_anvil_project, m.flame_anvil_user flame_anvil_user, m.webhook_sent webhook_sent,
cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.flame_anvil_project flame_anvil_project, m.flame_anvil_user flame_anvil_user, m.webhook_sent webhook_sent, m.color,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories,
JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions,
@@ -725,6 +731,7 @@ impl Project {
flame_anvil_project: m.flame_anvil_project,
flame_anvil_user: m.flame_anvil_user.map(UserId),
webhook_sent: m.webhook_sent,
color: m.color.map(|x| x as u32),
},
project_type: m.project_type_name,
categories: m.categories.unwrap_or_default(),
@@ -794,7 +801,7 @@ impl Project {
m.updated updated, m.approved approved, m.status status, m.requested_status requested_status,
m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url,
m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,
cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.flame_anvil_project flame_anvil_project, m.flame_anvil_user flame_anvil_user, m.webhook_sent,
cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.flame_anvil_project flame_anvil_project, m.flame_anvil_user flame_anvil_user, m.webhook_sent, m.color,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories,
JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions,
@@ -856,6 +863,7 @@ impl Project {
flame_anvil_project: m.flame_anvil_project,
flame_anvil_user: m.flame_anvil_user.map(UserId),
webhook_sent: m.webhook_sent,
color: m.color.map(|x| x as u32),
},
project_type: m.project_type_name,
categories: m.categories.unwrap_or_default(),

View File

@@ -97,6 +97,9 @@ pub struct Project {
pub flame_anvil_project: Option<i32>,
/// The user_id of the team member whose token
pub flame_anvil_user: Option<UserId>,
/// The color of the project (picked from icon)
pub color: Option<u32>,
}
impl From<QueryProject> for Project {
@@ -181,6 +184,7 @@ impl From<QueryProject> for Project {
.collect(),
flame_anvil_project: m.flame_anvil_project,
flame_anvil_user: m.flame_anvil_user.map(|x| x.into()),
color: m.color,
}
}
}

View File

@@ -30,6 +30,7 @@ pub use self::index::index_get;
pub use self::not_found::not_found;
use crate::file_hosting::FileHostingError;
use actix_web::web;
use image::ImageError;
pub fn v2_config(cfg: &mut web::ServiceConfig) {
cfg.service(
@@ -230,6 +231,8 @@ pub enum ApiError {
DiscordError(String),
#[error("Error while decoding Base62: {0}")]
Decoding(#[from] crate::models::ids::DecodingError),
#[error("Image Parsing Error: {0}")]
ImageError(#[from] ImageError),
}
impl actix_web::ResponseError for ApiError {
@@ -280,6 +283,9 @@ impl actix_web::ResponseError for ApiError {
actix_web::http::StatusCode::FAILED_DEPENDENCY
}
ApiError::Decoding(..) => actix_web::http::StatusCode::BAD_REQUEST,
ApiError::ImageError(..) => {
actix_web::http::StatusCode::BAD_REQUEST
}
}
}
@@ -304,6 +310,7 @@ impl actix_web::ResponseError for ApiError {
ApiError::Payments(..) => "payments_error",
ApiError::DiscordError(..) => "discord_error",
ApiError::Decoding(..) => "decoding_error",
ApiError::ImageError(..) => "invalid_image",
},
description: &self.to_string(),
},

View File

@@ -18,6 +18,7 @@ use actix_web::web::Data;
use actix_web::{post, HttpRequest, HttpResponse};
use chrono::Utc;
use futures::stream::StreamExt;
use image::ImageError;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool;
@@ -65,6 +66,8 @@ pub enum CreateError {
Unauthorized(#[from] AuthenticationError),
#[error("Authentication Error: {0}")]
CustomAuthenticationError(String),
#[error("Image Parsing Error: {0}")]
ImageError(#[from] ImageError),
}
impl actix_web::ResponseError for CreateError {
@@ -95,6 +98,7 @@ impl actix_web::ResponseError for CreateError {
CreateError::SlugCollision => StatusCode::BAD_REQUEST,
CreateError::ValidationError(..) => StatusCode::BAD_REQUEST,
CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST,
CreateError::ImageError(..) => StatusCode::BAD_REQUEST,
}
}
@@ -120,6 +124,7 @@ impl actix_web::ResponseError for CreateError {
CreateError::SlugCollision => "invalid_input",
CreateError::ValidationError(..) => "invalid_input",
CreateError::FileValidationError(..) => "invalid_input",
CreateError::ImageError(..) => "invalid_image",
},
description: &self.to_string(),
})
@@ -468,7 +473,7 @@ pub async fn project_create_inner(
))
})?;
let mut icon_url = None;
let mut icon_data = None;
while let Some(item) = payload.next().await {
let mut field: Field = item.map_err(CreateError::MultipartError)?;
@@ -482,13 +487,13 @@ pub async fn project_create_inner(
super::version_creation::get_name_ext(&content_disposition)?;
if name == "icon" {
if icon_url.is_some() {
if icon_data.is_some() {
return Err(CreateError::InvalidInput(String::from(
"Projects can only have one icon",
)));
}
// Upload the icon to the cdn
icon_url = Some(
icon_data = Some(
process_icon_upload(
uploaded_files,
project_id,
@@ -731,7 +736,7 @@ pub async fn project_create_inner(
title: project_create_data.title,
description: project_create_data.description,
body: project_create_data.body,
icon_url,
icon_url: icon_data.clone().map(|x| x.0),
issues_url: project_create_data.issues_url,
source_url: project_create_data.source_url,
wiki_url: project_create_data.wiki_url,
@@ -759,6 +764,7 @@ pub async fn project_create_inner(
ordering: x.ordering,
})
.collect(),
color: icon_data.and_then(|x| x.1),
};
let now = Utc::now();
@@ -803,6 +809,7 @@ pub async fn project_create_inner(
gallery: gallery_urls,
flame_anvil_project: None,
flame_anvil_user: None,
color: project_builder.color,
};
let _project_id = project_builder.insert(&mut *transaction).await?;
@@ -911,9 +918,9 @@ async fn process_icon_upload(
project_id: ProjectId,
file_extension: &str,
file_host: &dyn FileHost,
mut field: actix_multipart::Field,
mut field: Field,
cdn_url: &str,
) -> Result<String, CreateError> {
) -> Result<(String, Option<u32>), CreateError> {
if let Some(content_type) =
crate::util::ext::get_image_content_type(file_extension)
{
@@ -924,6 +931,8 @@ async fn process_icon_upload(
)
.await?;
let color = crate::util::img::get_color_from_img(&data)?;
let hash = sha1::Sha1::from(&data).hexdigest();
let upload_data = file_host
.upload_file(
@@ -938,7 +947,7 @@ async fn process_icon_upload(
file_name: upload_data.file_name.clone(),
});
Ok(format!("{}/{}", cdn_url, upload_data.file_name))
Ok((format!("{}/{}", cdn_url, upload_data.file_name), color))
} else {
Err(CreateError::InvalidIconFormat(file_extension.to_string()))
}

View File

@@ -1288,6 +1288,9 @@ pub async fn project_icon_edit(
"Icons must be smaller than 256KiB",
)
.await?;
let color = crate::util::img::get_color_from_img(&bytes)?;
let hash = sha1::Sha1::from(&bytes).hexdigest();
let project_id: ProjectId = project_item.id.into();
let upload_data = file_host
@@ -1303,10 +1306,11 @@ pub async fn project_icon_edit(
sqlx::query!(
"
UPDATE mods
SET icon_url = $1
WHERE (id = $2)
SET icon_url = $1, color = $2
WHERE (id = $3)
",
format!("{}/{}", cdn_url, upload_data.file_name),
color.map(|x| x as i32),
project_item.id as database::models::ids::ProjectId,
)
.execute(&mut *transaction)
@@ -1379,7 +1383,7 @@ pub async fn delete_project_icon(
sqlx::query!(
"
UPDATE mods
SET icon_url = NULL
SET icon_url = NULL, color = NULL
WHERE (id = $1)
",
project_item.id as database::models::ids::ProjectId,

View File

@@ -16,7 +16,7 @@ pub async fn index_local(
"
SELECT m.id id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,
m.icon_url icon_url, m.published published, m.approved approved, m.updated updated,
m.team_id team_id, m.license license, m.slug slug, m.status status_name,
m.team_id team_id, m.license license, m.slug slug, m.status status_name, m.color color,
cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, u.username username,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories,
@@ -96,6 +96,7 @@ pub async fn index_local(
gallery,
display_categories,
open_source,
color: m.color.map(|x| x as u32),
}
}))
})

View File

@@ -98,6 +98,7 @@ pub struct UploadSearchProject {
/// Unix timestamp of the last major modification
pub modified_timestamp: i64,
pub open_source: bool,
pub color: Option<u32>,
}
#[derive(Serialize, Deserialize, Debug)]
@@ -132,6 +133,7 @@ pub struct ResultSearchProject {
pub client_side: String,
pub server_side: String,
pub gallery: Vec<String>,
pub color: Option<u32>,
}
impl Document for UploadSearchProject {

View File

@@ -2,12 +2,9 @@ pub fn get_image_content_type(extension: &str) -> Option<&'static str> {
match extension {
"bmp" => Some("image/bmp"),
"gif" => Some("image/gif"),
"jpeg" | "jpg" | "jpe" => Some("image/jpeg"),
"jpeg" | "jpg" => Some("image/jpeg"),
"png" => Some("image/png"),
"svg" | "svgz" => Some("image/svg+xml"),
"webp" => Some("image/webp"),
"rgb" => Some("image/x-rgb"),
"mp4" => Some("video/mp4"),
_ => None,
}
}

19
src/util/img.rs Normal file
View File

@@ -0,0 +1,19 @@
use color_thief::ColorFormat;
use image::imageops::FilterType;
use image::{EncodableLayout, ImageError};
pub fn get_color_from_img(data: &[u8]) -> Result<Option<u32>, ImageError> {
let image =
image::load_from_memory(data)?.resize(256, 256, FilterType::Nearest);
let color = color_thief::get_palette(
image.to_rgb8().as_bytes(),
ColorFormat::Rgb,
10,
2,
)
.ok()
.and_then(|x| x.get(0).copied())
.map(|x| (x.r as u32) << 16 | (x.g as u32) << 8 | (x.b as u32));
Ok(color)
}

View File

@@ -2,6 +2,7 @@ pub mod auth;
pub mod env;
pub mod ext;
pub mod guards;
pub mod img;
pub mod routes;
pub mod validate;
pub mod webhook;

View File

@@ -157,7 +157,13 @@ pub async fn send_discord_webhook(
_ => 1049805243866681424,
};
let mut x = loader.clone();
let mut x = if loader == "datapack" {
"Data Pack"
} else {
loader
}
.to_string();
formatted_loaders.push_str(&format!(
"<:{loader}:{emoji_id}> {}{x}\n",
x.remove(0).to_uppercase()
@@ -190,6 +196,13 @@ pub async fn send_discord_webhook(
project_type = "datapack".to_string();
}
let mut display_project_type = match &*project_type {
"datapack" => "data pack",
"resourcepack" => "resource pack",
_ => &*project_type,
}
.to_string();
let embed = DiscordEmbed {
author: Some(DiscordEmbedAuthor {
name: project.username.clone(),
@@ -224,8 +237,8 @@ pub async fn send_discord_webhook(
.map(|x| DiscordEmbedImage { url: Some(x) }),
footer: Some(DiscordEmbedFooter {
text: format!(
"{}{project_type} on Modrinth",
project_type.remove(0).to_uppercase()
"{}{display_project_type} on Modrinth",
display_project_type.remove(0).to_uppercase()
),
icon_url: Some(
"https://cdn-raw.modrinth.com/modrinth-new.png".to_string(),