Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a14d576b | ||
|
|
b26d048a63 | ||
|
|
d5be2bd739 | ||
|
|
0dac5684af | ||
|
|
ca0b8d5ca4 | ||
|
|
b6651efe52 | ||
|
|
eb28121c0a | ||
|
|
a5ebc85356 | ||
|
|
c530671be1 | ||
|
|
bb6d8a6fc4 | ||
|
|
256e8f946a | ||
|
|
7e5590b8b9 | ||
|
|
d20f05fb04 |
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
After Width: | Height: | Size: 777 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
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.",
|
||||
|
||||
@@ -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);
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¬ification_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(¬ification_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)]
|
||||
|
||||
@@ -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!(
|
||||
¬ification.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!(
|
||||
¬ification.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(¬ification)?).await;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
Modrinth’s total made up nearly half of Rainbow Railroad’s 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!
|
||||
|
||||

|
||||
|
||||
_Special thanks to [Drago](https://x.com/iDragolyte) for creating Mr. Pack’s updated design and pride variants._
|
||||
|
||||
### Badges and Modrinth+
|
||||
|
||||
<img src="./pride-badge.webp" alt="Modrinth’s 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
|
||||
|
||||

|
||||
|
||||
Rainbow Railroad’s 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 you’d 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. 💚
|
||||
@@ -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',
|
||||
|
||||
@@ -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 Modrinth’s 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>Modrinth’s total made up nearly half of Rainbow Railroad’s 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. Pack’s 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. Pack’s updated design and pride variants.</em></p><h3>Badges and Modrinth+</h3><img src="./pride-badge.webp" alt="Modrinth’s 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 Railroad’s 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 you’d 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,
|
||||
|
||||
};
|
||||
|
After Width: | Height: | Size: 777 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 138 KiB |
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
},
|
||||
|
||||