Merge branch 'main' into boris/dev-1205-trace-rules

This commit is contained in:
aecsocket
2026-08-19 15:56:58 +09:00
474 changed files with 17427 additions and 8122 deletions
-3
View File
@@ -15,12 +15,9 @@ import { I18nDebugPanel, LoadingBar, NotificationPanel } from '@modrinth/ui'
import AdsConsentNotification from '~/components/ui/AdsConsentNotification.vue'
import { setupProviders } from '~/providers/setup.ts'
import { setupUserCountryProvider } from '~/providers/setup/user-country.ts'
import { useAuth } from './composables/auth'
setupUserCountryProvider()
const auth = await useAuth()
setupProviders(auth)
</script>
@@ -330,9 +330,13 @@ const sharedInstanceBanPending = ref(false)
const sharedInstanceVersions = new Map<string, SharedInstances.Instances.v1.InstanceVersion>()
let sharedInstanceDetailsRequest: Promise<void> | null = null
watch(isThreadCollapsed, (collapsed) => {
if (!collapsed) void loadSharedInstanceDetails()
})
watch(
isThreadCollapsed,
(collapsed) => {
if (!collapsed) void loadSharedInstanceDetails()
},
{ immediate: true },
)
const didCloseReport = ref(false)
const reportClosed = computed(() => {
@@ -400,10 +400,10 @@ defineExpose({ show, hide })
</Combobox>
<IconButton
v-tooltip="formatMessage(messages.deleteAllGroups)"
type="quiet"
color="red"
type="base"
:label="formatMessage(messages.deleteAllGroups)"
:disabled="titleButtonsDisabled"
class="[&:not(:disabled):focus-visible>svg]:!text-red [&:not(:disabled):hover>svg]:!text-red"
@click="showConfirmClearGroups"
>
<TrashIcon v-if="!isClearing" aria-hidden="true" />
@@ -6,26 +6,25 @@ import type {
CreationFlowContextValue,
EnvironmentSearchOverride,
FilterValue,
PendingServerContentInstall,
PendingServerContentInstallType,
} from '@modrinth/ui'
import {
addPendingServerContentInstalls,
commonMessages,
defineMessages,
flushStoredServerAddonInstallQueue,
getServerAddonInstallPlanProjectIds,
getStoredServerAddonInstallQueue,
getTargetInstallPreferences,
injectModrinthClient,
injectNotificationManager,
readPendingServerContentInstalls,
readStoredServerInstallQueue,
removePendingServerContentInstall,
requestInstall,
resolveServerAddonInstallPlans,
stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides,
useServerContextRuntime,
useServerPanelSync,
useVIntl,
writePendingServerContentInstallBaseline,
waitForServerContextRuntimeReady,
writeStoredServerInstallQueue,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
@@ -35,7 +34,6 @@ import { computed, nextTick, ref, watch } from 'vue'
import { navigateTo, useRoute } from '#app'
import { queryAsString } from '~/utils/router'
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
type ServerInstallBrowseSearchState = Pick<
BrowseSearchState,
'currentFilters' | 'overriddenProvidedFilterTypes'
@@ -88,34 +86,6 @@ const messages = defineMessages({
},
})
function getQueuedInstallOwnerFallback(project: ServerInstallSearchResult) {
if (project.organization) {
const ownerId = project.organization_id ?? project.organization
return {
id: ownerId,
name: project.organization,
type: 'organization' as const,
link: `/organization/${ownerId}`,
}
}
if (!project.author) return null
const ownerId = project.author_id ?? project.author
return {
id: ownerId,
name: project.author,
type: 'user' as const,
link: `/user/${ownerId}`,
}
}
function getQueuedAddonInstallPlans(
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
}
export function useServerInstallContent({
projectType,
onboardingModalRef,
@@ -136,6 +106,11 @@ export function useServerInstallContent({
const currentServerId = computed(() => queryAsString(route.query.sid) || null)
const fromContext = computed(() => queryAsString(route.query.from) || null)
const currentWorldId = computed(() => queryAsString(route.query.wid) || null)
useServerContextRuntime(currentServerId)
useServerPanelSync({
serverId: currentServerId,
worldId: currentWorldId,
})
const {
data: serverData,
@@ -177,7 +152,12 @@ export function useServerInstallContent({
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<ServerInstallSearchResult>>>(
readStoredServerInstallQueue(currentServerId.value, currentWorldId.value),
)
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
const queuedServerInstallRootProjectIds = computed(
() => new Set(queuedServerInstalls.value.keys()),
)
const queuedServerInstallProjectIds = computed(() =>
getServerAddonInstallPlanProjectIds(queuedServerInstalls.value.values()),
)
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
const selectedServerInstallProjects = computed(() =>
Array.from(queuedServerInstalls.value.values()).map((plan) => ({
@@ -219,81 +199,6 @@ export function useServerInstallContent({
writeStoredServerInstallQueue(serverId, worldId, plans)
}
async function getQueuedInstallOwner(project: ServerInstallSearchResult) {
const fallback = getQueuedInstallOwnerFallback(project)
try {
if (project.organization) {
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
if (organization) {
return {
id: organization.id,
name: organization.name,
type: 'organization' as const,
avatar_url: organization.icon_url ?? undefined,
link: `/organization/${organization.slug}`,
}
}
}
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
const owner =
members.find((member) => member.user.id === project.author_id)?.user ??
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
members[0]?.user
if (owner) {
return {
id: owner.id,
name: owner.username,
type: 'user' as const,
avatar_url: owner.avatar_url,
link: `/user/${owner.username}`,
}
}
} catch {
return fallback
}
return fallback
}
function getQueuedInstallPlaceholder(
plan: BrowseInstallPlan<ServerInstallSearchResult>,
owner: PendingServerContentInstallInput['owner'],
): PendingServerContentInstallInput {
return {
projectId: plan.projectId,
versionId: plan.versionId,
contentType: plan.contentType as PendingServerContentInstallType,
title: getInstallProjectName(plan.project),
versionName: plan.versionName ?? null,
versionNumber: plan.versionNumber ?? null,
fileName: plan.fileName ?? null,
owner,
slug: plan.project.slug ?? plan.projectId,
iconUrl: plan.project.icon_url ?? null,
}
}
function getQueuedInstallPlaceholderFallbacks(
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return getQueuedAddonInstallPlans(plans).map((plan) =>
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
)
}
async function getQueuedInstallPlaceholders(
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return Promise.all(
getQueuedAddonInstallPlans(plans).map(async (plan) =>
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)),
),
)
}
function setProjectInstalling(projectId: string, installing: boolean) {
const next = new Set(installingProjectIds.value)
if (installing) {
@@ -319,10 +224,6 @@ export function useServerInstallContent({
)
}
function getServerInstalledContentKeys(data = serverContentData.value) {
return new Set((data?.addons ?? []).map((addon) => addon.project_id ?? addon.filename))
}
function syncHiddenInstalledProjectIds() {
hiddenInstalledProjectIds.value = new Set([
...getServerInstalledProjectIds(),
@@ -437,32 +338,43 @@ export function useServerInstallContent({
}
}
async function resolveAddonPlan(
plan: BrowseInstallPlan<ServerInstallSearchResult>,
existingProjectIds: string[],
) {
const resolved = await client.labrinth.content_v3.resolve({
project_id: plan.projectId,
version_id: plan.versionId,
content_type: plan.contentType as Labrinth.Content.v3.ContentType,
selected: toResolvePreferences(plan.preferences),
target: toResolvePreferences(getServerInstallTargetPreferences(plan.contentType)),
existing_project_ids: existingProjectIds,
})
return [resolved.primary, ...resolved.dependencies].map((item) => ({
projectId: item.project_id,
versionId: item.version_id,
}))
}
async function resolveAndStoreQueuedAddonPlan(
plan: BrowseInstallPlan<ServerInstallSearchResult>,
) {
const resolvedContent = await resolveAddonPlan(plan, Array.from(getServerInstalledProjectIds()))
const storedPlan = queuedServerInstalls.value.get(plan.projectId)
if (!storedPlan || storedPlan.versionId !== plan.versionId) return
const nextPlans = new Map(queuedServerInstalls.value)
nextPlans.set(plan.projectId, { ...storedPlan, resolvedContent })
serverInstallQueue.set(nextPlans)
}
async function resolveQueuedAddonPlans(plans: BrowseInstallPlan<ServerInstallSearchResult>[]) {
const existingProjectIds = getServerInstalledProjectIds()
const resolvedAddons: Array<{ project_id: string; version_id: string }> = []
for (const plan of plans) {
const resolved = await client.labrinth.content_v3.resolve({
project_id: plan.projectId,
version_id: plan.versionId,
content_type: plan.contentType as Labrinth.Content.v3.ContentType,
selected: toResolvePreferences(plan.preferences),
target: toResolvePreferences(getServerInstallTargetPreferences(plan.contentType)),
existing_project_ids: Array.from(existingProjectIds),
})
const content = [resolved.primary, ...resolved.dependencies]
for (const item of content) {
if (existingProjectIds.has(item.project_id)) continue
existingProjectIds.add(item.project_id)
resolvedAddons.push({
project_id: item.project_id,
version_id: item.version_id,
})
}
}
return resolvedAddons
return await resolveServerAddonInstallPlans({
plans,
existingProjectIds: getServerInstalledProjectIds(),
resolvePlan: resolveAddonPlan,
})
}
function getInstallProjectVersions(projectId: string) {
@@ -498,6 +410,13 @@ export function useServerInstallContent({
)
if (queuedPlans.size === 0) return true
try {
await waitForServerContextRuntimeReady(client, serverId)
} catch (error) {
handleError(error as Error)
return false
}
isInstallingQueuedServerInstalls.value = true
queuedInstallProgress.value = {
completed: 0,
@@ -518,9 +437,6 @@ export function useServerInstallContent({
})
if (!result.ok) {
for (const plan of result.attemptedPlans) {
removePendingServerContentInstall(serverId, worldId, plan.projectId)
}
handleError(result.error as Error)
return false
}
@@ -533,10 +449,7 @@ export function useServerInstallContent({
total: result.flushedPlans.length,
}
if (result.flushedPlans.length > 0) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }),
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }),
])
await queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
}
return true
@@ -559,26 +472,10 @@ export function useServerInstallContent({
if (sid && wid) {
writeStoredServerInstallQueue(sid, wid, plans)
writePendingServerContentInstallBaseline(sid, wid, [
...getServerInstalledContentKeys(),
...optimisticallyInstalledProjectIds.value,
])
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
void getQueuedInstallPlaceholders(plans)
.then((items) => {
const pendingProjectIds = new Set(
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
)
addPendingServerContentInstalls(
sid,
wid,
items.filter((item) => pendingProjectIds.has(item.projectId)),
)
})
.catch((err) => handleError(err as Error))
}
const installed = await flushQueuedServerInstalls(sid, wid)
if (!installed) return false
await navigateTo(backUrl)
void flushQueuedServerInstalls(sid, wid)
return true
}
@@ -598,16 +495,17 @@ export function useServerInstallContent({
const isModpack = contentType === 'modpack'
try {
if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) {
if (!isModpack && queuedServerInstallRootProjectIds.value.has(project.project_id)) {
removeQueuedServerInstall(project.project_id)
return
}
if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) return
if (isModpack || !queuedServerInstallProjectIds.value.has(project.project_id)) {
setProjectInstalling(project.project_id, true)
}
await requestInstall({
const plan = await requestInstall({
project,
contentType,
mode: isModpack ? 'immediate' : 'queue',
@@ -646,10 +544,13 @@ export function useServerInstallContent({
ctx.modal.value?.setStage('final-config')
},
})
if (!isModpack) await resolveAndStoreQueuedAddonPlan(plan)
} catch (e) {
console.error(e)
if (isModpack) {
setProjectInstalling(project.project_id, false)
} else {
removeQueuedServerInstall(project.project_id)
}
handleError(e instanceof Error ? e : new Error(`Error installing content ${e}`))
} finally {
@@ -804,6 +705,7 @@ export function useServerInstallContent({
hideSelectedServerInstalls,
installingProjectIds,
optimisticallyInstalledProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
isInstallingQueuedServerInstalls,
+2 -25
View File
@@ -97,39 +97,16 @@ import {
LoadingBar,
normalizeChildren,
NotificationPanel,
provideModrinthClient,
provideNotificationManager,
providePageContext,
useVIntl,
} from '@modrinth/ui'
import Logo404 from '~/assets/images/404.svg'
import { getSignInRouteObj } from '~/composables/auth.js'
import { logout } from '~/composables/user.js'
import { createModrinthClient } from './helpers/api.ts'
import { FrontendNotificationManager } from './providers/frontend-notifications.ts'
import { setupLoadingStateProvider } from './providers/setup/loading-state.ts'
import { setupProviders } from '~/providers/setup.ts'
const auth = await useAuth()
const config = useRuntimeConfig()
provideNotificationManager(new FrontendNotificationManager())
setupLoadingStateProvider()
const client = createModrinthClient(auth.value, {
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
sharedInstancesBaseUrl: config.public.sharedInstancesBaseUrl,
rateLimitKey: config.rateLimitKey,
})
provideModrinthClient(client)
providePageContext({
hierarchicalSidebarAvailable: ref(false),
showAds: ref(false),
adConsentAvailable: ref(false),
openExternalUrl: (url) => window.open(url, '_blank'),
})
setupProviders(auth)
const { formatMessage } = useVIntl()
+101 -32
View File
@@ -6,6 +6,7 @@ import type { GameVersion, InferredVersionInfo, Project } from './infer'
import {
getGameVersionsMatchingMavenRange,
getGameVersionsMatchingSemverRange,
semverRangeIntersects,
} from './version-ranges'
import { versionType } from './version-utils'
@@ -132,38 +133,73 @@ export function createLoaderParsers(
),
}
},
// Fabric (or Babric for mc version beta 1.7.3)
'fabric.mod.json': (file: string): InferredVersionInfo => {
// Fabric (or its derivatives: Babric, Legacy Fabric, Ornithe)
'fabric.mod.json': async (file: string, zip: JSZip): Promise<InferredVersionInfo> => {
const metadata = JSON.parse(file) as any
const mcDependency = metadata.depends?.minecraft
const mcDependencies = Array.isArray(mcDependency) ? mcDependency : [mcDependency]
let detectedGameVersions = metadata.depends
? getGameVersionsMatchingSemverRange(metadata.depends.minecraft, simplifiedGameVersions)
? getGameVersionsMatchingSemverRange(mcDependency, simplifiedGameVersions)
: []
const loaders: string[] = []
// Detect Beta 1.7.3 -> Babric
const hasBabricVersion = mcDependencies.some(
(version: string | undefined) => version?.includes('1.0.0-beta.7.3'), // this is fabric's normalized mc version format
)
// Both Legacy Fabric and Ornithe add details about their intermediary to the jar manifest
const manifestFile = zip.file('META-INF/MANIFEST.MF')
if (manifestFile !== null) {
const manifestText = await manifestFile.async('text')
if (manifestText.match(/^Legacy-Fabric-Intermediary-Version: (.*)$/)) {
loaders.push('legacy-fabric')
}
if (manifestText.match(/^Calamus-Generation: (.*)$/)) {
loaders.push('ornithe')
}
}
// Detect 1.3-1.13 -> legacy-fabric
const hasLegacyVersions = detectedGameVersions.some((version) => {
const match = version.match(/^1\.(\d+)/)
return match && parseInt(match[1]) >= 3 && parseInt(match[1]) <= 13
})
// fall back to version comparison if nothing found in jar manifest
if (loaders.length == 0) {
// Fabric and its derivates all support different version ranges
// - Fabric supports 18w43b and later
// - Legacy Fabric supports (almost) only release versions 1.3-1.13.2
// - Ornithe supports versions up to 1.14.4
// - Babric supports only b1.7.3
// These ranges overlap but given the jar manifest logic above the only likely overlap scenarios are
// - a b1.7.3 mod that could be Ornithe but is most likely Babric
// - a Fabric mod that is dependent on "*" (i.e. all versions)
// In other cases, resolve as follows:
// 1. if the dependency falls entirely in >=1.3 <=1.13.2, assume Legacy Fabric
// 2. if the dependency falls entirely in <=18w43a, assume Ornithe
if (hasBabricVersion) {
loaders.push('babric')
detectedGameVersions = gameVersions
.filter((version) => version.version === 'b1.7.3')
.map((version) => version.version)
} else if (hasLegacyVersions) {
loaders.push('legacy-fabric')
} else {
loaders.push('fabric')
// dependency version strings are normalized to be Semver 2.0.0 compliant through
// https://github.com/FabricMC/fabric-loader/blob/master/minecraft/src/main/java/net/fabricmc/loader/impl/game/minecraft/McVersionLookup.java
// assume Babric only if dependent on b1.7.3 exactly
const hasBabricVersion = mcDependencies.every(
(version: string | undefined) => version == '1.0.0-beta.7.3',
)
// Legacy Fabric supports (almost) only release versions which is easy to check
const hasLegacyFabricVersions = detectedGameVersions.every((version) => {
const match = version.match(/^1\.(\d+)/)
return match && parseInt(match[1]) >= 3 && parseInt(match[1]) <= 13
})
// Assume Ornithe only if the dependency range falls entirely into <18w43b
const hasOrnitheVersions = !semverRangeIntersects(mcDependencies, '>=1.14.0-alpha.18.43.b')
if (hasBabricVersion) {
loaders.push('babric')
detectedGameVersions = gameVersions
.filter((version) => version.version === 'b1.7.3')
.map((version) => version.version)
} else if (hasLegacyFabricVersions) {
loaders.push('legacy-fabric')
} else if (hasOrnitheVersions) {
loaders.push('ornithe')
} else {
loaders.push('fabric')
}
}
return {
@@ -174,23 +210,56 @@ export function createLoaderParsers(
game_versions: detectedGameVersions,
}
},
// Quilt
'quilt.mod.json': (file: string): InferredVersionInfo => {
// Quilt (or its derivatives: Ornithe)
'quilt.mod.json': async (file: string, zip: JSZip): Promise<InferredVersionInfo> => {
const metadata = JSON.parse(file) as any
const mcDependency = metadata.quilt_loader.depends?.find(
(x: any) => x.id === 'minecraft',
)?.versions
const mcDependencies = Array.isArray(mcDependency) ? mcDependency : [mcDependency]
const detectedGameVersions = metadata.quilt_loader.depends
? getGameVersionsMatchingSemverRange(mcDependency, simplifiedGameVersions)
: []
const loaders: string[] = []
// Ornithe add details about their intermediary to the jar manifest
const manifestFile = zip.file('META-INF/MANIFEST.MF')
if (manifestFile !== null) {
const manifestText = await manifestFile.async('text')
if (manifestText.match(/^Calamus-Generation: (.*)$/)) {
loaders.push('ornithe')
}
}
// fall back to version comparison if nothing found in jar manifest
if (loaders.length == 0) {
// Quilt and its derivates all support different version ranges
// - Quilt supports 1.14.4 and later
// - Ornithe supports versions up to 1.14.4
// These ranges overlap but given the jar manifest logic above
// we can simply prioritize Quilt for the small overlap range
// dependency version strings are normalized to be Semver 2.0.0 compliant through
// https://github.com/QuiltMC/quilt-loader/blob/develop/minecraft/src/main/java/org/quiltmc/loader/impl/game/minecraft/McVersionLookup.java
// Assume Ornithe only if the dependency range falls entirely into <1.14.4`
const hasOrnitheVersions = !semverRangeIntersects(mcDependencies, '>=1.14.4')
if (hasOrnitheVersions) {
loaders.push('ornithe')
} else {
loaders.push('quilt')
}
}
return {
name: `${project.title} ${metadata.quilt_loader.version}`,
version_number: metadata.quilt_loader.version,
loaders: ['quilt'],
loaders,
version_type: versionType(metadata.quilt_loader.version),
game_versions: metadata.quilt_loader.depends
? getGameVersionsMatchingSemverRange(
metadata.quilt_loader.depends.find((x: any) => x.id === 'minecraft')
? metadata.quilt_loader.depends.find((x: any) => x.id === 'minecraft').versions
: [],
simplifiedGameVersions,
)
: [],
game_versions: detectedGameVersions,
}
},
// Bukkit + Other Forks
@@ -1,4 +1,4 @@
import { satisfies } from 'semver'
import { intersects, satisfies } from 'semver'
/**
* Returns game versions that match a semver range or array of ranges.
@@ -19,6 +19,21 @@ export function getGameVersionsMatchingSemverRange(
})
}
/**
* Returns whether a semver range intersects another semver range.
*/
export function semverRangeIntersects(
range: string | string[] | undefined,
other: string,
): boolean {
if (!range) {
return true
}
const ranges = Array.isArray(range) ? range : [range]
return ranges.some((r) => intersects(r, other))
}
/**
* Returns game versions that match a Maven-style version range.
*/
+30 -3
View File
@@ -3461,6 +3461,12 @@
"project.actions.back-to-project-page": {
"message": "Back to project page"
},
"project.actions.check-modpack-archives": {
"message": "Check modpack unzip"
},
"project.actions.checking-modpack-archives": {
"message": "Checking..."
},
"project.actions.create-server": {
"message": "Create a server"
},
@@ -3746,9 +3752,24 @@
"project.moderation.title": {
"message": "Moderation"
},
"project.modpack-archive-warning.description": {
"message": "Importing this .mrpack might be broken."
},
"project.modpack-archive-warning.title": {
"message": "This modpack was published during export bug"
},
"project.navigation.changelog": {
"message": "Changelog"
},
"project.notification.check-modpack-archives.failed": {
"message": "Some modpack files could not be unzipped"
},
"project.notification.check-modpack-archives.no-files": {
"message": "No .mrpack files were found for this project."
},
"project.notification.check-modpack-archives.success": {
"message": "{count, plural, one {The modpack file can be unzipped.} other {All # modpack files can be unzipped.}}"
},
"project.notification.icon-updated.message": {
"message": "Your project's icon has been updated."
},
@@ -4304,12 +4325,21 @@
"report.not-for.dmca.description": {
"message": "See our <policy-link>Copyright Policy</policy-link>."
},
"report.note.ai-images.1": {
"message": "We've just updated our rules to prohibit AI-generated images in icons, galleries, and descriptions. We're giving creators a grace period to remove AI images from their projects before we begin accepting reports for them."
},
"report.note.check-back-later": {
"message": "Check back later once the grace period has ended. Reports of this type are not being accepted at this time."
},
"report.note.copyright.1": {
"message": "Please note that you are *not* submitting a DMCA takedown request, but rather a report of reuploaded content."
},
"report.note.copyright.2": {
"message": "If you meant to file a DMCA takedown request (which is a legal action) instead, please see our <copyright-policy-link>Copyright Policy</copyright-policy-link>."
},
"report.note.fully-ai-generated.1": {
"message": "We've just updated our rules to prohibit fully AI-generated projects. We're giving creators a grace period to update or take down fully AI-generated projects before we begin accepting reports for them."
},
"report.note.malicious.1": {
"message": "Reports for malicious or deceptive content must include substantial evidence of the behavior, such as code samples."
},
@@ -4319,9 +4349,6 @@
"report.note.missing-disclosure.1": {
"message": "Content disclosures are a new feature we've just added. We're giving creators a grace period to get their disclosures up-to-date before we begin accepting reports for missing or incorrect disclosures."
},
"report.note.missing-disclosure.2": {
"message": "Check back later once the grace period has ended. Reports of this type are not being accepted at this time."
},
"report.please-report": {
"message": "Please report:"
},
+180 -11
View File
@@ -405,6 +405,36 @@
:auth="auth"
:tags="tags"
/>
<Admonition
v-if="
auth.user &&
tags.staffRoles.includes(auth.user.role) &&
project.actualProjectType === 'modpack' &&
hasModpackArchiveInWarningWindow
"
type="warning"
:header="formatMessage(messages.modpackArchiveWarningTitle)"
class="mt-3"
>
{{ formatMessage(messages.modpackArchiveWarningDescription) }}
<template #actions>
<Button
type="colored"
color="orange"
:loading="isCheckingModpackArchives"
@click="checkModpackArchives"
>
<FileArchiveIcon />
{{
formatMessage(
isCheckingModpackArchives
? messages.checkingModpackArchives
: messages.checkModpackArchives,
)
}}
</Button>
</template>
</Admonition>
<Admonition
v-if="
currentMember &&
@@ -529,6 +559,7 @@ import {
ClipboardCopyIcon,
CompassIcon,
DownloadIcon,
FileArchiveIcon,
FolderSearchIcon,
HeartIcon,
LeftArrowIcon,
@@ -663,6 +694,7 @@ const downloadModal = ref()
const openInAppModal = ref()
const overTheTopDownloadAnimation = ref()
const scanModal = ref()
const isCheckingModpackArchives = ref(false)
const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.value != null)
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
@@ -818,6 +850,35 @@ const messages = defineMessages({
id: 'project.actions.rescan-modpack',
defaultMessage: 'Rescan modpack',
},
checkModpackArchives: {
id: 'project.actions.check-modpack-archives',
defaultMessage: 'Check modpack unzip',
},
checkingModpackArchives: {
id: 'project.actions.checking-modpack-archives',
defaultMessage: 'Checking...',
},
checkModpackArchivesSuccess: {
id: 'project.notification.check-modpack-archives.success',
defaultMessage:
'{count, plural, one {The modpack file can be unzipped.} other {All # modpack files can be unzipped.}}',
},
checkModpackArchivesFailed: {
id: 'project.notification.check-modpack-archives.failed',
defaultMessage: 'Some modpack files could not be unzipped',
},
checkModpackArchivesNoFiles: {
id: 'project.notification.check-modpack-archives.no-files',
defaultMessage: 'No .mrpack files were found for this project.',
},
modpackArchiveWarningTitle: {
id: 'project.modpack-archive-warning.title',
defaultMessage: 'This modpack was published during export bug',
},
modpackArchiveWarningDescription: {
id: 'project.modpack-archive-warning.description',
defaultMessage: 'Importing this .mrpack might be broken.',
},
serversPromoDescription: {
id: 'project.actions.servers-promo.description',
defaultMessage: 'Modrinth Hosting is the easiest way to play with your friends without hassle!',
@@ -1135,7 +1196,7 @@ const {
const dependencies = computed(() => dependenciesRaw.value ?? null)
// V3 Versions - lazy loaded client-side only
// V3 Versions - lazy loaded client-side only (except for staff, who need v3 versions for moderation)
const versionsEnabled = ref(false)
const {
data: versionsV3,
@@ -1149,7 +1210,7 @@ const {
apiVersion: 3,
}),
staleTime: STALE_TIME_LONG,
enabled: computed(() => !!projectId.value && versionsEnabled.value),
enabled: computed(() => !!projectId.value && (versionsEnabled.value || isStaff(auth.value.user))),
})
// Organization
@@ -1684,16 +1745,24 @@ const following = computed(() => {
return !!user.value.follows.find((x) => x.id === project.value.id)
})
const PROJECT_NOT_FOUND_DESCRIPTION =
"There's no project here, check that you have the right link! It may still be under review or no longer publicly available on Modrinth."
const title = computed(() =>
project.value ? `${project.value.title} - Minecraft ${projectTypeDisplay.value}` : '',
)
const description = computed(() =>
project.value
? `${project.value.description} - Download the Minecraft ${projectTypeDisplay.value} ${
project.value.title
} by ${members.value.find((x) => x.is_owner)?.user?.username || 'a creator'} on Modrinth`
: '',
? `${project.value.title} - Minecraft ${projectTypeDisplay.value}`
: 'Project not found',
)
const description = computed(() => {
if (!project.value) {
return PROJECT_NOT_FOUND_DESCRIPTION
}
const creator = organization.value?.name || members.value.find((x) => x.is_owner)?.user?.username
const byLine = creator ? ` by ${creator}` : ''
return `${project.value.description} - Download the Minecraft ${projectTypeDisplay.value} ${project.value.title}${byLine} on Modrinth`
})
const canCreateServerFrom = computed(() => {
if (!project.value) return false
@@ -1749,6 +1818,90 @@ const showProjectHeaderCreateServerAction = computed(
const projectHeaderCreateServerTo = computed(() =>
project.value ? `/hosting?project=${project.value.id}#plan` : '/hosting',
)
const MRPACK_ARCHIVE_WARNING_START = new Date('2026-08-10T17:00:00.000Z').getTime()
const MRPACK_ARCHIVE_WARNING_END = new Date('2026-08-13T20:00:00.000Z').getTime()
const hasModpackArchiveInWarningWindow = computed(() =>
(versionsV3.value ?? []).some((version) => {
const publishedAt = new Date(version.date_published).getTime()
return (
version.files.some((file) => file.filename.toLowerCase().endsWith('.mrpack')) &&
publishedAt >= MRPACK_ARCHIVE_WARNING_START &&
publishedAt <= MRPACK_ARCHIVE_WARNING_END
)
}),
)
async function checkModpackArchives() {
if (!project.value || isCheckingModpackArchives.value) return
isCheckingModpackArchives.value = true
startLoading()
try {
const versions = await client.labrinth.versions_v2.getProjectVersions(project.value.id)
const filesByUrl = new Map(
versions
.flatMap((version) => version.files)
.filter((file) => file.filename.toLowerCase().endsWith('.mrpack'))
.map((file) => [file.url, file]),
)
const files = [...filesByUrl.values()]
if (files.length === 0) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: formatMessage(messages.checkModpackArchivesNoFiles),
type: 'error',
})
return
}
const { default: JSZip } = await import('jszip')
const failures = []
for (const file of files) {
try {
const response = await fetch(file.url)
if (!response.ok) {
throw new Error(`Download failed (${response.status} ${response.statusText})`)
}
await JSZip.loadAsync(await response.blob(), { checkCRC32: true })
} catch (error) {
failures.push({
filename: file.filename,
error: error?.message ?? String(error),
})
}
}
if (failures.length > 0) {
addNotification({
title: formatMessage(messages.checkModpackArchivesFailed),
text: failures.map((failure) => `${failure.filename}: ${failure.error}`).join('\n'),
type: 'error',
})
return
}
addNotification({
title: formatMessage(commonMessages.successLabel),
text: formatMessage(messages.checkModpackArchivesSuccess, { count: files.length }),
type: 'success',
})
} catch (error) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: error?.data?.description ?? error?.message ?? String(error),
type: 'error',
})
} finally {
isCheckingModpackArchives.value = false
stopLoading()
}
}
const projectHeaderMoreActions = computed(() => {
const isStaff = !!(auth.value.user && tags.value.staffRoles.includes(auth.value.user.role))
@@ -1787,6 +1940,19 @@ const projectHeaderMoreActions = computed(() => {
tone: 'orange',
shown: !!auth.value.user && isStaff && project.value?.actualProjectType === 'modpack',
},
{
id: 'moderation-modpack-check-archives',
label: formatMessage(
isCheckingModpackArchives.value
? messages.checkingModpackArchives
: messages.checkModpackArchives,
),
icon: FileArchiveIcon,
action: checkModpackArchives,
tone: 'orange',
disabled: isCheckingModpackArchives.value,
shown: !!auth.value.user && isStaff && project.value?.actualProjectType === 'modpack',
},
{ type: 'divider', shown: !!auth.value.user && isStaff },
{
id: 'report',
@@ -1828,8 +1994,11 @@ if (!route.name.startsWith('type-project-settings')) {
title: () => title.value,
description: () => description.value,
ogTitle: () => title.value,
ogDescription: () => project.value?.description ?? '',
ogImage: () => project.value?.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
ogDescription: () => project.value?.description ?? PROJECT_NOT_FOUND_DESCRIPTION,
ogImage: () =>
project.value
? (project.value?.icon_url ?? 'https://cdn-raw.modrinth.com/placeholder-square.png')
: 'https://cdn-raw.modrinth.com/not-found-transparent.png',
ogUrl: createCanonicalUrl,
robots: () => (project.value?.status === 'approved' ? 'all' : 'noindex'),
})
@@ -740,7 +740,7 @@ watch(
}),
ogTitle: formatMessage(messages.collectionTitle, { name: col.name }),
ogDescription: col.description,
ogImage: col.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
ogImage: col.icon_url ?? 'https://cdn-raw.modrinth.com/placeholder-square.png',
ogUrl: canonicalUrl,
robots: col.status === 'listed' ? 'all' : 'noindex',
})
@@ -163,6 +163,7 @@ const {
hideSelectedServerInstalls,
installingProjectIds,
optimisticallyInstalledProjectIds,
queuedServerInstallRootProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
isInstallingQueuedServerInstalls,
@@ -327,6 +328,7 @@ function getCardActions(
if (serverData.value) {
const isQueued = queuedServerInstallProjectIds.value.has(result.project_id)
const isQueuedRoot = queuedServerInstallRootProjectIds.value.has(result.project_id)
const isInstalled =
projectResult.installed ||
optimisticallyInstalledProjectIds.value.has(result.project_id) ||
@@ -362,7 +364,8 @@ function getCardActions(
? CheckIcon
: DownloadIcon,
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
disabled: !!isInstalled || isInstalling || isInstallingSelection,
disabled:
!!isInstalled || isInstalling || isInstallingSelection || (isQueued && !isQueuedRoot),
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
type: 'outlined',
onClick: () => serverInstall(projectResult),
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="markdown-body">
<h1>Content Rules</h1>
<p><em>Last modified: August 12, 2026</em></p>
<p><em>Last modified: August 13, 2026</em></p>
<p>
These Content Rules are to be considered part of our
@@ -483,7 +483,7 @@ watch(
description,
ogTitle: title,
ogDescription: org.description,
ogImage: org.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
ogImage: org.icon_url ?? 'https://cdn-raw.modrinth.com/placeholder-square.png',
ogUrl: canonicalUrl,
})
useHead({
+127 -104
View File
@@ -109,17 +109,7 @@
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.whatContentType) }}
</span>
<RadioButtons
v-slot="{ item }"
v-model="reportItem"
:items="reportItems"
@update:model-value="
() => {
prefilled = false
fetchItem()
}
"
>
<RadioButtons v-slot="{ item }" v-model="reportItem" :items="reportItems">
{{ capitalizeString(item) }}
</RadioButtons>
</div>
@@ -139,13 +129,6 @@
autocomplete="off"
:disabled="reportItem === ''"
wrapper-class="w-40"
@blur="
() => {
prefilled = false
reportItemID = reportItemID.trim()
fetchItem()
}
"
/>
<div v-if="checkingId || checkedId" class="flex items-center gap-1">
<template v-if="checkingId">
@@ -163,8 +146,8 @@
class="flex items-center gap-1 font-semibold text-contrast hover:underline"
>
<Avatar
v-if="typeof itemIcon === 'string'"
:src="itemIcon"
v-if="typeof itemIcon === 'string' || !itemIcon"
:src="itemIcon ?? null"
:alt="itemName"
size="24px"
:circle="reportItem === 'user'"
@@ -303,22 +286,22 @@ import {
useVIntl,
} from '@modrinth/ui'
import type { Project, Report, User, Version } from '@modrinth/utils'
import { useDebounceFn } from '@vueuse/core'
import { useImageUpload } from '~/composables/image-upload.ts'
definePageMeta({
middleware: 'auth',
})
const { addNotification } = injectNotificationManager()
const tags = useGeneratedState()
const route = useNativeRoute()
const router = useRouter()
const auth = await useAuth()
const { formatMessage } = useVIntl()
if (!auth.value.user) {
router.push('/auth/sign-in?redirect=' + encodeURIComponent(route.fullPath))
}
const accessQuery = (id: string): string => {
return route.query?.[id]?.toString() || ''
}
@@ -368,58 +351,83 @@ async function fetchExistingReports() {
)
}
async function fetchItem() {
if (reportItem.value && reportItemID.value) {
checkingId.value = true
itemIcon.value = undefined
itemName.value = undefined
itemLink.value = undefined
itemId.value = undefined
itemIssueTracker.value = undefined
try {
if (reportItem.value === 'project') {
const project = (await useBaseFetch(`project/${reportItemID.value}`)) as Project
currentProject.value = project
const fetchItemDebounced = useDebounceFn(fetchItem, 500)
itemIcon.value = project.icon_url
itemName.value = project.title
itemLink.value = `/project/${project.id}`
itemId.value = project.id
itemIssueTracker.value = project.issues_url
} else if (reportItem.value === 'version') {
const version = (await useBaseFetch(`version/${reportItemID.value}`)) as Version
currentVersion.value = version
itemIcon.value = VersionIcon
itemName.value = version.version_number
itemLink.value = `project/${version.project_id}/version/${version.id}`
itemId.value = version.id
} else if (reportItem.value === 'user') {
const user = (await useBaseFetch(`user/${reportItemID.value}`)) as User
currentUser.value = user
itemIcon.value = user.avatar_url
itemName.value = user.username
itemLink.value = `/user/${user.username}`
itemId.value = user.id
}
} catch {
// Ignored
}
checkedId.value = true
checkingId.value = false
watch([reportItem, reportItemID], ([type, id], [prevType]) => {
prefilled.value = false
if (!id.trim() || type !== prevType) {
fetchItem()
} else {
fetchItemDebounced()
}
})
async function fetchItem() {
const type = reportItem.value
const id = reportItemID.value.trim()
currentProject.value = null
currentVersion.value = null
currentUser.value = null
itemIcon.value = undefined
itemName.value = undefined
itemLink.value = undefined
itemId.value = undefined
itemIssueTracker.value = undefined
if (!type || !id) {
checkedId.value = false
checkingId.value = false
return
}
checkingId.value = true
try {
if (type === 'project') {
const project = (await useBaseFetch(`project/${id}`)) as Project
currentProject.value = project
itemIcon.value = project.icon_url
itemName.value = project.title
itemLink.value = `/project/${project.id}`
itemId.value = project.id
itemIssueTracker.value = project.issues_url
} else if (type === 'version') {
const version = (await useBaseFetch(`version/${id}`)) as Version
currentVersion.value = version
itemIcon.value = VersionIcon
itemName.value = version.version_number
itemLink.value = `project/${version.project_id}/version/${version.id}`
itemId.value = version.id
} else if (type === 'user') {
const user = (await useBaseFetch(`user/${id}`)) as User
currentUser.value = user
itemIcon.value = user.avatar_url
itemName.value = user.username
itemLink.value = `/user/${user.username}`
itemId.value = user.id
}
} catch {
// do nothing
}
checkedId.value = true
checkingId.value = false
}
const reportItems = ['project', 'version', 'user']
const dummyProjectReportTypes = ['missing-disclosure', 'ai-images', 'fully-ai-generated'] as const
const reportTypes = computed(() => {
const types = [...tags.value.reportTypes]
if (reportItem.value === 'project') {
types.push('missing-disclosure')
const otherIndex = types.indexOf('other')
if (otherIndex === -1) {
types.push(...dummyProjectReportTypes)
} else {
types.splice(otherIndex, 0, ...dummyProjectReportTypes)
}
}
return types
})
const disableReporting = computed(() => reportType.value === 'missing-disclosure')
const disableReporting = computed(() => dummyProjectReportTypes.includes(reportType.value))
const canSubmit = computed(() => {
return (
@@ -531,44 +539,6 @@ const onImageUpload = async (file: File) => {
return item.url
}
const warnings: Record<string, MessageDescriptor[]> = {
copyright: [
defineMessage({
id: 'report.note.copyright.1',
defaultMessage:
'Please note that you are *not* submitting a DMCA takedown request, but rather a report of reuploaded content.',
}),
defineMessage({
id: 'report.note.copyright.2',
defaultMessage:
'If you meant to file a DMCA takedown request (which is a legal action) instead, please see our <copyright-policy-link>Copyright Policy</copyright-policy-link>.',
}),
],
malicious: [
defineMessage({
id: 'report.note.malicious.1',
defaultMessage:
'Reports for malicious or deceptive content must include substantial evidence of the behavior, such as code samples.',
}),
defineMessage({
id: 'report.note.malicious.2',
defaultMessage:
'Summaries from Microsoft Defender, VirusTotal, or AI malware detection are not sufficient forms of evidence and will not be accepted.',
}),
],
'missing-disclosure': [
defineMessage({
id: 'report.note.missing-disclosure.1',
defaultMessage: `Content disclosures are a new feature we've just added. We're giving creators a grace period to get their disclosures up-to-date before we begin accepting reports for missing or incorrect disclosures.`,
}),
defineMessage({
id: 'report.note.missing-disclosure.2',
defaultMessage:
'Check back later once the grace period has ended. Reports of this type are not being accepted at this time.',
}),
],
}
const messages = defineMessages({
reportContent: {
id: 'report.report-content',
@@ -662,7 +632,60 @@ const messages = defineMessages({
id: 'report.submit',
defaultMessage: 'Submit report',
},
checkBackLater: {
id: 'report.note.check-back-later',
defaultMessage:
'Check back later once the grace period has ended. Reports of this type are not being accepted at this time.',
},
})
const warnings: Record<string, MessageDescriptor[]> = {
copyright: [
defineMessage({
id: 'report.note.copyright.1',
defaultMessage:
'Please note that you are *not* submitting a DMCA takedown request, but rather a report of reuploaded content.',
}),
defineMessage({
id: 'report.note.copyright.2',
defaultMessage:
'If you meant to file a DMCA takedown request (which is a legal action) instead, please see our <copyright-policy-link>Copyright Policy</copyright-policy-link>.',
}),
],
malicious: [
defineMessage({
id: 'report.note.malicious.1',
defaultMessage:
'Reports for malicious or deceptive content must include substantial evidence of the behavior, such as code samples.',
}),
defineMessage({
id: 'report.note.malicious.2',
defaultMessage:
'Summaries from Microsoft Defender, VirusTotal, or AI malware detection are not sufficient forms of evidence and will not be accepted.',
}),
],
'missing-disclosure': [
defineMessage({
id: 'report.note.missing-disclosure.1',
defaultMessage: `Content disclosures are a new feature we've just added. We're giving creators a grace period to get their disclosures up-to-date before we begin accepting reports for missing or incorrect disclosures.`,
}),
messages.checkBackLater,
],
'ai-images': [
defineMessage({
id: 'report.note.ai-images.1',
defaultMessage: `We've just updated our rules to prohibit AI-generated images in icons, galleries, and descriptions. We're giving creators a grace period to remove AI images from their projects before we begin accepting reports for them.`,
}),
messages.checkBackLater,
],
'fully-ai-generated': [
defineMessage({
id: 'report.note.fully-ai-generated.1',
defaultMessage: `We've just updated our rules to prohibit fully AI-generated projects. We're giving creators a grace period to update or take down fully AI-generated projects before we begin accepting reports for them.`,
}),
messages.checkBackLater,
],
}
</script>
<style scoped lang="scss">
+8 -3
View File
@@ -85,10 +85,12 @@ if (projectsResult.status === 'fulfilled') {
warmProjectCheckCaches(queryClient, projectsResult.value)
}
const title = computed(() =>
prefetchedUser ? `${prefetchedUser.username} - Modrinth` : 'Modrinth',
prefetchedUser ? `${prefetchedUser.username} - Modrinth` : 'User not found',
)
const description = computed(() => {
if (!prefetchedUser) return ''
if (!prefetchedUser) {
return `There's no user here, check that you have the right link!`
}
return prefetchedUser.bio
? `${prefetchedUser.bio} - Download ${prefetchedUser.username}'s projects on Modrinth`
: `Download ${prefetchedUser.username}'s projects on Modrinth`
@@ -99,7 +101,10 @@ useSeoMeta({
description: () => description.value,
ogTitle: () => title.value,
ogDescription: () => description.value,
ogImage: () => prefetchedUser?.avatar_url ?? 'https://cdn.modrinth.com/placeholder.png',
ogImage: () =>
prefetchedUser
? (prefetchedUser?.avatar_url ?? 'https://cdn-raw.modrinth.com/placeholder-circle.png')
: 'https://cdn-raw.modrinth.com/not-found-transparent.png',
})
const projectCreateModal = ref<InstanceType<typeof ProjectCreateModal> | null>(null)
+2
View File
@@ -7,6 +7,7 @@ import { setupLoadingStateProvider } from './setup/loading-state'
import { setupModrinthClientProvider } from './setup/modrinth-client'
import { setupPageContextProvider } from './setup/page-context'
import { setupTagsProvider } from './setup/tags'
import { setupUserCountryProvider } from './setup/user-country'
export function setupProviders(auth: Awaited<ReturnType<typeof useAuth>>) {
provideNotificationManager(new FrontendNotificationManager())
@@ -17,4 +18,5 @@ export function setupProviders(auth: Awaited<ReturnType<typeof useAuth>>) {
setupFilePickerProvider()
setupPageContextProvider()
setupLoadingStateProvider()
setupUserCountryProvider()
}
+2 -1
View File
@@ -27,7 +27,8 @@ conversantmedia.com, 100141, RESELLER
# Adagio - Equativ
smartadserver.com, 3554, RESELLER
loopme.com, 5679, RESELLER, 6c8d5f95897a5a3b
sharethrough.com, OAW69Fon, RESELLER, d53b998a7bd4ecd2
smartadserver.com,3262,RESELLER,060d053dcf45cbf3
sharethrough.com,3262,RESELLER,d53b998a7bd4ecd2
# Adagio - Sovrn
lijit.com, 367236, RESELLER, fafdf38b16bf6b2b
openx.com, 538959099, RESELLER, 6a698e2ec38604c6
Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

@@ -1,5 +1,12 @@
{
"articles": [
{
"title": "More ways to personalize your library",
"summary": "The new Play page, overhauled instance groups, custom icons, and a smoother onboarding experience.",
"thumbnail": "https://modrinth.com/news/article/app-personalization/thumbnail.webp",
"date": "2026-08-18T00:30:00.000Z",
"link": "https://modrinth.com/news/article/app-personalization"
},
{
"title": "New AI rules and project disclosures",
"summary": "An update to Modrinths Content Rules and our new mandatory content disclosures.",
File diff suppressed because one or more lines are too long