Compare commits

...
Author SHA1 Message Date
coolbot100s b6a14d576b add alt keybind for resetting moderation progress. 2026-07-03 17:16:51 -07:00
aecsocketandGitHub b26d048a63 Remove AutoMod code (#6604)
* 🦀 automod is gone 🦀

* prepare
2026-07-03 21:32:11 +00:00
François-Xavier TalbotandGitHub d5be2bd739 fix(labrinth): include SharedInstanceInvite notifications for ws (#6603)
* fix(labrinth): include SharedInstanceInvite notifications for ws

* chore: fmt
2026-07-03 17:06:58 +00:00
Prospector 0dac5684af changelog 2026-07-03 08:16:29 -07:00
coolbotandGitHub ca0b8d5ca4 add excessive languages warning nag (#6587) 2026-07-03 12:12:07 +00:00
coolbotandGitHub b6651efe52 Moderation Fix: Handle more license cases (#6590)
Apply correct behavior in edge cases
2026-07-02 17:26:10 +00:00
aecsocketandGitHub eb28121c0a fix: scan logic file whitelist + rescan route (#6585)
* explicitly only scan jar/zip/disabled files for attributions

* add route to force scan file

* prepare
2026-07-02 16:33:16 +00:00
SychicandGitHub a5ebc85356 refactor(labrinth): combine logic for external notification endpoints (#6578)
* refactor(labrinth): combine logic for external notification endpoints

also introduce new notification that doesn't send an email

* style(labrinth): derive default

* feat(labrinth): return more detailed failure information for external
notifications
2026-07-02 15:44:47 +00:00
Prospector c530671be1 fix collection widget on mobile 2026-07-01 18:09:12 -07:00
Prospector bb6d8a6fc4 changelog 2026-07-01 17:44:29 -07:00
ProspectorandGitHub 256e8f946a chore: pride month fundraiser conclusion (#6588)
* pride end blog post

* Fix drago link

* prepr oops
2026-07-01 17:43:44 -07:00
Calum H.andGitHub 7e5590b8b9 fix: improve observability during fetch failure (#6555)
* fix: improve observability during fetch failure

+ remove analytics log spam

* fix: rev comments
2026-07-01 15:58:20 +00:00
SychicandGitHub d20f05fb04 feat(labrinth): instance share notification (#6581) 2026-07-01 14:21:56 +00:00
60 changed files with 1182 additions and 681 deletions
+48
View File
@@ -5,6 +5,10 @@
<div class="pointer-events-none absolute inset-0 z-[-1]">
<div id="absolute-background-teleport" class="relative"></div>
</div>
<div
class="pride-backdrop pointer-events-none absolute inset-0 z-[-1]"
:class="{ shown: showPrideBackdrop }"
></div>
<div class="pointer-events-none absolute inset-0 z-50">
<div
class="over-the-top-random-animation"
@@ -801,6 +805,7 @@ import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { errors as generatedStateErrors } from '~/generated/state.json'
import { provideCurrentProjectId } from '~/providers/current-project.ts'
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'
import { hasActiveMidas } from '~/utils/user-membership.ts'
@@ -867,6 +872,32 @@ const showTinMismatchBanner = computed(() => {
return !!auth.value.user && status === 'tin-mismatch'
})
const PRIDE_COLLECTION_ID = 'M4c3ITvd'
const PRIDE_ARTICLE_SLUGS = ['pride-campaign-2025', 'pride-campaign-2026', 'proud-of-you-2026']
const PRIDE_CACHE_TIME = 1000 * 60 * 60 * 24
const { data: prideCollection } = useQuery({
queryKey: computed(() => ['collection', PRIDE_COLLECTION_ID]),
queryFn: () => client.labrinth.collections.get(PRIDE_COLLECTION_ID),
staleTime: PRIDE_CACHE_TIME,
gcTime: PRIDE_CACHE_TIME,
})
const prideProjectIds = computed(() => new Set(prideCollection.value?.projects ?? []))
const currentProjectId = ref()
provideCurrentProjectId(currentProjectId)
const showPrideBackdrop = computed(() => {
if (PRIDE_ARTICLE_SLUGS.includes(route.params.slug)) {
return true
}
if (route.params.collection === PRIDE_COLLECTION_ID) {
return true
}
return !!currentProjectId.value && prideProjectIds.value.has(currentProjectId.value)
})
const basePopoutId = useId()
async function fetchIntercomToken() {
@@ -1695,4 +1726,21 @@ const { cycle: changeTheme } = useTheme()
transform: translateY(0);
}
}
.pride-backdrop {
background-image: linear-gradient(to right, #c20732, #f57203, #ffd632, #21ca8b, #2f9ff2, #e420fc);
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1), rgba(0, 0, 0, 0) 80%);
height: 30rem;
opacity: 0;
transition: opacity 1s ease;
}
.pride-backdrop.shown {
opacity: 0.08;
}
.light-mode .pride-backdrop.shown,
.light .pride-backdrop.shown {
opacity: 0.15;
}
</style>
+12 -1
View File
@@ -1114,7 +1114,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import { nextTick, readonly, ref, useTemplateRef, watch } from 'vue'
import { nextTick, onScopeDispose, readonly, ref, useTemplateRef, watch, watchEffect } from 'vue'
import { navigateTo } from '#app'
import Accordion from '~/components/ui/Accordion.vue'
@@ -1130,6 +1130,7 @@ import { saveFeatureFlags } from '~/composables/featureFlags.ts'
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import { userCollectProject, userFollowProject } from '~/composables/user.js'
import { injectCurrentProjectId } from '~/providers/current-project.ts'
import {
loadChecklistOpenState,
saveChecklistOpenState,
@@ -1705,6 +1706,16 @@ const project = computed(() => {
// Use actual project ID for dependent queries (ensures cache consistency)
const projectId = computed(() => projectRaw.value?.id)
const sharedProjectId = injectCurrentProjectId(null)
if (sharedProjectId) {
watchEffect(() => {
sharedProjectId.value = projectId.value ?? undefined
})
onScopeDispose(() => {
sharedProjectId.value = undefined
})
}
// V3 Project
const {
data: projectV3,
@@ -954,5 +954,9 @@ function openEditModal(event) {
color: var(--color-brand);
font-weight: 600;
}
a:hover {
text-decoration: underline;
}
}
</style>
@@ -0,0 +1,9 @@
import { createContext } from '@modrinth/ui'
import type { Ref } from 'vue'
/**
* Informs the default layout of the current project ID, if any, because it can't be gleaned from the route which may be a slug
*/
export const [injectCurrentProjectId, provideCurrentProjectId] = createContext<
Ref<string | undefined>
>('root', 'currentProjectId')
Binary file not shown.

After

Width:  |  Height:  |  Size: 777 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

@@ -1,5 +1,12 @@
{
"articles": [
{
"title": "Proud of you all",
"summary": "Over 2,400 of you came together to raise more than $40,000 for charity this Pride month!",
"thumbnail": "https://modrinth.com/news/article/proud-of-you-2026/thumbnail.webp",
"date": "2026-07-02T01:00:00.000Z",
"link": "https://modrinth.com/news/article/proud-of-you-2026"
},
{
"title": "Improving Modpack review delays",
"summary": "Reducing the back-and-forth needed to get your modpack approved.",
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE AND user_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tselect\n\t\t\tf.url,\n\t\t\tf.version_id as \"version_id: DBVersionId\",\n\t\t\tv.mod_id as \"project_id: DBProjectId\"\n\t\tfrom files f\n\t\tinner join versions v on v.id = f.version_id\n\t\twhere f.id = $1\n\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "url",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "version_id: DBVersionId",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "project_id: DBProjectId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "17f415f1140df5b3dd42a161c0f77b2475edb8041bc2b9701d51cf9cbfd69ba1"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id,\n status AS \"status: PayoutStatus\"\n FROM payouts\n ORDER BY id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "status: PayoutStatus",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "20cff8fdf7971e91c9d473b9a4663ce02ca16781e32232ae0fa7a0af1973d3a4"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tdelete from attributions_exemptions\n\t\twhere version_id = $1\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "5ed1e727901573ee92683f206f3321fa4e192c75caa5a62a2790b5136e9a7dde"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO payouts_values_notifications (date_available, user_id, notified)\n VALUES ($1, $2, FALSE)\n ON CONFLICT (date_available, user_id) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz",
"Int8"
]
},
"nullable": []
},
"hash": "6678cd4c51771cfaae2be8021ba66908ea41a06ba858dc5b523aef6aae27b850"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n select\n (\n select count(*)\n from project_attribution_groups\n where project_id = $1\n ) as \"groups!\",\n (\n select count(*)\n from project_attribution_files paf\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where pag.project_id = $1\n ) as \"files!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "groups!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "files!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null,
null
]
},
"hash": "686994a5bfc061b4c6e1ca594fae9d5d3fb18974de6605a650f666d440dfe684"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO payouts_values (user_id, mod_id, amount, created, date_available)\n VALUES ($1, NULL, $2, NOW(), $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Numeric",
"Timestamptz"
]
},
"nullable": []
},
"hash": "69a1cb4b7f1115a990d1fc4805d58541fc78e910111c09ba3d50a12d9ca4a9f8"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT status FROM mods WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "status",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "91d2ce7ee6a29a47a20655fef577c42f1cbb2f8de4d9ea8ab361fc210f08aa20"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET status = 'rejected'\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "95e17b2512494ffcbfe6278b87aa273edc5729633aeaa87f6239667d2f861e68"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT status AS \"status: PayoutStatus\" FROM payouts WHERE id = 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "status: PayoutStatus",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, $3, $4, $5, 10.0, NOW())\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Text",
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, NULL, $3, $4, 10.00, NOW())\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND body->>'type' = 'payout_available'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "fd5c773a61d35bcd71503ec4d5f86e8917cfab9679d5064074681663ba467e41"
}
@@ -0,0 +1,6 @@
INSERT INTO notifications_types
(name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications)
VALUES ('shared_instance_invite', 1, FALSE, TRUE);
INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
VALUES (NULL, 'email', 'shared_instance_invite', FALSE);
-19
View File
@@ -20,7 +20,6 @@ use crate::background_task::update_versions;
use crate::database::{PgPool, ReadOnlyPgPool};
use crate::env::ENV;
use crate::queue::billing::{index_billing, index_subscriptions};
use crate::queue::moderation::AutomatedModerationQueue;
use crate::routes::internal::delphi::rescan::rescan_projects_in_queue;
use crate::util::anrok;
use crate::util::archon::ArchonClient;
@@ -68,7 +67,6 @@ pub struct LabrinthConfig {
pub payouts_queue: web::Data<PayoutsQueue>,
pub analytics_queue: Arc<AnalyticsQueue>,
pub active_sockets: web::Data<ActiveSockets>,
pub automated_moderation_queue: web::Data<AutomatedModerationQueue>,
pub rate_limiter: web::Data<AsyncRateLimiter>,
pub stripe_client: stripe::Client,
pub anrok_client: anrok::Client,
@@ -98,21 +96,6 @@ pub fn app_setup(
) -> LabrinthConfig {
info!("Starting labrinth on {}", &ENV.BIND_ADDR);
let automated_moderation_queue =
web::Data::new(AutomatedModerationQueue::default());
{
let automated_moderation_queue_ref = automated_moderation_queue.clone();
let pool_ref = pool.clone();
let ro_pool_ref = ro_pool.clone();
let redis_pool_ref = redis_pool.clone();
actix_rt::spawn(async move {
automated_moderation_queue_ref
.task(pool_ref, ro_pool_ref, redis_pool_ref)
.await;
});
}
let scheduler = scheduler::Scheduler::new();
let http_client = web::Data::new(HttpClient::new());
@@ -341,7 +324,6 @@ pub fn app_setup(
payouts_queue: web::Data::new(PayoutsQueue::new()),
analytics_queue,
active_sockets,
automated_moderation_queue,
rate_limiter: limiter,
stripe_client,
anrok_client,
@@ -391,7 +373,6 @@ pub fn app_config(
.app_data(web::Data::new(labrinth_config.analytics_queue.clone()))
.app_data(web::Data::new(labrinth_config.clickhouse.clone()))
.app_data(labrinth_config.active_sockets.clone())
.app_data(labrinth_config.automated_moderation_queue.clone())
.app_data(labrinth_config.archon_client.clone())
.app_data(web::Data::new(labrinth_config.stripe_client.clone()))
.app_data(web::Data::new(labrinth_config.anrok_client.clone()))
@@ -73,6 +73,10 @@ pub enum LegacyNotificationBody {
invited_by: UserId,
role: String,
},
SharedInstanceInvite {
shared_instance_id: String,
shared_instance_name: String,
},
StatusChange {
project_id: ProjectId,
old_status: ProjectStatus,
@@ -177,6 +181,9 @@ impl LegacyNotification {
NotificationBody::ServerInvite { .. } => {
Some("server_invite".to_string())
}
NotificationBody::SharedInstanceInvite { .. } => {
Some("shared_instance_invite".to_string())
}
NotificationBody::StatusChange { .. } => {
Some("status_change".to_string())
}
@@ -294,6 +301,13 @@ impl LegacyNotification {
invited_by,
role,
},
NotificationBody::SharedInstanceInvite {
shared_instance_id,
shared_instance_name,
} => LegacyNotificationBody::SharedInstanceInvite {
shared_instance_id,
shared_instance_name,
},
NotificationBody::StatusChange {
project_id,
old_status,
@@ -36,6 +36,7 @@ pub enum NotificationType {
TeamInvite,
OrganizationInvite,
ServerInvite,
SharedInstanceInvite,
StatusChange,
ModeratorMessage,
LegacyMarkdown,
@@ -71,6 +72,7 @@ impl NotificationType {
NotificationType::TeamInvite => "team_invite",
NotificationType::OrganizationInvite => "organization_invite",
NotificationType::ServerInvite => "server_invite",
NotificationType::SharedInstanceInvite => "shared_instance_invite",
NotificationType::StatusChange => "status_change",
NotificationType::ModeratorMessage => "moderator_message",
NotificationType::LegacyMarkdown => "legacy_markdown",
@@ -112,6 +114,7 @@ impl NotificationType {
"team_invite" => NotificationType::TeamInvite,
"organization_invite" => NotificationType::OrganizationInvite,
"server_invite" => NotificationType::ServerInvite,
"shared_instance_invite" => NotificationType::SharedInstanceInvite,
"status_change" => NotificationType::StatusChange,
"moderator_message" => NotificationType::ModeratorMessage,
"legacy_markdown" => NotificationType::LegacyMarkdown,
@@ -173,6 +176,10 @@ pub enum NotificationBody {
invited_by: UserId,
role: String,
},
SharedInstanceInvite {
shared_instance_id: String,
shared_instance_name: String,
},
StatusChange {
project_id: ProjectId,
old_status: ProjectStatus,
@@ -288,6 +295,9 @@ impl NotificationBody {
NotificationBody::ServerInvite { .. } => {
NotificationType::ServerInvite
}
NotificationBody::SharedInstanceInvite { .. } => {
NotificationType::SharedInstanceInvite
}
NotificationBody::StatusChange { .. } => {
NotificationType::StatusChange
}
@@ -470,6 +480,32 @@ impl From<DBNotification> for Notification {
},
],
),
NotificationBody::SharedInstanceInvite {
shared_instance_name,
..
} => (
"You have been invited to a shared instance!".to_string(),
format!(
"An invite has been sent for you to join {shared_instance_name}"
),
"#".to_string(),
vec![
NotificationAction {
name: "Accept".to_string(),
action_route: (
"POST".to_string(),
String::new(),
),
},
NotificationAction {
name: "Deny".to_string(),
action_route: (
"POST".to_string(),
String::new(),
),
},
],
),
NotificationBody::StatusChange {
old_status,
new_status,
@@ -772,6 +772,7 @@ async fn collect_template_variables(
}
NotificationBody::ProjectUpdate { .. }
| NotificationBody::SharedInstanceInvite { .. }
| NotificationBody::ModeratorMessage { .. }
| NotificationBody::LegacyMarkdown { .. }
| NotificationBody::Unknown => Ok(EmailTemplate::Static(map)),
+73 -7
View File
@@ -5,6 +5,7 @@ use std::sync::Arc;
use chrono::Utc;
use eyre::{Result, eyre};
use hex::ToHex;
use serde::Serialize;
use sha1::Digest;
use tokio::task::{spawn, spawn_blocking};
use tracing::{Instrument, info, info_span, warn};
@@ -42,6 +43,16 @@ struct PendingFileScan {
project_id: DBProjectId,
}
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
pub struct FileScanSummary {
/// Number of attribution groups newly created by the scan.
pub new_attribution_groups: u64,
/// Number of attribution files newly created by the scan.
pub new_attribution_files: u64,
/// Override file paths found and scanned in the file archive.
pub scanned_file_names: Vec<String>,
}
/// Attribution enforcement is version/project-scoped, not file-hash-scoped.
///
/// Versions or projects listed in `attributions_exemptions` predate this
@@ -277,7 +288,7 @@ pub async fn scan_file(
project_id: DBProjectId,
file_id: DBFileId,
file_url: &str,
) -> Result<()> {
) -> Result<FileScanSummary> {
let result =
scan_file_inner(txn, redis, file_host, project_id, file_id, file_url)
.await;
@@ -296,7 +307,7 @@ async fn scan_file_inner(
project_id: DBProjectId,
file_id: DBFileId,
file_url: &str,
) -> Result<()> {
) -> Result<FileScanSummary> {
let overrides =
extract_override_files_from_storage(file_host, file_id, file_url)
.await
@@ -304,7 +315,16 @@ async fn scan_file_inner(
eyre!("extracting overrides for file {file_id:?}")
})?;
let scanned_file_names =
overrides.iter().map(|file| file.path.clone()).collect();
let mut summary = FileScanSummary {
scanned_file_names,
..Default::default()
};
if !overrides.is_empty() {
let before = count_project_attributions(project_id, txn).await?;
let resolved = resolve_overrides(&overrides, redis, txn)
.await
.wrap_err_with(|| {
@@ -318,15 +338,58 @@ async fn scan_file_inner(
.wrap_err_with(|| {
eyre!("persisting attribution results for file {file_id:?}")
})?;
let after = count_project_attributions(project_id, txn).await?;
summary.new_attribution_groups =
after.groups.saturating_sub(before.groups);
summary.new_attribution_files =
after.files.saturating_sub(before.files);
log_marked_override_projects(&resolved);
}
Ok(())
Ok(summary)
}
fn file_scan_result(result: &Result<()>) -> FileScanResult<'static> {
struct ProjectAttributionCounts {
groups: u64,
files: u64,
}
async fn count_project_attributions(
project_id: DBProjectId,
txn: &mut PgTransaction<'_>,
) -> Result<ProjectAttributionCounts> {
let row = sqlx::query!(
r#"
select
(
select count(*)
from project_attribution_groups
where project_id = $1
) as "groups!",
(
select count(*)
from project_attribution_files paf
inner join project_attribution_groups pag on pag.id = paf.group_id
where pag.project_id = $1
) as "files!"
"#,
project_id as DBProjectId,
)
.fetch_one(&mut *txn)
.await
.wrap_err("counting project attributions")?;
Ok(ProjectAttributionCounts {
groups: row.groups as u64,
files: row.files as u64,
})
}
fn file_scan_result<T>(result: &Result<T>) -> FileScanResult<'static> {
match result {
Ok(()) => Ok(()),
Ok(_) => Ok(()),
Err(err) => Err(ApiError {
error: "internal_error",
description: format!("{err:#}"),
@@ -480,6 +543,7 @@ const OVERRIDE_PREFIXES: &[&str] = &[
];
fn should_scan(name: &str) -> bool {
let name = name.to_lowercase();
let should_skip = name.starts_with("mods/.connector/")
|| name.starts_with(".sable/natives/")
|| name.starts_with("local/crash_assistant/")
@@ -491,8 +555,10 @@ fn should_scan(name: &str) -> bool {
|| name.starts_with("essential/")
|| name.ends_with(".rpo")
|| name.ends_with(".txt");
let is_archive = name.contains(".jar") || name.contains(".zip");
let is_archive = name.ends_with(".jar")
|| name.ends_with(".zip")
|| name.ends_with(".jar.disabled")
|| name.ends_with(".zip.disabled");
is_archive && !should_skip
}
-451
View File
@@ -1,456 +1,5 @@
use crate::database;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::redis::RedisPool;
use crate::database::{PgPool, ReadOnlyPgPool};
use crate::env::ENV;
use crate::models::ids::ProjectId;
use crate::models::notifications::NotificationBody;
use crate::models::projects::ProjectStatus;
use crate::models::threads::MessageBody;
use crate::routes::ApiError;
use dashmap::DashSet;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt::Write;
use std::time::Duration;
pub const AUTOMOD_ID: i64 = 0;
pub struct ModerationMessages {
pub messages: Vec<ModerationMessage>,
pub version_specific: HashMap<String, Vec<ModerationMessage>>,
}
impl ModerationMessages {
pub fn is_empty(&self) -> bool {
self.messages.is_empty() && self.version_specific.is_empty()
}
pub fn markdown(&self, auto_mod: bool) -> String {
let mut str = String::new();
for message in &self.messages {
write!(&mut str, "## {}\n{}\n\n", message.header(), message.body())
.unwrap();
}
for (version_num, messages) in &self.version_specific {
for message in messages {
write!(
&mut str,
"### Version {}: {}\n{}\n\n",
version_num,
message.header(),
message.body()
)
.unwrap();
}
}
if auto_mod {
str.push_str(
"<hr />\n\n\
🤖 This is an automated message generated by AutoMod (BETA). If you are facing issues, please [contact support](https://support.modrinth.com)."
);
}
str
}
pub fn should_reject(&self, first_time: bool) -> bool {
self.messages.iter().any(|x| x.rejectable(first_time))
|| self
.version_specific
.values()
.any(|x| x.iter().any(|x| x.rejectable(first_time)))
}
pub fn approvable(&self) -> bool {
self.messages.iter().all(|x| x.approvable())
&& self
.version_specific
.values()
.all(|x| x.iter().all(|x| x.approvable()))
}
}
pub enum ModerationMessage {
MissingGalleryImage,
NoPrimaryFile,
NoSideTypes,
PackFilesNotAllowed {
files: HashMap<String, IdentifiedFile>,
incomplete: bool,
},
MissingLicense,
MissingCustomLicenseUrl {
license: String,
},
}
impl ModerationMessage {
pub fn rejectable(&self, first_time: bool) -> bool {
match self {
ModerationMessage::NoPrimaryFile => true,
ModerationMessage::PackFilesNotAllowed { files, incomplete } => {
(!incomplete || first_time)
&& files.values().any(|x| match x.status {
ApprovalType::Yes => false,
ApprovalType::WithAttributionAndSource => false,
ApprovalType::WithAttribution => false,
ApprovalType::No => first_time,
ApprovalType::PermanentNo => true,
ApprovalType::Unidentified => first_time,
})
}
ModerationMessage::MissingGalleryImage => true,
ModerationMessage::MissingLicense => true,
ModerationMessage::MissingCustomLicenseUrl { .. } => true,
ModerationMessage::NoSideTypes => true,
}
}
pub fn approvable(&self) -> bool {
match self {
ModerationMessage::NoPrimaryFile => false,
ModerationMessage::PackFilesNotAllowed { files, .. } => {
files.values().all(|x| x.status.approved())
}
ModerationMessage::MissingGalleryImage => false,
ModerationMessage::MissingLicense => false,
ModerationMessage::MissingCustomLicenseUrl { .. } => false,
ModerationMessage::NoSideTypes => false,
}
}
pub fn header(&self) -> &'static str {
match self {
ModerationMessage::NoPrimaryFile => "No primary files",
ModerationMessage::PackFilesNotAllowed { .. } => {
"Copyrighted Content"
}
ModerationMessage::MissingGalleryImage => "Missing Gallery Images",
ModerationMessage::MissingLicense => "Missing License",
ModerationMessage::MissingCustomLicenseUrl { .. } => {
"Missing License URL"
}
ModerationMessage::NoSideTypes => "Missing Environment Information",
}
}
pub fn body(&self) -> String {
match self {
ModerationMessage::NoPrimaryFile => "Please attach a file to this version. All files on Modrinth must have files associated with their versions.\n".to_string(),
ModerationMessage::PackFilesNotAllowed { files, .. } => {
let mut str = String::from("This pack redistributes copyrighted material. Please refer to [Modrinth's guide on obtaining modpack permissions](https://support.modrinth.com/en/articles/8797527-obtaining-modpack-permissions) for more information.\n\n");
let mut attribute_mods = Vec::new();
let mut no_mods = Vec::new();
let mut permanent_no_mods = Vec::new();
let mut unidentified_mods = Vec::new();
for approval in files.values() {
match approval.status {
ApprovalType::Yes | ApprovalType::WithAttributionAndSource => {}
ApprovalType::WithAttribution => attribute_mods.push(&approval.file_name),
ApprovalType::No => no_mods.push(&approval.file_name),
ApprovalType::PermanentNo => permanent_no_mods.push(&approval.file_name),
ApprovalType::Unidentified => unidentified_mods.push(&approval.file_name),
}
}
fn print_mods(projects: Vec<&String>, headline: &str, val: &mut String) {
if projects.is_empty() { return }
write!(val, "{headline}\n\n").unwrap();
for project in &projects {
let additional_text = if project.contains("ftb-quests") {
Some(("Odyssey Quests", "lo90fZoB"))
} else if project.contains("ftb-ranks") || project.contains("ftb-essentials") {
Some(("Odyssey Roles", "iYcNKH7W"))
} else if project.contains("ftb-teams") {
Some(("Odyssey Guilds", "bb2EpKpx"))
} else if project.contains("ftb-chunks") {
Some(("Odyssey Claims", "fEWKxVzh"))
} else {
None
};
if let Some(additional_text) = additional_text {
writeln!(val, "- {project} (consider using [{}](https://modrinth.com/project/{}) instead)", additional_text.0, additional_text.1).unwrap();
} else {
writeln!(val, "- {project}").unwrap();
}
}
if !projects.is_empty() {
val.push('\n');
}
}
print_mods(attribute_mods, "The following content has attribution requirements, meaning that you must link back to the page where you originally found this content in your modpack description or version changelog (e.g. linking a mod's CurseForge page if you got it from CurseForge):", &mut str);
print_mods(no_mods, "The following content is not allowed in Modrinth modpacks due to licensing restrictions. Please contact the author(s) directly for permission or remove the content from your modpack:", &mut str);
print_mods(permanent_no_mods, "The following content is not allowed in Modrinth modpacks, regardless of permission obtained. This may be because it breaks Modrinth's content rules or because the authors, upon being contacted for permission, have declined. Please remove the content from your modpack:", &mut str);
print_mods(unidentified_mods, "The following content could not be identified. Please provide proof of its origin along with proof that you have permission to include it:", &mut str);
str
},
ModerationMessage::MissingGalleryImage => "We ask that resource packs like yours show off their content using images in the Gallery, or optionally in the Description, in order to effectively and clearly inform users of the content in your pack per section 2.1 of [Modrinth's content rules](https://modrinth.com/legal/rules#general-expectations).\n
Keep in mind that you should:\n
- Set a featured image that best represents your pack.
- Ensure all your images have titles that accurately label the image, and optionally, details on the contents of the image in the images Description.
- Upload any relevant images in your Description to your Gallery tab for best results.".to_string(),
ModerationMessage::MissingLicense => "You must select a License before your project can be published publicly, having a License associated with your project is important to protecting your rights and allowing others to use your content as you intend. For more information, you can see our [Guide to Licensing Mods](<https://modrinth.com/news/article/licensing-guide/>).".to_string(),
ModerationMessage::MissingCustomLicenseUrl { license } => format!("It looks like you've selected the License \"{license}\" without providing a valid License link. When using a custom License you must provide a link directly to the License in the License Link field."),
ModerationMessage::NoSideTypes => "Your project's side types are currently set to Unknown on both sides. Please set accurate side types!".to_string(),
}
}
}
pub struct AutomatedModerationQueue {
pub projects: DashSet<ProjectId>,
}
impl Default for AutomatedModerationQueue {
fn default() -> Self {
Self {
projects: DashSet::new(),
}
}
}
impl AutomatedModerationQueue {
pub async fn task(
&self,
pool: PgPool,
ro_pool: ReadOnlyPgPool,
redis: RedisPool,
) {
loop {
let projects = self.projects.clone();
self.projects.clear();
for project in projects {
async {
let project =
database::DBProject::get_id((project).into(), &*ro_pool, &redis)
.await?;
if let Some(project) = project {
let res = async {
let mut mod_messages = ModerationMessages {
messages: vec![],
version_specific: HashMap::new(),
};
if project.project_types.iter().any(|x| ["mod", "modpack"].contains(&&**x)) && !project.aggregate_version_fields.iter().any(|x| x.field_name == "environment") {
mod_messages.messages.push(ModerationMessage::NoSideTypes);
}
if project.inner.components.minecraft_server.is_none() {
let license = &project.inner.license;
if license == "LicenseRef-Unknown" || license == "LicenseRef-" {
mod_messages
.messages
.push(ModerationMessage::MissingLicense);
} else if license.starts_with("LicenseRef-")
&& license != "LicenseRef-All-Rights-Reserved"
&& project.inner.license_url.is_none()
{
mod_messages.messages.push(
ModerationMessage::MissingCustomLicenseUrl {
license: project.inner.license.clone(),
},
);
}
}
if (project.project_types.contains(&"resourcepack".to_string()) || project.project_types.contains(&"shader".to_string())) &&
project.gallery_items.is_empty() &&
!project.categories.contains(&"audio".to_string()) &&
!project.categories.contains(&"locale".to_string())
{
mod_messages.messages.push(ModerationMessage::MissingGalleryImage);
}
let versions =
database::DBVersion::get_many(&project.versions, &*ro_pool, &redis)
.await?
.into_iter()
// we only support modpacks at this time
.filter(|x| x.project_types.contains(&"modpack".to_string()))
.collect::<Vec<_>>();
for version in versions {
let primary_file = version.files.iter().find_or_first(|x| x.primary);
if primary_file.is_none() {
let val = mod_messages.version_specific.entry(version.inner.version_number).or_default();
val.push(ModerationMessage::NoPrimaryFile);
}
}
if !mod_messages.is_empty() {
let is_server_project =
project.inner.components.minecraft_server.is_some();
let first_time = database::models::DBThread::get(project.thread_id, &pool).await?
.is_none_or(|x| x.messages.iter().all(|x| x.author_id == Some(database::models::DBUserId(AUTOMOD_ID)) || x.hide_identity));
let mut transaction = pool.begin().await?;
let id = ThreadMessageBuilder {
author_id: Some(database::models::DBUserId(AUTOMOD_ID)),
body: MessageBody::Text {
body: mod_messages.markdown(true),
private: is_server_project,
replying_to: None,
associated_images: vec![],
},
thread_id: project.thread_id,
hide_identity: false,
}
.insert(&mut transaction)
.await?;
let members = database::models::DBTeamMember::get_from_team_full(
project.inner.team_id,
&pool,
&redis,
)
.await?;
if mod_messages.should_reject(first_time) && !is_server_project {
ThreadMessageBuilder {
author_id: Some(database::models::DBUserId(AUTOMOD_ID)),
body: MessageBody::StatusChange {
new_status: ProjectStatus::Rejected,
old_status: project.inner.status,
},
thread_id: project.thread_id,
hide_identity: false,
}
.insert(&mut transaction)
.await?;
NotificationBuilder {
body: NotificationBody::StatusChange {
project_id: project.inner.id.into(),
old_status: project.inner.status,
new_status: ProjectStatus::Rejected,
},
}
.insert_many(members.into_iter().map(|x| x.user_id).collect(), &mut transaction, &redis)
.await?;
if !ENV.MODERATION_SLACK_WEBHOOK.is_empty() {
crate::util::webhook::send_slack_project_webhook(
project.inner.id.into(),
&pool,
&redis,
&ENV.MODERATION_SLACK_WEBHOOK,
Some(
format!(
"*<{}/user/AutoMod|AutoMod>* changed project status from *{}* to *Rejected*",
ENV.SITE_URL,
&project.inner.status.as_friendly_str(),
)
.to_string(),
),
)
.await
.ok();
}
sqlx::query!(
"
UPDATE mods
SET status = 'rejected'
WHERE id = $1
",
project.inner.id.0
)
.execute(&pool)
.await?;
database::models::DBProject::clear_cache(
project.inner.id,
project.inner.slug.clone(),
None,
&redis,
)
.await?;
} else {
NotificationBuilder {
body: NotificationBody::ModeratorMessage {
thread_id: project.thread_id.into(),
message_id: id.into(),
project_id: Some(project.inner.id.into()),
report_id: None,
},
}
.insert_many(
members.iter().map(|x| x.user_id).collect(),
&mut transaction,
&redis,
)
.await?;
NotificationBuilder {
body: NotificationBody::ModerationMessageReceived {
project_id: project.inner.id.into(),
},
}
.insert_many(
members.iter().map(|x| x.user_id).collect(),
&mut transaction,
&redis,
)
.await?;
}
transaction.commit().await?;
}
Ok::<(), ApiError>(())
}.await;
if let Err(err) = res {
let err = err.as_api_error();
let str = format!(
"## Internal AutoMod Error\n\n\
Error code: {}\n\n\
Error description: {}\n\n",
err.error, err.description
);
let mut transaction = pool.begin().await?;
ThreadMessageBuilder {
author_id: Some(database::models::DBUserId(AUTOMOD_ID)),
body: MessageBody::Text {
body: str,
private: true,
replying_to: None,
associated_images: vec![],
},
thread_id: project.thread_id,
hide_identity: false,
}
.insert(&mut transaction)
.await?;
transaction.commit().await?;
}
}
Ok::<(), ApiError>(())
}.await.ok();
}
tokio::time::sleep(Duration::from_secs(5)).await
}
}
}
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MissingMetadata {
@@ -3,17 +3,18 @@ use chrono::{DateTime, Utc};
use eyre::eyre;
use serde::{Deserialize, Serialize};
use crate::auth::get_user_from_headers;
use crate::auth::{check_is_moderator_from_headers, get_user_from_headers};
use crate::database::PgPool;
use crate::database::models::{
DBOrganization, DBTeamMember, DBVersion,
DBFileId, DBOrganization, DBTeamMember, DBVersion,
ids::{
DBAttributionGroupId, DBProjectId, DBVersionId,
generate_attribution_group_id,
},
};
use crate::database::redis::RedisPool;
use crate::models::ids::{ProjectId, VersionId};
use crate::file_hosting::FileHost;
use crate::models::ids::{FileId, ProjectId, VersionId};
use crate::models::pats::Scopes;
use crate::models::projects::{
AttributionModerationStatusKind, AttributionResolution,
@@ -21,6 +22,7 @@ use crate::models::projects::{
};
use crate::models::teams::ProjectPermissions;
use crate::models::users::User;
use crate::queue::file_scan::{FileScanSummary, scan_file};
use crate::queue::moderation::ApprovalType;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
@@ -30,6 +32,7 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(list)
.service(update_group)
.service(scan)
.service(force_scan_file)
.service(assign)
.service(split);
}
@@ -201,6 +204,80 @@ async fn scan(
}))
}
#[utoipa::path]
#[post("/file/{file_id}/scan")]
async fn force_scan_file(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
file_host: web::Data<dyn FileHost>,
path: web::Path<FileId>,
) -> Result<web::Json<FileScanSummary>, ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
let file_id: DBFileId = path.into_inner().into();
let file = sqlx::query!(
r#"
select
f.url,
f.version_id as "version_id: DBVersionId",
v.mod_id as "project_id: DBProjectId"
from files f
inner join versions v on v.id = f.version_id
where f.id = $1
"#,
file_id as DBFileId,
)
.fetch_optional(pool.as_ref())
.await
.wrap_internal_err("failed to fetch attribution scan file")?
.ok_or(ApiError::NotFound)?;
let mut transaction = pool.begin().await.wrap_internal_err(
"failed to begin attribution file scan transaction",
)?;
sqlx::query!(
r#"
delete from attributions_exemptions
where version_id = $1
"#,
file.version_id as DBVersionId,
)
.execute(&mut transaction)
.await
.wrap_internal_err("failed to remove attribution scan exemption")?;
let scan_summary = scan_file(
&mut transaction,
redis.as_ref(),
&**file_host,
file.project_id,
file_id,
&file.url,
)
.await
.wrap_internal_err("failed to scan file for attributions")?;
transaction.commit().await.wrap_internal_err(
"failed to commit attribution file scan transaction",
)?;
DBVersion::clear_cache_ids(&[file.version_id], redis.as_ref())
.await
.wrap_internal_err("failed to clear version cache")?;
Ok(web::Json(scan_summary))
}
#[utoipa::path]
#[get("/{project_id}")]
async fn list(
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use crate::auth::get_user_from_headers;
use crate::database::PgPool;
use crate::database::models::ids::{DBNotificationId, DBUserId};
@@ -5,26 +7,26 @@ use crate::database::models::notification_item::DBNotification;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::user_item::DBUser;
use crate::database::redis::RedisPool;
use crate::models::notifications::NotificationDeliveryStatus;
use crate::models::users::Role;
use crate::models::v3::notifications::{
Notification, NotificationBody, NotificationDeliveryStatus,
};
use crate::models::v3::notifications::{Notification, NotificationBody};
use crate::models::v3::pats::Scopes;
use crate::queue::email::EmailQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::routes::internal::external_notifications::EmailFailure::{
FailedToSend, MailboxNotFound, UserNotFound,
};
use crate::routes::internal::statuses::broadcast_friends_message;
use crate::sync::friends::RedisFriendsMessage;
use crate::util::guards::external_notification_key_guard;
use actix_web::http::StatusCode;
use actix_web::web;
use actix_web::{
CustomizeResponder, HttpRequest, HttpResponse, Responder, delete, post,
};
use actix_web::{HttpRequest, HttpResponse, delete, post};
use ariadne::ids::UserId;
use eyre::eyre;
use lettre::message::Mailbox;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(create)
@@ -33,66 +35,72 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(send_custom_email);
}
#[derive(Deserialize, PartialEq, Default)]
enum EmailStrategy {
#[default]
Async,
Sync,
None,
}
#[derive(Deserialize)]
struct CreateNotification {
pub body: NotificationBody,
pub user_ids: Vec<UserId>,
#[serde(default)]
pub email: EmailStrategy,
}
#[derive(thiserror::Error, Debug, Serialize)]
#[serde(tag = "type", content = "data")]
enum EmailFailure {
#[error("user not found")]
UserNotFound,
#[error("mailbox not found")]
MailboxNotFound,
#[error("failed to send: {0:?}")]
FailedToSend(NotificationDeliveryStatus),
#[error("api error: {0}")]
ApiError(
#[serde(serialize_with = "serialize_api_error")]
#[from]
crate::routes::ApiError,
),
}
fn serialize_api_error<S>(
error: &ApiError,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
error.as_api_error().serialize(serializer)
}
#[post("external_notifications", guard = "external_notification_key_guard")]
pub async fn create(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
email_queue: web::Data<EmailQueue>,
create_notification: web::Json<CreateNotification>,
) -> Result<HttpResponse, ApiError> {
let CreateNotification { body, user_ids } =
create_notification.into_inner();
let user_ids = user_ids
.into_iter()
.map(|x| DBUserId(x.0 as i64))
.collect::<Vec<_>>();
let mut txn = pool.begin().await?;
if !DBUser::exists_many(&user_ids, &mut txn).await? {
return Err(ApiError::InvalidInput(
"One of the specified users do not exist.".to_owned(),
));
}
let notification_ids = NotificationBuilder { body }
.insert_many(user_ids, &mut txn, &redis)
.await?;
let notifications =
get_site_exposed_notifications(&notification_ids, &mut txn).await?;
txn.commit().await?;
broadcast_notifications(&redis, notifications).await;
Ok(HttpResponse::Accepted().finish())
) -> Result<(web::Json<HashMap<UserId, EmailFailure>>, StatusCode), ApiError> {
create_impl(pool, redis, email_queue, create_notification.into_inner())
.await
}
/// Inserts notifications for all users and tries to send emails immediately.
///
/// Responds with the user IDs that could not be emailed:
/// - `200` if every recipient was emailed (empty list)
/// - `207` if some recipients could not be emailed (list of failed IDs)
#[post(
"external_notifications/email-sync",
guard = "external_notification_key_guard"
)]
pub async fn create_email_sync(
async fn create_impl(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
email_queue: web::Data<EmailQueue>,
create_notification: web::Json<CreateNotification>,
) -> Result<CustomizeResponder<web::Json<Vec<UserId>>>, ApiError> {
let CreateNotification { body, user_ids } =
create_notification.into_inner();
data: CreateNotification,
) -> Result<(web::Json<HashMap<UserId, EmailFailure>>, StatusCode), ApiError> {
let CreateNotification {
body,
user_ids,
email: email_strategy,
} = data;
let raw_user_ids = user_ids.iter().map(|x| x.0 as i64).collect::<Vec<_>>();
let user_ids = raw_user_ids
.iter()
.map(|x| DBUserId(*x))
@@ -129,9 +137,21 @@ pub async fn create_email_sync(
.filter(|id| !already_notified.contains(id))
.collect::<Vec<_>>();
let notification_ids = NotificationBuilder { body: body.clone() }
.insert_many_without_delivery(notification_user_ids, &mut txn, &redis)
.await?;
let notification_builder = NotificationBuilder { body: body.clone() };
let notification_ids = if email_strategy == EmailStrategy::Async {
notification_builder
.insert_many(notification_user_ids, &mut txn, &redis)
.await?
} else {
notification_builder
.insert_many_without_delivery(
notification_user_ids,
&mut txn,
&redis,
)
.await?
};
let notifications =
get_site_exposed_notifications(&notification_ids, &mut txn).await?;
@@ -140,42 +160,98 @@ pub async fn create_email_sync(
broadcast_notifications(&redis, notifications).await;
let mut email_txn = pool.begin().await?;
if email_strategy == EmailStrategy::Sync {
let mut email_txn = pool.begin().await?;
let mut failed = Vec::new();
for user_id in &user_ids {
let Some(user) =
DBUser::get_id(*user_id, &mut email_txn, &redis).await?
else {
failed.push(UserId(user_id.0 as u64));
continue;
};
let mut failed = HashMap::new();
let users = DBUser::get_many_ids(&user_ids, &mut email_txn, &redis)
.await?
.into_iter()
.map(|user| (user.id, user))
.collect::<HashMap<_, _>>();
let delivered = match user
.email
.and_then(|email| email.parse::<Mailbox>().ok())
{
Some(mailbox) => {
email_queue
.send_one(&mut email_txn, body.clone(), *user_id, mailbox)
.await?
== NotificationDeliveryStatus::Delivered
}
None => false,
};
for db_user_id in &user_ids {
let user_id = UserId(db_user_id.0 as u64);
let Some(user) = users.get(db_user_id) else {
failed.insert(user_id, UserNotFound);
continue;
};
if !delivered {
failed.push(UserId(user_id.0 as u64));
let Some(mailbox) = user
.email
.as_ref()
.and_then(|email| email.parse::<Mailbox>().ok())
else {
failed.insert(user_id, MailboxNotFound);
continue;
};
match email_queue
.send_one(&mut email_txn, body.clone(), *db_user_id, mailbox)
.await
{
Ok(status) => {
if status != NotificationDeliveryStatus::Delivered {
failed.insert(user_id, FailedToSend(status));
}
}
Err(error) => {
if matches!(
error,
ApiError::SqlxDatabase(_) | ApiError::Database(_)
) {
return Err(error);
};
failed.insert(user_id, error.into());
}
};
}
email_txn.commit().await?;
let status = if failed
.values()
.any(|x| matches!(x, EmailFailure::ApiError(_)))
{
StatusCode::INTERNAL_SERVER_ERROR
} else {
StatusCode::OK
};
return Ok((web::Json(failed), status));
}
let status = if failed.is_empty() {
StatusCode::OK
} else {
StatusCode::MULTI_STATUS
};
Ok((web::Json(HashMap::new()), StatusCode::ACCEPTED))
}
Ok(web::Json(failed).customize().with_status(status))
/// Inserts notifications for all users and tries to send emails immediately.
///
/// Responds with the user IDs that could not be emailed and a reason why
#[post(
"external_notifications/email-sync",
guard = "external_notification_key_guard"
)]
pub async fn create_email_sync(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
email_queue: web::Data<EmailQueue>,
data: web::Json<CreateNotification>,
) -> Result<(web::Json<Vec<UserId>>, StatusCode), ApiError> {
let data = data.into_inner();
create_impl(
pool,
redis,
email_queue,
CreateNotification {
body: data.body,
user_ids: data.user_ids,
email: EmailStrategy::Sync,
},
)
.await
.map(|(res, code)| {
(web::Json(res.into_inner().into_keys().collect()), code)
})
}
#[derive(Deserialize)]
+18 -16
View File
@@ -130,23 +130,25 @@ pub async fn ws_init(
)?)
.await;
let unread_server_invites = DBNotification::get_many_user_exposed_on_site(
user_id.into(),
&**pool,
&redis,
)
.await?
.into_iter()
.filter(|notification| {
!notification.read
&& matches!(
&notification.body,
NotificationBody::ServerInvite { .. }
)
})
.map(Notification::from);
let unread_launcher_invites =
DBNotification::get_many_user_exposed_on_site(
user_id.into(),
&**pool,
&redis,
)
.await?
.into_iter()
.filter(|notification| {
!notification.read
&& matches!(
&notification.body,
NotificationBody::ServerInvite { .. }
| NotificationBody::SharedInstanceInvite { .. }
)
})
.map(Notification::from);
for notification in unread_server_invites {
for notification in unread_launcher_invites {
let _ = session.text(serde_json::to_string(&notification)?).await;
}
-3
View File
@@ -10,7 +10,6 @@ use crate::models::v2::projects::{
DonationLink, LegacyProject, LegacySideType, LegacyVersion,
};
use crate::models::v2::search::LegacySearchResults;
use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
use crate::routes::v3::projects::ProjectIds;
use crate::routes::{ApiError, v2_reroute, v3};
@@ -536,7 +535,6 @@ pub async fn project_edit(
new_project: web::Json<EditProject>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
moderation_queue: web::Data<AutomatedModerationQueue>,
search_state: web::Data<SearchState>,
) -> Result<HttpResponse, ApiError> {
let v2_new_project = new_project.into_inner();
@@ -650,7 +648,6 @@ pub async fn project_edit(
web::Json(new_project),
redis.clone(),
session_queue.clone(),
moderation_queue,
search_state.clone(),
)
.await
@@ -8,7 +8,6 @@ use crate::models::projects::{
Dependency, FileType, Loader, Version, VersionStatus, VersionType,
};
use crate::models::v2::projects::LegacyVersion;
use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
use crate::routes::v3::project_creation::CreateError;
use crate::routes::v3::version_creation;
@@ -102,7 +101,6 @@ pub async fn version_create(
redis: Data<RedisPool>,
file_host: Data<dyn FileHost>,
session_queue: Data<AuthQueue>,
moderation_queue: Data<AutomatedModerationQueue>,
http: Data<HttpClient>,
search_state: Data<SearchState>,
) -> Result<HttpResponse, CreateError> {
@@ -262,7 +260,6 @@ pub async fn version_create(
redis.clone(),
file_host,
session_queue,
moderation_queue,
http,
search_state,
)
-8
View File
@@ -25,7 +25,6 @@ use crate::models::projects::{
use crate::models::teams::ProjectPermissions;
use crate::models::threads::MessageBody;
use crate::models::{self, exp};
use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::routes::internal::delphi;
@@ -315,7 +314,6 @@ async fn project_edit(
web::Json(new_project): web::Json<EditProject>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
moderation_queue: web::Data<AutomatedModerationQueue>,
search_state: web::Data<SearchState>,
) -> Result<HttpResponse, ApiError> {
project_edit_internal(
@@ -325,7 +323,6 @@ async fn project_edit(
web::Json(new_project),
redis,
session_queue,
moderation_queue,
search_state,
)
.await
@@ -338,7 +335,6 @@ pub async fn project_edit_internal(
web::Json(new_project): web::Json<EditProject>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
moderation_queue: web::Data<AutomatedModerationQueue>,
search_state: web::Data<SearchState>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
@@ -480,10 +476,6 @@ pub async fn project_edit_internal(
)
.execute(&mut transaction)
.await?;
moderation_queue
.projects
.insert(project_item.inner.id.into());
}
if status.is_approved() && !project_item.inner.status.is_approved() {
@@ -23,9 +23,8 @@ use crate::models::projects::{
Dependency, FileType, Loader, Version, VersionFile, VersionStatus,
VersionType,
};
use crate::models::projects::{DependencyType, ProjectStatus, skip_nulls};
use crate::models::projects::{DependencyType, skip_nulls};
use crate::models::teams::ProjectPermissions;
use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
use crate::search::SearchState;
use crate::util::http::HttpClient;
@@ -112,7 +111,6 @@ pub async fn version_create(
redis: Data<RedisPool>,
file_host: Data<dyn FileHost>,
session_queue: Data<AuthQueue>,
moderation_queue: web::Data<AutomatedModerationQueue>,
http: web::Data<HttpClient>,
search_state: Data<SearchState>,
) -> Result<HttpResponse, CreateError> {
@@ -128,7 +126,6 @@ pub async fn version_create(
&mut uploaded_files,
&client,
&session_queue,
&moderation_queue,
&http,
)
.await;
@@ -170,7 +167,6 @@ async fn version_create_inner(
uploaded_files: &mut Vec<UploadedFile>,
pool: &PgPool,
session_queue: &AuthQueue,
moderation_queue: &AutomatedModerationQueue,
http: &reqwest::Client,
) -> Result<(HttpResponse, models::DBProjectId), CreateError> {
let mut initial_version_data = None;
@@ -530,19 +526,6 @@ async fn version_create_inner(
}
}
let project_status = sqlx::query!(
"SELECT status FROM mods WHERE id = $1",
project_id as models::DBProjectId,
)
.fetch_optional(pool)
.await?;
if let Some(project_status) = project_status
&& project_status.status == ProjectStatus::Processing.as_str()
{
moderation_queue.projects.insert(project_id.into());
}
Ok((HttpResponse::Ok().json(response), project_id))
}
+43 -25
View File
@@ -5,6 +5,7 @@ use crate::event::LoadingBarId;
use crate::event::emit::emit_loading;
use bytes::Bytes;
use chrono::{DateTime, TimeDelta, Utc};
use eyre::{Context, eyre};
use parking_lot::Mutex;
use rand::Rng;
use reqwest::Method;
@@ -445,7 +446,7 @@ async fn fetch_advanced_with_client_and_progress(
}
if let Some((name, value)) = &download_meta_header {
tracing::info!("Sending download analytics: {value}");
tracing::debug!("Sending download analytics: {value}");
req = req.header(name.as_str(), value.as_str());
}
@@ -472,38 +473,55 @@ async fn fetch_advanced_with_client_and_progress(
return Err(backup_error.into());
}
let bytes = if loading_bar.is_some() || progress.is_some() {
let bytes: eyre::Result<Bytes> = if loading_bar.is_some()
|| progress.is_some()
{
let length = resp.content_length();
if let Some(total_size) = length {
use futures::StreamExt;
let mut stream = resp.bytes_stream();
let mut bytes = Vec::new();
let mut downloaded = 0_u64;
while let Some(item) = stream.next().await {
let chunk = item.or(Err(ErrorKind::NoValueFor(
"fetch bytes".to_string(),
)))?;
downloaded += chunk.len() as u64;
bytes.append(&mut chunk.to_vec());
if let Some((bar, total)) = &loading_bar {
emit_loading(
bar,
(chunk.len() as f64 / total_size as f64)
* total,
None,
)?;
}
if let Some(progress) = progress.as_mut() {
progress(downloaded, total_size).await?;
}
}
Ok(bytes::Bytes::from(bytes))
async {
let mut bytes = Vec::new();
let mut downloaded = 0_u64;
while let Some(item) = stream.next().await {
let chunk = item.wrap_err_with(|| {
eyre!(
"failed to read response body from {url}"
)
})?;
downloaded += chunk.len() as u64;
bytes.extend_from_slice(&chunk);
if let Some((bar, total)) = &loading_bar {
emit_loading(
bar,
(chunk.len() as f64
/ total_size as f64)
* total,
None,
)?;
}
if let Some(progress) = progress.as_mut() {
progress(downloaded, total_size).await?;
}
}
Ok(Bytes::from(bytes))
}
.await
} else {
resp.bytes().await
resp.bytes().await.wrap_err_with(|| {
eyre!("failed to read response body from {url}")
})
}
} else {
resp.bytes().await
resp.bytes().await.wrap_err_with(|| {
eyre!("failed to read response body from {url}")
})
};
if let Ok(bytes) = bytes {
@@ -0,0 +1,66 @@
---
title: Proud of you all
summary: Over 2,400 of you came together to raise more than $40,000 for charity this Pride month!
date: 2026-07-01T18:00:00-07:00
authors: ['vNcGR3Fd', 'bOHH0P9Z', '2cqK8Q5p', 'VmRGDAOP', 'nHj8InZT', 'K86yjB3D']
---
Earlier this month, we announced that we would be raising money for Rainbow Railroad throughout Pride Month.
When we created our campaign, we wanted to set a realistic goal, so we started at $5,000.
You hit it within 3 hours, before Pride Month had even begun. So we raised the goal to $10,000 - and less than an hour into June 1st (UTC), this was achieved.
We were completely astounded and, honestly, lost for words.
## Your Accomplishments
![A Minecraft screenshot of a theater stage with a large Modrinth logo, featuring members of the Modrinth team. Mr. Pack is at the very front speaking into a microphone while a staff member leads a Ribbit Pride parade and another leads a bee Pride parade.](./stage.webp)
Together, you helped fund work that supports LGBTQIA+ people facing danger around the world. That can mean emergency support, temporary safe housing, travel documents, crisis response, local partner networks, and relocation pathways when staying where they are is no longer safe.
![A screenshot showing the top 10 donors for Modrinths 2026 Pride fundraiser.](./donors.webp)
Thank you to all of our top donors!
By the end of the campaign, you raised over $40,000 for Rainbow Railroad. We thank all 2,478 who donated, no matter how big or small - you should all be incredibly proud.
Modrinths total made up nearly half of Rainbow Railroads entire total for their Solidarity in Pride event on Tiltify of $80,000. Absolutely incredible!
### Mr. Pack
Historically, Modrinth has had a Frog mascot for many years. It has appeared across our branding, social posts, Discord emotes, seasonal artwork, and community memes, but it never really had a proper name or identity.
Mr. Pack was created as a small thank-you to everyone who supported the fundraiser: a new mascot skin for the Modrinth App, with several Pride variants available to donors who donated more than $5. Mr. Pack is the 5th most popular skin on NameMC for June 2026 - over 4,000 players are using him and his variants!
![All of Mr. Packs variants (MLM, Genderfluid, Lesbian, Transgender, Asexual, Intersex, Pride, Bisexual, and Nonbinary) posed and lined up in a row.](./pride-skins.webp)
_Special thanks to [Drago](https://x.com/iDragolyte) for creating Mr. Packs updated design and pride variants._
### Badges and Modrinth+
<img src="./pride-badge.webp" alt="Modrinths Pride badge over a rainbow background." width="150" height="150" style="float: right;margin-left: 1rem;" />
Everyone who donated to the fundraiser, regardless of the amount, received the limited Pride 2026 badge on their Modrinth profile as a thank you for supporting Rainbow Railroad. We also gave eligible donors a free month of Modrinth+ as an additional thank you - if you have this badge, you are a _very_ cool person.
### Pride Collection
This year, we launched our Pride Collection, an assortment of queer content hosted on Modrinth and submitted to us by our amazing community of creators.
<div id="pride-collection-widget"></div>
Thank you to everyone who submitted their projects; we look forward to adding even more content to this collection in the years to come!
## Pride Is All Year
![A Minecraft screenshot of a theater featuring Modrinth team and community members in the audience. There are also many pride flags along the sides of the room.](./audience.webp)
Rainbow Railroads work continues all year round, and LGBTQIA+ people around the world continue to face danger simply for being who they are. This fundraiser was one way for us to turn Pride into practical support, but it cannot be the only time we show up. Pride month may be over, but the fight continues.
Modrinth is home to queer creators, players, developers, moderators, artists, and community members. They are part of what makes this platform what it is, and supporting them should not be limited to one month of the year.
If youd still like to support this cause, you can donate to Rainbow Railroad at any time - and we encourage you to do so.
Thank you for what has been an incredible Pride month, and we look forward to doing even more next year. 💚
+15
View File
@@ -10,6 +10,21 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-07-03T15:16:21+00:00`,
product: 'web',
body: `## Fixed
- Fixed project license URL being required when not using a custom license.`,
},
{
date: `2026-07-02T00:44:14+00:00`,
product: 'web',
body: `## Changed
- Added pride backdrop to the Pride collection, projects featured in the Pride collection, and the pride blog posts.
## Fixed
- Fixed issue where you cannot create a project directly with an organization as the owner.`,
},
{
date: `2026-06-29T23:56:37+00:00`,
product: 'app',
+2
View File
@@ -30,6 +30,7 @@ import { article as new_site_beta } from "./new_site_beta";
import { article as plugins_resource_packs } from "./plugins_resource_packs";
import { article as pride_campaign_2025 } from "./pride_campaign_2025";
import { article as pride_campaign_2026 } from "./pride_campaign_2026";
import { article as proud_of_you_2026 } from "./proud_of_you_2026";
import { article as redesign } from "./redesign";
import { article as russian_censorship } from "./russian_censorship";
import { article as server_access } from "./server_access";
@@ -74,6 +75,7 @@ export const articles = [
plugins_resource_packs,
pride_campaign_2025,
pride_campaign_2026,
proud_of_you_2026,
redesign,
russian_censorship,
server_access,
@@ -0,0 +1,2 @@
// AUTO-GENERATED FILE - DO NOT EDIT
export const html = `<p>Earlier this month, we announced that we would be raising money for Rainbow Railroad throughout Pride Month.</p><p>When we created our campaign, we wanted to set a realistic goal, so we started at $5,000.</p><p>You hit it within 3 hours, before Pride Month had even begun. So we raised the goal to $10,000 - and less than an hour into June 1st (UTC), this was achieved.</p><p>We were completely astounded and, honestly, lost for words.</p><h2>Your Accomplishments</h2><p><img src="/news/article/proud-of-you-2026/stage.webp" alt="A Minecraft screenshot of a theater stage with a large Modrinth logo, featuring members of the Modrinth team. Mr. Pack is at the very front speaking into a microphone while a staff member leads a Ribbit Pride parade and another leads a bee Pride parade."></p><p>Together, you helped fund work that supports LGBTQIA+ people facing danger around the world. That can mean emergency support, temporary safe housing, travel documents, crisis response, local partner networks, and relocation pathways when staying where they are is no longer safe.</p><p><img src="/news/article/proud-of-you-2026/donors.webp" alt="A screenshot showing the top 10 donors for Modrinths 2026 Pride fundraiser."></p><p>Thank you to all of our top donors!</p><p>By the end of the campaign, you raised over $40,000 for Rainbow Railroad. We thank all 2,478 who donated, no matter how big or small - you should all be incredibly proud.</p><p>Modrinths total made up nearly half of Rainbow Railroads entire total for their Solidarity in Pride event on Tiltify of $80,000. Absolutely incredible!</p><h3>Mr. Pack</h3><p>Historically, Modrinth has had a Frog mascot for many years. It has appeared across our branding, social posts, Discord emotes, seasonal artwork, and community memes, but it never really had a proper name or identity.</p><p>Mr. Pack was created as a small thank-you to everyone who supported the fundraiser: a new mascot skin for the Modrinth App, with several Pride variants available to donors who donated more than $5. Mr. Pack is the 5th most popular skin on NameMC for June 2026 - over 4,000 players are using him and his variants!</p><p><img src="/news/article/proud-of-you-2026/pride-skins.webp" alt="All of Mr. Packs variants (MLM, Genderfluid, Lesbian, Transgender, Asexual, Intersex, Pride, Bisexual, and Nonbinary) posed and lined up in a row."></p><p><em>Special thanks to <a href="https://x.com/iDragolyte" rel="noopener nofollow ugc">Drago</a> for creating Mr. Packs updated design and pride variants.</em></p><h3>Badges and Modrinth+</h3><img src="./pride-badge.webp" alt="Modrinths Pride badge over a rainbow background." width="150" height="150" style="float: right;margin-left: 1rem;"><p>Everyone who donated to the fundraiser, regardless of the amount, received the limited Pride 2026 badge on their Modrinth profile as a thank you for supporting Rainbow Railroad. We also gave eligible donors a free month of Modrinth+ as an additional thank you - if you have this badge, you are a <em>very</em> cool person.</p><h3>Pride Collection</h3><p>This year, we launched our Pride Collection, an assortment of queer content hosted on Modrinth and submitted to us by our amazing community of creators.</p><div id="pride-collection-widget"></div><p>Thank you to everyone who submitted their projects; we look forward to adding even more content to this collection in the years to come!</p><h2>Pride Is All Year</h2><p><img src="/news/article/proud-of-you-2026/audience.webp" alt="A Minecraft screenshot of a theater featuring Modrinth team and community members in the audience. There are also many pride flags along the sides of the room."></p><p>Rainbow Railroads work continues all year round, and LGBTQIA+ people around the world continue to face danger simply for being who they are. This fundraiser was one way for us to turn Pride into practical support, but it cannot be the only time we show up. Pride month may be over, but the fight continues.</p><p>Modrinth is home to queer creators, players, developers, moderators, artists, and community members. They are part of what makes this platform what it is, and supporting them should not be limited to one month of the year.</p><p>If youd still like to support this cause, you can donate to Rainbow Railroad at any time - and we encourage you to do so.</p><p>—</p><p>Thank you for what has been an incredible Pride month, and we look forward to doing even more next year. 💚</p>`;
@@ -0,0 +1,12 @@
// AUTO-GENERATED FILE - DO NOT EDIT
export const article = {
html: () => import(`./proud_of_you_2026.content`).then(m => m.html),
title: "Proud of you all",
summary: "Over 2,400 of you came together to raise more than $40,000 for charity this Pride month!",
date: "2026-07-02T01:00:00.000Z",
slug: "proud-of-you-2026",
authors: ["vNcGR3Fd","bOHH0P9Z","2cqK8Q5p","VmRGDAOP","nHj8InZT","K86yjB3D"],
unlisted: false,
thumbnail: true,
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 777 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+6
View File
@@ -33,6 +33,12 @@ const keybinds: KeybindListener[] = [
description: 'Reset moderation progress',
action: (ctx) => ctx.actions.tryResetProgress(),
},
{
id: 'reset-progress-alt',
keybind: 'Alt+R',
description: 'Reset moderation progress',
action: (ctx) => ctx.actions.tryResetProgress(),
},
{
id: 'skip-project',
keybind: 'Ctrl+Shift+S',
+10 -5
View File
@@ -192,7 +192,10 @@ export const coreNags: Nag[] = [
},
status: 'required',
shouldShow: (context: NagContext) =>
context.project.license.id === 'LicenseRef-Unknown' && !context.projectV3?.minecraft_server,
(context.project.license.id === 'LicenseRef-Unknown' ||
context.project.license.id === 'NOASSERTION' ||
context.project.license.id === 'LicenseRef-NOASSERTION') &&
!context.projectV3?.minecraft_server,
link: {
path: 'settings/license',
title: defineMessage({
@@ -222,10 +225,12 @@ export const coreNags: Nag[] = [
},
status: 'required',
shouldShow: (context: NagContext) =>
context.project.license.id === 'LicenseRef-' ||
((!context.project.license.url || context.project.license.url === '') &&
!context.projectV3?.minecraft_server &&
context.project.license.id !== 'LicenseRef-Unknown'),
!context.projectV3?.minecraft_server &&
(context.project.license.id === 'LicenseRef-' ||
(context.project.license.id.search('LicenseRef-') === 0 &&
(!context.project.license.url || context.project.license.url === '') &&
context.project.license.id !== 'LicenseRef-Unknown' &&
context.project.license.id !== 'LicenseRef-All-Rights-Reserved')),
link: {
path: 'settings/license',
title: defineMessage({
@@ -1,7 +1,10 @@
import { defineMessage } from '@modrinth/ui'
import { defineMessage, useVIntl } from '@modrinth/ui'
import type { Nag, NagContext } from '../../types/nags'
const MAX_LANGUAGE_COUNT = 10
const ALL_LANGUAGE_COUNT = 72
export const serverProjectsNags: Nag[] = [
{
id: 'select-country',
@@ -25,6 +28,84 @@ export const serverProjectsNags: Nag[] = [
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-server',
},
},
{
id: 'too-many-languages',
title: defineMessage({
id: 'nags.too-many-languages.title',
defaultMessage: 'Select accurate languages',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const languageCount = context.projectV3?.minecraft_server?.languages?.length || 0
const maxLanguageCount = MAX_LANGUAGE_COUNT
return formatMessage(
defineMessage({
id: 'nags.too-many-languages.description',
defaultMessage:
"You've selected {languageCount, plural, one {# language} other {# languages}}. Please list only the languages your server actively supports.",
}),
{
languageCount,
maxLanguageCount,
},
)
},
status: 'warning',
shouldShow: (context: NagContext) => {
const languageCount = context.projectV3?.minecraft_server?.languages?.length || 0
return (
languageCount > MAX_LANGUAGE_COUNT &&
//languageCount <= ALL_LANGUAGE_COUNT &&
context.projectV3?.minecraft_server != null
)
},
link: {
path: 'settings/server',
title: defineMessage({
id: 'nags.server.title',
defaultMessage: 'Visit server settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-server',
},
},
{
id: 'all-languages',
title: defineMessage({
id: 'nags.all-languages.title',
defaultMessage: 'Select accurate languages',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
const languageCount = context.projectV3?.minecraft_server?.languages?.length || 0
const allLanguageCount = ALL_LANGUAGE_COUNT
return formatMessage(
defineMessage({
id: 'nags.all-languages.description',
defaultMessage:
"You've selected all available language options. Please list only the languages your server actively supports.",
}),
{
languageCount,
allLanguageCount,
},
)
},
status: 'required',
shouldShow: (context: NagContext) => {
// const languageCount = context.projectV3?.minecraft_server?.languages?.length || 0
return false //languageCount >= ALL_LANGUAGE_COUNT && context.projectV3?.minecraft_server != null
},
link: {
path: 'settings/server',
title: defineMessage({
id: 'nags.server.title',
defaultMessage: 'Visit server settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-server',
},
},
{
id: 'select-language',
title: defineMessage({
@@ -35,6 +35,12 @@
"nags.add-links.title": {
"defaultMessage": "Add external links"
},
"nags.all-languages.description": {
"defaultMessage": "You've selected all available language options. Please list only the languages your server actively supports."
},
"nags.all-languages.title": {
"defaultMessage": "Select accurate languages"
},
"nags.all-tags-selected.description": {
"defaultMessage": "You've selected all {totalAvailableTags, plural, one {# available tag} other {# available tags}}. This defeats the purpose of tags, which are meant to help users find relevant projects. Please select only the tags that are relevant to your project."
},
@@ -227,6 +233,12 @@
"nags.title-contains-technical-info.title": {
"defaultMessage": "Clean up the name"
},
"nags.too-many-languages.description": {
"defaultMessage": "You've selected {languageCount, plural, one {# language} other {# languages}}. Please list only the languages your server actively supports."
},
"nags.too-many-languages.title": {
"defaultMessage": "Select accurate languages"
},
"nags.too-many-tags-server.description": {
"defaultMessage": "You've selected {tagCount, plural, one {# tag} other {# tags}}. Please reduce to {maxTagCount} or fewer to make sure your server appears in relevant search results."
},
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { type Component, computed } from 'vue'
import PrideCollectionWidget from './PrideCollectionWidget.vue'
import SparkLiveWidget from './SparkLiveWidget.vue'
import SparkLiveWidgetEmbed from './SparkLiveWidgetEmbed.vue'
const ARTICLE_WIDGETS: Record<string, Component> = {
'spark-live-widget': SparkLiveWidget,
'spark-live-widget-embed': SparkLiveWidgetEmbed,
'pride-collection-widget': PrideCollectionWidget,
}
type ArticleBodyPart = { type: 'html'; content: string } | { type: 'widget'; id: string }
@@ -50,9 +52,9 @@ const parts = computed(() => parseArticleHtml(props.html))
class="markdown-body"
v-html="parts[0]?.content"
/>
<div v-else class="markdown-body">
<div v-else class="flex flex-col gap-4">
<template v-for="(part, index) in parts" :key="index">
<div v-if="part.type === 'html'" v-html="part.content" />
<div v-if="part.type === 'html'" class="markdown-body" v-html="part.content" />
<component :is="ARTICLE_WIDGETS[part.id]" v-else />
</template>
</div>
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { DownloadIcon, HeartIcon, SearchIcon } from '@modrinth/assets'
import { renderString } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { useCompactNumber } from '#ui/composables/format-number.ts'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { injectModrinthClient } from '../../providers/api-client'
import AutoLink from '../base/AutoLink.vue'
import Avatar from '../base/Avatar.vue'
import StyledInput from '../base/StyledInput.vue'
const { formatMessage } = useVIntl()
const { formatCompactNumber } = useCompactNumber()
const props = defineProps<{
collectionId: string
}>()
const client = injectModrinthClient()
const { data: collection, isLoading: isLoadingCollection } = useQuery({
queryKey: ['collection', () => props.collectionId],
queryFn: () => client.labrinth.collections.get(props.collectionId),
enabled: computed(() => !!props.collectionId),
})
const projectIds = computed(() => collection.value?.projects ?? [])
const { data: projects, isLoading: isLoadingProjects } = useQuery({
queryKey: ['collection-projects', () => projectIds.value],
queryFn: () => client.labrinth.projects_v3.getMultiple(projectIds.value),
enabled: computed(() => projectIds.value.length > 0),
})
const query = ref<string>('')
const sortedProjects = computed(
() =>
projects.value
?.slice()
.sort((a, b) => b.followers - a.followers)
.filter((project) => project.name.toLowerCase().includes(query.value.toLowerCase())) ?? [],
)
const supportsMarkdown = computed(() => collection.value?.user === '2REoufqX')
const messages = defineMessages({
projectCount: {
id: 'collection-widget.project-count',
defaultMessage: '{count, plural, one {# project} other {# projects}}',
},
searchPlaceholder: {
id: 'collection-widget.search-placeholder',
defaultMessage: 'Search projects',
},
loadingProjects: {
id: 'collection-widget.loading-projects',
defaultMessage: 'Loading projects...',
},
emptyCollection: {
id: 'collection-widget.empty-collection',
defaultMessage: 'This collection is empty.',
},
noSearchResults: {
id: 'collection-widget.no-search-results',
defaultMessage: 'No projects match your search.',
},
})
</script>
<template>
<div
class="rounded-2xl border border-solid border-surface-4 bg-surface-3 overflow-hidden grid grid-cols-1 md:grid-cols-[2fr_4fr]"
>
<div class="flex flex-col gap-2 p-4">
<template v-if="isLoadingCollection">
<div class="size-[96px] rounded-[16px] bg-surface-4 animate-pulse"></div>
<div class="w-52 h-8 rounded-full bg-surface-4 animate-pulse"></div>
<div class="w-28 h-5 rounded-full bg-surface-4 animate-pulse"></div>
<div class="w-44 h-5 rounded-full bg-surface-4 animate-pulse"></div>
</template>
<template v-else>
<AutoLink
:to="`/collection/${collection?.id}`"
class="flex flex-col gap-2 hover:underline"
target="_blank"
>
<Avatar :src="collection?.icon_url" size="96px" />
<span class="text-contrast font-semibold text-xl">{{ collection?.name }}</span>
</AutoLink>
<span>
{{ formatMessage(messages.projectCount, { count: collection?.projects.length ?? 0 }) }}
</span>
<div
v-if="supportsMarkdown"
class="description-body"
v-html="renderString(collection?.description ?? '')"
/>
<p v-else class="m-0 break-words">{{ collection?.description }}</p>
</template>
</div>
<div
class="flex flex-col bg-surface-2 border-0 border-l border-solid border-surface-4 overflow-hidden"
>
<StyledInput
v-if="(projects?.length ?? 0) > 5"
v-model="query"
:placeholder="formatMessage(messages.searchPlaceholder)"
input-class="bg-transparent !rounded-l-none !rounded-b-none m-1"
:icon="SearchIcon"
:disabled="isLoadingCollection || isLoadingProjects"
/>
<div
class="flex flex-col h-[20rem] overflow-y-auto"
:class="{ 'border-0 border-t border-solid border-surface-4': (projects?.length ?? 0) > 5 }"
>
<span v-if="isLoadingProjects" class="w-full py-12 text-center">
{{ formatMessage(messages.loadingProjects) }}
</span>
<template v-else>
<AutoLink
v-for="project in sortedProjects"
:key="project.id"
:to="`/project/${project.id}`"
class="flex items-center gap-2 px-3 py-2 even:bg-surface-2.5 hover:bg-surface-4 group"
target="_blank"
>
<Avatar :src="project.icon_url" size="48px" />
<div class="flex flex-col gap-1 truncate">
<span class="flex items-center gap-2">
<span class="text-contrast font-medium text-base group-hover:underline">
{{ project.name }}
</span>
<span class="flex items-center gap-1 text-sm">
<DownloadIcon class="size-4 text-secondary" />
{{ formatCompactNumber(project.downloads) }}
</span>
<span class="flex items-center gap-1 text-sm">
<HeartIcon class="size-4 text-secondary" />
{{ formatCompactNumber(project.followers) }}
</span>
</span>
<span class="text-primary text-sm truncate">{{ project.summary }}</span>
</div>
</AutoLink>
<span v-if="projects?.length === 0" class="w-full py-12 text-center">
{{ formatMessage(messages.emptyCollection) }}
</span>
<span v-else-if="sortedProjects.length === 0" class="w-full py-12 text-center">
{{ formatMessage(messages.noSearchResults) }}
</span>
</template>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
:deep(.description-body) {
p {
margin: 0;
line-height: 1.25;
}
a {
color: var(--color-brand);
font-weight: 600;
}
a:hover {
text-decoration: underline;
}
}
</style>
@@ -0,0 +1,9 @@
<script setup lang="ts">
import CollectionWidget from './CollectionWidget.vue'
</script>
<template>
<ClientOnly>
<CollectionWidget collection-id="M4c3ITvd" />
</ClientOnly>
</template>
+15
View File
@@ -317,6 +317,21 @@
"changelog.product.web": {
"defaultMessage": "Platform"
},
"collection-widget.empty-collection": {
"defaultMessage": "This collection is empty."
},
"collection-widget.loading-projects": {
"defaultMessage": "Loading projects..."
},
"collection-widget.no-search-results": {
"defaultMessage": "No projects match your search."
},
"collection-widget.project-count": {
"defaultMessage": "{count, plural, one {# project} other {# projects}}"
},
"collection-widget.search-placeholder": {
"defaultMessage": "Search projects"
},
"collections.label.private": {
"defaultMessage": "Private"
},