Merge branch 'main' into boris/dev-1126

This commit is contained in:
aecsocket
2026-08-14 11:13:22 +00:00
1206 changed files with 64611 additions and 33568 deletions
+5 -2
View File
@@ -3,6 +3,7 @@ use std::time::Duration;
use chrono::{DateTime, Utc};
use eyre::Result;
use serde::{Deserialize, Serialize};
use serde_binhum::serde_binhum;
use tracing::warn;
use validator::Validate;
@@ -324,7 +325,8 @@ impl ComponentEdit for JavaServerProjectEdit {
}
/// What game content a [`JavaServerProject`] is using.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[derive(Debug, Clone)]
#[serde_binhum(schema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ServerContent {
/// Server runs modded content with a modpack found on the Modrinth platform.
@@ -346,7 +348,8 @@ pub enum ServerContent {
}
/// What game content a [`JavaServerProject`] is using.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[derive(Debug, Clone)]
#[serde_binhum(schema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ServerContentQuery {
/// Server runs modded content with a modpack found on the Modrinth platform.
+1 -2
View File
@@ -54,7 +54,7 @@ macro_rules! define_project_components {
pub struct ProjectSerial {
$(
#[validate(nested)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub $field_name: Option<$ty>,
)*
}
@@ -114,7 +114,6 @@ macro_rules! define_project_components {
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectQuery {
$(
#[serde(skip_serializing_if = "Option::is_none")]
pub $field_name: Option<Query<$ty>>,
)*
}
+1
View File
@@ -6,6 +6,7 @@ pub mod v3;
pub use v3::analytics;
pub use v3::billing;
pub use v3::collections;
pub use v3::disclosures;
pub use v3::ids;
pub use v3::images;
pub use v3::moderation_notes;
+49 -9
View File
@@ -3,7 +3,10 @@ use std::convert::TryFrom;
use std::collections::HashMap;
use super::super::ids::OrganizationId;
use crate::database::models::{DatabaseError, version_item};
use crate::database::models::{
DBProjectDisclosure, DBProjectId, DatabaseError, version_item,
};
use crate::models::disclosures::ProjectDisclosureType;
use crate::models::ids::{ProjectId, TeamId, ThreadId, VersionId};
use crate::models::projects::{
Dependency, License, Link, Loader, ModeratorMessage, MonetizationStatus,
@@ -28,6 +31,8 @@ pub struct LegacyProject {
pub server_side: LegacySideType,
/// A list of game versions this project supports
pub game_versions: Vec<String>,
/// The environments this project supports
pub environment: Vec<String>,
// All other fields are the same as V3
// If they change, or their constituent types change, we may need to
@@ -56,6 +61,7 @@ pub struct LegacyProject {
pub loaders: Vec<String>,
pub versions: Vec<VersionId>,
pub icon_url: Option<String>,
pub raw_icon_url: Option<String>,
pub issues_url: Option<String>,
pub source_url: Option<String>,
pub wiki_url: Option<String>,
@@ -125,6 +131,14 @@ impl LegacyProject {
.filter_map(|v| v.as_str())
.map(|v| v.to_string())
.collect();
let environment = data
.fields
.get("environment")
.unwrap_or(&Vec::new())
.iter()
.filter_map(|v| v.as_str())
.map(|v| v.to_string())
.collect();
if let Some(versions_item) = versions_item {
// Extract side types from remaining fields
@@ -205,6 +219,7 @@ impl LegacyProject {
loaders,
versions: data.versions,
icon_url: data.icon_url,
raw_icon_url: data.raw_icon_url,
issues_url,
source_url,
wiki_url,
@@ -221,32 +236,47 @@ impl LegacyProject {
client_side,
server_side,
game_versions,
environment,
}
}
// Because from needs a version_item, this is a helper function to get many from one db query.
pub async fn from_many<'a, E>(
pub async fn from_many(
data: Vec<Project>,
exec: E,
pool: &crate::database::PgPool,
redis: &RedisPool,
) -> Result<Vec<Self>, DatabaseError>
where
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
{
) -> Result<Vec<Self>, DatabaseError> {
let version_ids: Vec<_> = data
.iter()
.filter_map(|p| p.versions.first().map(|i| (*i).into()))
.collect();
let project_ids: Vec<DBProjectId> =
data.iter().map(|p| p.id.into()).collect();
let example_versions =
version_item::DBVersion::get_many(&version_ids, exec, redis)
version_item::DBVersion::get_many(&version_ids, pool, redis)
.await?;
let archived_disclosure_ids = DBProjectDisclosure::projects_with_type(
ProjectDisclosureType::Archived,
&project_ids,
pool,
)
.await?;
let mut legacy_projects = Vec::new();
for project in data {
let version_item = example_versions
.iter()
.find(|v| v.inner.project_id == project.id.into())
.cloned();
let project = LegacyProject::from(project, version_item);
let has_archived_disclosure =
archived_disclosure_ids.contains(&project.id.into());
let mut project = LegacyProject::from(project, version_item);
if has_archived_disclosure
&& project.status == ProjectStatus::Approved
{
project.status = ProjectStatus::Archived;
}
legacy_projects.push(project);
}
Ok(legacy_projects)
@@ -302,6 +332,9 @@ pub struct LegacyVersion {
/// A list of loaders this project supports (has a newtype struct)
pub loaders: Vec<Loader>,
/// The environment this version supports
pub environment: String,
pub id: VersionId,
pub project_id: ProjectId,
pub author_id: UserId,
@@ -332,6 +365,12 @@ impl From<Version> for LegacyVersion {
}
}
}
let environment = data
.fields
.get("environment")
.and_then(|value| value.as_str())
.unwrap_or("unknown")
.to_string();
// - if loader is mrpack, this is a modpack
// the v2 loaders are whatever the corresponding loader fields are
@@ -366,6 +405,7 @@ impl From<Version> for LegacyVersion {
dependencies: data.dependencies,
game_versions,
loaders,
environment,
}
}
}
+10
View File
@@ -41,6 +41,7 @@ pub struct LegacyResultSearchProject {
pub license: String,
pub client_side: String,
pub server_side: String,
pub environment: Vec<String>,
pub gallery: Vec<String>,
pub featured_gallery: Option<String>,
pub color: Option<u32>,
@@ -118,6 +119,14 @@ impl LegacyResultSearchProject {
let environment =
get_one_string_loader_field("environment").unwrap_or("unknown");
let environments = result_search_project
.loader_fields
.get("environment")
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|environment| environment.as_str().map(String::from))
.collect();
let (client_side, server_side) =
v2_reroute::convert_v3_environment_to_v2_side_types(
@@ -141,6 +150,7 @@ impl LegacyResultSearchProject {
all_project_types: result_search_project.all_project_types,
client_side,
server_side,
environment: environments,
versions,
latest_version: result_search_project.version_id,
categories,
+3 -2
View File
@@ -4,6 +4,7 @@ use crate::models::ids::{
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_binhum::serde_binhum;
use std::collections::HashMap;
#[derive(Serialize, Deserialize)]
@@ -14,7 +15,7 @@ pub struct Product {
pub unitary: bool,
}
#[derive(Serialize, Deserialize)]
#[serde_binhum]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ProductMetadata {
Midas,
@@ -55,7 +56,7 @@ pub struct ProductPrice {
pub currency_code: String,
}
#[derive(Serialize, Deserialize)]
#[serde_binhum]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum Price {
OneTime {
+177
View File
@@ -0,0 +1,177 @@
use crate::database::models::DBProjectDisclosure;
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use strum::{EnumDiscriminants, EnumString, IntoStaticStr};
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, EnumDiscriminants)]
#[serde(rename_all = "snake_case", tag = "type")]
#[strum_discriminants(
name(ProjectDisclosureType),
derive(IntoStaticStr, EnumString),
strum(serialize_all = "snake_case")
)]
pub enum ProjectDisclosure {
AiContent {
note: Option<String>,
uses: BTreeSet<AiUsages>,
},
Advertisements {
note: Option<String>,
},
EpilepsyTriggers {
note: Option<String>,
},
SystemInteractions {
note: Option<String>,
interactions: Vec<String>,
},
Telemetry {
consent: TelemetryConsent,
data_collected: Vec<String>,
},
DerivativeWork {
sources: Vec<DerivativeSource>,
},
PaidFeatures {
features: Vec<String>,
},
Archived {
note: Option<String>,
},
}
impl ProjectDisclosure {
pub fn to_parts(
&self,
) -> Result<(&'static str, serde_json::Value), serde_json::Error> {
let serde_json::Value::Object(mut object) = serde_json::to_value(self)?
else {
return Err(serde::ser::Error::custom(
"project disclosure must serialize to a JSON object",
));
};
object.remove("type");
Ok((
ProjectDisclosureType::from(self).into(),
serde_json::Value::Object(object),
))
}
pub fn from_parts(
kind: &str,
metadata: serde_json::Value,
) -> Result<Self, serde_json::Error> {
let serde_json::Value::Object(mut object) = metadata else {
return Err(serde::de::Error::custom(
"project disclosure metadata must be a JSON object",
));
};
object.insert(
"type".to_owned(),
serde_json::Value::String(kind.to_owned()),
);
serde_json::from_value(serde_json::Value::Object(object))
}
}
#[derive(
Debug,
Clone,
Copy,
Default,
Serialize,
Deserialize,
ToSchema,
IntoStaticStr,
EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum DisclosureLockStatus {
#[default]
Unlocked,
CannotDisable,
FullyLocked,
}
impl DisclosureLockStatus {
pub fn allows_edit(self) -> bool {
!matches!(self, Self::FullyLocked)
}
pub fn allows_removal(self) -> bool {
matches!(self, Self::Unlocked)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProjectDisclosureData {
#[serde(flatten)]
pub disclosure: ProjectDisclosure,
pub set_by_moderator: bool,
pub lock_status: DisclosureLockStatus,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<UserId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTime<Utc>>,
}
impl ProjectDisclosureData {
pub fn from_db(
value: DBProjectDisclosure,
viewer_is_moderator: bool,
) -> Self {
let updated_by = (!value.set_by_moderator || viewer_is_moderator)
.then_some(value.updated_by.into());
Self {
disclosure: value.disclosure,
set_by_moderator: value.set_by_moderator,
lock_status: value.lock_status,
updated_at: value.updated_at,
updated_by,
deleted_at: value.deleted_at,
}
}
}
#[derive(
Debug,
Clone,
Serialize,
Deserialize,
ToSchema,
IntoStaticStr,
PartialEq,
Eq,
PartialOrd,
Ord,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum AiUsages {
Code,
Assets,
Text,
Functionality,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoStaticStr)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum TelemetryConsent {
OptIn,
OptOut,
AlwaysActive,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct DerivativeSource {
pub link: Option<String>,
pub label: String,
pub note: Option<String>,
}
+1
View File
@@ -3,6 +3,7 @@ pub mod analytics;
pub mod analytics_event;
pub mod billing;
pub mod collections;
pub mod disclosures;
pub mod ids;
pub mod images;
pub mod moderation_notes;
+10 -11
View File
@@ -4,6 +4,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::routes::ApiError;
use crate::util::error::Context as _;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ModerationNote {
@@ -37,9 +38,9 @@ pub struct PatchModerationNote {
impl PatchModerationNote {
pub fn validate_not_empty(&self) -> Result<(), ApiError> {
if self.notes.is_none() && self.user_rating.is_none() {
return Err(ApiError::InvalidInput(
"must specify `notes` or `user_rating`".to_string(),
));
return Err(ApiError::Request(eyre::eyre!(
"must specify `notes` or `user_rating`",
)));
}
Ok(())
@@ -53,16 +54,14 @@ pub fn parse_if_match_header(
return Ok(None);
};
let value = value.to_str().map_err(|_| {
ApiError::InvalidInput(
"`if-match` header must be a valid integer".to_string(),
)
})?;
let value = value.to_str().wrap_request_err(
"`if-match` header must be a valid integer".to_string(),
)?;
Some(value.parse::<i32>().map_err(|_| {
ApiError::InvalidInput(
"`if-match` header must be a valid integer".to_string(),
)
ApiError::Request(eyre::eyre!(
"`if-match` header must be a valid integer",
))
}))
.transpose()
}
+5 -3
View File
@@ -12,6 +12,7 @@ use crate::routes::ApiError;
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_binhum::serde_binhum;
use uuid::Uuid;
#[derive(Serialize, Deserialize)]
@@ -151,7 +152,8 @@ impl NotificationType {
}
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Clone)]
#[serde_binhum]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum NotificationBody {
ProjectUpdate {
@@ -765,8 +767,8 @@ impl NotificationDeliveryStatus {
NotificationDeliveryStatus::Delivered => Ok(()),
NotificationDeliveryStatus::SkippedPreferences |
NotificationDeliveryStatus::SkippedDefault |
NotificationDeliveryStatus::Pending => Err(ApiError::InvalidInput("An error occurred while sending an email to your email address. Please try again later.".to_owned())),
NotificationDeliveryStatus::PermanentlyFailed => Err(ApiError::InvalidInput("This email address doesn't exist! Please try another one.".to_owned())),
NotificationDeliveryStatus::Pending => Err(ApiError::Request(eyre::eyre!("An error occurred while sending an email to your email address. Please try again later.".to_owned()))),
NotificationDeliveryStatus::PermanentlyFailed => Err(ApiError::Request(eyre::eyre!("This email address doesn't exist! Please try another one.".to_owned()))),
}
}
+6 -1
View File
@@ -79,6 +79,8 @@ pub struct Project {
pub versions: Vec<VersionId>,
/// The URL of the icon of the project
pub icon_url: Option<String>,
/// The URL of the unoptimized icon of the project
pub raw_icon_url: Option<String>,
/// A collection of links to the project's various pages.
pub link_urls: HashMap<String, Link>,
@@ -193,6 +195,7 @@ impl From<ProjectQueryResult> for Project {
loaders: m.loaders,
versions: data.versions.into_iter().map(|v| v.into()).collect(),
icon_url: m.icon_url,
raw_icon_url: m.raw_icon_url,
link_urls: data
.urls
.into_iter()
@@ -567,11 +570,13 @@ impl ProjectStatus {
pub fn can_be_requested(&self) -> bool {
match self {
ProjectStatus::Approved => true,
ProjectStatus::Archived => true,
ProjectStatus::Unlisted => true,
ProjectStatus::Private => true,
ProjectStatus::Draft => true,
// `archived` is represented by a disclosure, not a status, so it
// can no longer be requested or set as a status.
ProjectStatus::Archived => false,
ProjectStatus::Rejected => false,
ProjectStatus::Processing => false,
ProjectStatus::Unknown => false,