Compare commits

...

22 Commits

Author SHA1 Message Date
Alejandro González
d22c9e24f4
tweak(frontend): improve Nuxt build state generation logging and caching (#4133) 2025-08-06 22:05:33 +00:00
fishstiz
e31197f649
feat(app): pass selected version to incompatibility warning modal (#4115)
Co-authored-by: IMB11 <hendersoncal117@gmail.com>
2025-08-05 11:10:02 +00:00
Emma Alexia
0dee21814d
Change "Billing" link on dashboard for admins (#3951)
* Change "Billing" link on dashboard for admins

Requires an archon change before merging

* change order

* steal changes from prospector's old PR

supersedes #3234

Co-authored-by: Prospector <prospectordev@gmail.com>

* lint?

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-08-04 20:13:33 +00:00
Josiah Glosson
0657e4466f
Allow direct joining servers on old instances (#4094)
* Implement direct server joining for 1.6.2 through 1.19.4

* Implement direct server joining for versions before 1.6.2

* Ignore methods with a $ in them

* Run intl:extract

* Improve code of MinecraftTransformer

* Support showing last played time for profiles before 1.7

* Reorganize QuickPlayVersion a bit to prepare for singleplayer

* Only inject quick play checking in versions where it's needed

* Optimize agent some and fix error on NeoForge

* Remove some code for quickplay singleplayer support before 1.20, as we can't reasonably support that with an agent

* Invert the default hasServerQuickPlaySupport return value

* Remove Play Anyway button

* Fix "Server couldn't be contacted" on singleplayer worlds

* Fix "Jump back in" section not working
2025-08-04 19:29:20 +00:00
Josiah Glosson
13dbb4c57e
Fix most packs showing as "Optimization" on the app homepage (#4119) 2025-08-04 19:21:37 +00:00
Prospector
99493b9917 Updated changelog 2025-08-01 21:31:22 -04:00
IMB11
72a52eb7b1
fix: improve error message for rate limiting (#4101)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-08-01 21:27:25 +00:00
IMB11
b33e12c71d
fix: startup settings not visible on hard page refresh/direct load (#4100)
* fix: startup settings not visible on hard page refresh/direct load

* refactor: const func => named
2025-08-01 21:22:22 +00:00
IMB11
82d86839c7
fix: approve status incorrect (#4104) 2025-08-01 20:24:40 +00:00
coolbot
3a20e15340
Coolbot/moderation updates aug1 (#4103)
* oop, all commas!

* Only show slug stuff when needed.

* Move status alerts to top of message, getting rid of separators.

* redist libs message altered, and now shows on plugins too

* Update versions.ts

remove unnecessary import

Signed-off-by: coolbot <76798835+coolbot100s@users.noreply.github.com>

* Tweak summary formatting msg

* Update license messages to use flink

* reorder link text to match the settings page

* add Description clarity button

---------

Signed-off-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
2025-08-01 20:21:28 +00:00
jade
1c89b84314
fix(moderation): Replace dead modpack link with a valid one in side-types message (#4095) 2025-07-31 17:50:33 +00:00
IMB11
6387fb21c6
feat: Moderation Dashboard Overhaul (#4059)
* feat: Moderation Dashboard Overhaul

* fix: lint issues

* fix: issues

* fix: report layout

* fix: lint

* fix: impl quick replies

* fix: remove test qr

* feat: individual report page + use new backend

* feat: memoize filtering

* feat: apply optimizations to moderation queue

* fix: lint issues

* feat: impl quick reply functionality

* fix: top level await

* fix: dep issue

* fix: dep issue x2

* fix: dep issue

* feat: intl extract

* fix: dev-187

* fix: dev-186 & review project btn

* fix: dev-176

* remove redundant moderation button from user dropdown

* correct a msg and add admin to read filter

---------

Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
2025-07-29 21:19:25 +00:00
Alejandro González
c7d0839bfb
fix(labrinth): retire Sendy for new email newsletter subscriptions (#4073)
* tweak(frontend): do not sign up for the newsletter by default

* fix(labrinth): retire Sendy for new email newsletter subscriptions
2025-07-29 09:51:50 +00:00
Josiah Glosson
175b90be5a
Legacy ping support (#4062)
* Detection of protocol versions before 18w47b

* Refactor old_protocol_versions into protocol_version

* Ping servers closer to how a client of an instance's version would ping a server

* Allow pinging legacy servers from a modern profile in the same way a modern client would

* Ping 1.4.2 through 1.5.2 like a Vanilla client in those versions would when in such an instance
2025-07-28 14:44:34 +00:00
coolbot
13103b4950
various moderation fixes and improvements (#4061)
* Typo correction

* show optimization button when present in additional categories

* add more formatted link shortcuts

* Add info text to env info stage

* Only show gallery relevancy button when relevant.

* add unsupported project type message to versions stage

* Fix misuse of slug message.

* Update unsupported_project.md

* lint fix
2025-07-28 12:56:47 +00:00
Alejandro González
8804478221
fix(frontend): hide subscription button in blog before sub status is determined (#4072) 2025-07-27 20:29:21 +00:00
Emma Alexia
b8982a6d17
Hopefully fix collection visibility once and for all (#4070)
* Hopefully fix collection visibility once and for all

Follow up to #3408 and #3864

* Use same unlisted approach for collections as is used for projects
2025-07-27 18:23:49 +00:00
Emma Alexia
ff88724d01
Allow modification of failed charges on admin billing page (#4045)
* Allow modification of failed charges on admin billing page

Allows cancelling a failed subscription and forcing another charge attempt

* use addNotification
2025-07-27 17:30:16 +00:00
Emma Alexia
7dffb352d5
Fix duplicate "Upload icon Select file" on collections (#4069)
* Fix duplicate "Upload icon Select file" on collections

![lol](https://i.imgur.com/NKfvfQD.png)

* fix lint
2025-07-27 17:27:02 +00:00
Emma Alexia
1df6e29aa1
Ensure server status info is always passed to "My servers" page (#4071)
This took an insanely long time to debug and figure out you would not believe
2025-07-27 17:10:52 +00:00
Emma Alexia
5deb4179ad
Re-enable the Moderation tab for projects that are approved (#4067)
By request of the moderation team. This would allow easier access
if, e.g., the moderators tell the author of a metadata problem they
need to correct.
2025-07-27 17:07:39 +00:00
Alejandro González
358cf31c87
feat(labrinth): basic offset pagination for moderation reports and projects (#4063) 2025-07-26 12:32:35 +00:00
156 changed files with 5283 additions and 2492 deletions

1
.idea/code.iml generated
View File

@ -10,7 +10,6 @@
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" /> <sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/rust-common/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" /> <excludeFolder url="file://$MODULE_DIR$/target" />
</content> </content>

44
Cargo.lock generated
View File

@ -5731,6 +5731,17 @@ dependencies = [
"phf_shared 0.11.3", "phf_shared 0.11.3",
] ]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_macros 0.12.1",
"phf_shared 0.12.1",
"serde",
]
[[package]] [[package]]
name = "phf_codegen" name = "phf_codegen"
version = "0.8.0" version = "0.8.0"
@ -5781,6 +5792,16 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
] ]
[[package]]
name = "phf_generator"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b"
dependencies = [
"fastrand 2.3.0",
"phf_shared 0.12.1",
]
[[package]] [[package]]
name = "phf_macros" name = "phf_macros"
version = "0.10.0" version = "0.10.0"
@ -5808,6 +5829,19 @@ dependencies = [
"syn 2.0.101", "syn 2.0.101",
] ]
[[package]]
name = "phf_macros"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368"
dependencies = [
"phf_generator 0.12.1",
"phf_shared 0.12.1",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]] [[package]]
name = "phf_shared" name = "phf_shared"
version = "0.8.0" version = "0.8.0"
@ -5835,6 +5869,15 @@ dependencies = [
"siphasher 1.0.1", "siphasher 1.0.1",
] ]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher 1.0.1",
]
[[package]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.10" version = "1.1.10"
@ -8946,6 +8989,7 @@ dependencies = [
"notify-debouncer-mini", "notify-debouncer-mini",
"p256", "p256",
"paste", "paste",
"phf 0.12.1",
"png", "png",
"quartz_nbt", "quartz_nbt",
"quick-xml 0.37.5", "quick-xml 0.37.5",

View File

@ -99,6 +99,7 @@ notify = { version = "8.0.0", default-features = false }
notify-debouncer-mini = { version = "0.6.0", default-features = false } notify-debouncer-mini = { version = "0.6.0", default-features = false }
p256 = "0.13.2" p256 = "0.13.2"
paste = "1.0.15" paste = "1.0.15"
phf = { version = "0.12.1", features = ["macros"] }
png = "0.17.16" png = "0.17.16"
prometheus = "0.14.0" prometheus = "0.14.0"
quartz_nbt = "0.2.9" quartz_nbt = "0.2.9"

View File

@ -9,7 +9,7 @@
"tsc:check": "vue-tsc --noEmit", "tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .", "fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", "intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "vue-tsc --noEmit" "test": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {

View File

@ -21,14 +21,11 @@ const props = defineProps({
}) })
const featuredCategory = computed(() => { const featuredCategory = computed(() => {
if (props.project.categories.includes('optimization')) { if (props.project.display_categories.includes('optimization')) {
return 'optimization' return 'optimization'
} }
if (props.project.categories.length > 0) { return props.project.display_categories[0] ?? props.project.categories[0]
return props.project.categories[0]
}
return undefined
}) })
const toColor = computed(() => { const toColor = computed(() => {

View File

@ -76,10 +76,10 @@ const installing = ref(false)
const onInstall = ref(() => {}) const onInstall = ref(() => {})
defineExpose({ defineExpose({
show: (instanceVal, projectVal, projectVersions, callback) => { show: (instanceVal, projectVal, projectVersions, selected, callback) => {
instance.value = instanceVal instance.value = instanceVal
versions.value = projectVersions versions.value = projectVersions
selectedVersion.value = projectVersions[0] selectedVersion.value = selected ?? projectVersions[0]
project.value = projectVal project.value = projectVal

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
type ProtocolVersion,
type ServerWorld, type ServerWorld,
type ServerData, type ServerData,
type WorldWithProfile, type WorldWithProfile,
@ -33,7 +34,7 @@ const theme = useTheming()
const jumpBackInItems = ref<JumpBackInItem[]>([]) const jumpBackInItems = ref<JumpBackInItem[]>([])
const serverData = ref<Record<string, ServerData>>({}) const serverData = ref<Record<string, ServerData>>({})
const protocolVersions = ref<Record<string, number | null>>({}) const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
const MIN_JUMP_BACK_IN = 3 const MIN_JUMP_BACK_IN = 3
const MAX_JUMP_BACK_IN = 6 const MAX_JUMP_BACK_IN = 6
@ -121,11 +122,8 @@ async function populateJumpBackIn() {
} }
}) })
// fetch each server's data servers.forEach(({ instancePath, address }) =>
Promise.all(
servers.map(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address), refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
),
) )
} }
@ -150,8 +148,8 @@ async function populateJumpBackIn() {
.slice(0, MAX_JUMP_BACK_IN) .slice(0, MAX_JUMP_BACK_IN)
} }
async function refreshServer(address: string, instancePath: string) { function refreshServer(address: string, instancePath: string) {
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address) refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
} }
async function joinWorld(world: WorldWithProfile) { async function joinWorld(world: WorldWithProfile) {

View File

@ -1,6 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts' import type {
ProtocolVersion,
ServerStatus,
ServerWorld,
SingleplayerWorld,
World,
} from '@/helpers/worlds.ts'
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts' import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
import { formatNumber, getPingLevel } from '@modrinth/utils' import { formatNumber, getPingLevel } from '@modrinth/utils'
import { import {
@ -54,8 +60,9 @@ const props = withDefaults(
playingInstance?: boolean playingInstance?: boolean
playingWorld?: boolean playingWorld?: boolean
startingInstance?: boolean startingInstance?: boolean
supportsQuickPlay?: boolean supportsServerQuickPlay?: boolean
currentProtocol?: number | null supportsWorldQuickPlay?: boolean
currentProtocol?: ProtocolVersion | null
highlighted?: boolean highlighted?: boolean
// Server only // Server only
@ -78,7 +85,8 @@ const props = withDefaults(
playingInstance: false, playingInstance: false,
playingWorld: false, playingWorld: false,
startingInstance: false, startingInstance: false,
supportsQuickPlay: false, supportsServerQuickPlay: true,
supportsWorldQuickPlay: false,
currentProtocol: null, currentProtocol: null,
refreshing: false, refreshing: false,
@ -102,7 +110,8 @@ const serverIncompatible = computed(
!!props.serverStatus && !!props.serverStatus &&
!!props.serverStatus.version?.protocol && !!props.serverStatus.version?.protocol &&
!!props.currentProtocol && !!props.currentProtocol &&
props.serverStatus.version.protocol !== props.currentProtocol, (props.serverStatus.version.protocol !== props.currentProtocol.version ||
props.serverStatus.version.legacy !== props.currentProtocol.legacy),
) )
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked) const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
@ -120,9 +129,13 @@ const messages = defineMessages({
id: 'instance.worlds.a_minecraft_server', id: 'instance.worlds.a_minecraft_server',
defaultMessage: 'A Minecraft Server', defaultMessage: 'A Minecraft Server',
}, },
noQuickPlay: { noServerQuickPlay: {
id: 'instance.worlds.no_quick_play', id: 'instance.worlds.no_server_quick_play',
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+', defaultMessage: 'You can only jump straight into servers on Minecraft Alpha 1.0.5+',
},
noSingleplayerQuickPlay: {
id: 'instance.worlds.no_singleplayer_quick_play',
defaultMessage: 'You can only jump straight into singleplayer worlds on Minecraft 1.20+',
}, },
gameAlreadyOpen: { gameAlreadyOpen: {
id: 'instance.worlds.game_already_open', id: 'instance.worlds.game_already_open',
@ -144,10 +157,6 @@ const messages = defineMessages({
id: 'instance.worlds.view_instance', id: 'instance.worlds.view_instance',
defaultMessage: 'View instance', defaultMessage: 'View instance',
}, },
playAnyway: {
id: 'instance.worlds.play_anyway',
defaultMessage: 'Play anyway',
},
playInstance: { playInstance: {
id: 'instance.worlds.play_instance', id: 'instance.worlds.play_instance',
defaultMessage: 'Play instance', defaultMessage: 'Play instance',
@ -322,17 +331,24 @@ const messages = defineMessages({
<ButtonStyled v-else> <ButtonStyled v-else>
<button <button
v-tooltip=" v-tooltip="
!serverStatus world.type == 'server' && !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: world.type == 'singleplayer' && !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact) ? formatMessage(messages.noContact)
: serverIncompatible : serverIncompatible
? formatMessage(messages.incompatibleServer) ? formatMessage(messages.incompatibleServer)
: !supportsQuickPlay
? formatMessage(messages.noQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null : null
" "
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance" :disabled="
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
"
@click="emit('play')" @click="emit('play')"
> >
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" /> <SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
@ -349,11 +365,6 @@ const messages = defineMessages({
disabled: playingInstance, disabled: playingInstance,
action: () => emit('play-instance'), action: () => emit('play-instance'),
}, },
{
id: 'play-anyway',
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
action: () => emit('play'),
},
{ {
id: 'open-instance', id: 'open-instance',
shown: !!instancePath, shown: !!instancePath,
@ -419,10 +430,6 @@ const messages = defineMessages({
<PlayIcon aria-hidden="true" /> <PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }} {{ formatMessage(messages.playInstance) }}
</template> </template>
<template #play-anyway>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playAnyway) }}
</template>
<template #open-instance> <template #open-instance>
<EyeIcon aria-hidden="true" /> <EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }} {{ formatMessage(messages.viewInstance) }}

View File

@ -51,6 +51,7 @@ export type ServerStatus = {
version?: { version?: {
name: string name: string
protocol: number protocol: number
legacy: boolean
} }
favicon?: string favicon?: string
enforces_secure_chat: boolean enforces_secure_chat: boolean
@ -70,11 +71,17 @@ export interface Chat {
export type ServerData = { export type ServerData = {
refreshing: boolean refreshing: boolean
lastSuccessfulRefresh?: number
status?: ServerStatus status?: ServerStatus
rawMotd?: string | Chat rawMotd?: string | Chat
renderedMotd?: string renderedMotd?: string
} }
export type ProtocolVersion = {
version: number
legacy: boolean
}
export async function get_recent_worlds( export async function get_recent_worlds(
limit: number, limit: number,
displayStatuses?: DisplayStatus[], displayStatuses?: DisplayStatus[],
@ -156,13 +163,13 @@ export async function remove_server_from_profile(path: string, index: number): P
return await invoke('plugin:worlds|remove_server_from_profile', { path, index }) return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
} }
export async function get_profile_protocol_version(path: string): Promise<number | null> { export async function get_profile_protocol_version(path: string): Promise<ProtocolVersion | null> {
return await invoke('plugin:worlds|get_profile_protocol_version', { path }) return await invoke('plugin:worlds|get_profile_protocol_version', { path })
} }
export async function get_server_status( export async function get_server_status(
address: string, address: string,
protocolVersion: number | null = null, protocolVersion: ProtocolVersion | null = null,
): Promise<ServerStatus> { ): Promise<ServerStatus> {
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion }) return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
} }
@ -206,30 +213,39 @@ export function isServerWorld(world: World): world is ServerWorld {
export async function refreshServerData( export async function refreshServerData(
serverData: ServerData, serverData: ServerData,
protocolVersion: number | null, protocolVersion: ProtocolVersion | null,
address: string, address: string,
): Promise<void> { ): Promise<void> {
const refreshTime = Date.now()
serverData.refreshing = true serverData.refreshing = true
await get_server_status(address, protocolVersion) await get_server_status(address, protocolVersion)
.then((status) => { .then((status) => {
if (serverData.lastSuccessfulRefresh && serverData.lastSuccessfulRefresh > refreshTime) {
// Don't update if there was a more recent successful refresh
return
}
serverData.lastSuccessfulRefresh = Date.now()
serverData.status = status serverData.status = status
if (status.description) { if (status.description) {
serverData.rawMotd = status.description serverData.rawMotd = status.description
serverData.renderedMotd = autoToHTML(status.description) serverData.renderedMotd = autoToHTML(status.description)
} }
}) })
.catch((err) => {
console.error(`Refreshing addr: ${address}`, err)
})
.finally(() => { .finally(() => {
serverData.refreshing = false serverData.refreshing = false
}) })
.catch((err) => {
console.error(`Refreshing addr ${address}`, protocolVersion, err)
if (!protocolVersion?.legacy) {
refreshServerData(serverData, { version: 74, legacy: true }, address)
}
})
} }
export async function refreshServers( export function refreshServers(
worlds: World[], worlds: World[],
serverData: Record<string, ServerData>, serverData: Record<string, ServerData>,
protocolVersion: number | null, protocolVersion: ProtocolVersion | null,
) { ) {
const servers = worlds.filter(isServerWorld) const servers = worlds.filter(isServerWorld)
servers.forEach((server) => { servers.forEach((server) => {
@ -243,10 +259,8 @@ export async function refreshServers(
}) })
// noinspection ES6MissingAwait - handled with .then by refreshServerData already // noinspection ES6MissingAwait - handled with .then by refreshServerData already
Promise.all( Object.keys(serverData).forEach((address) =>
Object.keys(serverData).map((address) =>
refreshServerData(serverData[address], protocolVersion, address), refreshServerData(serverData[address], protocolVersion, address),
),
) )
} }
@ -297,15 +311,24 @@ export async function refreshWorlds(instancePath: string): Promise<World[]> {
return worlds ?? [] return worlds ?? []
} }
const FIRST_QUICK_PLAY_VERSION = '23w14a' export function hasServerQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) {
return true
}
export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) { const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
const targetIndex = gameVersions.findIndex((v) => v.version === 'a1.0.5_01')
return versionIndex === -1 || targetIndex === -1 || versionIndex <= targetIndex
}
export function hasWorldQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) { if (!gameVersions.length) {
return false return false
} }
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion) const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION) const targetIndex = gameVersions.findIndex((v) => v.version === '23w14a')
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
} }

View File

@ -383,11 +383,11 @@
"instance.worlds.no_contact": { "instance.worlds.no_contact": {
"message": "Server couldn't be contacted" "message": "Server couldn't be contacted"
}, },
"instance.worlds.no_quick_play": { "instance.worlds.no_server_quick_play": {
"message": "You can only jump straight into worlds on Minecraft 1.20+" "message": "You can only jump straight into servers on Minecraft Alpha 1.0.5+"
}, },
"instance.worlds.play_anyway": { "instance.worlds.no_singleplayer_quick_play": {
"message": "Play anyway" "message": "You can only jump straight into singleplayer worlds on Minecraft 1.20+"
}, },
"instance.worlds.play_instance": { "instance.worlds.play_instance": {
"message": "Play instance" "message": "Play instance"

View File

@ -67,7 +67,8 @@
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`" :key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
:world="world" :world="world"
:highlighted="highlightedWorld === getWorldIdentifier(world)" :highlighted="highlightedWorld === getWorldIdentifier(world)"
:supports-quick-play="supportsQuickPlay" :supports-server-quick-play="supportsServerQuickPlay"
:supports-world-quick-play="supportsWorldQuickPlay"
:current-protocol="protocolVersion" :current-protocol="protocolVersion"
:playing-instance="playing" :playing-instance="playing"
:playing-world="worldsMatch(world, worldPlaying)" :playing-world="worldsMatch(world, worldPlaying)"
@ -134,6 +135,7 @@ import {
} from '@modrinth/ui' } from '@modrinth/ui'
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets' import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
import { import {
type ProtocolVersion,
type SingleplayerWorld, type SingleplayerWorld,
type World, type World,
type ServerWorld, type ServerWorld,
@ -149,10 +151,11 @@ import {
refreshWorld, refreshWorld,
sortWorlds, sortWorlds,
refreshServers, refreshServers,
hasQuickPlaySupport, hasWorldQuickPlaySupport,
refreshWorlds, refreshWorlds,
handleDefaultProfileUpdateEvent, handleDefaultProfileUpdateEvent,
showWorldInFolder, showWorldInFolder,
hasServerQuickPlaySupport,
} from '@/helpers/worlds.ts' } from '@/helpers/worlds.ts'
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue' import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue' import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
@ -210,7 +213,9 @@ const worldPlaying = ref<World>()
const worlds = ref<World[]>([]) const worlds = ref<World[]>([])
const serverData = ref<Record<string, ServerData>>({}) const serverData = ref<Record<string, ServerData>>({})
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path)) const protocolVersion = ref<ProtocolVersion | null>(
await get_profile_protocol_version(instance.value.path),
)
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => { const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return if (e.profile_path_id !== instance.value.path) return
@ -246,7 +251,7 @@ async function refreshAllWorlds() {
worlds.value = await refreshWorlds(instance.value.path).finally( worlds.value = await refreshWorlds(instance.value.path).finally(
() => (refreshingAll.value = false), () => (refreshingAll.value = false),
) )
await refreshServers(worlds.value, serverData.value, protocolVersion.value) refreshServers(worlds.value, serverData.value, protocolVersion.value)
const hasNoWorlds = worlds.value.length === 0 const hasNoWorlds = worlds.value.length === 0
@ -352,8 +357,11 @@ function worldsMatch(world: World, other: World | undefined) {
} }
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => [])) const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
const supportsQuickPlay = computed(() => const supportsServerQuickPlay = computed(() =>
hasQuickPlaySupport(gameVersions.value, instance.value.game_version), hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
const supportsWorldQuickPlay = computed(() =>
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
) )
const filterOptions = computed(() => { const filterOptions = computed(() => {

View File

@ -29,8 +29,8 @@ export const useInstall = defineStore('installStore', {
setIncompatibilityWarningModal(ref) { setIncompatibilityWarningModal(ref) {
this.incompatibilityWarningModal = ref this.incompatibilityWarningModal = ref
}, },
showIncompatibilityWarningModal(instance, project, versions, onInstall) { showIncompatibilityWarningModal(instance, project, versions, selected, onInstall) {
this.incompatibilityWarningModal.show(instance, project, versions, onInstall) this.incompatibilityWarningModal.show(instance, project, versions, selected, onInstall)
}, },
setModInstallModal(ref) { setModInstallModal(ref) {
this.modInstallModal = ref this.modInstallModal = ref
@ -133,7 +133,13 @@ export const install = async (
callback(version.id) callback(version.id)
} else { } else {
const install = useInstall() const install = useInstall()
install.showIncompatibilityWarningModal(instance, project, projectVersions, callback) install.showIncompatibilityWarningModal(
instance,
project,
projectVersions,
version,
callback,
)
} }
} else { } else {
const versions = (await get_version_many(project.versions).catch(handleError)).sort( const versions = (await get_version_many(project.versions).catch(handleError)).sort(

View File

@ -250,7 +250,7 @@ pub async fn profile_get_pack_export_candidates(
// invoke('plugin:profile|profile_run', path) // invoke('plugin:profile|profile_run', path)
#[tauri::command] #[tauri::command]
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> { pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
let process = profile::run(path, &QuickPlayType::None).await?; let process = profile::run(path, QuickPlayType::None).await?;
Ok(process) Ok(process)
} }

View File

@ -4,9 +4,10 @@ use enumset::EnumSet;
use tauri::{AppHandle, Manager, Runtime}; use tauri::{AppHandle, Manager, Runtime};
use theseus::prelude::ProcessMetadata; use theseus::prelude::ProcessMetadata;
use theseus::profile::{QuickPlayType, get_full_path}; use theseus::profile::{QuickPlayType, get_full_path};
use theseus::server_address::ServerAddress;
use theseus::worlds::{ use theseus::worlds::{
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType, DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
WorldWithProfile, WorldType, WorldWithProfile,
}; };
use theseus::{profile, worlds}; use theseus::{profile, worlds};
@ -183,14 +184,16 @@ pub async fn remove_server_from_profile(
} }
#[tauri::command] #[tauri::command]
pub async fn get_profile_protocol_version(path: &str) -> Result<Option<i32>> { pub async fn get_profile_protocol_version(
path: &str,
) -> Result<Option<ProtocolVersion>> {
Ok(worlds::get_profile_protocol_version(path).await?) Ok(worlds::get_profile_protocol_version(path).await?)
} }
#[tauri::command] #[tauri::command]
pub async fn get_server_status( pub async fn get_server_status(
address: &str, address: &str,
protocol_version: Option<i32>, protocol_version: Option<ProtocolVersion>,
) -> Result<ServerStatus> { ) -> Result<ServerStatus> {
Ok(worlds::get_server_status(address, protocol_version).await?) Ok(worlds::get_server_status(address, protocol_version).await?)
} }
@ -201,7 +204,7 @@ pub async fn start_join_singleplayer_world(
world: String, world: String,
) -> Result<ProcessMetadata> { ) -> Result<ProcessMetadata> {
let process = let process =
profile::run(path, &QuickPlayType::Singleplayer(world)).await?; profile::run(path, QuickPlayType::Singleplayer(world)).await?;
Ok(process) Ok(process)
} }
@ -211,8 +214,11 @@ pub async fn start_join_server(
path: &str, path: &str,
address: &str, address: &str,
) -> Result<ProcessMetadata> { ) -> Result<ProcessMetadata> {
let process = let process = profile::run(
profile::run(path, &QuickPlayType::Server(address.to_owned())).await?; path,
QuickPlayType::Server(ServerAddress::Unresolved(address.to_owned())),
)
.await?;
Ok(process) Ok(process)
} }

View File

@ -143,8 +143,13 @@ export default defineNuxtConfig({
state.lastGenerated && state.lastGenerated &&
new Date(state.lastGenerated).getTime() + TTL > new Date().getTime() && new Date(state.lastGenerated).getTime() + TTL > new Date().getTime() &&
// ...but only if the API URL is the same // ...but only if the API URL is the same
state.apiUrl === API_URL state.apiUrl === API_URL &&
// ...and if no errors were caught during the last generation
(state.errors ?? []).length === 0
) { ) {
console.log(
"Tags already recently generated. Delete apps/frontend/generated/state.json to force regeneration.",
);
return; return;
} }

View File

@ -59,10 +59,12 @@
"markdown-it": "14.1.0", "markdown-it": "14.1.0",
"pathe": "^1.1.2", "pathe": "^1.1.2",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^4.4.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"qrcode.vue": "^3.4.0", "qrcode.vue": "^3.4.0",
"semver": "^7.5.4", "semver": "^7.5.4",
"three": "^0.172.0", "three": "^0.172.0",
"vue-confetti-explosion": "^1.0.2",
"vue-multiselect": "3.0.0-alpha.2", "vue-multiselect": "3.0.0-alpha.2",
"vue-typed-virtual-list": "^1.0.10", "vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4", "vue3-ace-editor": "^2.2.4",

File diff suppressed because it is too large Load Diff

View File

@ -1,29 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui"; import { ButtonStyled } from "@modrinth/ui";
import { MailIcon, CheckIcon } from "@modrinth/assets"; import { MailIcon, CheckIcon } from "@modrinth/assets";
import { ref, watchEffect } from "vue"; import { ref } from "vue";
import { useBaseFetch } from "~/composables/fetch.js"; import { useBaseFetch } from "~/composables/fetch.js";
const auth = await useAuth(); const auth = await useAuth();
const showSubscriptionConfirmation = ref(false); const showSubscriptionConfirmation = ref(false);
const subscribed = ref(false); const showSubscribeButton = useAsyncData(
async () => {
async function checkSubscribed() {
if (auth.value?.user) { if (auth.value?.user) {
try { try {
const { data } = await useBaseFetch("auth/email/subscribe", { const { subscribed } = await useBaseFetch("auth/email/subscribe", {
method: "GET", method: "GET",
}); });
subscribed.value = data?.subscribed || false; return !subscribed;
} catch { } catch {
subscribed.value = false; return true;
} }
} else {
return false;
} }
} },
{ watch: [auth], server: false },
watchEffect(() => { );
checkSubscribed();
});
async function subscribe() { async function subscribe() {
try { try {
@ -35,14 +34,19 @@ async function subscribe() {
} finally { } finally {
setTimeout(() => { setTimeout(() => {
showSubscriptionConfirmation.value = false; showSubscriptionConfirmation.value = false;
subscribed.value = true; showSubscribeButton.status.value = "success";
showSubscribeButton.data.value = false;
}, 2500); }, 2500);
} }
} }
</script> </script>
<template> <template>
<ButtonStyled v-if="auth?.user && !subscribed" color="brand" type="outlined"> <ButtonStyled
v-if="showSubscribeButton.status.value === 'success' && showSubscribeButton.data.value"
color="brand"
type="outlined"
>
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe"> <button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template> <template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
<template v-else> <CheckIcon /> Subscribed! </template> <template v-else> <CheckIcon /> Subscribed! </template>

View File

@ -0,0 +1,179 @@
<template>
<div class="universal-card">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Avatar :src="report.project.icon_url" size="3rem" class="flex-shrink-0" />
<div class="min-w-0 flex-1">
<h3 class="truncate text-lg font-semibold">{{ report.project.title }}</h3>
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
<nuxt-link
v-if="report.target"
:to="`/${report.target.type}/${report.target.slug}`"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.target.avatar_url"
:circle="report.target.type === 'user'"
size="1rem"
class="flex-shrink-0"
/>
<span class="truncate">
<OrganizationIcon
v-if="report.target.type === 'organization'"
class="align-middle"
/>
{{ report.target.name }}
</span>
</nuxt-link>
<div class="flex flex-wrap items-center gap-2">
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
>
Score: {{ report.priority_score }}
</span>
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold"
:class="{
'text-brand': report.status === 'approved',
'text-red': report.status === 'rejected',
'text-secondary': report.status === 'pending',
}"
>
{{ report.status.charAt(0).toUpperCase() + report.status.slice(1) }}
</span>
<span class="max-w-[200px] truncate font-mono text-xs sm:max-w-none">
{{
report.version.files.find((file) => file.primary)?.filename ||
"Unknown primary file"
}}
</span>
</div>
</div>
</div>
</div>
<div
class="mt-2 flex flex-col items-stretch gap-2 sm:mt-0 sm:flex-row sm:items-center sm:gap-2"
>
<span class="hidden whitespace-nowrap text-sm text-secondary sm:block">
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
</span>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex gap-2">
<ButtonStyled class="flex-1 sm:flex-none">
<button
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
:disabled="!isPending"
class="w-full sm:w-auto"
>
Accept
</button>
</ButtonStyled>
<ButtonStyled class="flex-1 sm:flex-none">
<button
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
:disabled="!isPending"
class="w-full sm:w-auto"
>
Reject
</button>
</ButtonStyled>
</div>
<div class="flex justify-center gap-2 sm:justify-start">
<ButtonStyled circular>
<nuxt-link :to="versionUrl">
<EyeIcon />
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div class="text-sm text-secondary sm:hidden">
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from "dayjs";
import {
Avatar,
useRelativeTime,
OverflowMenu,
type OverflowMenuOption,
ButtonStyled,
} from "@modrinth/ui";
import {
EllipsisVerticalIcon,
OrganizationIcon,
EyeIcon,
ClipboardCopyIcon,
LinkIcon,
} from "@modrinth/assets";
import type { ExtendedDelphiReport } from "@modrinth/moderation";
const props = defineProps<{
report: ExtendedDelphiReport;
}>();
const formatRelativeTime = useRelativeTime();
const isPending = computed(() => props.report.status === "pending");
const quickActions: OverflowMenuOption[] = [
{
id: "copy-link",
action: () => {
const base = window.location.origin;
const reviewUrl = `${base}/moderation/tech-reviews?q=${props.report.version.id}`;
navigator.clipboard.writeText(reviewUrl).then(() => {
addNotification({
type: "success",
title: "Tech review link copied",
text: "The link to this tech review has been copied to your clipboard.",
});
});
},
},
{
id: "copy-id",
action: () => {
navigator.clipboard.writeText(props.report.version.id).then(() => {
addNotification({
type: "success",
title: "Version ID copied",
text: "The ID of this version has been copied to your clipboard.",
});
});
},
},
];
const versionUrl = computed(() => {
return `/${props.report.project.project_type}/${props.report.project.slug}/versions/${props.report.version.id}`;
});
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,204 @@
<template>
<div
class="universal-card flex min-h-[6rem] flex-col justify-between gap-3 rounded-lg p-4 sm:h-24 sm:flex-row sm:items-center sm:gap-0"
>
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex-shrink-0 rounded-lg">
<Avatar size="48px" :src="queueEntry.project.icon_url" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<h3 class="truncate text-lg font-semibold">
{{ queueEntry.project.name }}
</h3>
<nuxt-link
v-if="queueEntry.owner"
target="_blank"
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
:to="`/user/${queueEntry.owner.user.username}`"
>
<Avatar
:src="queueEntry.owner.user.avatar_url"
circle
size="16px"
class="inline-block flex-shrink-0"
/>
<span class="truncate">{{ queueEntry.owner.user.username }}</span>
</nuxt-link>
<nuxt-link
v-else-if="queueEntry.org"
target="_blank"
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
:to="`/organization/${queueEntry.org.slug}`"
>
<Avatar
:src="queueEntry.org.icon_url"
circle
size="16px"
class="inline-block flex-shrink-0"
/>
<span class="truncate">{{ queueEntry.org.name }}</span>
</nuxt-link>
</div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-1">
<span class="flex items-center gap-1 whitespace-nowrap text-sm">
<BoxIcon
v-if="queueEntry.project.project_type === 'mod'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PaintbrushIcon
v-else-if="queueEntry.project.project_type === 'resourcepack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<BracesIcon
v-else-if="queueEntry.project.project_type === 'datapack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PackageOpenIcon
v-else-if="queueEntry.project.project_type === 'modpack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<GlassesIcon
v-else-if="queueEntry.project.project_type === 'shader'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PlugIcon
v-else-if="queueEntry.project.project_type === 'plugin'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<span class="hidden sm:inline">{{
props.queueEntry.project.project_types.map(formatProjectType).join(", ")
}}</span>
<span class="sm:hidden">{{
formatProjectType(props.queueEntry.project.project_type ?? "project").substring(0, 3)
}}</span>
</span>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<div class="flex flex-row gap-2 text-sm">
Requesting
<Badge
v-if="props.queueEntry.project.requested_status"
:type="props.queueEntry.project.requested_status"
class="status"
/>
</div>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<span
v-tooltip="`Since ${queuedDate.toLocaleString()}`"
class="truncate text-sm"
:class="{
'text-red': daysInQueue > 4,
'text-orange': daysInQueue > 2,
}"
>
<span class="hidden sm:inline">{{ getSubmittedTime(queueEntry) }}</span>
<span class="sm:hidden">{{
getSubmittedTime(queueEntry).replace("Submitted ", "")
}}</span>
</span>
</div>
<div class="flex items-center justify-end gap-2 sm:justify-start">
<ButtonStyled circular>
<NuxtLink target="_blank" :to="`/project/${queueEntry.project.slug}`">
<EyeIcon class="size-4" />
</NuxtLink>
</ButtonStyled>
<ButtonStyled circular color="orange" @click="openProjectForReview">
<button>
<ScaleIcon class="size-4" />
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from "dayjs";
import {
EyeIcon,
PaintbrushIcon,
ScaleIcon,
BoxIcon,
GlassesIcon,
PlugIcon,
PackageOpenIcon,
BracesIcon,
} from "@modrinth/assets";
import { useRelativeTime, Avatar, ButtonStyled, Badge } from "@modrinth/ui";
import {
formatProjectType,
type Organization,
type Project,
type TeamMember,
} from "@modrinth/utils";
import { computed } from "vue";
import { useModerationStore } from "~/store/moderation.ts";
import type { ModerationProject } from "~/helpers/moderation";
const formatRelativeTime = useRelativeTime();
const moderationStore = useModerationStore();
const props = defineProps<{
queueEntry: ModerationProject;
}>();
function getDaysQueued(date: Date): number {
const now = new Date();
const diff = now.getTime() - date.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
const queuedDate = computed(() => {
return dayjs(
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated,
);
});
const daysInQueue = computed(() => {
return getDaysQueued(queuedDate.value.toDate());
});
function openProjectForReview() {
moderationStore.setSingleProject(props.queueEntry.project.id);
navigateTo({
name: "type-id",
params: {
type: "project",
id: props.queueEntry.project.id,
},
state: {
showChecklist: true,
},
});
}
function getSubmittedTime(project: any): string {
const date =
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated;
if (!date) return "Unknown";
try {
return `Submitted ${formatRelativeTime(dayjs(date).toISOString())}`;
} catch {
return "Unknown";
}
}
</script>

View File

@ -0,0 +1,275 @@
<template>
<div class="universal-card">
<div
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
>
<span class="text-md flex flex-col gap-2 sm:flex-row sm:items-center">
<span class="flex items-center gap-2">
Reported for
<span class="whitespace-nowrap rounded-full align-middle font-semibold text-contrast">
{{ formattedReportType }}
</span>
</span>
<span class="flex items-center gap-2">
<span class="hidden sm:inline">By</span>
<span class="sm:hidden">Reporter:</span>
<nuxt-link
:to="`/user/${report.reporter_user.username}`"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.reporter_user.avatar_url"
circle
size="1.75rem"
class="flex-shrink-0"
/>
<span class="truncate">{{ report.reporter_user.username }}</span>
</nuxt-link>
</span>
</span>
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
<span class="text-md whitespace-nowrap text-secondary">{{
formatRelativeTime(report.created)
}}</span>
<ButtonStyled v-if="visibleQuickReplies.length > 0" circular>
<OverflowMenu :options="visibleQuickReplies">
<span class="hidden sm:inline">Quick Reply</span>
<span class="sr-only sm:hidden">Quick Reply</span>
<ChevronDownIcon />
</OverflowMenu>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
<hr class="my-4 rounded-xl border-solid text-divider" />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Avatar
:src="reportItemAvatarUrl"
:circle="report.item_type === 'user'"
size="3rem"
class="flex-shrink-0"
/>
<div class="min-w-0 flex-1">
<span class="block truncate text-lg font-semibold">{{ reportItemTitle }}</span>
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
<nuxt-link
v-if="report.target && report.item_type != 'user'"
:to="`/${report.target.type}/${report.target.slug}`"
class="inline-flex flex-row items-center gap-1 truncate transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.target?.avatar_url"
:circle="report.target.type === 'user'"
size="1rem"
class="flex-shrink-0"
/>
<span class="truncate">
<OrganizationIcon
v-if="report.target.type === 'organization'"
class="align-middle"
/>
{{ report.target.name || "Unknown User" }}
</span>
</nuxt-link>
<div class="flex flex-wrap items-center gap-2">
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
>
{{ formattedItemType }}
</span>
<span
v-if="report.item_type === 'version' && report.version"
class="max-w-[200px] truncate font-mono text-xs sm:max-w-none"
>
{{
report.version.files.find((file) => file.primary)?.filename || "Unknown Version"
}}
</span>
</div>
</div>
</div>
</div>
<div class="flex justify-end sm:justify-start">
<ButtonStyled circular>
<nuxt-link :to="reportItemUrl">
<EyeIcon />
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<CollapsibleRegion class="my-4" ref="collapsibleRegion">
<ReportThread
v-if="report.thread"
ref="reportThread"
class="mb-16 sm:mb-0"
:thread="report.thread"
:report="report"
:reporter="report.reporter_user"
@update-thread="updateThread"
/>
</CollapsibleRegion>
</div>
</template>
<script setup lang="ts">
import {
Avatar,
useRelativeTime,
OverflowMenu,
type OverflowMenuOption,
CollapsibleRegion,
ButtonStyled,
} from "@modrinth/ui";
import {
EllipsisVerticalIcon,
OrganizationIcon,
EyeIcon,
ClipboardCopyIcon,
LinkIcon,
} from "@modrinth/assets";
import {
type ExtendedReport,
reportQuickReplies,
type ReportQuickReply,
} from "@modrinth/moderation";
import ChevronDownIcon from "../servers/icons/ChevronDownIcon.vue";
import ReportThread from "../thread/ReportThread.vue";
const props = defineProps<{
report: ExtendedReport;
}>();
const reportThread = ref<InstanceType<typeof ReportThread> | null>(null);
const collapsibleRegion = ref<InstanceType<typeof CollapsibleRegion> | null>(null);
const formatRelativeTime = useRelativeTime();
function updateThread(newThread: any) {
if (props.report.thread) {
Object.assign(props.report.thread, newThread);
}
}
const quickActions: OverflowMenuOption[] = [
{
id: "copy-link",
action: () => {
const base = window.location.origin;
const reportUrl = `${base}/moderation/reports/${props.report.id}`;
navigator.clipboard.writeText(reportUrl).then(() => {
addNotification({
type: "success",
title: "Report link copied",
text: "The link to this report has been copied to your clipboard.",
});
});
},
},
{
id: "copy-id",
action: () => {
navigator.clipboard.writeText(props.report.id).then(() => {
addNotification({
type: "success",
title: "Report ID copied",
text: "The ID of this report has been copied to your clipboard.",
});
});
},
},
];
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
return reportQuickReplies
.filter((reply) => {
if (reply.shouldShow === undefined) return true;
if (typeof reply.shouldShow === "function") {
return reply.shouldShow(props.report);
}
return reply.shouldShow;
})
.map(
(reply) =>
({
id: reply.label,
action: () => handleQuickReply(reply),
}) as OverflowMenuOption,
);
});
async function handleQuickReply(reply: ReportQuickReply) {
const message =
typeof reply.message === "function" ? await reply.message(props.report) : reply.message;
collapsibleRegion.value?.setCollapsed(false);
await nextTick();
reportThread.value?.setReplyContent(message);
}
const reportItemAvatarUrl = computed(() => {
switch (props.report.item_type) {
case "project":
case "version":
return props.report.project?.icon_url || "";
case "user":
return props.report.user?.avatar_url || "";
default:
return undefined;
}
});
const reportItemTitle = computed(() => {
if (props.report.item_type === "user") return props.report.user?.username || "Unknown User";
return props.report.project?.title || "Unknown Project";
});
const reportItemUrl = computed(() => {
switch (props.report.item_type) {
case "user":
return `/user/${props.report.user?.username}`;
case "project":
return `/${props.report.project?.project_type}/${props.report.project?.slug}`;
case "version":
return `/${props.report.project?.project_type}/${props.report.project?.slug}/versions/${props.report.version?.id}`;
}
});
const formattedItemType = computed(() => {
const itemType = props.report.item_type;
return itemType.charAt(0).toUpperCase() + itemType.slice(1);
});
const formattedReportType = computed(() => {
const reportType = props.report.report_type;
// some are split by -, some are split by " "
const words = reportType.includes("-") ? reportType.split("-") : reportType.split(" ");
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
});
</script>
<style lang="scss" scoped></style>

View File

@ -29,7 +29,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; import { ref } from "vue";
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue"; import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation"; import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
@ -64,7 +64,7 @@ function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
} }
function isMac() { function isMac() {
return navigator.platform.toUpperCase().indexOf("MAC") >= 0; return navigator.platform.toUpperCase().includes("MAC");
} }
function show(event?: MouseEvent) { function show(event?: MouseEvent) {

View File

@ -42,9 +42,9 @@
<div v-if="done"> <div v-if="done">
<p> <p>
You are done moderating this project! You are done moderating this project!
<template v-if="futureProjectCount > 0"> <template v-if="moderationStore.hasItems">
There are There are
{{ futureProjectCount }} left. {{ moderationStore.queueLength }} left.
</template> </template>
</p> </p>
</div> </div>
@ -98,7 +98,7 @@
<div v-if="toggleActions.length > 0" class="toggle-actions-group space-y-3"> <div v-if="toggleActions.length > 0" class="toggle-actions-group space-y-3">
<template v-for="action in toggleActions" :key="getActionKey(action)"> <template v-for="action in toggleActions" :key="getActionKey(action)">
<Checkbox <Checkbox
:model-value="actionStates[getActionId(action)]?.selected ?? false" :model-value="isActionSelected(action)"
:label="action.label" :label="action.label"
:description="action.description" :description="action.description"
:disabled="false" :disabled="false"
@ -215,26 +215,26 @@
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4" class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ButtonStyled v-if="!done && !generatedMessage && futureProjectCount > 0"> <ButtonStyled v-if="!done && !generatedMessage && moderationStore.hasItems">
<button @click="goToNextProject"> <button @click="skipCurrentProject">
<XIcon aria-hidden="true" /> <XIcon aria-hidden="true" />
Skip Skip ({{ moderationStore.queueLength }} left)
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div v-if="done"> <div v-if="done">
<ButtonStyled v-if="futureProjectCount > 0" color="brand"> <ButtonStyled color="brand">
<button @click="goToNextProject"> <button @click="endChecklist(undefined)">
<template v-if="hasNextProject">
<RightArrowIcon aria-hidden="true" /> <RightArrowIcon aria-hidden="true" />
Next Project Next Project ({{ moderationStore.queueLength }} left)
</button> </template>
</ButtonStyled> <template v-else>
<ButtonStyled v-else color="brand">
<button @click="exitModeration">
<CheckIcon aria-hidden="true" /> <CheckIcon aria-hidden="true" />
Done All Done!
</template>
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@ -259,7 +259,7 @@
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled color="green"> <ButtonStyled color="green">
<button @click="sendMessage('approved')"> <button @click="sendMessage(project.requested_status ?? 'approved')">
<CheckIcon aria-hidden="true" /> <CheckIcon aria-hidden="true" />
Approve Approve
</button> </button>
@ -355,6 +355,7 @@ import {
renderHighlightedString, renderHighlightedString,
type ModerationJudgements, type ModerationJudgements,
type ModerationModpackItem, type ModerationModpackItem,
type ProjectStatus,
} from "@modrinth/utils"; } from "@modrinth/utils";
import { computedAsync, useLocalStorage } from "@vueuse/core"; import { computedAsync, useLocalStorage } from "@vueuse/core";
import { import {
@ -370,29 +371,21 @@ import {
import * as prettier from "prettier"; import * as prettier from "prettier";
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue"; import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
import KeybindsModal from "./ChecklistKeybindsModal.vue"; import KeybindsModal from "./ChecklistKeybindsModal.vue";
import { useModerationStore } from "~/store/moderation.ts";
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>(); const keybindsModal = ref<InstanceType<typeof KeybindsModal>>();
const props = withDefaults( const props = defineProps<{
defineProps<{
project: Project; project: Project;
futureProjectIds?: string[];
collapsed: boolean; collapsed: boolean;
}>(), }>();
{
futureProjectIds: () => [] as string[], const moderationStore = useModerationStore();
},
);
const variables = computed(() => { const variables = computed(() => {
return flattenProjectVariables(props.project); return flattenProjectVariables(props.project);
}); });
const futureProjectCount = computed(() => {
const ids = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
return ids.length;
});
const modpackPermissionsComplete = ref(false); const modpackPermissionsComplete = ref(false);
const modpackJudgements = ref<ModerationJudgements>({}); const modpackJudgements = ref<ModerationJudgements>({});
const isModpackPermissionsStage = computed(() => { const isModpackPermissionsStage = computed(() => {
@ -516,7 +509,7 @@ function handleKeybinds(event: KeyboardEvent) {
isLoadingMessage: loadingMessage.value, isLoadingMessage: loadingMessage.value,
isModpackPermissionsStage: isModpackPermissionsStage.value, isModpackPermissionsStage: isModpackPermissionsStage.value,
futureProjectCount: futureProjectCount.value, futureProjectCount: moderationStore.queueLength,
visibleActionsCount: visibleActions.value.length, visibleActionsCount: visibleActions.value.length,
focusedActionIndex: focusedActionIndex.value, focusedActionIndex: focusedActionIndex.value,
@ -529,13 +522,13 @@ function handleKeybinds(event: KeyboardEvent) {
tryGoNext: nextStage, tryGoNext: nextStage,
tryGoBack: previousStage, tryGoBack: previousStage,
tryGenerateMessage: generateMessage, tryGenerateMessage: generateMessage,
trySkipProject: goToNextProject, trySkipProject: skipCurrentProject,
tryToggleCollapse: () => emit("toggleCollapsed"), tryToggleCollapse: () => emit("toggleCollapsed"),
tryResetProgress: resetProgress, tryResetProgress: resetProgress,
tryExitModeration: () => emit("exit"), tryExitModeration: () => emit("exit"),
tryApprove: () => sendMessage("approved"), tryApprove: () => sendMessage(props.project.requested_status),
tryReject: () => sendMessage("rejected"), tryReject: () => sendMessage("rejected"),
tryWithhold: () => sendMessage("withheld"), tryWithhold: () => sendMessage("withheld"),
tryEditMessage: goBackToStages, tryEditMessage: goBackToStages,
@ -652,12 +645,17 @@ function initializeStageActions(stage: Stage, stageIndex: number) {
} }
function getActionId(action: Action, index?: number): string { function getActionId(action: Action, index?: number): string {
// If index is not provided, find it in the current stage's actions
if (index === undefined) {
index = currentStageObj.value.actions.indexOf(action);
}
return getActionIdForStage(action, currentStage.value, index); return getActionIdForStage(action, currentStage.value, index);
} }
function getActionKey(action: Action): string { function getActionKey(action: Action): string {
const index = visibleActions.value.indexOf(action); // Find the actual index of this action in the current stage's actions array
return `${currentStage.value}-${index}-${getActionId(action)}`; const index = currentStageObj.value.actions.indexOf(action);
return `${currentStage.value}-${index}-${getActionId(action, index)}`;
} }
const visibleActions = computed(() => { const visibleActions = computed(() => {
@ -727,7 +725,8 @@ const multiSelectActions = computed(() =>
); );
function getDropdownValue(action: DropdownAction) { function getDropdownValue(action: DropdownAction) {
const actionId = getActionId(action); const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const visibleOptions = getVisibleDropdownOptions(action); const visibleOptions = getVisibleDropdownOptions(action);
const currentValue = actionStates.value[actionId]?.value ?? action.defaultOption ?? 0; const currentValue = actionStates.value[actionId]?.value ?? action.defaultOption ?? 0;
@ -742,12 +741,14 @@ function getDropdownValue(action: DropdownAction) {
} }
function isActionSelected(action: Action): boolean { function isActionSelected(action: Action): boolean {
const actionId = getActionId(action); const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
return actionStates.value[actionId]?.selected || false; return actionStates.value[actionId]?.selected || false;
} }
function toggleAction(action: Action) { function toggleAction(action: Action) {
const actionId = getActionId(action); const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const state = actionStates.value[actionId]; const state = actionStates.value[actionId];
if (state) { if (state) {
state.selected = !state.selected; state.selected = !state.selected;
@ -756,7 +757,8 @@ function toggleAction(action: Action) {
} }
function selectDropdownOption(action: DropdownAction, selected: any) { function selectDropdownOption(action: DropdownAction, selected: any) {
const actionId = getActionId(action); const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const state = actionStates.value[actionId]; const state = actionStates.value[actionId];
if (state && selected !== undefined && selected !== null) { if (state && selected !== undefined && selected !== null) {
const optionIndex = action.options.findIndex( const optionIndex = action.options.findIndex(
@ -772,7 +774,8 @@ function selectDropdownOption(action: DropdownAction, selected: any) {
} }
function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): boolean { function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): boolean {
const actionId = getActionId(action); const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const selectedSet = actionStates.value[actionId]?.value as Set<number> | undefined; const selectedSet = actionStates.value[actionId]?.value as Set<number> | undefined;
const visibleOptions = getVisibleMultiSelectOptions(action); const visibleOptions = getVisibleMultiSelectOptions(action);
@ -783,7 +786,8 @@ function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): bo
} }
function toggleChip(action: MultiSelectChipsAction, optionIndex: number) { function toggleChip(action: MultiSelectChipsAction, optionIndex: number) {
const actionId = getActionId(action); const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const state = actionStates.value[actionId]; const state = actionStates.value[actionId];
if (state && state.value instanceof Set) { if (state && state.value instanceof Set) {
const visibleOptions = getVisibleMultiSelectOptions(action); const visibleOptions = getVisibleMultiSelectOptions(action);
@ -1056,7 +1060,7 @@ function nextStage() {
if (isModpackPermissionsStage.value && !modpackPermissionsComplete.value) { if (isModpackPermissionsStage.value && !modpackPermissionsComplete.value) {
addNotification({ addNotification({
title: "Modpack permissions stage unfinished", title: "Modpack permissions stage unfinished",
message: "Please complete the modpack permissions stage before proceeding.", text: "Please complete the modpack permissions stage before proceeding.",
type: "error", type: "error",
}); });
@ -1133,7 +1137,7 @@ async function generateMessage() {
console.error("Error generating message:", error); console.error("Error generating message:", error);
addNotification({ addNotification({
title: "Error generating message", title: "Error generating message",
message: "Failed to generate moderation message. Please try again.", text: "Failed to generate moderation message. Please try again.",
type: "error", type: "error",
}); });
} finally { } finally {
@ -1161,6 +1165,8 @@ function generateModpackMessage(allFiles: {
attributeMods.push(file.file_name); attributeMods.push(file.file_name);
} else if (file.status === "no" && file.approved === "no") { } else if (file.status === "no" && file.approved === "no") {
noMods.push(file.file_name); noMods.push(file.file_name);
} else if (file.status === "permanent-no") {
permanentNoMods.push(file.file_name);
} }
}); });
@ -1202,7 +1208,8 @@ function generateModpackMessage(allFiles: {
return issues.join("\n\n"); return issues.join("\n\n");
} }
async function sendMessage(status: "approved" | "rejected" | "withheld") { const hasNextProject = ref(false);
async function sendMessage(status: ProjectStatus) {
try { try {
await useBaseFetch(`project/${props.project.id}`, { await useBaseFetch(`project/${props.project.id}`, {
method: "PATCH", method: "PATCH",
@ -1236,55 +1243,73 @@ async function sendMessage(status: "approved" | "rejected" | "withheld") {
done.value = true; done.value = true;
// Clear local storage for future reviews hasNextProject.value = await moderationStore.completeCurrentProject(
localStorage.removeItem(`modpack-permissions-${props.project.id}`); props.project.id,
localStorage.removeItem(`modpack-permissions-index-${props.project.id}`); "completed",
localStorage.removeItem(`moderation-actions-${props.project.slug}`); );
localStorage.removeItem(`moderation-inputs-${props.project.slug}`);
actionStates.value = {};
addNotification({
title: "Moderation submitted",
message: `Project ${status} successfully.`,
type: "success",
});
} catch (error) { } catch (error) {
console.error("Error submitting moderation:", error); console.error("Error submitting moderation:", error);
addNotification({ addNotification({
title: "Error submitting moderation", title: "Error submitting moderation",
message: "Failed to submit moderation decision. Please try again.", text: "Failed to submit moderation decision. Please try again.",
type: "error", type: "error",
}); });
} }
} }
async function goToNextProject() { async function endChecklist(status?: string) {
const currentIds = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]"); clearProjectLocalStorage();
if (currentIds.length === 0) { if (!hasNextProject.value) {
await navigateTo("/moderation/review"); await navigateTo({
return; name: "moderation",
state: {
confetti: true,
},
});
await nextTick();
if (moderationStore.currentQueue.total > 1) {
addNotification({
title: "Moderation completed",
text: `You have completed the moderation queue.`,
type: "success",
});
} else {
addNotification({
title: "Moderation submitted",
text: `Project ${status ?? "completed successfully"}.`,
type: "success",
});
} }
} else {
const nextProjectId = currentIds[0]; navigateTo({
const remainingIds = currentIds.slice(1);
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingIds));
await router.push({
name: "type-id", name: "type-id",
params: { params: {
type: "project", type: "project",
id: nextProjectId, id: moderationStore.getCurrentProjectId(),
}, },
state: { state: {
showChecklist: true, showChecklist: true,
}, },
}); });
} }
}
async function exitModeration() { async function skipCurrentProject() {
await navigateTo("/moderation/review"); hasNextProject.value = await moderationStore.completeCurrentProject(props.project.id, "skipped");
await endChecklist("skipped");
}
function clearProjectLocalStorage() {
localStorage.removeItem(`modpack-permissions-${props.project.id}`);
localStorage.removeItem(`modpack-permissions-index-${props.project.id}`);
localStorage.removeItem(`moderation-actions-${props.project.slug}`);
localStorage.removeItem(`moderation-inputs-${props.project.slug}`);
localStorage.removeItem(`moderation-stage-${props.project.slug}`);
actionStates.value = {};
} }
const isLastVisibleStage = computed(() => { const isLastVisibleStage = computed(() => {

View File

@ -66,6 +66,27 @@
<UiServersPanelSpinner /> <UiServersPanelSpinner />
Your server's hardware is currently being upgraded and will be back online shortly. Your server's hardware is currently being upgraded and will be back online shortly.
</div> </div>
<div
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been cancelled. Please
update your billing information or contact Modrinth Support for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended' && suspension_reason"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended:
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div <div
v-else-if="status === 'suspended'" v-else-if="status === 'suspended'"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast" class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
@ -87,7 +108,8 @@ import { Avatar, CopyCode } from "@modrinth/ui";
const props = defineProps<Partial<Server>>(); const props = defineProps<Partial<Server>>();
if (props.server_id) { if (props.server_id && props.status === "available") {
// Necessary only to get server icon
await useModrinthServers(props.server_id, ["general"]); await useModrinthServers(props.server_id, ["general"]);
} }
@ -109,11 +131,6 @@ if (props.upstream) {
} }
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined); const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
if (import.meta.server && projectData.value?.icon_url) {
await useModrinthServers(props.server_id!, ["general"]);
}
const iconUrl = computed(() => projectData.value?.icon_url || undefined); const iconUrl = computed(() => projectData.value?.icon_url || undefined);
const isConfiguring = computed(() => props.flows?.intro); const isConfiguring = computed(() => props.flows?.intro);
</script> </script>

View File

@ -2,7 +2,10 @@
<div class="static w-full grid-cols-1 md:relative md:flex"> <div class="static w-full grid-cols-1 md:relative md:flex">
<div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4"> <div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4">
<div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]"> <div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]">
<div v-for="link in navLinks" :key="link.label"> <div
v-for="link in navLinks.filter((x) => x.shown === undefined || x.shown)"
:key="link.label"
>
<NuxtLink <NuxtLink
:to="link.href" :to="link.href"
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg" class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
@ -40,7 +43,7 @@ import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const emit = defineEmits(["reinstall"]); const emit = defineEmits(["reinstall"]);
defineProps<{ defineProps<{
navLinks: { label: string; href: string; icon: Component; external?: boolean }[]; navLinks: { label: string; href: string; icon: Component; external?: boolean; shown?: boolean }[];
route: RouteLocationNormalized; route: RouteLocationNormalized;
server: ModrinthServer; server: ModrinthServer;
backupInProgress?: BackupInProgressReason; backupInProgress?: BackupInProgressReason;

View File

@ -34,6 +34,38 @@
</div> </div>
</div> </div>
</Modal> </Modal>
<Modal ref="modalReply" header="Reply to thread">
<div class="modal-submit universal-body">
<span>
Your project is already approved. As such, the moderation team does not actively monitor
this thread. However, they may still see your message if there is a problem with your
project.
</span>
<span>
If you need to get in contact with the moderation team, please use the
<a class="text-link" href="https://support.modrinth.com" target="_blank">
Modrinth Help Center
</a>
and click the green bubble to contact support.
</span>
<Checkbox
v-model="replyConfirmation"
description="Confirm moderators do not actively monitor this"
>
I acknowledge that the moderators do not actively monitor the thread.
</Checkbox>
<div class="input-group push-right">
<button
class="btn btn-primary"
:disabled="!replyConfirmation"
@click="sendReplyFromModal()"
>
<ReplyIcon aria-hidden="true" />
Reply to thread
</button>
</div>
</div>
</Modal>
<div v-if="flags.developerMode" class="thread-id"> <div v-if="flags.developerMode" class="thread-id">
Thread ID: Thread ID:
<CopyCode :text="thread.id" /> <CopyCode :text="thread.id" />
@ -71,12 +103,17 @@
v-if="sortedMessages.length > 0" v-if="sortedMessages.length > 0"
class="btn btn-primary" class="btn btn-primary"
:disabled="!replyBody" :disabled="!replyBody"
@click="sendReply()" @click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
> >
<ReplyIcon aria-hidden="true" /> <ReplyIcon aria-hidden="true" />
Reply Reply
</button> </button>
<button v-else class="btn btn-primary" :disabled="!replyBody" @click="sendReply()"> <button
v-else
class="btn btn-primary"
:disabled="!replyBody"
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
>
<SendIcon aria-hidden="true" /> <SendIcon aria-hidden="true" />
Send Send
</button> </button>
@ -289,6 +326,7 @@ const sortedMessages = computed(() => {
}); });
const modalSubmit = ref(null); const modalSubmit = ref(null);
const modalReply = ref(null);
async function updateThreadLocal() { async function updateThreadLocal() {
let threadId = null; let threadId = null;
@ -316,6 +354,11 @@ async function onUploadImage(file) {
return response.url; return response.url;
} }
async function sendReplyFromModal(status = null, privateMessage = false) {
modalReply.value.hide();
await sendReply(status, privateMessage);
}
async function sendReply(status = null, privateMessage = false) { async function sendReply(status = null, privateMessage = false) {
try { try {
const body = { const body = {
@ -398,6 +441,7 @@ async function reopenReport() {
const replyWithSubmission = ref(false); const replyWithSubmission = ref(false);
const submissionConfirmation = ref(false); const submissionConfirmation = ref(false);
const replyConfirmation = ref(false);
function openResubmitModal(reply) { function openResubmitModal(reply) {
submissionConfirmation.value = false; submissionConfirmation.value = false;
@ -405,6 +449,11 @@ function openResubmitModal(reply) {
modalSubmit.value.show(); modalSubmit.value.show();
} }
function openReplyModal(reply) {
replyConfirmation.value = false;
modalReply.value.show();
}
async function resubmit() { async function resubmit() {
if (replyWithSubmission.value) { if (replyWithSubmission.value) {
await sendReply("processing"); await sendReply("processing");

View File

@ -0,0 +1,282 @@
<template>
<div>
<div v-if="flags.developerMode" class="mb-4 font-bold text-heading">
Thread ID:
<CopyCode :text="thread.id" />
</div>
<div
v-if="sortedMessages.length > 0"
class="bg-raised flex flex-col space-y-4 rounded-xl p-3 sm:p-4"
>
<ThreadMessage
v-for="message in sortedMessages"
:key="'message-' + message.id"
:thread="thread"
:message="message"
:members="members"
:report="report"
:auth="auth"
raised
@update-thread="() => updateThreadLocal()"
/>
</div>
<template v-if="reportClosed">
<p class="text-secondary">This thread is closed and new messages cannot be sent to it.</p>
<ButtonStyled v-if="isStaff(auth.user)" color="green" class="mt-2 w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="reopenReport()"
>
<CheckCircleIcon class="size-4" />
Reopen Thread
</button>
</ButtonStyled>
</template>
<template v-else>
<div class="mt-4">
<MarkdownEditor
v-model="replyBody"
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
:on-image-upload="onUploadImage"
/>
</div>
<div
class="mt-4 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center sm:gap-2"
>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<ButtonStyled v-if="sortedMessages.length > 0" color="brand" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply()"
>
<ReplyIcon class="size-4" />
Reply
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply()"
>
<SendIcon class="size-4" />
Send
</button>
</ButtonStyled>
<ButtonStyled v-if="isStaff(auth.user)" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply(true)"
>
<ScaleIcon class="size-4" />
<span class="hidden sm:inline">Add private note</span>
<span class="sm:hidden">Private note</span>
</button>
</ButtonStyled>
</div>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<template v-if="isStaff(auth.user)">
<ButtonStyled v-if="replyBody" color="red" class="w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="closeReport(true)"
>
<CheckCircleIcon class="size-4" />
<span class="hidden sm:inline">Close with reply</span>
<span class="sm:hidden">Close & reply</span>
</button>
</ButtonStyled>
<ButtonStyled v-else color="red" class="w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="closeReport()"
>
<CheckCircleIcon class="size-4" />
Close report
</button>
</ButtonStyled>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { CopyCode, MarkdownEditor, ButtonStyled } from "@modrinth/ui";
import { ReplyIcon, SendIcon, CheckCircleIcon, ScaleIcon } from "@modrinth/assets";
import type { Thread, Report, User, ThreadMessage as TypeThreadMessage } from "@modrinth/utils";
import dayjs from "dayjs";
import ThreadMessage from "./ThreadMessage.vue";
import { useImageUpload } from "~/composables/image-upload.ts";
import { isStaff } from "~/helpers/users.js";
const props = defineProps<{
thread: Thread;
reporter: User;
report: Report;
}>();
const auth = await useAuth();
const emit = defineEmits<{
updateThread: [thread: Thread];
}>();
const flags = useFeatureFlags();
const members = computed(() => {
const membersMap: Record<string, User> = {
[props.reporter.id]: props.reporter,
};
for (const member of props.thread.members) {
membersMap[member.id] = member;
}
return membersMap;
});
const replyBody = ref("");
function setReplyContent(content: string) {
replyBody.value = content;
}
defineExpose({
setReplyContent,
});
const sortedMessages = computed(() => {
const messages: TypeThreadMessage[] = [
{
id: null,
author_id: props.reporter.id,
body: {
type: "text",
body: props.report.body || "Report opened.",
private: false,
replying_to: null,
associated_images: [],
},
created: props.report.created,
hide_identity: false,
},
];
if (props.thread) {
messages.push(
...[...props.thread.messages].sort(
(a, b) => dayjs(a.created).toDate().getTime() - dayjs(b.created).toDate().getTime(),
),
);
}
return messages;
});
async function updateThreadLocal() {
const threadId = props.report.thread_id;
if (threadId) {
try {
const thread = (await useBaseFetch(`thread/${threadId}`)) as Thread;
emit("updateThread", thread);
} catch (error) {
console.error("Failed to update thread:", error);
}
}
}
const imageIDs = ref<string[]>([]);
async function onUploadImage(file: File) {
const response = await useImageUpload(file, { context: "thread_message" });
imageIDs.value.push(response.id);
imageIDs.value = imageIDs.value.slice(-10);
return response.url;
}
async function sendReply(privateMessage = false) {
try {
const body: any = {
body: {
type: "text",
body: replyBody.value,
private: privateMessage,
},
};
if (imageIDs.value.length > 0) {
body.body = {
...body.body,
uploaded_images: imageIDs.value,
};
}
await useBaseFetch(`thread/${props.thread.id}`, {
method: "POST",
body,
});
replyBody.value = "";
await updateThreadLocal();
} catch (err: any) {
addNotification({
title: "Error sending message",
text: err.data ? err.data.description : err,
type: "error",
});
}
}
const didCloseReport = ref(false);
const reportClosed = computed(() => {
return didCloseReport.value || (props.report && props.report.closed);
});
async function closeReport(reply = false) {
if (reply) {
await sendReply();
}
try {
await useBaseFetch(`report/${props.report.id}`, {
method: "PATCH",
body: {
closed: true,
},
});
await updateThreadLocal();
didCloseReport.value = true;
} catch (err: any) {
addNotification({
title: "Error closing report",
text: err.data ? err.data.description : err,
type: "error",
});
}
}
async function reopenReport() {
try {
await useBaseFetch(`report/${props.report.id}`, {
method: "PATCH",
body: {
closed: false,
},
});
await updateThreadLocal();
} catch (err: any) {
addNotification({
title: "Error reopening report",
text: err.data ? err.data.description : err,
type: "error",
});
}
}
</script>

View File

@ -36,7 +36,7 @@
v-tooltip="'Modrinth Team'" v-tooltip="'Modrinth Team'"
/> />
<MicrophoneIcon <MicrophoneIcon
v-if="report && message.author_id === report.reporterUser.id" v-if="report && message.author_id === report.reporter_user?.id"
v-tooltip="'Reporter'" v-tooltip="'Reporter'"
class="reporter-icon" class="reporter-icon"
/> />

View File

@ -6,6 +6,7 @@ import { ServerModule } from "./base.ts";
export class GeneralModule extends ServerModule implements ServerGeneral { export class GeneralModule extends ServerModule implements ServerGeneral {
server_id!: string; server_id!: string;
name!: string; name!: string;
owner_id!: string;
net!: { ip: string; port: number; domain: string }; net!: { ip: string; port: number; domain: string };
game!: string; game!: string;
backup_quota!: number; backup_quota!: number;

View File

@ -147,7 +147,7 @@ export async function useServersFetch<T>(
404: "Not Found", 404: "Not Found",
405: "Method Not Allowed", 405: "Method Not Allowed",
408: "Request Timeout", 408: "Request Timeout",
429: "Too Many Requests", 429: "You're making requests too quickly. Please wait a moment and try again.",
500: "Internal Server Error", 500: "Internal Server Error",
502: "Bad Gateway", 502: "Bad Gateway",
503: "Service Unavailable", 503: "Service Unavailable",
@ -167,11 +167,17 @@ export async function useServersFetch<T>(
console.error("Fetch error:", error); console.error("Fetch error:", error);
const fetchError = new ModrinthServersFetchError( const fetchError = new ModrinthServersFetchError(
`[Modrinth Servers] ${message}`, `[Modrinth Servers] ${error.message}`,
statusCode, statusCode,
error, error,
); );
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error); throw new ModrinthServerError(
`[Modrinth Servers] ${message}`,
statusCode,
fetchError,
module,
v1Error,
);
} }
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000; const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000;

View File

@ -0,0 +1,236 @@
import type { ExtendedReport, OwnershipTarget } from "@modrinth/moderation";
import type {
Thread,
Version,
User,
Project,
TeamMember,
Organization,
Report,
} from "@modrinth/utils";
export const useModerationCache = () => ({
threads: useState<Map<string, Thread>>("moderation-report-cache-threads", () => new Map()),
users: useState<Map<string, User>>("moderation-report-cache-users", () => new Map()),
projects: useState<Map<string, Project>>("moderation-report-cache-projects", () => new Map()),
versions: useState<Map<string, Version>>("moderation-report-cache-versions", () => new Map()),
teams: useState<Map<string, TeamMember[]>>("moderation-report-cache-teams", () => new Map()),
orgs: useState<Map<string, Organization>>("moderation-report-cache-orgs", () => new Map()),
});
// TODO: @AlexTMjugador - backend should do all of these functions.
export async function enrichReportBatch(reports: Report[]): Promise<ExtendedReport[]> {
if (reports.length === 0) return [];
const cache = useModerationCache();
const threadIDs = reports
.map((r) => r.thread_id)
.filter(Boolean)
.filter((id) => !cache.threads.value.has(id));
const userIDs = [
...reports.filter((r) => r.item_type === "user").map((r) => r.item_id),
...reports.map((r) => r.reporter),
].filter((id) => !cache.users.value.has(id));
const versionIDs = reports
.filter((r) => r.item_type === "version")
.map((r) => r.item_id)
.filter((id) => !cache.versions.value.has(id));
const projectIDs = reports
.filter((r) => r.item_type === "project")
.map((r) => r.item_id)
.filter((id) => !cache.projects.value.has(id));
const [newThreads, newVersions, newUsers] = await Promise.all([
threadIDs.length > 0
? (fetchSegmented(threadIDs, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`) as Promise<
Thread[]
>)
: Promise.resolve([]),
versionIDs.length > 0
? (fetchSegmented(versionIDs, (ids) => `versions?ids=${asEncodedJsonArray(ids)}`) as Promise<
Version[]
>)
: Promise.resolve([]),
[...new Set(userIDs)].length > 0
? (fetchSegmented(
[...new Set(userIDs)],
(ids) => `users?ids=${asEncodedJsonArray(ids)}`,
) as Promise<User[]>)
: Promise.resolve([]),
]);
newThreads.forEach((t) => cache.threads.value.set(t.id, t));
newVersions.forEach((v) => cache.versions.value.set(v.id, v));
newUsers.forEach((u) => cache.users.value.set(u.id, u));
const allVersions = [...newVersions, ...Array.from(cache.versions.value.values())];
const fullProjectIds = new Set([
...projectIDs,
...allVersions
.filter((v) => versionIDs.includes(v.id))
.map((v) => v.project_id)
.filter(Boolean),
]);
const uncachedProjectIds = Array.from(fullProjectIds).filter(
(id) => !cache.projects.value.has(id),
);
const newProjects =
uncachedProjectIds.length > 0
? ((await fetchSegmented(
uncachedProjectIds,
(ids) => `projects?ids=${asEncodedJsonArray(ids)}`,
)) as Project[])
: [];
newProjects.forEach((p) => cache.projects.value.set(p.id, p));
const allProjects = [...newProjects, ...Array.from(cache.projects.value.values())];
const teamIds = [...new Set(allProjects.map((p) => p.team).filter(Boolean))].filter(
(id) => !cache.teams.value.has(id || "invalid team id"),
);
const orgIds = [...new Set(allProjects.map((p) => p.organization).filter(Boolean))].filter(
(id) => !cache.orgs.value.has(id),
);
const [newTeams, newOrgs] = await Promise.all([
teamIds.length > 0
? (fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`) as Promise<
TeamMember[][]
>)
: Promise.resolve([]),
orgIds.length > 0
? (fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
}) as Promise<Organization[]>)
: Promise.resolve([]),
]);
newTeams.forEach((team) => {
if (team.length > 0) cache.teams.value.set(team[0].team_id, team);
});
newOrgs.forEach((org) => cache.orgs.value.set(org.id, org));
return reports.map((report) => {
const thread = cache.threads.value.get(report.thread_id) || ({} as Thread);
const version =
report.item_type === "version" ? cache.versions.value.get(report.item_id) : undefined;
const project =
report.item_type === "project"
? cache.projects.value.get(report.item_id)
: report.item_type === "version" && version
? cache.projects.value.get(version.project_id)
: undefined;
let target: OwnershipTarget | undefined;
if (report.item_type === "user") {
const targetUser = cache.users.value.get(report.item_id);
if (targetUser) {
target = {
name: targetUser.username,
slug: targetUser.username,
avatar_url: targetUser.avatar_url,
type: "user",
};
}
} else if (project) {
let owner: TeamMember | null = null;
let org: Organization | null = null;
if (project.team) {
const teamMembers = cache.teams.value.get(project.team);
if (teamMembers) {
owner = teamMembers.find((member) => member.role === "Owner") || null;
}
}
if (project.organization) {
org = cache.orgs.value.get(project.organization) || null;
}
if (org) {
target = {
name: org.name,
avatar_url: org.icon_url,
type: "organization",
slug: org.slug,
};
} else if (owner) {
target = {
name: owner.user.username,
avatar_url: owner.user.avatar_url,
type: "user",
slug: owner.user.username,
};
}
}
return {
...report,
thread,
reporter_user: cache.users.value.get(report.reporter) || ({} as User),
project,
user: report.item_type === "user" ? cache.users.value.get(report.item_id) : undefined,
version,
target,
};
});
}
// Doesn't need to be in @modrinth/moderation because it is specific to the frontend.
export interface ModerationProject {
project: any;
owner: TeamMember | null;
org: Organization | null;
}
export async function enrichProjectBatch(projects: any[]): Promise<ModerationProject[]> {
const teamIds = [...new Set(projects.map((p) => p.team_id).filter(Boolean))];
const orgIds = [...new Set(projects.map((p) => p.organization).filter(Boolean))];
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
teamIds.length > 0
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: Promise.resolve([]),
orgIds.length > 0
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
})
: Promise.resolve([]),
]);
const cache = useModerationCache();
teamsData.forEach((team) => {
if (team.length > 0) cache.teams.value.set(team[0].team_id, team);
});
orgsData.forEach((org: Organization) => {
cache.orgs.value.set(org.id, org);
});
return projects.map((project) => {
let owner: TeamMember | null = null;
let org: Organization | null = null;
if (project.team_id) {
const teamMembers = cache.teams.value.get(project.team_id);
if (teamMembers) {
owner = teamMembers.find((member) => member.role === "Owner") || null;
}
}
if (project.organization) {
org = cache.orgs.value.get(project.organization) || null;
}
return {
project,
owner,
org,
} as ModerationProject;
});
}

View File

@ -295,7 +295,7 @@
{ {
id: 'review-projects', id: 'review-projects',
color: 'orange', color: 'orange',
link: '/moderation/review', link: '/moderation/',
}, },
{ {
id: 'review-reports', id: 'review-reports',
@ -981,23 +981,6 @@ const userMenuOptions = computed(() => {
}, },
]; ];
if (
(auth.value && auth.value.user && auth.value.user.role === "moderator") ||
auth.value.user.role === "admin"
) {
options = [
...options,
{
divider: true,
},
{
id: "moderation",
color: "orange",
link: "/moderation/review",
},
];
}
options = [ options = [
...options, ...options,
{ {

View File

@ -182,9 +182,6 @@
"collection.button.unfollow-project": { "collection.button.unfollow-project": {
"message": "Unfollow project" "message": "Unfollow project"
}, },
"collection.button.upload-icon": {
"message": "Upload icon"
},
"collection.delete-modal.description": { "collection.delete-modal.description": {
"message": "This will remove this collection forever. This action cannot be undone." "message": "This will remove this collection forever. This action cannot be undone."
}, },
@ -479,6 +476,30 @@
"layout.nav.search": { "layout.nav.search": {
"message": "Search" "message": "Search"
}, },
"moderation.filter.by": {
"message": "Filter by"
},
"moderation.moderate": {
"message": "Moderate"
},
"moderation.page.projects": {
"message": "Projects"
},
"moderation.page.reports": {
"message": "Reports"
},
"moderation.page.technicalReview": {
"message": "Technical Review"
},
"moderation.search.placeholder": {
"message": "Search..."
},
"moderation.sort.by": {
"message": "Sort by"
},
"moderation.technical.search.placeholder": {
"message": "Search tech reviews..."
},
"profile.button.billing": { "profile.button.billing": {
"message": "Manage user billing" "message": "Manage user billing"
}, },

View File

@ -689,7 +689,10 @@
}, },
{ {
id: 'moderation-checklist', id: 'moderation-checklist',
action: () => (showModerationChecklist = true), action: () => {
moderationStore.setSingleProject(project.id);
showModerationChecklist = true;
},
color: 'orange', color: 'orange',
hoverOnly: true, hoverOnly: true,
shown: shown:
@ -870,19 +873,6 @@
@delete-version="deleteVersion" @delete-version="deleteVersion"
/> />
</div> </div>
<div class="normal-page__ultimate-sidebar">
<!-- Uncomment this to enable the old moderation checklist. -->
<!-- <ModerationChecklist
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
:project="project"
:future-projects="futureProjects"
:reset-project="resetProject"
:collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/> -->
</div>
</div> </div>
</div> </div>
@ -890,9 +880,8 @@
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist" v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
class="moderation-checklist" class="moderation-checklist"
> >
<NewModerationChecklist <ModerationChecklist
:project="project" :project="project"
:future-project-ids="futureProjectIds"
:collapsed="collapsedModerationChecklist" :collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false" @exit="showModerationChecklist = false"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist" @toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
@ -951,14 +940,7 @@ import {
useRelativeTime, useRelativeTime,
} from "@modrinth/ui"; } from "@modrinth/ui";
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue"; import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
import { import { formatCategory, formatProjectType, renderString } from "@modrinth/utils";
formatCategory,
formatProjectType,
isRejected,
isStaff,
isUnderReview,
renderString,
} from "@modrinth/utils";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Tooltip } from "floating-vue"; import { Tooltip } from "floating-vue";
import { useLocalStorage } from "@vueuse/core"; import { useLocalStorage } from "@vueuse/core";
@ -976,11 +958,13 @@ import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
import { userCollectProject } from "~/composables/user.js"; import { userCollectProject } from "~/composables/user.js";
import { reportProject } from "~/utils/report-helpers.ts"; import { reportProject } from "~/utils/report-helpers.ts";
import { saveFeatureFlags } from "~/composables/featureFlags.ts"; import { saveFeatureFlags } from "~/composables/featureFlags.ts";
import NewModerationChecklist from "~/components/ui/moderation/NewModerationChecklist.vue"; import ModerationChecklist from "~/components/ui/moderation/checklist/ModerationChecklist.vue";
import { useModerationStore } from "~/store/moderation.ts";
const data = useNuxtApp(); const data = useNuxtApp();
const route = useNativeRoute(); const route = useNativeRoute();
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const moderationStore = useModerationStore();
const auth = await useAuth(); const auth = await useAuth();
const user = await useUser(); const user = await useUser();
@ -1568,12 +1552,6 @@ const showModerationChecklist = useLocalStorage(
); );
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false); const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
const futureProjectIds = useLocalStorage("moderation-future-projects", []);
watch(futureProjectIds, (newValue) => {
console.log("Future project IDs updated:", newValue);
});
watch( watch(
showModerationChecklist, showModerationChecklist,
(newValue) => { (newValue) => {
@ -1646,9 +1624,7 @@ const navLinks = computed(() => {
{ {
label: formatMessage(messages.moderationTab), label: formatMessage(messages.moderationTab),
href: `${projectUrl}/moderation`, href: `${projectUrl}/moderation`,
shown: shown: !!currentMember.value,
!!currentMember.value &&
(isRejected(project.value) || isUnderReview(project.value) || isStaff(auth.value.user)),
}, },
]; ];
}); });

View File

@ -365,8 +365,10 @@ export default defineNuxtComponent({
if (e.key === "Escape") { if (e.key === "Escape") {
this.expandedGalleryItem = null; this.expandedGalleryItem = null;
} else if (e.key === "ArrowLeft") { } else if (e.key === "ArrowLeft") {
e.stopPropagation();
this.previousImage(); this.previousImage();
} else if (e.key === "ArrowRight") { } else if (e.key === "ArrowRight") {
e.stopPropagation();
this.nextImage(); this.nextImage();
} }
} }

View File

@ -76,8 +76,15 @@
<p> <p>
This is a private conversation thread with the Modrinth moderators. They may message you This is a private conversation thread with the Modrinth moderators. They may message you
with issues concerning this project. This thread is only checked when you submit your with issues concerning this project. This thread is only checked when you submit your
project for review. For additional inquiries, contact project for review. For additional inquiries, please go to the
<a href="https://support.modrinth.com">Modrinth Support</a>. <a class="text-link" href="https://support.modrinth.com" target="_blank">
Modrinth Help Center
</a>
and click the green bubble to contact support.
</p>
<p v-if="isApproved(project)" class="warning">
<IssuesIcon /> The moderators do not actively monitor this chat. However, they may still see
messages here if there is a problem with your project.
</p> </p>
<ConversationThread <ConversationThread
v-if="thread" v-if="thread"

View File

@ -58,6 +58,41 @@
</div> </div>
</div> </div>
</NewModal> </NewModal>
<NewModal ref="modifyModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">Modify charge</span>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="cancel" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Cancel server
<span class="text-brand-red">*</span>
</span>
<span>
Whether or not the subscription should be cancelled. Submitting this as "true" will
cancel the subscription, while submitting it as "false" will force another charge
attempt to be made.
</span>
</label>
<Toggle id="cancel" v-model="cancel" />
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button :disabled="modifying" @click="modifyCharge">
<CheckIcon aria-hidden="true" />
Modify charge
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modifyModal.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="page experimental-styles-within"> <div class="page experimental-styles-within">
<div <div
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4" class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
@ -201,6 +236,12 @@
Refund options Refund options
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else-if="charge.status === 'failed'" color="red" color-fill="text">
<button @click="showModifyModal(subscription)">
<CurrencyIcon />
Modify charge
</button>
</ButtonStyled>
</div> </div>
</div> </div>
</div> </div>
@ -234,7 +275,6 @@ import { products } from "~/generated/state.json";
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue"; import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
const route = useRoute(); const route = useRoute();
const data = useNuxtApp();
const vintl = useVIntl(); const vintl = useVIntl();
const { formatMessage } = vintl; const { formatMessage } = vintl;
@ -304,6 +344,10 @@ const refundTypes = ref(["full", "partial", "none"]);
const refundAmount = ref(0); const refundAmount = ref(0);
const unprovision = ref(true); const unprovision = ref(true);
const modifying = ref(false);
const modifyModal = ref();
const cancel = ref(false);
function showRefundModal(charge) { function showRefundModal(charge) {
selectedCharge.value = charge; selectedCharge.value = charge;
refundType.value = "full"; refundType.value = "full";
@ -312,6 +356,12 @@ function showRefundModal(charge) {
refundModal.value.show(); refundModal.value.show();
} }
function showModifyModal(charge) {
selectedCharge.value = charge;
cancel.value = false;
modifyModal.value.show();
}
async function refundCharge() { async function refundCharge() {
refunding.value = true; refunding.value = true;
try { try {
@ -327,8 +377,7 @@ async function refundCharge() {
await refreshCharges(); await refreshCharges();
refundModal.value.hide(); refundModal.value.hide();
} catch (err) { } catch (err) {
data.$notify({ addNotification({
group: "main",
title: "Error refunding", title: "Error refunding",
text: err.data?.description ?? err, text: err.data?.description ?? err,
type: "error", type: "error",
@ -337,6 +386,32 @@ async function refundCharge() {
refunding.value = false; refunding.value = false;
} }
async function modifyCharge() {
modifying.value = true;
try {
await useBaseFetch(`billing/subscription/${selectedCharge.value.id}`, {
method: "PATCH",
body: JSON.stringify({
cancelled: cancel.value,
}),
internal: true,
});
addNotification({
title: "Resubscription request submitted",
text: "If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.",
type: "success",
});
await refreshCharges();
} catch (err) {
addNotification({
title: "Error reattempting charge",
text: err.data?.description ?? err,
type: "error",
});
}
modifying.value = false;
}
const chargeStatuses = { const chargeStatuses = {
open: { open: {
color: "bg-blue", color: "bg-blue",

View File

@ -218,7 +218,7 @@ const username = ref("");
const password = ref(""); const password = ref("");
const confirmPassword = ref(""); const confirmPassword = ref("");
const token = ref(""); const token = ref("");
const subscribe = ref(true); const subscribe = ref(false);
async function createAccount() { async function createAccount() {
startLoading(); startLoading();

View File

@ -40,7 +40,6 @@
@change="showPreviewImage" @change="showPreviewImage"
> >
<UploadIcon aria-hidden="true" /> <UploadIcon aria-hidden="true" />
{{ formatMessage(messages.uploadIconButton) }}
</FileInput> </FileInput>
<Button <Button
v-if="!deletedIcon && (previewImage || collection.icon_url)" v-if="!deletedIcon && (previewImage || collection.icon_url)"
@ -479,10 +478,6 @@ const messages = defineMessages({
id: "collection.label.updated-at", id: "collection.label.updated-at",
defaultMessage: "Updated {ago}", defaultMessage: "Updated {ago}",
}, },
uploadIconButton: {
id: "collection.button.upload-icon",
defaultMessage: "Upload icon",
},
}); });
const data = useNuxtApp(); const data = useNuxtApp();

View File

@ -1,33 +1,84 @@
<template> <template>
<div class="normal-page"> <div
<div class="normal-page__sidebar"> class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
<aside class="universal-card"> >
<h1>Moderation</h1> <h1>Moderation</h1>
<NavStack> <NavTabs :links="moderationLinks" class="mb-4 hidden sm:flex" />
<NavStackItem link="/moderation" label="Overview"> <div class="mb-4 sm:hidden">
<ModrinthIcon aria-hidden="true" /> <Chips
</NavStackItem> v-model="selectedChip"
<NavStackItem link="/moderation/review" label="Review projects"> :items="mobileNavOptions"
<ScaleIcon aria-hidden="true" /> :never-empty="true"
</NavStackItem> @change="navigateToPage"
<NavStackItem link="/moderation/reports" label="Reports"> />
<ReportIcon aria-hidden="true" />
</NavStackItem>
</NavStack>
</aside>
</div> </div>
<div class="normal-page__content">
<NuxtPage /> <NuxtPage />
</div> </div>
</div>
</template> </template>
<script setup> <script setup lang="ts">
import { ModrinthIcon, ScaleIcon, ReportIcon } from "@modrinth/assets"; import { defineMessages, useVIntl } from "@vintl/vintl";
import NavStack from "~/components/ui/NavStack.vue"; import { Chips } from "@modrinth/ui";
import NavStackItem from "~/components/ui/NavStackItem.vue"; import NavTabs from "@/components/ui/NavTabs.vue";
definePageMeta({ definePageMeta({
middleware: "auth", middleware: "auth",
}); });
const { formatMessage } = useVIntl();
const route = useRoute();
const router = useRouter();
const messages = defineMessages({
projectsTitle: {
id: "moderation.page.projects",
defaultMessage: "Projects",
},
technicalReviewTitle: {
id: "moderation.page.technicalReview",
defaultMessage: "Technical Review",
},
reportsTitle: {
id: "moderation.page.reports",
defaultMessage: "Reports",
},
});
const moderationLinks = [
{ label: formatMessage(messages.projectsTitle), href: "/moderation" },
{ label: formatMessage(messages.technicalReviewTitle), href: "/moderation/technical-review" },
{ label: formatMessage(messages.reportsTitle), href: "/moderation/reports" },
];
const mobileNavOptions = [
formatMessage(messages.projectsTitle),
formatMessage(messages.technicalReviewTitle),
formatMessage(messages.reportsTitle),
];
const selectedChip = computed({
get() {
const path = route.path;
if (path === "/moderation/technical-review") {
return formatMessage(messages.technicalReviewTitle);
} else if (path.startsWith("/moderation/reports/")) {
return formatMessage(messages.reportsTitle);
} else {
return formatMessage(messages.projectsTitle);
}
},
set(value: string) {
navigateToPage(value);
},
});
function navigateToPage(selectedOption: string) {
if (selectedOption === formatMessage(messages.technicalReviewTitle)) {
router.push("/moderation/technical-review");
} else if (selectedOption === formatMessage(messages.reportsTitle)) {
router.push("/moderation/reports");
} else {
router.push("/moderation");
}
}
</script> </script>

View File

@ -1,42 +1,339 @@
<template> <template>
<div> <div class="flex flex-col gap-3">
<section class="universal-card"> <div class="flex flex-col justify-between gap-3 lg:flex-row">
<h2>Statistics</h2> <div class="iconified-input flex-1 lg:max-w-md">
<div class="grid-display"> <SearchIcon aria-hidden="true" class="text-lg" />
<div class="grid-display__item"> <input
<div class="label">Projects</div> v-model="query"
<div class="value"> class="h-[40px]"
{{ formatNumber(stats.projects, false) }} autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="goToPage(1)"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<div class="flex flex-col gap-2 sm:flex-row">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredProjects.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
<ButtonStyled color="orange" class="w-full sm:w-auto">
<button
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
@click="moderateAllInFilter()"
>
<ScaleIcon class="size-4 flex-shrink-0" />
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
<span class="sm:hidden">Moderate</span>
</button>
</ButtonStyled>
</div> </div>
</div> </div>
<div class="grid-display__item">
<div class="label">Versions</div> <div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<div class="value"> <Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
{{ formatNumber(stats.versions, false) }} <ConfettiExplosion v-if="visible" />
</div> </div>
<div class="mt-4 flex flex-col gap-2">
<div v-if="paginatedProjects.length === 0" class="universal-card h-24 animate-pulse"></div>
<ModerationQueueCard
v-for="item in paginatedProjects"
v-else
:key="item.project.id"
:queue-entry="item"
:owner="item.owner"
:org="item.org"
/>
</div> </div>
<div class="grid-display__item">
<div class="label">Files</div> <div v-if="totalPages > 1" class="mt-4 flex justify-center">
<div class="value"> <Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
{{ formatNumber(stats.files, false) }}
</div> </div>
</div> </div>
<div class="grid-display__item">
<div class="label">Authors</div>
<div class="value">
{{ formatNumber(stats.authors, false) }}
</div>
</div>
</div>
</section>
</div>
</template> </template>
<script setup> <script setup lang="ts">
import { formatNumber } from "@modrinth/utils"; import { DropdownSelect, Button, ButtonStyled, Pagination } from "@modrinth/ui";
import {
XIcon,
SearchIcon,
SortAscIcon,
SortDescIcon,
FilterIcon,
ScaleIcon,
} from "@modrinth/assets";
import { defineMessages, useVIntl } from "@vintl/vintl";
import { useLocalStorage } from "@vueuse/core";
import ConfettiExplosion from "vue-confetti-explosion";
import Fuse from "fuse.js";
import ModerationQueueCard from "~/components/ui/moderation/ModerationQueueCard.vue";
import { useModerationStore } from "~/store/moderation.ts";
import { enrichProjectBatch, type ModerationProject } from "~/helpers/moderation.ts";
useHead({ const { formatMessage } = useVIntl();
title: "Staff overview - Modrinth", const moderationStore = useModerationStore();
const route = useRoute();
const router = useRouter();
const visible = ref(false);
if (import.meta.client && history && history.state && history.state.confetti) {
setTimeout(async () => {
history.state.confetti = false;
visible.value = true;
await nextTick();
setTimeout(() => {
visible.value = false;
}, 5000);
}, 1000);
}
const messages = defineMessages({
searchPlaceholder: {
id: "moderation.search.placeholder",
defaultMessage: "Search...",
},
filterBy: {
id: "moderation.filter.by",
defaultMessage: "Filter by",
},
sortBy: {
id: "moderation.sort.by",
defaultMessage: "Sort by",
},
moderate: {
id: "moderation.moderate",
defaultMessage: "Moderate",
},
}); });
const { data: stats } = await useAsyncData("statistics", () => useBaseFetch("statistics")); const { data: allProjects } = await useLazyAsyncData("moderation-projects", async () => {
const startTime = performance.now();
let currentOffset = 0;
const PROJECT_ENDPOINT_COUNT = 350;
const allProjects: ModerationProject[] = [];
const enrichmentPromises: Promise<ModerationProject[]>[] = [];
while (true) {
const projects = (await useBaseFetch(
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ internal: true },
)) as any[];
if (projects.length === 0) break;
const enrichmentPromise = enrichProjectBatch(projects);
enrichmentPromises.push(enrichmentPromise);
currentOffset += projects.length;
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
allProjects.push(...completed.flat());
}
if (projects.length < PROJECT_ENDPOINT_COUNT) break;
}
const remainingBatches = await Promise.all(enrichmentPromises);
allProjects.push(...remainingBatches.flat());
const endTime = performance.now();
const duration = endTime - startTime;
console.debug(
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
);
return allProjects;
});
const query = ref(route.query.q?.toString() || "");
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query };
if (newQuery) {
currentQuery.q = newQuery;
} else {
delete currentQuery.q;
}
router.replace({
path: route.path,
query: currentQuery,
});
},
{ immediate: false },
);
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || "";
if (query.value !== newValue) {
query.value = newValue;
}
},
);
const currentFilterType = useLocalStorage("moderation-current-filter-type", () => "All projects");
const filterTypes: readonly string[] = readonly([
"All projects",
"Modpacks",
"Mods",
"Resource Packs",
"Data Packs",
"Plugins",
"Shaders",
]);
const currentSortType = useLocalStorage("moderation-current-sort-type", () => "Oldest");
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
const currentPage = ref(1);
const itemsPerPage = 15;
const totalPages = computed(() => Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage));
const fuse = computed(() => {
if (!allProjects.value || allProjects.value.length === 0) return null;
return new Fuse(allProjects.value, {
keys: [
{
name: "project.title",
weight: 3,
},
{
name: "project.slug",
weight: 2,
},
{
name: "project.description",
weight: 2,
},
{
name: "project.project_type",
weight: 1,
},
"owner.user.username",
"org.name",
"org.slug",
],
includeScore: true,
threshold: 0.4,
});
});
const searchResults = computed(() => {
if (!query.value || !fuse.value) return null;
return fuse.value.search(query.value).map((result) => result.item);
});
const baseFiltered = computed(() => {
if (!allProjects.value) return [];
return query.value && searchResults.value ? searchResults.value : [...allProjects.value];
});
const typeFiltered = computed(() => {
if (currentFilterType.value === "All projects") return baseFiltered.value;
const filterMap: Record<string, string> = {
Modpacks: "modpack",
Mods: "mod",
"Resource Packs": "resourcepack",
"Data Packs": "datapack",
Plugins: "plugin",
Shaders: "shader",
};
const projectType = filterMap[currentFilterType.value];
if (!projectType) return baseFiltered.value;
return baseFiltered.value.filter((queueItem) =>
queueItem.project.project_types.includes(projectType),
);
});
const filteredProjects = computed(() => {
const filtered = [...typeFiltered.value];
if (currentSortType.value === "Oldest") {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime();
const dateB = new Date(b.project.queued || b.project.published || 0).getTime();
return dateA - dateB;
});
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime();
const dateB = new Date(b.project.queued || b.project.published || 0).getTime();
return dateB - dateA;
});
}
return filtered;
});
const paginatedProjects = computed(() => {
if (!filteredProjects.value) return [];
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredProjects.value.slice(start, end);
});
function goToPage(page: number) {
currentPage.value = page;
}
function moderateAllInFilter() {
moderationStore.setQueue(filteredProjects.value.map((queueItem) => queueItem.project.id));
navigateTo({
name: "type-id",
params: {
type: "project",
id: moderationStore.getCurrentProjectId(),
},
state: {
showChecklist: true,
},
});
}
</script> </script>

View File

@ -1,17 +0,0 @@
<template>
<ReportView
:auth="auth"
:report-id="route.params.id"
:breadcrumbs-stack="[{ href: '/moderation/reports', label: 'Reports' }]"
/>
</template>
<script setup>
import ReportView from "~/components/ui/report/ReportView.vue";
const auth = await useAuth();
const route = useNativeRoute();
useHead({
title: `Report ${route.params.id} - Modrinth`,
});
</script>

View File

@ -1,16 +0,0 @@
<template>
<div>
<section class="universal-card">
<h2>Reports</h2>
<ReportsList :auth="auth" moderation />
</section>
</div>
</template>
<script setup>
import ReportsList from "~/components/ui/report/ReportsList.vue";
const auth = await useAuth();
useHead({
title: "Reports - Modrinth",
});
</script>

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import type { Report } from "@modrinth/utils";
import { enrichReportBatch } from "~/helpers/moderation.ts";
import ModerationReportCard from "~/components/ui/moderation/ModerationReportCard.vue";
const { params } = useRoute();
const reportId = params.id as string;
const { data: report } = await useAsyncData(`moderation-report-${reportId}`, async () => {
try {
const report = (await useBaseFetch(`report/${reportId}`, { apiVersion: 3 })) as Report;
const enrichedReport = (await enrichReportBatch([report]))[0];
return enrichedReport;
} catch (error) {
console.error("Error fetching report:", error);
throw createError({
statusCode: 404,
statusMessage: "Report not found",
});
}
});
</script>
<template>
<div class="flex flex-col gap-3">
<ModerationReportCard v-if="report" :report="report" />
</div>
</template>

View File

@ -0,0 +1,290 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="goToPage(1)"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="mt-4 flex flex-col gap-2">
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
<ReportCard v-for="report in paginatedReports" v-else :key="report.id" :report="report" />
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownSelect, Button, Pagination } from "@modrinth/ui";
import { XIcon, SearchIcon, SortAscIcon, SortDescIcon, FilterIcon } from "@modrinth/assets";
import { defineMessages, useVIntl } from "@vintl/vintl";
import { useLocalStorage } from "@vueuse/core";
import type { Report } from "@modrinth/utils";
import Fuse from "fuse.js";
import type { ExtendedReport } from "@modrinth/moderation";
import ReportCard from "~/components/ui/moderation/ModerationReportCard.vue";
import { enrichReportBatch } from "~/helpers/moderation.ts";
const { formatMessage } = useVIntl();
const route = useRoute();
const router = useRouter();
const messages = defineMessages({
searchPlaceholder: {
id: "moderation.search.placeholder",
defaultMessage: "Search...",
},
filterBy: {
id: "moderation.filter.by",
defaultMessage: "Filter by",
},
sortBy: {
id: "moderation.sort.by",
defaultMessage: "Sort by",
},
});
const { data: allReports } = await useLazyAsyncData("new-moderation-reports", async () => {
const startTime = performance.now();
let currentOffset = 0;
const REPORT_ENDPOINT_COUNT = 350;
const allReports: ExtendedReport[] = [];
const enrichmentPromises: Promise<ExtendedReport[]>[] = [];
while (true) {
const reports = (await useBaseFetch(
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ apiVersion: 3 },
)) as Report[];
if (reports.length === 0) break;
const enrichmentPromise = enrichReportBatch(reports);
enrichmentPromises.push(enrichmentPromise);
currentOffset += reports.length;
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
allReports.push(...completed.flat());
}
if (reports.length < REPORT_ENDPOINT_COUNT) break;
}
const remainingBatches = await Promise.all(enrichmentPromises);
allReports.push(...remainingBatches.flat());
const endTime = performance.now();
const duration = endTime - startTime;
console.debug(
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
);
return allReports;
});
const query = ref(route.query.q?.toString() || "");
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query };
if (newQuery) {
currentQuery.q = newQuery;
} else {
delete currentQuery.q;
}
router.replace({
path: route.path,
query: currentQuery,
});
},
{ immediate: false },
);
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || "";
if (query.value !== newValue) {
query.value = newValue;
}
},
);
const currentFilterType = useLocalStorage("moderation-reports-filter-type", () => "All");
const filterTypes: readonly string[] = readonly(["All", "Unread", "Read"]);
const currentSortType = useLocalStorage("moderation-reports-sort-type", () => "Oldest");
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
const currentPage = ref(1);
const itemsPerPage = 15;
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage));
const fuse = computed(() => {
if (!allReports.value || allReports.value.length === 0) return null;
return new Fuse(allReports.value, {
keys: [
{
name: "id",
weight: 3,
},
{
name: "body",
weight: 3,
},
{
name: "report_type",
weight: 3,
},
{
name: "item_id",
weight: 2,
},
{
name: "reporter_user.username",
weight: 2,
},
"project.name",
"project.slug",
"user.username",
"version.name",
"target.name",
"target.slug",
],
includeScore: true,
threshold: 0.4,
});
});
const memberRoleMap = computed(() => {
if (!allReports.value?.length) return new Map();
const map = new Map();
for (const report of allReports.value) {
if (report.thread?.members?.length) {
const roleMap = new Map();
for (const member of report.thread.members) {
roleMap.set(member.id, member.role);
}
map.set(report.id, roleMap);
}
}
return map;
});
const searchResults = computed(() => {
if (!query.value || !fuse.value) return null;
return fuse.value.search(query.value).map((result) => result.item);
});
const baseFiltered = computed(() => {
if (!allReports.value) return [];
return query.value && searchResults.value ? searchResults.value : [...allReports.value];
});
const typeFiltered = computed(() => {
if (currentFilterType.value === "All") return baseFiltered.value;
return baseFiltered.value.filter((report) => {
const messages = report.thread?.messages || [];
if (messages.length === 0) {
return currentFilterType.value === "Unread";
}
const lastMessage = messages[messages.length - 1];
if (!lastMessage.author_id) return false;
const roleMap = memberRoleMap.value.get(report.id);
if (!roleMap) return false;
const authorRole = roleMap.get(lastMessage.author_id);
const isModeratorMessage = authorRole === "moderator" || authorRole === "admin";
return currentFilterType.value === "Read" ? isModeratorMessage : !isModeratorMessage;
});
});
const filteredReports = computed(() => {
const filtered = [...typeFiltered.value];
if (currentSortType.value === "Oldest") {
filtered.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
} else {
filtered.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
}
return filtered;
});
const paginatedReports = computed(() => {
if (!filteredReports.value) return [];
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredReports.value.slice(start, end);
});
function goToPage(page: number) {
currentPage.value = page;
}
</script>

View File

@ -1,304 +0,0 @@
<template>
<section class="universal-card">
<h2>Review projects</h2>
<div class="input-group">
<Chips
v-model="projectType"
:items="projectTypes"
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x) + 's')"
/>
<button v-if="oldestFirst" class="iconified-button push-right" @click="oldestFirst = false">
<SortDescIcon />
Sorting by oldest
</button>
<button v-else class="iconified-button push-right" @click="oldestFirst = true">
<SortAscIcon />
Sorting by newest
</button>
<button
class="btn btn-highlight"
:disabled="projectsFiltered.length === 0"
@click="goToProjects()"
>
<ScaleIcon />
Start moderating
</button>
</div>
<p v-if="projectType !== 'all'" class="project-count">
Showing {{ projectsFiltered.length }} {{ projectTypePlural }} of {{ projects.length }} total
projects in the queue.
</p>
<p v-else class="project-count">There are {{ projects.length }} projects in the queue.</p>
<p v-if="projectsOver24Hours.length > 0" class="warning project-count">
<IssuesIcon />
{{ projectsOver24Hours.length }} {{ projectTypePlural }}
have been in the queue for over 24 hours.
</p>
<p v-if="projectsOver48Hours.length > 0" class="danger project-count">
<IssuesIcon />
{{ projectsOver48Hours.length }} {{ projectTypePlural }}
have been in the queue for over 48 hours.
</p>
<div
v-for="project in projectsFiltered.sort((a, b) => {
if (oldestFirst) {
return b.age - a.age;
} else {
return a.age - b.age;
}
})"
:key="`project-${project.id}`"
class="universal-card recessed project"
>
<div class="project-title">
<div class="mobile-row">
<nuxt-link :to="`/project/${project.id}`" class="iconified-stacked-link">
<Avatar :src="project.icon_url" size="xs" no-shadow raised />
<span class="stacked">
<span class="title">{{ project.name }}</span>
<span>{{ formatProjectType(project.inferred_project_type) }}</span>
</span>
</nuxt-link>
</div>
<div class="mobile-row">
by
<nuxt-link
v-if="project.owner"
:to="`/user/${project.owner.user.id}`"
class="iconified-link"
>
<Avatar :src="project.owner.user.avatar_url" circle size="xxs" raised />
<span>{{ project.owner.user.username }}</span>
</nuxt-link>
<nuxt-link
v-else-if="project.org"
:to="`/organization/${project.org.id}`"
class="iconified-link"
>
<Avatar :src="project.org.icon_url" circle size="xxs" raised />
<span>{{ project.org.name }}</span>
</nuxt-link>
</div>
<div class="mobile-row">
is requesting to be
<ProjectStatusBadge
:status="project.requested_status ? project.requested_status : 'approved'"
/>
</div>
</div>
<div class="input-group">
<nuxt-link :to="`/project/${project.id}`" class="iconified-button raised-button">
<EyeIcon />
View project
</nuxt-link>
</div>
<span v-if="project.queued" :class="`submitter-info ${project.age_warning}`">
<IssuesIcon v-if="project.age_warning" />
Submitted
<span v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')">{{
formatRelativeTime(project.queued)
}}</span>
</span>
<span v-else class="submitter-info"><UnknownIcon /> Unknown queue date</span>
</div>
</section>
</template>
<script setup>
import { Avatar, ProjectStatusBadge, Chips, useRelativeTime } from "@modrinth/ui";
import {
UnknownIcon,
EyeIcon,
SortAscIcon,
SortDescIcon,
IssuesIcon,
ScaleIcon,
} from "@modrinth/assets";
import { formatProjectType } from "@modrinth/utils";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
useHead({
title: "Review projects - Modrinth",
});
const app = useNuxtApp();
const router = useRouter();
const now = app.$dayjs();
const TIME_24H = 86400000;
const TIME_48H = TIME_24H * 2;
const formatRelativeTime = useRelativeTime();
const { data: projects } = await useAsyncData("moderation/projects?count=1000", () =>
useBaseFetch("moderation/projects?count=1000", { internal: true }),
);
const members = ref([]);
const projectType = ref("all");
const oldestFirst = ref(true);
const projectsFiltered = computed(() =>
projects.value.filter(
(x) =>
projectType.value === "all" ||
app.$getProjectTypeForUrl(x.project_types[0], x.loaders) === projectType.value,
),
);
const projectsOver24Hours = computed(() =>
projectsFiltered.value.filter((project) => project.age >= TIME_24H && project.age < TIME_48H),
);
const projectsOver48Hours = computed(() =>
projectsFiltered.value.filter((project) => project.age >= TIME_48H),
);
const projectTypePlural = computed(() =>
projectType.value === "all"
? "projects"
: (formatProjectType(projectType.value) + "s").toLowerCase(),
);
const projectTypes = computed(() => {
const set = new Set();
set.add("all");
if (projects.value) {
for (const project of projects.value) {
set.add(project.inferred_project_type);
}
}
return [...set];
});
if (projects.value) {
const teamIds = projects.value.map((x) => x.team_id);
const orgIds = projects.value.filter((x) => x.organization).map((x) => x.organization);
const [{ data: teams }, { data: orgs }] = await Promise.all([
useAsyncData(`teams?ids=${asEncodedJsonArray(teamIds)}`, () =>
fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`),
),
useAsyncData(`organizations?ids=${asEncodedJsonArray(orgIds)}`, () =>
fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
}),
),
]);
if (teams.value) {
members.value = teams.value;
projects.value = projects.value.map((project) => {
project.owner = members.value
? members.value.flat().find((x) => x.team_id === project.team_id && x.role === "Owner")
: null;
project.org = orgs.value ? orgs.value.find((x) => x.id === project.organization) : null;
project.age = project.queued ? now - app.$dayjs(project.queued) : Number.MAX_VALUE;
project.age_warning = "";
if (project.age > TIME_24H * 2) {
project.age_warning = "danger";
} else if (project.age > TIME_24H) {
project.age_warning = "warning";
}
project.inferred_project_type = app.$getProjectTypeForUrl(
project.project_types[0],
project.loaders,
);
return project;
});
}
}
async function goToProjects() {
const project = projectsFiltered.value[0];
const remainingProjectIds = projectsFiltered.value.slice(1).map((p) => p.id);
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingProjectIds));
await router.push({
name: "type-id",
params: {
type: project.project_types[0],
id: project.slug ? project.slug : project.id,
},
state: {
showChecklist: true,
},
});
}
</script>
<style lang="scss" scoped>
.project {
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
@media screen and (min-width: 650px) {
display: grid;
grid-template: "title action" "date action";
grid-template-columns: 1fr auto;
}
}
.submitter-info {
margin: 0;
grid-area: date;
svg {
vertical-align: top;
}
}
.warning {
color: var(--color-orange);
}
.danger {
color: var(--color-red);
font-weight: bold;
}
.project-count {
margin-block: var(--spacing-card-md);
svg {
vertical-align: top;
}
}
.input-group {
grid-area: action;
}
.project-title {
display: flex;
gap: var(--spacing-card-xs);
align-items: center;
flex-wrap: wrap;
.mobile-row {
display: contents;
}
@media screen and (max-width: 800px) {
flex-direction: column;
align-items: flex-start;
.mobile-row {
display: flex;
flex-direction: row;
gap: var(--spacing-card-xs);
align-items: center;
flex-wrap: wrap;
}
}
}
:deep(.avatar) {
flex-shrink: 0;
&.size-xs {
margin-right: var(--spacing-card-xs);
}
}
</style>

View File

@ -0,0 +1,386 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="updateSearchResults()"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="updateSearchResults()"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="updateSearchResults()"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="mt-4 flex flex-col gap-2">
<DelphiReportCard
v-for="report in paginatedReports"
:key="report.version.id"
:report="report"
/>
<div
v-if="!paginatedReports || paginatedReports.length === 0"
class="universal-card h-24 animate-pulse"
></div>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownSelect, Button, Pagination } from "@modrinth/ui";
import { XIcon, SearchIcon, SortAscIcon, SortDescIcon, FilterIcon } from "@modrinth/assets";
import { defineMessages, useVIntl } from "@vintl/vintl";
import { useLocalStorage } from "@vueuse/core";
import type { TeamMember, Organization, DelphiReport, Project, Version } from "@modrinth/utils";
import Fuse from "fuse.js";
import type { OwnershipTarget, ExtendedDelphiReport } from "@modrinth/moderation";
import DelphiReportCard from "~/components/ui/moderation/ModerationDelphiReportCard.vue";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
const { formatMessage } = useVIntl();
const route = useRoute();
const router = useRouter();
const messages = defineMessages({
searchPlaceholder: {
id: "moderation.technical.search.placeholder",
defaultMessage: "Search tech reviews...",
},
filterBy: {
id: "moderation.filter.by",
defaultMessage: "Filter by",
},
sortBy: {
id: "moderation.sort.by",
defaultMessage: "Sort by",
},
});
async function getProjectQuicklyForMock(projectId: string): Promise<Project> {
return (await useBaseFetch(`project/${projectId}`)) as Project;
}
async function getVersionQuicklyForMock(versionId: string): Promise<Version> {
return (await useBaseFetch(`version/${versionId}`)) as Version;
}
const mockDelphiReports: DelphiReport[] = [
{
project: await getProjectQuicklyForMock("7MoE34WK"),
version: await getVersionQuicklyForMock("cTkKLWgA"),
trace_type: "url_usage",
file_path: "me/decce/gnetum/ASMEventHandlerHelper.java",
priority_score: 29,
status: "pending",
detected_at: "2025-04-01T12:00:00Z",
} as DelphiReport,
{
project: await getProjectQuicklyForMock("7MoE34WK"),
version: await getVersionQuicklyForMock("cTkKLWgA"),
trace_type: "url_usage",
file_path: "me/decce/gnetum/SomeOtherFile.java",
priority_score: 48,
status: "rejected",
detected_at: "2025-03-02T12:00:00Z",
} as DelphiReport,
{
project: await getProjectQuicklyForMock("7MoE34WK"),
version: await getVersionQuicklyForMock("cTkKLWgA"),
trace_type: "url_usage",
file_path: "me/decce/gnetum/YetAnotherFile.java",
priority_score: 15,
status: "approved",
detected_at: "2025-02-03T12:00:00Z",
} as DelphiReport,
];
const { data: allReports } = await useAsyncData("moderation-tech-reviews", async () => {
// TODO: replace with actual API call
const delphiReports = mockDelphiReports;
if (delphiReports.length === 0) {
return [];
}
const teamIds = [...new Set(delphiReports.map((report) => report.project.team).filter(Boolean))];
const orgIds = [
...new Set(delphiReports.map((report) => report.project.organization).filter(Boolean)),
];
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
teamIds.length > 0
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: Promise.resolve([]),
orgIds.length > 0
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
})
: Promise.resolve([]),
]);
const orgTeamIds = orgsData.map((org) => org.team_id).filter(Boolean);
const orgTeamsData: TeamMember[][] =
orgTeamIds.length > 0
? await fetchSegmented(orgTeamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: [];
const teamMap = new Map<string, TeamMember[]>();
const orgMap = new Map<string, Organization>();
teamsData.forEach((team) => {
let teamId = null;
for (const member of team) {
teamId = member.team_id;
if (!teamMap.has(teamId)) {
teamMap.set(teamId, team);
break;
}
}
});
orgTeamsData.forEach((team) => {
let teamId = null;
for (const member of team) {
teamId = member.team_id;
if (!teamMap.has(teamId)) {
teamMap.set(teamId, team);
break;
}
}
});
orgsData.forEach((org: Organization) => {
orgMap.set(org.id, org);
});
const extendedReports: ExtendedDelphiReport[] = delphiReports.map((report) => {
let target: OwnershipTarget | undefined;
const project = report.project;
if (project) {
let owner: TeamMember | null = null;
let org: Organization | null = null;
if (project.team) {
const teamMembers = teamMap.get(project.team);
if (teamMembers) {
owner = teamMembers.find((member) => member.role === "Owner") || null;
}
}
if (project.organization) {
org = orgMap.get(project.organization) || null;
}
if (org) {
target = {
name: org.name,
avatar_url: org.icon_url,
type: "organization",
slug: org.slug,
};
} else if (owner) {
target = {
name: owner.user.username,
avatar_url: owner.user.avatar_url,
type: "user",
slug: owner.user.username,
};
}
}
return {
...report,
target,
};
});
extendedReports.sort((a, b) => b.priority_score - a.priority_score);
return extendedReports;
});
const query = ref(route.query.q?.toString() || "");
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query };
if (newQuery) {
currentQuery.q = newQuery;
} else {
delete currentQuery.q;
}
router.replace({
path: route.path,
query: currentQuery,
});
},
{ immediate: false },
);
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || "";
if (query.value !== newValue) {
query.value = newValue;
}
},
);
const currentFilterType = useLocalStorage("moderation-tech-reviews-filter-type", () => "Pending");
const filterTypes: readonly string[] = readonly(["All", "Pending", "Approved", "Rejected"]);
const currentSortType = useLocalStorage("moderation-tech-reviews-sort-type", () => "Priority");
const sortTypes: readonly string[] = readonly(["Priority", "Oldest", "Newest"]);
const currentPage = ref(1);
const itemsPerPage = 15;
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage));
const fuse = computed(() => {
if (!allReports.value || allReports.value.length === 0) return null;
return new Fuse(allReports.value, {
keys: [
{
name: "version.id",
weight: 3,
},
{
name: "version.version_number",
weight: 3,
},
{
name: "project.title",
weight: 3,
},
{
name: "project.slug",
weight: 3,
},
{
name: "version.files.filename",
weight: 2,
},
{
name: "trace_type",
weight: 2,
},
{
name: "content",
weight: 0.5,
},
"file_path",
"project.id",
"target.name",
"target.slug",
],
includeScore: true,
threshold: 0.4,
});
});
const filteredReports = computed(() => {
if (!allReports.value) return [];
let filtered;
if (query.value && fuse.value) {
const results = fuse.value.search(query.value);
filtered = results.map((result) => result.item);
} else {
filtered = [...allReports.value];
}
if (currentFilterType.value === "Pending") {
filtered = filtered.filter((report) => report.status === "pending");
} else if (currentFilterType.value === "Approved") {
filtered = filtered.filter((report) => report.status === "approved");
} else if (currentFilterType.value === "Rejected") {
filtered = filtered.filter((report) => report.status === "rejected");
}
if (currentSortType.value === "Priority") {
filtered.sort((a, b) => b.priority_score - a.priority_score);
} else if (currentSortType.value === "Oldest") {
filtered.sort((a, b) => {
const dateA = new Date(a.detected_at).getTime();
const dateB = new Date(b.detected_at).getTime();
return dateA - dateB;
});
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.detected_at).getTime();
const dateB = new Date(b.detected_at).getTime();
return dateB - dateA;
});
}
return filtered;
});
const paginatedReports = computed(() => {
if (!filteredReports.value) return [];
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredReports.value.slice(start, end);
});
function updateSearchResults() {
currentPage.value = 1;
}
function goToPage(page: number) {
currentPage.value = page;
}
</script>

View File

@ -0,0 +1,3 @@
<template>
<p>Not yet implemented.</p>
</template>

View File

@ -16,12 +16,15 @@ import {
CardIcon, CardIcon,
UserIcon, UserIcon,
WrenchIcon, WrenchIcon,
ModrinthIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { isAdmin as isUserAdmin, type User } from "@modrinth/utils";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts"; import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue"; import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
const route = useRoute(); const route = useRoute();
const serverId = route.params.id as string; const serverId = route.params.id as string;
const auth = await useAuth();
const props = defineProps<{ const props = defineProps<{
server: ModrinthServer; server: ModrinthServer;
@ -32,7 +35,11 @@ useHead({
title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`, title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`,
}); });
const navLinks = [ const ownerId = computed(() => props.server.general?.owner_id ?? "Ghost");
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value);
const isAdmin = computed(() => isUserAdmin(auth.value?.user));
const navLinks = computed(() => [
{ icon: SettingsIcon, label: "General", href: `/servers/manage/${serverId}/options` }, { icon: SettingsIcon, label: "General", href: `/servers/manage/${serverId}/options` },
{ icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` }, { icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` },
{ icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` }, { icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` },
@ -48,7 +55,15 @@ const navLinks = [
label: "Billing", label: "Billing",
href: `/settings/billing#server-${serverId}`, href: `/settings/billing#server-${serverId}`,
external: true, external: true,
shown: isOwner.value,
},
{
icon: ModrinthIcon,
label: "Admin Billing",
href: `/admin/billing/${ownerId.value}`,
external: true,
shown: isAdmin.value,
}, },
{ icon: InfoIcon, label: "Info", href: `/servers/manage/${serverId}/options/info` }, { icon: InfoIcon, label: "Info", href: `/servers/manage/${serverId}/options/info` },
]; ]);
</script> </script>

View File

@ -42,7 +42,7 @@
</label> </label>
<ButtonStyled> <ButtonStyled>
<button <button
:disabled="invocation === startupSettings?.original_invocation" :disabled="invocation === originalInvocation"
class="!w-full sm:!w-auto" class="!w-full sm:!w-auto"
@click="resetToDefault" @click="resetToDefault"
> >
@ -120,8 +120,9 @@ const props = defineProps<{
server: ModrinthServer; server: ModrinthServer;
}>(); }>();
await props.server.startup.fetch();
const data = computed(() => props.server.general); const data = computed(() => props.server.general);
const startupSettings = computed(() => props.server.startup);
const showAllVersions = ref(false); const showAllVersions = ref(false);
const jdkVersionMap = [ const jdkVersionMap = [
@ -137,33 +138,15 @@ const jdkBuildMap = [
{ value: "graal", label: "GraalVM" }, { value: "graal", label: "GraalVM" },
]; ];
const invocation = ref(""); const invocation = ref(props.server.startup.invocation);
const jdkVersion = ref(""); const jdkVersion = ref(
const jdkBuild = ref(""); jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label,
const originalInvocation = ref("");
const originalJdkVersion = ref("");
const originalJdkBuild = ref("");
watch(
startupSettings,
(newSettings) => {
if (newSettings) {
invocation.value = newSettings.invocation;
originalInvocation.value = newSettings.invocation;
const jdkVersionLabel =
jdkVersionMap.find((v) => v.value === newSettings.jdk_version)?.label || "";
jdkVersion.value = jdkVersionLabel;
originalJdkVersion.value = jdkVersionLabel;
const jdkBuildLabel = jdkBuildMap.find((v) => v.value === newSettings.jdk_build)?.label || "";
jdkBuild.value = jdkBuildLabel;
originalJdkBuild.value = jdkBuildLabel;
}
},
{ immediate: true },
); );
const jdkBuild = ref(jdkBuildMap.find((v) => v.value === props.server.startup.jdk_build)?.label);
const originalInvocation = ref(invocation.value);
const originalJdkVersion = ref(jdkVersion.value);
const originalJdkBuild = ref(jdkBuild.value);
const hasUnsavedChanges = computed( const hasUnsavedChanges = computed(
() => () =>
@ -195,7 +178,7 @@ const displayedJavaVersions = computed(() => {
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value; return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
}); });
const saveStartup = async () => { async function saveStartup() {
try { try {
isUpdating.value = true; isUpdating.value = true;
const invocationValue = invocation.value ?? ""; const invocationValue = invocation.value ?? "";
@ -232,17 +215,17 @@ const saveStartup = async () => {
} finally { } finally {
isUpdating.value = false; isUpdating.value = false;
} }
}; }
const resetStartup = () => { function resetStartup() {
invocation.value = originalInvocation.value; invocation.value = originalInvocation.value;
jdkVersion.value = originalJdkVersion.value; jdkVersion.value = originalJdkVersion.value;
jdkBuild.value = originalJdkBuild.value; jdkBuild.value = originalJdkBuild.value;
}; }
const resetToDefault = () => { function resetToDefault() {
invocation.value = startupSettings.value?.original_invocation ?? ""; invocation.value = originalInvocation.value ?? "";
}; }
</script> </script>
<style scoped> <style scoped>

View File

@ -96,16 +96,7 @@
<UiServersServerListing <UiServersServerListing
v-for="server in filteredData" v-for="server in filteredData"
:key="server.server_id" :key="server.server_id"
:server_id="server.server_id" v-bind="server"
:name="server.name"
:status="server.status"
:game="server.game"
:loader="server.loader"
:loader_version="server.loader_version"
:mc_version="server.mc_version"
:upstream="server.upstream"
:net="server.net"
:flows="server.flows"
/> />
<LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" /> <LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" />
</ul> </ul>

View File

@ -208,15 +208,7 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<UiServersServerListing <UiServersServerListing
v-if="subscription.serverInfo" v-if="subscription.serverInfo"
:server_id="subscription.serverInfo.server_id" v-bind="subscription.serverInfo"
:name="subscription.serverInfo.name"
:status="subscription.serverInfo.status"
:game="subscription.serverInfo.game"
:loader="subscription.serverInfo.loader"
:loader_version="subscription.serverInfo.loader_version"
:mc_version="subscription.serverInfo.mc_version"
:upstream="subscription.serverInfo.upstream"
:net="subscription.serverInfo.net"
/> />
<div v-else class="w-fit"> <div v-else class="w-fit">
<p> <p>

View File

@ -0,0 +1,98 @@
import { defineStore, createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
export interface ModerationQueue {
items: string[];
total: number;
completed: number;
skipped: number;
lastUpdated: Date;
}
const EMPTY_QUEUE: Partial<ModerationQueue> = {
items: [],
// TODO: Consider some form of displaying this in the checklist, maybe at the end
total: 0,
completed: 0,
skipped: 0,
};
function createEmptyQueue(): ModerationQueue {
return { ...EMPTY_QUEUE, lastUpdated: new Date() } as ModerationQueue;
}
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
export const useModerationStore = defineStore("moderation", {
state: () => ({
currentQueue: createEmptyQueue(),
}),
getters: {
queueLength: (state) => state.currentQueue.items.length,
hasItems: (state) => state.currentQueue.items.length > 0,
progress: (state) => {
if (state.currentQueue.total === 0) return 0;
return (state.currentQueue.completed + state.currentQueue.skipped) / state.currentQueue.total;
},
},
actions: {
setQueue(projectIDs: string[]) {
this.currentQueue = {
items: [...projectIDs],
total: projectIDs.length,
completed: 0,
skipped: 0,
lastUpdated: new Date(),
};
},
setSingleProject(projectId: string) {
this.currentQueue = {
items: [projectId],
total: 1,
completed: 0,
skipped: 0,
lastUpdated: new Date(),
};
},
completeCurrentProject(projectId: string, status: "completed" | "skipped" = "completed") {
if (status === "completed") {
this.currentQueue.completed++;
} else {
this.currentQueue.skipped++;
}
this.currentQueue.items = this.currentQueue.items.filter((id: string) => id !== projectId);
this.currentQueue.lastUpdated = new Date();
return this.currentQueue.items.length > 0;
},
getCurrentProjectId(): string | null {
return this.currentQueue.items[0] || null;
},
resetQueue() {
this.currentQueue = createEmptyQueue();
},
},
persist: {
key: "moderation-store",
serializer: {
serialize: JSON.stringify,
deserialize: (value: string) => {
const parsed = JSON.parse(value);
if (parsed.currentQueue?.lastUpdated) {
parsed.currentQueue.lastUpdated = new Date(parsed.currentQueue.lastUpdated);
}
return parsed;
},
},
},
});

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21\n )\n ", "query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21, $22\n )\n ",
"describe": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
@ -25,10 +25,11 @@
"Text", "Text",
"Text", "Text",
"Text", "Text",
"Bool",
"Bool" "Bool"
] ]
}, },
"nullable": [] "nullable": []
}, },
"hash": "32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55" "hash": "010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE\n ORDER BY created ASC\n LIMIT $1;\n ", "query": "\n SELECT id FROM reports\n WHERE closed = FALSE\n ORDER BY created ASC\n OFFSET $2\n LIMIT $1\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -11,6 +11,7 @@
], ],
"parameters": { "parameters": {
"Left": [ "Left": [
"Int8",
"Int8" "Int8"
] ]
}, },
@ -18,5 +19,5 @@
false false
] ]
}, },
"hash": "29e171bd746ac5dc1fabae4c9f81c3d1df4e69c860b7d0f6a907377664199217" "hash": "1aea0d5e6936b043cb7727b779d60598aa812c8ef0f5895fa740859321092a1c"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -122,6 +122,11 @@
"ordinal": 23, "ordinal": 23,
"name": "allow_friend_requests", "name": "allow_friend_requests",
"type_info": "Bool" "type_info": "Bool"
},
{
"ordinal": 24,
"name": "is_subscribed_to_newsletter",
"type_info": "Bool"
} }
], ],
"parameters": { "parameters": {
@ -154,8 +159,9 @@
true, true,
true, true,
true, true,
false,
false false
] ]
}, },
"hash": "b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0" "hash": "5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE AND reporter = $1\n ORDER BY created ASC\n LIMIT $2;\n ", "query": "\n SELECT id FROM reports\n WHERE closed = FALSE AND reporter = $1\n ORDER BY created ASC\n OFFSET $3\n LIMIT $2\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -11,6 +11,7 @@
], ],
"parameters": { "parameters": {
"Left": [ "Left": [
"Int8",
"Int8", "Int8",
"Int8" "Int8"
] ]
@ -19,5 +20,5 @@
false false
] ]
}, },
"hash": "f17a109913015a7a5ab847bb2e73794d6261a08d450de24b450222755e520881" "hash": "be8a5dd2b71fdc279a6fa68fe5384da31afd91d4b480527e2dd8402aef36f12c"
} }

View File

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE users\n SET is_subscribed_to_newsletter = TRUE\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT id FROM mods\n WHERE status = $1\n ORDER BY queued ASC\n LIMIT $2;\n ", "query": "\n SELECT id FROM mods\n WHERE status = $1\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -12,6 +12,7 @@
"parameters": { "parameters": {
"Left": [ "Left": [
"Text", "Text",
"Int8",
"Int8" "Int8"
] ]
}, },
@ -19,5 +20,5 @@
false false
] ]
}, },
"hash": "3baabc9f08401801fa290866888c540746fc50c1d79911f08f3322b605ce5c30" "hash": "ccb0315ff52ea4402f53508334a7288fc9f8e77ffd7bce665441ff682384cbf9"
} }

View File

@ -0,0 +1 @@
CREATE INDEX reports_closed ON reports (closed);

View File

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN is_subscribed_to_newsletter BOOLEAN NOT NULL DEFAULT FALSE;

View File

@ -315,9 +315,13 @@ pub async fn filter_enlisted_version_ids(
pub async fn is_visible_collection( pub async fn is_visible_collection(
collection_data: &DBCollection, collection_data: &DBCollection,
user_option: &Option<User>, user_option: &Option<User>,
hide_unlisted: bool,
) -> Result<bool, ApiError> { ) -> Result<bool, ApiError> {
let mut authorized = !collection_data.status.is_hidden() let mut authorized = (if hide_unlisted {
&& !collection_data.projects.is_empty(); collection_data.status.is_searchable()
} else {
!collection_data.status.is_hidden()
}) && !collection_data.projects.is_empty();
if let Some(user) = &user_option { if let Some(user) = &user_option {
if !authorized if !authorized
&& (user.role.is_mod() || user.id == collection_data.user_id.into()) && (user.role.is_mod() || user.id == collection_data.user_id.into())
@ -331,12 +335,17 @@ pub async fn is_visible_collection(
pub async fn filter_visible_collections( pub async fn filter_visible_collections(
collections: Vec<DBCollection>, collections: Vec<DBCollection>,
user_option: &Option<User>, user_option: &Option<User>,
hide_unlisted: bool,
) -> Result<Vec<crate::models::collections::Collection>, ApiError> { ) -> Result<Vec<crate::models::collections::Collection>, ApiError> {
let mut return_collections = Vec::new(); let mut return_collections = Vec::new();
let mut check_collections = Vec::new(); let mut check_collections = Vec::new();
for collection in collections { for collection in collections {
if (!collection.status.is_hidden() && !collection.projects.is_empty()) if ((if hide_unlisted {
collection.status.is_searchable()
} else {
!collection.status.is_hidden()
}) && !collection.projects.is_empty())
|| user_option.as_ref().is_some_and(|x| x.role.is_mod()) || user_option.as_ref().is_some_and(|x| x.role.is_mod())
{ {
return_collections.push(collection.into()); return_collections.push(collection.into());

View File

@ -1,6 +1,6 @@
use super::AuthProvider; use super::AuthProvider;
use crate::auth::AuthenticationError; use crate::auth::AuthenticationError;
use crate::database::models::user_item; use crate::database::models::{DBUser, user_item};
use crate::database::redis::RedisPool; use crate::database::redis::RedisPool;
use crate::models::pats::Scopes; use crate::models::pats::Scopes;
use crate::models::users::User; use crate::models::users::User;
@ -44,17 +44,16 @@ where
Ok(Some((scopes, User::from_full(db_user)))) Ok(Some((scopes, User::from_full(db_user))))
} }
pub async fn get_user_from_headers<'a, E>( pub async fn get_full_user_from_headers<'a, E>(
req: &HttpRequest, req: &HttpRequest,
executor: E, executor: E,
redis: &RedisPool, redis: &RedisPool,
session_queue: &AuthQueue, session_queue: &AuthQueue,
required_scopes: Scopes, required_scopes: Scopes,
) -> Result<(Scopes, User), AuthenticationError> ) -> Result<(Scopes, DBUser), AuthenticationError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{ {
// Fetch DB user record and minos user from headers
let (scopes, db_user) = get_user_record_from_bearer_token( let (scopes, db_user) = get_user_record_from_bearer_token(
req, req,
None, None,
@ -65,13 +64,33 @@ where
.await? .await?
.ok_or_else(|| AuthenticationError::InvalidCredentials)?; .ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let user = User::from_full(db_user);
if !scopes.contains(required_scopes) { if !scopes.contains(required_scopes) {
return Err(AuthenticationError::InvalidCredentials); return Err(AuthenticationError::InvalidCredentials);
} }
Ok((scopes, user)) Ok((scopes, db_user))
}
pub async fn get_user_from_headers<'a, E>(
req: &HttpRequest,
executor: E,
redis: &RedisPool,
session_queue: &AuthQueue,
required_scopes: Scopes,
) -> Result<(Scopes, User), AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let (scopes, db_user) = get_full_user_from_headers(
req,
executor,
redis,
session_queue,
required_scopes,
)
.await?;
Ok((scopes, User::from_full(db_user)))
} }
pub async fn get_user_record_from_bearer_token<'a, 'b, E>( pub async fn get_user_record_from_bearer_token<'a, 'b, E>(

View File

@ -49,6 +49,8 @@ pub struct DBUser {
pub badges: Badges, pub badges: Badges,
pub allow_friend_requests: bool, pub allow_friend_requests: bool,
pub is_subscribed_to_newsletter: bool,
} }
impl DBUser { impl DBUser {
@ -63,13 +65,13 @@ impl DBUser {
avatar_url, raw_avatar_url, bio, created, avatar_url, raw_avatar_url, bio, created,
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
email_verified, password, paypal_id, paypal_country, paypal_email, email_verified, password, paypal_id, paypal_country, paypal_email,
venmo_handle, stripe_customer_id, allow_friend_requests venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
) )
VALUES ( VALUES (
$1, $2, $3, $4, $5, $1, $2, $3, $4, $5,
$6, $7, $6, $7,
$8, $9, $10, $11, $12, $13, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21 $14, $15, $16, $17, $18, $19, $20, $21, $22
) )
", ",
self.id as DBUserId, self.id as DBUserId,
@ -93,6 +95,7 @@ impl DBUser {
self.venmo_handle, self.venmo_handle,
self.stripe_customer_id, self.stripe_customer_id,
self.allow_friend_requests, self.allow_friend_requests,
self.is_subscribed_to_newsletter,
) )
.execute(&mut **transaction) .execute(&mut **transaction)
.await?; .await?;
@ -178,7 +181,7 @@ impl DBUser {
created, role, badges, created, role, badges,
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email, email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,
venmo_handle, stripe_customer_id, allow_friend_requests venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
FROM users FROM users
WHERE id = ANY($1) OR LOWER(username) = ANY($2) WHERE id = ANY($1) OR LOWER(username) = ANY($2)
", ",
@ -212,6 +215,7 @@ impl DBUser {
stripe_customer_id: u.stripe_customer_id, stripe_customer_id: u.stripe_customer_id,
totp_secret: u.totp_secret, totp_secret: u.totp_secret,
allow_friend_requests: u.allow_friend_requests, allow_friend_requests: u.allow_friend_requests,
is_subscribed_to_newsletter: u.is_subscribed_to_newsletter,
}; };
acc.insert(u.id, (Some(u.username), user)); acc.insert(u.id, (Some(u.username), user));

View File

@ -92,7 +92,7 @@ impl CollectionStatus {
} }
} }
// Project pages + info cannot be viewed // Collection pages + info cannot be viewed
pub fn is_hidden(&self) -> bool { pub fn is_hidden(&self) -> bool {
match self { match self {
CollectionStatus::Rejected => true, CollectionStatus::Rejected => true,
@ -103,6 +103,11 @@ impl CollectionStatus {
} }
} }
// Collection can be displayed in on user page
pub fn is_searchable(&self) -> bool {
matches!(self, CollectionStatus::Listed)
}
pub fn is_approved(&self) -> bool { pub fn is_approved(&self) -> bool {
match self { match self {
CollectionStatus::Listed => true, CollectionStatus::Listed => true,

View File

@ -276,7 +276,11 @@ pub async fn refund_charge(
subscription_interval: charge.subscription_interval, subscription_interval: charge.subscription_interval,
payment_platform: charge.payment_platform, payment_platform: charge.payment_platform,
payment_platform_id: id, payment_platform_id: id,
parent_charge_id: Some(charge.id), parent_charge_id: if refund_amount != 0 {
Some(charge.id)
} else {
None
},
net, net,
} }
.upsert(&mut transaction) .upsert(&mut transaction)

View File

@ -1,5 +1,7 @@
use crate::auth::email::send_email; use crate::auth::email::send_email;
use crate::auth::validate::get_user_record_from_bearer_token; use crate::auth::validate::{
get_full_user_from_headers, get_user_record_from_bearer_token,
};
use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers}; use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers};
use crate::database::models::DBUser; use crate::database::models::DBUser;
use crate::database::models::flow_item::DBFlow; use crate::database::models::flow_item::DBFlow;
@ -232,6 +234,7 @@ impl TempUser {
role: Role::Developer.to_string(), role: Role::Developer.to_string(),
badges: Badges::default(), badges: Badges::default(),
allow_friend_requests: true, allow_friend_requests: true,
is_subscribed_to_newsletter: false,
} }
.insert(transaction) .insert(transaction)
.await?; .await?;
@ -1291,37 +1294,6 @@ pub async fn delete_auth_provider(
Ok(HttpResponse::NoContent().finish()) Ok(HttpResponse::NoContent().finish())
} }
pub async fn sign_up_sendy(email: &str) -> Result<(), AuthenticationError> {
let url = dotenvy::var("SENDY_URL")?;
let id = dotenvy::var("SENDY_LIST_ID")?;
let api_key = dotenvy::var("SENDY_API_KEY")?;
let site_url = dotenvy::var("SITE_URL")?;
if url.is_empty() || url == "none" {
tracing::info!("Sendy URL not set, skipping signup");
return Ok(());
}
let mut form = HashMap::new();
form.insert("api_key", &*api_key);
form.insert("email", email);
form.insert("list", &*id);
form.insert("referrer", &*site_url);
let client = reqwest::Client::new();
client
.post(format!("{url}/subscribe"))
.form(&form)
.send()
.await?
.error_for_status()?
.text()
.await?;
Ok(())
}
pub async fn check_sendy_subscription( pub async fn check_sendy_subscription(
email: &str, email: &str,
) -> Result<bool, AuthenticationError> { ) -> Result<bool, AuthenticationError> {
@ -1456,6 +1428,9 @@ pub async fn create_account_with_password(
role: Role::Developer.to_string(), role: Role::Developer.to_string(),
badges: Badges::default(), badges: Badges::default(),
allow_friend_requests: true, allow_friend_requests: true,
is_subscribed_to_newsletter: new_account
.sign_up_newsletter
.unwrap_or(false),
} }
.insert(&mut transaction) .insert(&mut transaction)
.await?; .await?;
@ -1476,10 +1451,6 @@ pub async fn create_account_with_password(
&format!("Welcome to Modrinth, {}!", new_account.username), &format!("Welcome to Modrinth, {}!", new_account.username),
)?; )?;
if new_account.sign_up_newsletter.unwrap_or(false) {
sign_up_sendy(&new_account.email).await?;
}
transaction.commit().await?; transaction.commit().await?;
Ok(HttpResponse::Ok().json(res)) Ok(HttpResponse::Ok().json(res))
@ -2420,15 +2391,24 @@ pub async fn subscribe_newsletter(
.await? .await?
.1; .1;
if let Some(email) = user.email { sqlx::query!(
sign_up_sendy(&email).await?; "
UPDATE users
SET is_subscribed_to_newsletter = TRUE
WHERE id = $1
",
user.id.0 as i64,
)
.execute(&**pool)
.await?;
crate::database::models::DBUser::clear_caches(
&[(user.id.into(), None)],
&redis,
)
.await?;
Ok(HttpResponse::NoContent().finish()) Ok(HttpResponse::NoContent().finish())
} else {
Err(ApiError::InvalidInput(
"User does not have an email.".to_string(),
))
}
} }
#[get("email/subscribe")] #[get("email/subscribe")]
@ -2438,7 +2418,7 @@ pub async fn get_newsletter_subscription_status(
redis: Data<RedisPool>, redis: Data<RedisPool>,
session_queue: Data<AuthQueue>, session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers( let user = get_full_user_from_headers(
&req, &req,
&**pool, &**pool,
&redis, &redis,
@ -2448,16 +2428,16 @@ pub async fn get_newsletter_subscription_status(
.await? .await?
.1; .1;
if let Some(email) = user.email { let is_subscribed = user.is_subscribed_to_newsletter
let is_subscribed = check_sendy_subscription(&email).await?; || if let Some(email) = user.email {
check_sendy_subscription(&email).await?
} else {
false
};
Ok(HttpResponse::Ok().json(serde_json::json!({ Ok(HttpResponse::Ok().json(serde_json::json!({
"subscribed": is_subscribed "subscribed": is_subscribed
}))) })))
} else {
Ok(HttpResponse::Ok().json(serde_json::json!({
"subscribed": false
})))
}
} }
fn send_email_verify( fn send_email_verify(

View File

@ -18,12 +18,14 @@ pub fn config(cfg: &mut web::ServiceConfig) {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ResultCount { pub struct ProjectsRequestOptions {
#[serde(default = "default_count")] #[serde(default = "default_count")]
pub count: i16, pub count: u16,
#[serde(default)]
pub offset: u32,
} }
fn default_count() -> i16 { fn default_count() -> u16 {
100 100
} }
@ -31,7 +33,7 @@ pub async fn get_projects(
req: HttpRequest, req: HttpRequest,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
count: web::Query<ResultCount>, request_opts: web::Query<ProjectsRequestOptions>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers( check_is_moderator_from_headers(
@ -50,10 +52,12 @@ pub async fn get_projects(
SELECT id FROM mods SELECT id FROM mods
WHERE status = $1 WHERE status = $1
ORDER BY queued ASC ORDER BY queued ASC
LIMIT $2; OFFSET $3
LIMIT $2
", ",
ProjectStatus::Processing.as_str(), ProjectStatus::Processing.as_str(),
count.count as i64 request_opts.count as i64,
request_opts.offset as i64
) )
.fetch(&**pool) .fetch(&**pool)
.map_ok(|m| database::models::DBProjectId(m.id)) .map_ok(|m| database::models::DBProjectId(m.id))

View File

@ -15,10 +15,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ResultCount { pub struct ResultCount {
#[serde(default = "default_count")] #[serde(default = "default_count")]
pub count: i16, pub count: u16,
} }
fn default_count() -> i16 { fn default_count() -> u16 {
100 100
} }
@ -34,7 +34,10 @@ pub async fn get_projects(
req, req,
pool.clone(), pool.clone(),
redis.clone(), redis.clone(),
web::Query(internal::moderation::ResultCount { count: count.count }), web::Query(internal::moderation::ProjectsRequestOptions {
count: count.count,
offset: 0,
}),
session_queue, session_queue,
) )
.await .await

View File

@ -43,12 +43,12 @@ pub async fn report_create(
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ReportsRequestOptions { pub struct ReportsRequestOptions {
#[serde(default = "default_count")] #[serde(default = "default_count")]
count: i16, count: u16,
#[serde(default = "default_all")] #[serde(default = "default_all")]
all: bool, all: bool,
} }
fn default_count() -> i16 { fn default_count() -> u16 {
100 100
} }
fn default_all() -> bool { fn default_all() -> bool {
@ -60,7 +60,7 @@ pub async fn reports(
req: HttpRequest, req: HttpRequest,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
count: web::Query<ReportsRequestOptions>, request_opts: web::Query<ReportsRequestOptions>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let response = v3::reports::reports( let response = v3::reports::reports(
@ -68,8 +68,9 @@ pub async fn reports(
pool, pool,
redis, redis,
web::Query(v3::reports::ReportsRequestOptions { web::Query(v3::reports::ReportsRequestOptions {
count: count.count, count: request_opts.count,
all: count.all, offset: 0,
all: request_opts.all,
}), }),
session_queue, session_queue,
) )

View File

@ -163,7 +163,8 @@ pub async fn collections_get(
.ok(); .ok();
let collections = let collections =
filter_visible_collections(collections_data, &user_option).await?; filter_visible_collections(collections_data, &user_option, false)
.await?;
Ok(HttpResponse::Ok().json(collections)) Ok(HttpResponse::Ok().json(collections))
} }
@ -192,7 +193,7 @@ pub async fn collection_get(
.ok(); .ok();
if let Some(data) = collection_data { if let Some(data) = collection_data {
if is_visible_collection(&data, &user_option).await? { if is_visible_collection(&data, &user_option, false).await? {
return Ok(HttpResponse::Ok().json(Collection::from(data))); return Ok(HttpResponse::Ok().json(Collection::from(data)));
} }
} }

View File

@ -222,12 +222,14 @@ pub async fn report_create(
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ReportsRequestOptions { pub struct ReportsRequestOptions {
#[serde(default = "default_count")] #[serde(default = "default_count")]
pub count: i16, pub count: u16,
#[serde(default)]
pub offset: u32,
#[serde(default = "default_all")] #[serde(default = "default_all")]
pub all: bool, pub all: bool,
} }
fn default_count() -> i16 { fn default_count() -> u16 {
100 100
} }
fn default_all() -> bool { fn default_all() -> bool {
@ -238,7 +240,7 @@ pub async fn reports(
req: HttpRequest, req: HttpRequest,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
count: web::Query<ReportsRequestOptions>, request_opts: web::Query<ReportsRequestOptions>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers( let user = get_user_from_headers(
@ -253,15 +255,17 @@ pub async fn reports(
use futures::stream::TryStreamExt; use futures::stream::TryStreamExt;
let report_ids = if user.role.is_mod() && count.all { let report_ids = if user.role.is_mod() && request_opts.all {
sqlx::query!( sqlx::query!(
" "
SELECT id FROM reports SELECT id FROM reports
WHERE closed = FALSE WHERE closed = FALSE
ORDER BY created ASC ORDER BY created ASC
LIMIT $1; OFFSET $2
LIMIT $1
", ",
count.count as i64 request_opts.count as i64,
request_opts.offset as i64
) )
.fetch(&**pool) .fetch(&**pool)
.map_ok(|m| crate::database::models::ids::DBReportId(m.id)) .map_ok(|m| crate::database::models::ids::DBReportId(m.id))
@ -273,10 +277,12 @@ pub async fn reports(
SELECT id FROM reports SELECT id FROM reports
WHERE closed = FALSE AND reporter = $1 WHERE closed = FALSE AND reporter = $1
ORDER BY created ASC ORDER BY created ASC
LIMIT $2; OFFSET $3
LIMIT $2
", ",
user.id.0 as i64, user.id.0 as i64,
count.count as i64 request_opts.count as i64,
request_opts.offset as i64
) )
.fetch(&**pool) .fetch(&**pool)
.map_ok(|m| crate::database::models::ids::DBReportId(m.id)) .map_ok(|m| crate::database::models::ids::DBReportId(m.id))

View File

@ -1,14 +1,14 @@
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use super::{ApiError, oauth_clients::get_user_clients}; use super::{ApiError, oauth_clients::get_user_clients};
use crate::file_hosting::FileHostPublicity;
use crate::util::img::delete_old_images;
use crate::{ use crate::{
auth::{filter_visible_projects, get_user_from_headers}, auth::{
filter_visible_collections, filter_visible_projects,
get_user_from_headers,
},
database::{models::DBUser, redis::RedisPool}, database::{models::DBUser, redis::RedisPool},
file_hosting::FileHost, file_hosting::{FileHost, FileHostPublicity},
models::{ models::{
collections::{Collection, CollectionStatus},
notifications::Notification, notifications::Notification,
pats::Scopes, pats::Scopes,
projects::Project, projects::Project,
@ -16,7 +16,7 @@ use crate::{
}, },
queue::session::AuthQueue, queue::session::AuthQueue,
util::{ util::{
routes::read_limited_from_payload, img::delete_old_images, routes::read_limited_from_payload,
validate::validation_errors_to_string, validate::validation_errors_to_string,
}, },
}; };
@ -244,27 +244,19 @@ pub async fn collections_list(
let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?; let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(id) = id_option.map(|x| x.id) { if let Some(id) = id_option.map(|x| x.id) {
let user_id: UserId = id.into(); let collection_data = DBUser::get_collections(id, &**pool).await?;
let can_view_private =
user.is_some_and(|y| y.role.is_mod() || y.id == user_id);
let project_data = DBUser::get_collections(id, &**pool).await?;
let response: Vec<_> = crate::database::models::DBCollection::get_many( let response: Vec<_> = crate::database::models::DBCollection::get_many(
&project_data, &collection_data,
&**pool, &**pool,
&redis, &redis,
) )
.await? .await?;
.into_iter()
.filter(|x| {
can_view_private || matches!(x.status, CollectionStatus::Listed)
})
.map(Collection::from)
.collect();
Ok(HttpResponse::Ok().json(response)) let collections =
filter_visible_collections(response, &user, true).await?;
Ok(HttpResponse::Ok().json(collections))
} else { } else {
Err(ApiError::NotFound) Err(ApiError::NotFound)
} }

View File

@ -27,6 +27,7 @@ hashlink.workspace = true
png.workspace = true png.workspace = true
bytemuck.workspace = true bytemuck.workspace = true
rgb.workspace = true rgb.workspace = true
phf.workspace = true
chrono = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
daedalus.workspace = true daedalus.workspace = true

View File

@ -53,6 +53,7 @@ fn build_java_jars() {
.arg("build") .arg("build")
.arg("--no-daemon") .arg("--no-daemon")
.arg("--console=rich") .arg("--console=rich")
.arg("--info")
.current_dir(dunce::canonicalize("java").unwrap()) .current_dir(dunce::canonicalize("java").unwrap())
.status() .status()
.expect("Failed to wait on Gradle build"); .expect("Failed to wait on Gradle build");

View File

@ -1,6 +1,7 @@
plugins { plugins {
java java
id("com.diffplug.spotless") version "7.0.4" id("com.diffplug.spotless") version "7.0.4"
id("com.gradleup.shadow") version "9.0.0-rc2"
} }
repositories { repositories {
@ -8,6 +9,9 @@ repositories {
} }
dependencies { dependencies {
implementation("org.ow2.asm:asm:9.8")
implementation("org.ow2.asm:asm-tree:9.8")
testImplementation(libs.junit.jupiter) testImplementation(libs.junit.jupiter)
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
} }
@ -31,7 +35,17 @@ spotless {
} }
tasks.jar { tasks.jar {
enabled = false
}
tasks.shadowJar {
archiveFileName = "theseus.jar" archiveFileName = "theseus.jar"
manifest {
attributes["Premain-Class"] = "com.modrinth.theseus.agent.TheseusAgent"
}
enableRelocation = true
relocationPrefix = "com.modrinth.theseus.shadow"
} }
tasks.named<Test>("test") { tasks.named<Test>("test") {

View File

@ -0,0 +1,45 @@
package com.modrinth.theseus.agent;
import java.util.ListIterator;
import java.util.function.Predicate;
import org.objectweb.asm.Type;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.FieldInsnNode;
public interface InsnPattern extends Predicate<AbstractInsnNode> {
/**
* Advances past the first match of all instructions in the pattern.
* @return {@code true} if the pattern was found, {@code false} if not
*/
static boolean findAndSkip(ListIterator<AbstractInsnNode> iterator, InsnPattern... pattern) {
if (pattern.length == 0) {
return true;
}
int patternIndex = 0;
while (iterator.hasNext()) {
final AbstractInsnNode insn = iterator.next();
if (insn.getOpcode() == -1) continue;
if (pattern[patternIndex].test(insn) && ++patternIndex == pattern.length) {
return true;
} else {
patternIndex = 0;
}
}
return false;
}
static InsnPattern opcode(int opcode) {
return insn -> insn.getOpcode() == opcode;
}
static InsnPattern field(int opcode, Type fieldType) {
final String typeDescriptor = fieldType.getDescriptor();
return insn -> {
if (insn.getOpcode() != opcode || !(insn instanceof FieldInsnNode)) {
return false;
}
final FieldInsnNode fieldInsn = (FieldInsnNode) insn;
return typeDescriptor.equals(fieldInsn.desc);
};
}
}

View File

@ -0,0 +1,12 @@
package com.modrinth.theseus.agent;
// Must be kept up-to-date with quick_play_version.rs
public enum QuickPlayServerVersion {
BUILTIN,
BUILTIN_LEGACY,
INJECTED,
UNSUPPORTED;
public static final QuickPlayServerVersion CURRENT =
valueOf(System.getProperty("modrinth.internal.quickPlay.serverVersion"));
}

View File

@ -0,0 +1,85 @@
package com.modrinth.theseus.agent;
import com.modrinth.theseus.agent.transformers.ClassTransformer;
import com.modrinth.theseus.agent.transformers.MinecraftTransformer;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.instrument.Instrumentation;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.HashMap;
import java.util.Map;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
@SuppressWarnings({"NullableProblems", "CallToPrintStackTrace"})
public final class TheseusAgent {
private static final boolean DEBUG_AGENT = Boolean.getBoolean("modrinth.debugAgent");
public static void premain(String args, Instrumentation instrumentation) {
final Path debugPath = Paths.get("ModrinthDebugTransformed");
if (DEBUG_AGENT) {
System.out.println(
"===== Theseus agent debugging enabled. Dumping transformed classes to " + debugPath + " =====");
if (Files.exists(debugPath)) {
try {
Files.walkFileTree(debugPath, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
new UncheckedIOException("Failed to delete " + debugPath, e).printStackTrace();
}
}
System.out.println("===== Quick play server version: " + QuickPlayServerVersion.CURRENT + " =====");
}
final Map<String, ClassTransformer> transformers = new HashMap<>();
transformers.put("net/minecraft/client/Minecraft", new MinecraftTransformer());
instrumentation.addTransformer((loader, className, classBeingRedefined, protectionDomain, classData) -> {
final ClassTransformer transformer = transformers.get(className);
if (transformer == null) {
return null;
}
final ClassReader reader = new ClassReader(classData);
final ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS);
try {
if (!transformer.transform(reader, writer)) {
if (DEBUG_AGENT) {
System.out.println("Not writing " + className + " as its transformer returned false");
}
return null;
}
} catch (Throwable t) {
new IllegalStateException("Failed to transform " + className, t).printStackTrace();
return null;
}
final byte[] result = writer.toByteArray();
if (DEBUG_AGENT) {
try {
final Path path = debugPath.resolve(className + ".class");
Files.createDirectories(path.getParent());
Files.write(path, result);
System.out.println("Dumped class to " + path.toAbsolutePath());
} catch (IOException e) {
new UncheckedIOException("Failed to dump class " + className, e).printStackTrace();
}
}
return result;
});
}
}

View File

@ -0,0 +1,20 @@
package com.modrinth.theseus.agent.transformers;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.tree.ClassNode;
public abstract class ClassNodeTransformer extends ClassTransformer {
protected abstract boolean transform(ClassNode classNode);
@Override
public final boolean transform(ClassReader reader, ClassWriter writer) {
final ClassNode classNode = new ClassNode();
reader.accept(classNode, 0);
if (!transform(classNode)) {
return false;
}
classNode.accept(writer);
return true;
}
}

View File

@ -0,0 +1,14 @@
package com.modrinth.theseus.agent.transformers;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.ClassNode;
public abstract class ClassTransformer {
public abstract boolean transform(ClassReader reader, ClassWriter writer);
protected static boolean needsStackMap(ClassNode classNode) {
return (classNode.version & 0xffff) >= Opcodes.V1_6;
}
}

View File

@ -0,0 +1,99 @@
package com.modrinth.theseus.agent.transformers;
import com.modrinth.theseus.agent.InsnPattern;
import com.modrinth.theseus.agent.QuickPlayServerVersion;
import java.util.ListIterator;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.FrameNode;
import org.objectweb.asm.tree.InsnNode;
import org.objectweb.asm.tree.JumpInsnNode;
import org.objectweb.asm.tree.LabelNode;
import org.objectweb.asm.tree.LdcInsnNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
import org.objectweb.asm.tree.VarInsnNode;
public final class MinecraftTransformer extends ClassNodeTransformer {
private static final String SET_SERVER_NAME_DESC = "(Ljava/lang/String;I)V";
private static final InsnPattern[] INITIALIZE_THIS_PATTERN = {InsnPattern.opcode(Opcodes.INVOKESPECIAL)};
@Override
protected boolean transform(ClassNode classNode) {
if (QuickPlayServerVersion.CURRENT == QuickPlayServerVersion.INJECTED) {
return addServerJoinSupport(classNode);
}
return false;
}
private static boolean addServerJoinSupport(ClassNode classNode) {
String setServerName = null;
MethodNode constructor = null;
for (final MethodNode method : classNode.methods) {
if (constructor == null && method.name.equals("<init>")) {
constructor = method;
} else if (method.desc.equals(SET_SERVER_NAME_DESC) && method.name.indexOf('$') == -1) {
// Check for $ is because Mixin-injected methods should have $ in it
if (setServerName == null) {
setServerName = method.name;
} else {
// Already found a setServer method, but we found another one? Since we can't
// know which is real, just return so we don't call something we shouldn't.
// Note this can't happen unless some other mod is adding a method with this
// same descriptor.
return false;
}
}
}
if (constructor == null) {
return false;
}
final ListIterator<AbstractInsnNode> it = constructor.instructions.iterator();
if (!InsnPattern.findAndSkip(it, INITIALIZE_THIS_PATTERN)) {
return true;
}
final LabelNode noQuickPlayLabel = new LabelNode();
final LabelNode doneQuickPlayLabel = new LabelNode();
it.add(new LdcInsnNode("modrinth.internal.quickPlay.host"));
// String
it.add(new MethodInsnNode(
Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", "(Ljava/lang/String;)Ljava/lang/String;"));
// String
it.add(new InsnNode(Opcodes.DUP));
// String String
it.add(new JumpInsnNode(Opcodes.IFNULL, noQuickPlayLabel));
// String
it.add(new VarInsnNode(Opcodes.ALOAD, 0));
// String Minecraft
it.add(new InsnNode(Opcodes.SWAP));
// Minecraft String
it.add(new LdcInsnNode("modrinth.internal.quickPlay.port"));
// Minecraft String String
it.add(new MethodInsnNode(
Opcodes.INVOKESTATIC, "java/lang/System", "getProperty", "(Ljava/lang/String;)Ljava/lang/String;"));
// Minecraft String String
it.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/Integer", "parseInt", "(Ljava/lang/String;)I"));
// Minecraft String int
it.add(new MethodInsnNode(
Opcodes.INVOKEVIRTUAL, "net/minecraft/client/Minecraft", setServerName, SET_SERVER_NAME_DESC));
//
it.add(new JumpInsnNode(Opcodes.GOTO, doneQuickPlayLabel));
it.add(noQuickPlayLabel);
if (needsStackMap(classNode)) {
it.add(new FrameNode(Opcodes.F_SAME, 0, null, 0, null));
}
// String
it.add(new InsnNode(Opcodes.POP));
//
it.add(doneQuickPlayLabel);
if (needsStackMap(classNode)) {
it.add(new FrameNode(Opcodes.F_SAME, 0, null, 0, null));
}
//
return true;
}
}

View File

@ -11,6 +11,7 @@ pub mod mr_auth;
pub mod pack; pub mod pack;
pub mod process; pub mod process;
pub mod profile; pub mod profile;
pub mod server_address;
pub mod settings; pub mod settings;
pub mod tags; pub mod tags;
pub mod worlds; pub mod worlds;

View File

@ -23,6 +23,7 @@ use serde_json::json;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use crate::data::Settings; use crate::data::Settings;
use crate::server_address::ServerAddress;
use dashmap::DashMap; use dashmap::DashMap;
use std::iter::FromIterator; use std::iter::FromIterator;
use std::{ use std::{
@ -40,7 +41,7 @@ pub mod update;
pub enum QuickPlayType { pub enum QuickPlayType {
None, None,
Singleplayer(String), Singleplayer(String),
Server(String), Server(ServerAddress),
} }
/// Remove a profile /// Remove a profile
@ -630,7 +631,7 @@ fn pack_get_relative_path(
#[tracing::instrument] #[tracing::instrument]
pub async fn run( pub async fn run(
path: &str, path: &str,
quick_play_type: &QuickPlayType, quick_play_type: QuickPlayType,
) -> crate::Result<ProcessMetadata> { ) -> crate::Result<ProcessMetadata> {
let state = State::get().await?; let state = State::get().await?;
@ -646,7 +647,7 @@ pub async fn run(
async fn run_credentials( async fn run_credentials(
path: &str, path: &str,
credentials: &Credentials, credentials: &Credentials,
quick_play_type: &QuickPlayType, quick_play_type: QuickPlayType,
) -> crate::Result<ProcessMetadata> { ) -> crate::Result<ProcessMetadata> {
let state = State::get().await?; let state = State::get().await?;
let settings = Settings::get(&state.pool).await?; let settings = Settings::get(&state.pool).await?;

View File

@ -0,0 +1,166 @@
use crate::{Error, ErrorKind, Result};
use std::fmt::Display;
use std::mem;
use std::net::{Ipv4Addr, Ipv6Addr};
use tokio::sync::Semaphore;
#[derive(Debug, Clone)]
pub enum ServerAddress {
Unresolved(String),
Resolved {
original_host: String,
original_port: u16,
resolved_host: String,
resolved_port: u16,
},
}
impl ServerAddress {
pub async fn resolve(&mut self) -> Result<()> {
match self {
Self::Unresolved(address) => {
let (host, port) = parse_server_address(address)?;
let (resolved_host, resolved_port) =
resolve_server_address(host, port).await?;
*self = Self::Resolved {
original_host: if host.len() == address.len() {
mem::take(address)
} else {
host.to_owned()
},
original_port: port,
resolved_host,
resolved_port,
}
}
Self::Resolved { .. } => {}
}
Ok(())
}
pub fn require_resolved(&self) -> Result<(&str, u16)> {
match self {
Self::Resolved {
resolved_host,
resolved_port,
..
} => Ok((resolved_host, *resolved_port)),
Self::Unresolved(address) => Err(ErrorKind::InputError(format!(
"Unexpected unresolved server address: {address}"
))
.into()),
}
}
}
impl Display for ServerAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unresolved(address) => write!(f, "{address}"),
Self::Resolved {
resolved_host,
resolved_port,
..
} => {
if resolved_host.contains(':') {
write!(f, "[{resolved_host}]:{resolved_port}")
} else {
write!(f, "{resolved_host}:{resolved_port}")
}
}
}
}
}
pub fn parse_server_address(address: &str) -> Result<(&str, u16)> {
parse_server_address_inner(address)
.map_err(|e| Error::from(ErrorKind::InputError(e)))
}
// Reimplementation of Guava's HostAndPort#fromString with a default port of 25565
fn parse_server_address_inner(
address: &str,
) -> std::result::Result<(&str, u16), String> {
let (host, port_str) = if address.starts_with("[") {
let colon_index = address.find(':');
let close_bracket_index = address.rfind(']');
if colon_index.is_none() || close_bracket_index.is_none() {
return Err(format!("Invalid bracketed host/port: {address}"));
}
let close_bracket_index = close_bracket_index.unwrap();
let host = &address[1..close_bracket_index];
if close_bracket_index + 1 == address.len() {
(host, "")
} else {
if address.as_bytes().get(close_bracket_index).copied()
!= Some(b':')
{
return Err(format!(
"Only a colon may follow a close bracket: {address}"
));
}
let port_str = &address[close_bracket_index + 2..];
for c in port_str.chars() {
if !c.is_ascii_digit() {
return Err(format!("Port must be numeric: {address}"));
}
}
(host, port_str)
}
} else {
let colon_pos = address.find(':');
if let Some(colon_pos) = colon_pos {
(&address[..colon_pos], &address[colon_pos + 1..])
} else {
(address, "")
}
};
let mut port = None;
if !port_str.is_empty() {
if port_str.starts_with('+') {
return Err(format!("Unparseable port number: {port_str}"));
}
port = port_str.parse::<u16>().ok();
if port.is_none() {
return Err(format!("Unparseable port number: {port_str}"));
}
}
Ok((host, port.unwrap_or(25565)))
}
pub async fn resolve_server_address(
host: &str,
port: u16,
) -> Result<(String, u16)> {
static SIMULTANEOUS_DNS_QUERIES: Semaphore = Semaphore::const_new(24);
if port != 25565
|| host.parse::<Ipv4Addr>().is_ok()
|| host.parse::<Ipv6Addr>().is_ok()
{
return Ok((host.to_owned(), port));
}
let _permit = SIMULTANEOUS_DNS_QUERIES.acquire().await?;
let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build();
Ok(
match resolver.srv_lookup(format!("_minecraft._tcp.{host}")).await {
Err(e)
if e.proto()
.filter(|x| x.kind().is_no_records_found())
.is_some() =>
{
None
}
Err(e) => return Err(e.into()),
Ok(lookup) => lookup
.into_iter()
.next()
.map(|r| (r.target().to_string(), r.port())),
}
.unwrap_or_else(|| (host.to_owned(), port)),
)
}

View File

@ -1,15 +1,18 @@
use crate::data::ModLoader; use crate::data::ModLoader;
use crate::launcher::get_loader_version_from_profile; use crate::launcher::get_loader_version_from_profile;
use crate::profile::get_full_path; use crate::profile::get_full_path;
use crate::server_address::{parse_server_address, resolve_server_address};
use crate::state::attached_world_data::AttachedWorldData; use crate::state::attached_world_data::AttachedWorldData;
use crate::state::{ use crate::state::{
Profile, ProfileInstallStage, attached_world_data, server_join_log, Profile, ProfileInstallStage, attached_world_data, server_join_log,
}; };
use crate::util::protocol_version::OLD_PROTOCOL_VERSIONS;
pub use crate::util::protocol_version::ProtocolVersion;
pub use crate::util::server_ping::{ pub use crate::util::server_ping::{
ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion, ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion,
}; };
use crate::util::{io, server_ping}; use crate::util::{io, server_ping};
use crate::{Error, ErrorKind, Result, State, launcher}; use crate::{ErrorKind, Result, State, launcher};
use async_walkdir::WalkDir; use async_walkdir::WalkDir;
use async_zip::{Compression, ZipEntryBuilder}; use async_zip::{Compression, ZipEntryBuilder};
use chrono::{DateTime, Local, TimeZone, Utc}; use chrono::{DateTime, Local, TimeZone, Utc};
@ -22,11 +25,9 @@ use regex::{Regex, RegexBuilder};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::cmp::Reverse; use std::cmp::Reverse;
use std::io::Cursor; use std::io::Cursor;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::LazyLock; use std::sync::LazyLock;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use tokio::sync::Semaphore;
use tokio::task::JoinSet; use tokio::task::JoinSet;
use tokio_util::compat::FuturesAsyncWriteCompatExt; use tokio_util::compat::FuturesAsyncWriteCompatExt;
use url::Url; use url::Url;
@ -431,9 +432,9 @@ async fn get_server_worlds_in_profile(
let mut futures = JoinSet::new(); let mut futures = JoinSet::new();
for (index, world) in worlds.iter().enumerate().skip(first_server_index) for (index, world) in worlds.iter().enumerate().skip(first_server_index)
{ {
if world.last_played.is_some() { // We can't check for the profile already having a last_played, in case the user joined
continue; // the target address directly more recently. This is often the case when using
} // quick-play before 1.20.
if let WorldDetails::Server { address, .. } = &world.details if let WorldDetails::Server { address, .. } = &world.details
&& let Ok((host, port)) = parse_server_address(address) && let Ok((host, port)) = parse_server_address(address)
{ {
@ -835,7 +836,7 @@ mod servers_data {
pub async fn get_profile_protocol_version( pub async fn get_profile_protocol_version(
profile: &str, profile: &str,
) -> Result<Option<i32>> { ) -> Result<Option<ProtocolVersion>> {
let mut profile = super::profile::get(profile).await?.ok_or_else(|| { let mut profile = super::profile::get(profile).await?.ok_or_else(|| {
ErrorKind::UnmanagedProfileError(format!( ErrorKind::UnmanagedProfileError(format!(
"Could not find profile {profile}" "Could not find profile {profile}"
@ -846,7 +847,12 @@ pub async fn get_profile_protocol_version(
} }
if let Some(protocol_version) = profile.protocol_version { if let Some(protocol_version) = profile.protocol_version {
return Ok(Some(protocol_version)); return Ok(Some(ProtocolVersion::modern(protocol_version)));
}
if let Some(protocol_version) =
OLD_PROTOCOL_VERSIONS.get(&profile.game_version)
{
return Ok(Some(*protocol_version));
} }
let minecraft = crate::api::metadata::get_minecraft_versions().await?; let minecraft = crate::api::metadata::get_minecraft_versions().await?;
@ -854,7 +860,7 @@ pub async fn get_profile_protocol_version(
.versions .versions
.iter() .iter()
.position(|it| it.id == profile.game_version) .position(|it| it.id == profile.game_version)
.ok_or(crate::ErrorKind::LauncherError(format!( .ok_or(ErrorKind::LauncherError(format!(
"Invalid game version: {}", "Invalid game version: {}",
profile.game_version profile.game_version
)))?; )))?;
@ -890,16 +896,19 @@ pub async fn get_profile_protocol_version(
profile.protocol_version = version; profile.protocol_version = version;
profile.upsert(&state.pool).await?; profile.upsert(&state.pool).await?;
} }
Ok(version) Ok(version.map(ProtocolVersion::modern))
} }
pub async fn get_server_status( pub async fn get_server_status(
address: &str, address: &str,
protocol_version: Option<i32>, protocol_version: Option<ProtocolVersion>,
) -> Result<ServerStatus> { ) -> Result<ServerStatus> {
let (original_host, original_port) = parse_server_address(address)?; let (original_host, original_port) = parse_server_address(address)?;
let (host, port) = let (host, port) =
resolve_server_address(original_host, original_port).await?; resolve_server_address(original_host, original_port).await?;
tracing::debug!(
"Pinging {address} with protocol version {protocol_version:?}"
);
server_ping::get_server_status( server_ping::get_server_status(
&(&host as &str, port), &(&host as &str, port),
(original_host, original_port), (original_host, original_port),
@ -907,93 +916,3 @@ pub async fn get_server_status(
) )
.await .await
} }
pub fn parse_server_address(address: &str) -> Result<(&str, u16)> {
parse_server_address_inner(address)
.map_err(|e| Error::from(ErrorKind::InputError(e)))
}
// Reimplementation of Guava's HostAndPort#fromString with a default port of 25565
fn parse_server_address_inner(
address: &str,
) -> std::result::Result<(&str, u16), String> {
let (host, port_str) = if address.starts_with("[") {
let colon_index = address.find(':');
let close_bracket_index = address.rfind(']');
if colon_index.is_none() || close_bracket_index.is_none() {
return Err(format!("Invalid bracketed host/port: {address}"));
}
let close_bracket_index = close_bracket_index.unwrap();
let host = &address[1..close_bracket_index];
if close_bracket_index + 1 == address.len() {
(host, "")
} else {
if address.as_bytes().get(close_bracket_index).copied()
!= Some(b':')
{
return Err(format!(
"Only a colon may follow a close bracket: {address}"
));
}
let port_str = &address[close_bracket_index + 2..];
for c in port_str.chars() {
if !c.is_ascii_digit() {
return Err(format!("Port must be numeric: {address}"));
}
}
(host, port_str)
}
} else {
let colon_pos = address.find(':');
if let Some(colon_pos) = colon_pos {
(&address[..colon_pos], &address[colon_pos + 1..])
} else {
(address, "")
}
};
let mut port = None;
if !port_str.is_empty() {
if port_str.starts_with('+') {
return Err(format!("Unparseable port number: {port_str}"));
}
port = port_str.parse::<u16>().ok();
if port.is_none() {
return Err(format!("Unparseable port number: {port_str}"));
}
}
Ok((host, port.unwrap_or(25565)))
}
async fn resolve_server_address(
host: &str,
port: u16,
) -> Result<(String, u16)> {
static SIMULTANEOUS_DNS_QUERIES: Semaphore = Semaphore::const_new(24);
if host.parse::<Ipv4Addr>().is_ok() || host.parse::<Ipv6Addr>().is_ok() {
return Ok((host.to_owned(), port));
}
let _permit = SIMULTANEOUS_DNS_QUERIES.acquire().await?;
let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build();
Ok(
match resolver.srv_lookup(format!("_minecraft._tcp.{host}")).await {
Err(e)
if e.proto()
.filter(|x| x.kind().is_no_records_found())
.is_some() =>
{
None
}
Err(e) => return Err(e.into()),
Ok(lookup) => lookup
.into_iter()
.next()
.map(|r| (r.target().to_string(), r.port())),
}
.unwrap_or_else(|| (host.to_owned(), port)),
)
}

View File

@ -1,5 +1,6 @@
//! Minecraft CLI argument logic //! Minecraft CLI argument logic
use crate::launcher::parse_rules; use crate::launcher::quick_play_version::QuickPlayServerVersion;
use crate::launcher::{QuickPlayVersion, parse_rules};
use crate::profile::QuickPlayType; use crate::profile::QuickPlayType;
use crate::state::Credentials; use crate::state::Credentials;
use crate::{ use crate::{
@ -115,11 +116,13 @@ pub fn get_jvm_arguments(
libraries_path: &Path, libraries_path: &Path,
log_configs_path: &Path, log_configs_path: &Path,
class_paths: &str, class_paths: &str,
agent_path: &Path,
version_name: &str, version_name: &str,
memory: MemorySettings, memory: MemorySettings,
custom_args: Vec<String>, custom_args: Vec<String>,
java_arch: &str, java_arch: &str,
quick_play_type: &QuickPlayType, quick_play_type: &QuickPlayType,
quick_play_version: QuickPlayVersion,
log_config: Option<&LoggingConfiguration>, log_config: Option<&LoggingConfiguration>,
) -> crate::Result<Vec<String>> { ) -> crate::Result<Vec<String>> {
let mut parsed_arguments = Vec::new(); let mut parsed_arguments = Vec::new();
@ -155,13 +158,45 @@ pub fn get_jvm_arguments(
parsed_arguments.push("-cp".to_string()); parsed_arguments.push("-cp".to_string());
parsed_arguments.push(class_paths.to_string()); parsed_arguments.push(class_paths.to_string());
} }
parsed_arguments.push(format!("-Xmx{}M", memory.maximum)); parsed_arguments.push(format!("-Xmx{}M", memory.maximum));
if let Some(LoggingConfiguration::Log4j2Xml { argument, file }) = log_config if let Some(LoggingConfiguration::Log4j2Xml { argument, file }) = log_config
{ {
let full_path = log_configs_path.join(&file.id); let full_path = log_configs_path.join(&file.id);
let full_path = full_path.to_string_lossy(); let full_path = full_path.to_string_lossy();
parsed_arguments.push(argument.replace("${path}", &full_path)); parsed_arguments.push(argument.replace("${path}", &full_path));
} }
parsed_arguments.push(format!(
"-javaagent:{}",
canonicalize(agent_path)
.map_err(|_| {
crate::ErrorKind::LauncherError(format!(
"Specified Java Agent path {} does not exist",
libraries_path.to_string_lossy()
))
.as_error()
})?
.to_string_lossy()
));
parsed_arguments.push(format!(
"-Dmodrinth.internal.quickPlay.serverVersion={}",
serde_json::to_value(quick_play_version.server)?
.as_str()
.unwrap()
));
if let QuickPlayType::Server(server) = quick_play_type
&& quick_play_version.server == QuickPlayServerVersion::Injected
{
let (host, port) = server.require_resolved()?;
parsed_arguments.extend_from_slice(&[
format!("-Dmodrinth.internal.quickPlay.host={host}"),
format!("-Dmodrinth.internal.quickPlay.port={port}"),
]);
}
for arg in custom_args { for arg in custom_args {
if !arg.is_empty() { if !arg.is_empty() {
parsed_arguments.push(arg); parsed_arguments.push(arg);
@ -225,13 +260,13 @@ pub async fn get_minecraft_arguments(
resolution: WindowSize, resolution: WindowSize,
java_arch: &str, java_arch: &str,
quick_play_type: &QuickPlayType, quick_play_type: &QuickPlayType,
quick_play_version: QuickPlayVersion,
) -> crate::Result<Vec<String>> { ) -> crate::Result<Vec<String>> {
let access_token = credentials.access_token.clone(); let access_token = credentials.access_token.clone();
let profile = credentials.maybe_online_profile().await; let profile = credentials.maybe_online_profile().await;
if let Some(arguments) = arguments {
let mut parsed_arguments = Vec::new(); let mut parsed_arguments = Vec::new();
if let Some(arguments) = arguments {
parse_arguments( parse_arguments(
arguments, arguments,
&mut parsed_arguments, &mut parsed_arguments,
@ -253,10 +288,7 @@ pub async fn get_minecraft_arguments(
java_arch, java_arch,
quick_play_type, quick_play_type,
)?; )?;
Ok(parsed_arguments)
} else if let Some(legacy_arguments) = legacy_arguments { } else if let Some(legacy_arguments) = legacy_arguments {
let mut parsed_arguments = Vec::new();
for x in legacy_arguments.split(' ') { for x in legacy_arguments.split(' ') {
parsed_arguments.push(parse_minecraft_argument( parsed_arguments.push(parse_minecraft_argument(
&x.replace(' ', TEMPORARY_REPLACE_CHAR), &x.replace(' ', TEMPORARY_REPLACE_CHAR),
@ -272,10 +304,21 @@ pub async fn get_minecraft_arguments(
quick_play_type, quick_play_type,
)?); )?);
} }
Ok(parsed_arguments)
} else {
Ok(Vec::new())
} }
if let QuickPlayType::Server(server) = quick_play_type
&& quick_play_version.server == QuickPlayServerVersion::BuiltinLegacy
{
let (host, port) = server.require_resolved()?;
parsed_arguments.extend_from_slice(&[
"--server".to_string(),
host.to_string(),
"--port".to_string(),
port.to_string(),
]);
}
Ok(parsed_arguments)
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@ -354,9 +397,9 @@ fn parse_minecraft_argument(
) )
.replace( .replace(
"${quickPlayMultiplayer}", "${quickPlayMultiplayer}",
match quick_play_type { &match quick_play_type {
QuickPlayType::Server(address) => address, QuickPlayType::Server(address) => address.to_string(),
_ => "", _ => "".to_string(),
}, },
)) ))
} }

View File

@ -4,6 +4,9 @@ use crate::event::emit::{emit_loading, init_or_edit_loading};
use crate::event::{LoadingBarId, LoadingBarType}; use crate::event::{LoadingBarId, LoadingBarType};
use crate::launcher::download::download_log_config; use crate::launcher::download::download_log_config;
use crate::launcher::io::IOError; use crate::launcher::io::IOError;
use crate::launcher::quick_play_version::{
QuickPlayServerVersion, QuickPlayVersion,
};
use crate::profile::QuickPlayType; use crate::profile::QuickPlayType;
use crate::state::{ use crate::state::{
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage, Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
@ -25,6 +28,7 @@ use tokio::process::Command;
mod args; mod args;
pub mod download; pub mod download;
pub mod quick_play_version;
// All nones -> disallowed // All nones -> disallowed
// 1+ true -> allowed // 1+ true -> allowed
@ -419,7 +423,7 @@ pub async fn install_minecraft(
pub async fn read_protocol_version_from_jar( pub async fn read_protocol_version_from_jar(
path: PathBuf, path: PathBuf,
) -> crate::Result<Option<i32>> { ) -> crate::Result<Option<u32>> {
let zip = async_zip::tokio::read::fs::ZipFileReader::new(path).await?; let zip = async_zip::tokio::read::fs::ZipFileReader::new(path).await?;
let Some(entry_index) = zip let Some(entry_index) = zip
.file() .file()
@ -432,7 +436,7 @@ pub async fn read_protocol_version_from_jar(
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
struct VersionData { struct VersionData {
protocol_version: Option<i32>, protocol_version: Option<u32>,
} }
let mut data = vec![]; let mut data = vec![];
@ -457,7 +461,7 @@ pub async fn launch_minecraft(
credentials: &Credentials, credentials: &Credentials,
post_exit_hook: Option<String>, post_exit_hook: Option<String>,
profile: &Profile, profile: &Profile,
quick_play_type: &QuickPlayType, mut quick_play_type: QuickPlayType,
) -> crate::Result<ProcessMetadata> { ) -> crate::Result<ProcessMetadata> {
if profile.install_stage == ProfileInstallStage::PackInstalling if profile.install_stage == ProfileInstallStage::PackInstalling
|| profile.install_stage == ProfileInstallStage::MinecraftInstalling || profile.install_stage == ProfileInstallStage::MinecraftInstalling
@ -589,6 +593,18 @@ pub async fn launch_minecraft(
io::create_dir_all(&natives_dir).await?; io::create_dir_all(&natives_dir).await?;
} }
let quick_play_version =
QuickPlayVersion::find_version(version_index, &minecraft.versions);
tracing::debug!(
"Found QuickPlayVersion for {}: {quick_play_version:?}",
profile.game_version
);
if let QuickPlayType::Server(address) = &mut quick_play_type
&& quick_play_version.server >= QuickPlayServerVersion::BuiltinLegacy
{
address.resolve().await?;
}
let (main_class_keep_alive, main_class_path) = let (main_class_keep_alive, main_class_path) =
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?; get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
@ -606,11 +622,13 @@ pub async fn launch_minecraft(
&java_version.architecture, &java_version.architecture,
minecraft_updated, minecraft_updated,
)?, )?,
&main_class_path,
&version_jar, &version_jar,
*memory, *memory,
Vec::from(java_args), Vec::from(java_args),
&java_version.architecture, &java_version.architecture,
quick_play_type, &quick_play_type,
quick_play_version,
version_info version_info
.logging .logging
.as_ref() .as_ref()
@ -646,7 +664,8 @@ pub async fn launch_minecraft(
&version.type_, &version.type_,
*resolution, *resolution,
&java_version.architecture, &java_version.architecture,
quick_play_type, &quick_play_type,
quick_play_version,
) )
.await? .await?
.into_iter(), .into_iter(),

View File

@ -0,0 +1,102 @@
use daedalus::minecraft::Version;
use serde::{Deserialize, Serialize};
// If modified, also update QuickPlayServerVersion.java
#[derive(
Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum QuickPlayServerVersion {
Builtin,
BuiltinLegacy,
Injected,
Unsupported,
}
impl QuickPlayServerVersion {
pub fn min_version(&self) -> Option<&'static str> {
match self {
Self::Builtin => Some("23w14a"),
Self::BuiltinLegacy => Some("13w17a"),
Self::Injected => Some("a1.0.5_01"),
Self::Unsupported => None,
}
}
pub fn older_version(&self) -> Option<Self> {
match self {
Self::Builtin => Some(Self::BuiltinLegacy),
Self::BuiltinLegacy => Some(Self::Injected),
Self::Injected => Some(Self::Unsupported),
Self::Unsupported => None,
}
}
}
// If modified, also update QuickPlaySingleplayerVersion.java
#[derive(
Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize,
)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum QuickPlaySingleplayerVersion {
Builtin,
Unsupported,
}
impl QuickPlaySingleplayerVersion {
pub fn min_version(&self) -> Option<&'static str> {
match self {
Self::Builtin => Some("23w14a"),
Self::Unsupported => None,
}
}
pub fn older_version(&self) -> Option<Self> {
match self {
Self::Builtin => Some(Self::Unsupported),
Self::Unsupported => None,
}
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub struct QuickPlayVersion {
pub server: QuickPlayServerVersion,
pub singleplayer: QuickPlaySingleplayerVersion,
}
impl QuickPlayVersion {
pub fn find_version(version_index: usize, versions: &[Version]) -> Self {
let mut server = QuickPlayServerVersion::Builtin;
let mut server_version = server.min_version();
let mut singleplayer = QuickPlaySingleplayerVersion::Builtin;
let mut singleplayer_version = singleplayer.min_version();
for version in versions.iter().take(version_index - 1) {
if let Some(check_version) = server_version
&& version.id == check_version
{
// Safety: older_version will always be Some when min_version is Some
server = server.older_version().unwrap();
server_version = server.min_version();
}
if let Some(check_version) = singleplayer_version
&& version.id == check_version
{
singleplayer = singleplayer.older_version().unwrap();
singleplayer_version = singleplayer.min_version();
}
if server_version.is_none() && singleplayer_version.is_none() {
break;
}
}
Self {
server,
singleplayer,
}
}
}

View File

@ -2,7 +2,7 @@ use crate::event::emit::{emit_process, emit_profile};
use crate::event::{ProcessPayloadType, ProfilePayloadType}; use crate::event::{ProcessPayloadType, ProfilePayloadType};
use crate::profile; use crate::profile;
use crate::util::io::IOError; use crate::util::io::IOError;
use chrono::{DateTime, TimeZone, Utc}; use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
use dashmap::DashMap; use dashmap::DashMap;
use quick_xml::Reader; use quick_xml::Reader;
use quick_xml::events::Event; use quick_xml::events::Event;
@ -493,6 +493,16 @@ impl Process {
if let Err(e) = Self::append_to_log_file(&log_path, &line) { if let Err(e) = Self::append_to_log_file(&log_path, &line) {
tracing::warn!("Failed to write to log file: {}", e); tracing::warn!("Failed to write to log file: {}", e);
} }
if let Err(e) = Self::maybe_handle_old_server_join_logging(
profile_path,
line.trim_ascii_end(),
)
.await
{
tracing::error!(
"Failed to handle old server join logging: {e}"
);
}
} }
line.clear(); line.clear();
@ -540,17 +550,6 @@ impl Process {
timestamp: &str, timestamp: &str,
message: &str, message: &str,
) -> crate::Result<()> { ) -> crate::Result<()> {
let Some(host_port_string) = message.strip_prefix("Connecting to ")
else {
return Ok(());
};
let Some((host, port_string)) = host_port_string.rsplit_once(", ")
else {
return Ok(());
};
let Some(port) = port_string.parse::<u16>().ok() else {
return Ok(());
};
let timestamp = timestamp let timestamp = timestamp
.parse::<i64>() .parse::<i64>()
.map(|x| x / 1000) .map(|x| x / 1000)
@ -566,6 +565,46 @@ impl Process {
) )
}) })
})?; })?;
Self::parse_and_insert_server_join(profile_path, message, timestamp)
.await
}
async fn maybe_handle_old_server_join_logging(
profile_path: &str,
line: &str,
) -> crate::Result<()> {
if let Some((timestamp, message)) = line.split_once(" [CLIENT] [INFO] ")
{
let timestamp =
NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S")?
.and_local_timezone(chrono::Local)
.map(|x| x.to_utc())
.single()
.unwrap_or_else(Utc::now);
Self::parse_and_insert_server_join(profile_path, message, timestamp)
.await
} else {
Self::parse_and_insert_server_join(profile_path, line, Utc::now())
.await
}
}
async fn parse_and_insert_server_join(
profile_path: &str,
message: &str,
timestamp: DateTime<Utc>,
) -> crate::Result<()> {
let Some(host_port_string) = message.strip_prefix("Connecting to ")
else {
return Ok(());
};
let Some((host, port_string)) = host_port_string.rsplit_once(", ")
else {
return Ok(());
};
let Some(port) = port_string.parse::<u16>().ok() else {
return Ok(());
};
let state = crate::State::get().await?; let state = crate::State::get().await?;
crate::state::server_join_log::JoinLogEntry { crate::state::server_join_log::JoinLogEntry {

View File

@ -32,7 +32,7 @@ pub struct Profile {
pub icon_path: Option<String>, pub icon_path: Option<String>,
pub game_version: String, pub game_version: String,
pub protocol_version: Option<i32>, pub protocol_version: Option<u32>,
pub loader: ModLoader, pub loader: ModLoader,
pub loader_version: Option<String>, pub loader_version: Option<String>,
@ -320,7 +320,7 @@ impl TryFrom<ProfileQueryResult> for Profile {
name: x.name, name: x.name,
icon_path: x.icon_path, icon_path: x.icon_path,
game_version: x.game_version, game_version: x.game_version,
protocol_version: x.protocol_version.map(|x| x as i32), protocol_version: x.protocol_version.map(|x| x as u32),
loader: ModLoader::from_string(&x.mod_loader), loader: ModLoader::from_string(&x.mod_loader),
loader_version: x.mod_loader_version, loader_version: x.mod_loader_version,
groups: serde_json::from_value(x.groups).unwrap_or_default(), groups: serde_json::from_value(x.groups).unwrap_or_default(),

View File

@ -3,4 +3,5 @@ pub mod fetch;
pub mod io; pub mod io;
pub mod jre; pub mod jre;
pub mod platform; pub mod platform;
pub mod protocol_version;
pub mod server_ping; pub mod server_ping;

View File

@ -0,0 +1,478 @@
use phf::phf_map;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Debug, Copy, Clone)]
pub struct ProtocolVersion {
pub version: u32,
pub legacy: bool,
}
impl ProtocolVersion {
pub const fn modern(version: u32) -> Self {
Self {
version,
legacy: false,
}
}
pub const fn legacy(version: u32) -> Self {
Self {
version,
legacy: true,
}
}
}
/// The map of protocol versions from before version.json was added. For newer versions, the
/// protocol version can just be read from version.json.
pub const OLD_PROTOCOL_VERSIONS: phf::Map<&str, ProtocolVersion> = phf_map! {
// April fools versions
"1.RV-Pre1" => ProtocolVersion::modern(108),
"15w14a" => ProtocolVersion::modern(48),
"2point0_purple" => ProtocolVersion::legacy(92),
"2point0_red" => ProtocolVersion::legacy(91),
"2point0_blue" => ProtocolVersion::legacy(90),
// Normal versions
"18w47a" => ProtocolVersion::modern(446),
"18w46a" => ProtocolVersion::modern(445),
"18w45a" => ProtocolVersion::modern(444),
"18w44a" => ProtocolVersion::modern(443),
"18w43c" => ProtocolVersion::modern(442),
"18w43b" => ProtocolVersion::modern(441),
"18w43a" => ProtocolVersion::modern(440),
"1.13.2" => ProtocolVersion::modern(404),
"1.13.2-pre2" => ProtocolVersion::modern(403),
"1.13.2-pre1" => ProtocolVersion::modern(402),
"1.13.1" => ProtocolVersion::modern(401),
"1.13.1-pre2" => ProtocolVersion::modern(400),
"1.13.1-pre1" => ProtocolVersion::modern(399),
"18w33a" => ProtocolVersion::modern(398),
"18w32a" => ProtocolVersion::modern(397),
"18w31a" => ProtocolVersion::modern(396),
"18w30b" => ProtocolVersion::modern(395),
"18w30a" => ProtocolVersion::modern(394),
"1.13" => ProtocolVersion::modern(393),
"1.13-pre10" => ProtocolVersion::modern(392),
"1.13-pre9" => ProtocolVersion::modern(391),
"1.13-pre8" => ProtocolVersion::modern(390),
"1.13-pre7" => ProtocolVersion::modern(389),
"1.13-pre6" => ProtocolVersion::modern(388),
"1.13-pre5" => ProtocolVersion::modern(387),
"1.13-pre4" => ProtocolVersion::modern(386),
"1.13-pre3" => ProtocolVersion::modern(385),
"1.13-pre2" => ProtocolVersion::modern(384),
"1.13-pre1" => ProtocolVersion::modern(383),
"18w22c" => ProtocolVersion::modern(382),
"18w22b" => ProtocolVersion::modern(381),
"18w22a" => ProtocolVersion::modern(380),
"18w21b" => ProtocolVersion::modern(379),
"18w21a" => ProtocolVersion::modern(378),
"18w20c" => ProtocolVersion::modern(377),
"18w20b" => ProtocolVersion::modern(376),
"18w20a" => ProtocolVersion::modern(375),
"18w19b" => ProtocolVersion::modern(374),
"18w19a" => ProtocolVersion::modern(373),
"18w16a" => ProtocolVersion::modern(372),
"18w15a" => ProtocolVersion::modern(371),
"18w14b" => ProtocolVersion::modern(370),
"18w14a" => ProtocolVersion::modern(369),
"18w11a" => ProtocolVersion::modern(368),
"18w10d" => ProtocolVersion::modern(367),
"18w10c" => ProtocolVersion::modern(366),
"18w10b" => ProtocolVersion::modern(365),
"18w10a" => ProtocolVersion::modern(364),
"18w09a" => ProtocolVersion::modern(363),
"18w08b" => ProtocolVersion::modern(362),
"18w08a" => ProtocolVersion::modern(361),
"18w07c" => ProtocolVersion::modern(360),
"18w07b" => ProtocolVersion::modern(359),
"18w07a" => ProtocolVersion::modern(358),
"18w06a" => ProtocolVersion::modern(357),
"18w05a" => ProtocolVersion::modern(356),
"18w03b" => ProtocolVersion::modern(355),
"18w03a" => ProtocolVersion::modern(354),
"18w02a" => ProtocolVersion::modern(353),
"18w01a" => ProtocolVersion::modern(352),
"17w50a" => ProtocolVersion::modern(351),
"17w49b" => ProtocolVersion::modern(350),
"17w49a" => ProtocolVersion::modern(349),
"17w48a" => ProtocolVersion::modern(348),
"17w47b" => ProtocolVersion::modern(347),
"17w47a" => ProtocolVersion::modern(346),
"17w46a" => ProtocolVersion::modern(345),
"17w45b" => ProtocolVersion::modern(344),
"17w45a" => ProtocolVersion::modern(343),
"17w43b" => ProtocolVersion::modern(342),
"17w43a" => ProtocolVersion::modern(341),
"1.12.2" => ProtocolVersion::modern(340),
"1.21.2-pre2" => ProtocolVersion::modern(339),
"1.21.2-pre1" => ProtocolVersion::modern(339),
"1.12.1" => ProtocolVersion::modern(338),
"1.12.1-pre1" => ProtocolVersion::modern(337),
"17w31a" => ProtocolVersion::modern(336),
"1.12" => ProtocolVersion::modern(335),
"1.12-pre7" => ProtocolVersion::modern(334),
"1.12-pre6" => ProtocolVersion::modern(333),
"1.12-pre5" => ProtocolVersion::modern(332),
"1.12-pre4" => ProtocolVersion::modern(331),
"1.12-pre3" => ProtocolVersion::modern(330),
"1.12-pre2" => ProtocolVersion::modern(329),
"1.12-pre1" => ProtocolVersion::modern(328),
"17w18b" => ProtocolVersion::modern(327),
"17w18a" => ProtocolVersion::modern(326),
"17w17b" => ProtocolVersion::modern(325),
"17w17a" => ProtocolVersion::modern(324),
"17w16b" => ProtocolVersion::modern(323),
"17w16a" => ProtocolVersion::modern(322),
"17w15a" => ProtocolVersion::modern(321),
"17w14a" => ProtocolVersion::modern(320),
"17w13b" => ProtocolVersion::modern(319),
"17w13a" => ProtocolVersion::modern(318),
"17w06a" => ProtocolVersion::modern(317),
"1.11.2" => ProtocolVersion::modern(316),
"1.11.1" => ProtocolVersion::modern(316),
"16w50a" => ProtocolVersion::modern(316),
"1.11" => ProtocolVersion::modern(315),
"1.11-pre1" => ProtocolVersion::modern(314),
"16w44a" => ProtocolVersion::modern(313),
"16w43a" => ProtocolVersion::modern(313),
"16w42a" => ProtocolVersion::modern(312),
"16w41a" => ProtocolVersion::modern(311),
"16w40a" => ProtocolVersion::modern(310),
"16w39c" => ProtocolVersion::modern(309),
"16w39b" => ProtocolVersion::modern(308),
"16w39a" => ProtocolVersion::modern(307),
"16w38a" => ProtocolVersion::modern(306),
"16w36a" => ProtocolVersion::modern(305),
"16w35a" => ProtocolVersion::modern(304),
"16w33a" => ProtocolVersion::modern(303),
"16w32b" => ProtocolVersion::modern(302),
"16w32a" => ProtocolVersion::modern(301),
"1.10.2" => ProtocolVersion::modern(210),
"1.10.1" => ProtocolVersion::modern(210),
"1.10" => ProtocolVersion::modern(210),
"1.10-pre2" => ProtocolVersion::modern(205),
"1.10-pre1" => ProtocolVersion::modern(204),
"16w21b" => ProtocolVersion::modern(203),
"16w21a" => ProtocolVersion::modern(202),
"16w20a" => ProtocolVersion::modern(201),
"1.9.4" => ProtocolVersion::modern(110),
"1.9.3" => ProtocolVersion::modern(110),
"1.9.3-pre3" => ProtocolVersion::modern(110),
"1.9.3-pre2" => ProtocolVersion::modern(110),
"1.9.3-pre1" => ProtocolVersion::modern(109),
"16w15b" => ProtocolVersion::modern(109),
"16w15a" => ProtocolVersion::modern(109),
"16w14a" => ProtocolVersion::modern(109),
"1.9.2" => ProtocolVersion::modern(109),
"1.9.1" => ProtocolVersion::modern(108),
"1.9.1-pre3" => ProtocolVersion::modern(108),
"1.9.1-pre2" => ProtocolVersion::modern(108),
"1.9.1-pre1" => ProtocolVersion::modern(107),
"1.9" => ProtocolVersion::modern(107),
"1.9-pre4" => ProtocolVersion::modern(106),
"1.9-pre3" => ProtocolVersion::modern(105),
"1.9-pre2" => ProtocolVersion::modern(104),
"1.9-pre1" => ProtocolVersion::modern(103),
"16w07b" => ProtocolVersion::modern(102),
"16w07a" => ProtocolVersion::modern(101),
"16w06a" => ProtocolVersion::modern(100),
"16w05b" => ProtocolVersion::modern(99),
"16w05a" => ProtocolVersion::modern(98),
"16w04a" => ProtocolVersion::modern(97),
"16w03a" => ProtocolVersion::modern(96),
"16w02a" => ProtocolVersion::modern(95),
"15w51b" => ProtocolVersion::modern(94),
"15w51a" => ProtocolVersion::modern(93),
"15w50a" => ProtocolVersion::modern(92),
"15w49b" => ProtocolVersion::modern(91),
"15w49a" => ProtocolVersion::modern(90),
"15w47c" => ProtocolVersion::modern(89),
"15w47b" => ProtocolVersion::modern(88),
"15w47a" => ProtocolVersion::modern(87),
"15w46a" => ProtocolVersion::modern(86),
"15w45a" => ProtocolVersion::modern(85),
"15w44b" => ProtocolVersion::modern(84),
"15w44a" => ProtocolVersion::modern(83),
"15w43c" => ProtocolVersion::modern(82),
"15w43b" => ProtocolVersion::modern(81),
"15w43a" => ProtocolVersion::modern(80),
"15w42a" => ProtocolVersion::modern(79),
"15w41b" => ProtocolVersion::modern(78),
"15w41a" => ProtocolVersion::modern(77),
"15w40b" => ProtocolVersion::modern(76),
"15w40a" => ProtocolVersion::modern(75),
"15w39c" => ProtocolVersion::modern(74),
"15w39b" => ProtocolVersion::modern(74),
"15w39a" => ProtocolVersion::modern(74),
"15w38b" => ProtocolVersion::modern(73),
"15w38a" => ProtocolVersion::modern(72),
"15w37a" => ProtocolVersion::modern(71),
"15w36d" => ProtocolVersion::modern(70),
"15w36c" => ProtocolVersion::modern(69),
"15w36b" => ProtocolVersion::modern(68),
"15w36a" => ProtocolVersion::modern(67),
"15w35e" => ProtocolVersion::modern(66),
"15w35d" => ProtocolVersion::modern(65),
"15w35c" => ProtocolVersion::modern(64),
"15w35b" => ProtocolVersion::modern(63),
"15w35a" => ProtocolVersion::modern(62),
"15w34d" => ProtocolVersion::modern(61),
"15w34c" => ProtocolVersion::modern(60),
"15w34b" => ProtocolVersion::modern(59),
"15w34a" => ProtocolVersion::modern(58),
"15w33c" => ProtocolVersion::modern(57),
"15w33b" => ProtocolVersion::modern(56),
"15w33a" => ProtocolVersion::modern(55),
"15w32c" => ProtocolVersion::modern(54),
"15w32b" => ProtocolVersion::modern(53),
"15w32a" => ProtocolVersion::modern(52),
"15w31c" => ProtocolVersion::modern(51),
"15w31b" => ProtocolVersion::modern(50),
"15w31a" => ProtocolVersion::modern(49),
"1.8.9" => ProtocolVersion::modern(47),
"1.8.8" => ProtocolVersion::modern(47),
"1.8.7" => ProtocolVersion::modern(47),
"1.8.6" => ProtocolVersion::modern(47),
"1.8.5" => ProtocolVersion::modern(47),
"1.8.4" => ProtocolVersion::modern(47),
"1.8.3" => ProtocolVersion::modern(47),
"1.8.2" => ProtocolVersion::modern(47),
"1.8.2-pre7" => ProtocolVersion::modern(47),
"1.8.2-pre6" => ProtocolVersion::modern(47),
"1.8.2-pre5" => ProtocolVersion::modern(47),
"1.8.2-pre4" => ProtocolVersion::modern(47),
"1.8.2-pre3" => ProtocolVersion::modern(47),
"1.8.2-pre2" => ProtocolVersion::modern(47),
"1.8.2-pre1" => ProtocolVersion::modern(47),
"1.8.1" => ProtocolVersion::modern(47),
"1.8.1-pre5" => ProtocolVersion::modern(47),
"1.8.1-pre4" => ProtocolVersion::modern(47),
"1.8.1-pre3" => ProtocolVersion::modern(47),
"1.8.1-pre2" => ProtocolVersion::modern(47),
"1.8.1-pre1" => ProtocolVersion::modern(47),
"1.8" => ProtocolVersion::modern(47),
"1.8-pre3" => ProtocolVersion::modern(46),
"1.8-pre2" => ProtocolVersion::modern(45),
"1.8-pre1" => ProtocolVersion::modern(44),
"14w34d" => ProtocolVersion::modern(43),
"14w34c" => ProtocolVersion::modern(42),
"14w34b" => ProtocolVersion::modern(41),
"14w34a" => ProtocolVersion::modern(40),
"14w33c" => ProtocolVersion::modern(39),
"14w33b" => ProtocolVersion::modern(38),
"14w33a" => ProtocolVersion::modern(37),
"14w32d" => ProtocolVersion::modern(36),
"14w32c" => ProtocolVersion::modern(35),
"14w32b" => ProtocolVersion::modern(34),
"14w32a" => ProtocolVersion::modern(33),
"14w31a" => ProtocolVersion::modern(32),
"14w30c" => ProtocolVersion::modern(31),
"14w30b" => ProtocolVersion::modern(30),
"14w30a" => ProtocolVersion::modern(30),
"14w29b" => ProtocolVersion::modern(29),
"14w29a" => ProtocolVersion::modern(29),
"14w28b" => ProtocolVersion::modern(28),
"14w28a" => ProtocolVersion::modern(27),
"14w27b" => ProtocolVersion::modern(26),
"14w27a" => ProtocolVersion::modern(26),
"14w26c" => ProtocolVersion::modern(25),
"14w26b" => ProtocolVersion::modern(24),
"14w26a" => ProtocolVersion::modern(23),
"14w25b" => ProtocolVersion::modern(22),
"14w25a" => ProtocolVersion::modern(21),
"14w21b" => ProtocolVersion::modern(20),
"14w21a" => ProtocolVersion::modern(19),
"14w20b" => ProtocolVersion::modern(18),
"14w20a" => ProtocolVersion::modern(18),
"14w19a" => ProtocolVersion::modern(17),
"14w18b" => ProtocolVersion::modern(16),
"14w18a" => ProtocolVersion::modern(16),
"14w17a" => ProtocolVersion::modern(15),
"14w11b" => ProtocolVersion::modern(14),
"14w11a" => ProtocolVersion::modern(14),
"14w10c" => ProtocolVersion::modern(13),
"14w10b" => ProtocolVersion::modern(13),
"14w10a" => ProtocolVersion::modern(13),
"14w08a" => ProtocolVersion::modern(12),
"14w07a" => ProtocolVersion::modern(11),
"14w06b" => ProtocolVersion::modern(10),
"14w06a" => ProtocolVersion::modern(10),
"14w05b" => ProtocolVersion::modern(9),
"14w05a" => ProtocolVersion::modern(9),
"14w04b" => ProtocolVersion::modern(8),
"14w04a" => ProtocolVersion::modern(7),
"14w03b" => ProtocolVersion::modern(6),
"14w03a" => ProtocolVersion::modern(6),
"14w02c" => ProtocolVersion::modern(5),
"14w02b" => ProtocolVersion::modern(5),
"14w02a" => ProtocolVersion::modern(5),
"1.7.10" => ProtocolVersion::modern(5),
"1.7.10-pre4" => ProtocolVersion::modern(5),
"1.7.10-pre3" => ProtocolVersion::modern(5),
"1.7.10-pre2" => ProtocolVersion::modern(5),
"1.7.10-pre1" => ProtocolVersion::modern(5),
"1.7.9" => ProtocolVersion::modern(5),
"1.7.8" => ProtocolVersion::modern(5),
"1.7.7" => ProtocolVersion::modern(5),
"1.7.6" => ProtocolVersion::modern(5),
"1.7.6-pre2" => ProtocolVersion::modern(5),
"1.7.6-pre1" => ProtocolVersion::modern(5),
"1.7.5" => ProtocolVersion::modern(4),
"1.7.4" => ProtocolVersion::modern(4),
"1.7.3" => ProtocolVersion::modern(4),
"13w49a" => ProtocolVersion::modern(4),
"13w48b" => ProtocolVersion::modern(4),
"13w48a" => ProtocolVersion::modern(4),
"13w47e" => ProtocolVersion::modern(4),
"13w47d" => ProtocolVersion::modern(4),
"13w47c" => ProtocolVersion::modern(4),
"13w47b" => ProtocolVersion::modern(4),
"13w47a" => ProtocolVersion::modern(4),
"1.7.2" => ProtocolVersion::modern(4),
"1.7.1" => ProtocolVersion::modern(3),
"1.7" => ProtocolVersion::modern(3),
"13w43a" => ProtocolVersion::modern(2),
"13w42b" => ProtocolVersion::modern(1),
"13w42a" => ProtocolVersion::modern(1),
"13w41b" => ProtocolVersion::modern(0),
"13w41a" => ProtocolVersion::modern(0),
"13w39b" => ProtocolVersion::legacy(80),
"13w39a" => ProtocolVersion::legacy(80),
"13w38c" => ProtocolVersion::legacy(79),
"13w38b" => ProtocolVersion::legacy(79),
"13w38a" => ProtocolVersion::legacy(79),
"1.6.4" => ProtocolVersion::legacy(78),
"1.6.3" => ProtocolVersion::legacy(77),
"13w37b" => ProtocolVersion::legacy(76),
"13w37a" => ProtocolVersion::legacy(76),
"13w36b" => ProtocolVersion::legacy(75),
"13w36a" => ProtocolVersion::legacy(75),
"1.6.2" => ProtocolVersion::legacy(74),
"1.6.1" => ProtocolVersion::legacy(73),
"1.6" => ProtocolVersion::legacy(72),
"13w26a" => ProtocolVersion::legacy(72),
"13w25c" => ProtocolVersion::legacy(71),
"13w25b" => ProtocolVersion::legacy(71),
"13w25a" => ProtocolVersion::legacy(71),
"13w24b" => ProtocolVersion::legacy(70),
"13w24a" => ProtocolVersion::legacy(69),
"13w23b" => ProtocolVersion::legacy(68),
"13w23a" => ProtocolVersion::legacy(67),
"13w22a" => ProtocolVersion::legacy(67),
"13w21b" => ProtocolVersion::legacy(67),
"13w21a" => ProtocolVersion::legacy(67),
"13w19a" => ProtocolVersion::legacy(66),
"13w18c" => ProtocolVersion::legacy(65),
"13w18b" => ProtocolVersion::legacy(65),
"13w18a" => ProtocolVersion::legacy(65),
"13w17a" => ProtocolVersion::legacy(64),
"13w16b" => ProtocolVersion::legacy(63),
"13w16a" => ProtocolVersion::legacy(62),
"1.5.2" => ProtocolVersion::legacy(61),
"1.5.1" => ProtocolVersion::legacy(60),
"13w12~" => ProtocolVersion::legacy(60),
"13w11a" => ProtocolVersion::legacy(60),
"1.5" => ProtocolVersion::legacy(60),
"13w10b" => ProtocolVersion::legacy(60),
"13w10a" => ProtocolVersion::legacy(60),
"13w09c" => ProtocolVersion::legacy(60),
"13w09b" => ProtocolVersion::legacy(59),
"13w09a" => ProtocolVersion::legacy(59),
"13w07a" => ProtocolVersion::legacy(58),
"13w06a" => ProtocolVersion::legacy(58),
"13w05b" => ProtocolVersion::legacy(56),
"13w05a" => ProtocolVersion::legacy(56),
"13w04a" => ProtocolVersion::legacy(55),
"13w03a" => ProtocolVersion::legacy(54),
"13w02b" => ProtocolVersion::legacy(53),
"13w02a" => ProtocolVersion::legacy(53),
"13w01b" => ProtocolVersion::legacy(52),
"13w01a" => ProtocolVersion::legacy(52),
"1.4.7" => ProtocolVersion::legacy(51),
"1.4.6" => ProtocolVersion::legacy(51),
"12w50b" => ProtocolVersion::legacy(51),
"12w50a" => ProtocolVersion::legacy(51),
"12w49a" => ProtocolVersion::legacy(50),
"1.4.5" => ProtocolVersion::legacy(49),
"1.4.4" => ProtocolVersion::legacy(49),
"1.4.3" => ProtocolVersion::legacy(48),
"1.4.2" => ProtocolVersion::legacy(47),
"1.4.1" => ProtocolVersion::legacy(47),
"1.4" => ProtocolVersion::legacy(47),
"12w42b" => ProtocolVersion::legacy(47),
"12w42a" => ProtocolVersion::legacy(46),
"12w41b" => ProtocolVersion::legacy(46),
"12w41a" => ProtocolVersion::legacy(46),
"12w40b" => ProtocolVersion::legacy(45),
"12w40a" => ProtocolVersion::legacy(44),
"12w39b" => ProtocolVersion::legacy(43),
"12w39a" => ProtocolVersion::legacy(43),
"12w38b" => ProtocolVersion::legacy(43),
"12w38a" => ProtocolVersion::legacy(43),
"12w37a" => ProtocolVersion::legacy(42),
"12w36a" => ProtocolVersion::legacy(42),
"12w34b" => ProtocolVersion::legacy(42),
"12w34a" => ProtocolVersion::legacy(41),
"12w32a" => ProtocolVersion::legacy(40),
"1.3.2" => ProtocolVersion::legacy(39),
"1.3.1" => ProtocolVersion::legacy(39),
"1.3" => ProtocolVersion::legacy(39),
"12w30e" => ProtocolVersion::legacy(39),
"12w30d" => ProtocolVersion::legacy(39),
"12w30c" => ProtocolVersion::legacy(39),
"12w30b" => ProtocolVersion::legacy(38),
"12w30a" => ProtocolVersion::legacy(38),
"12w27a" => ProtocolVersion::legacy(38),
"12w26a" => ProtocolVersion::legacy(37),
"12w25a" => ProtocolVersion::legacy(37),
"12w24a" => ProtocolVersion::legacy(36),
"12w23b" => ProtocolVersion::legacy(35),
"12w23a" => ProtocolVersion::legacy(35),
"12w22a" => ProtocolVersion::legacy(34),
"12w21b" => ProtocolVersion::legacy(33),
"12w21a" => ProtocolVersion::legacy(33),
"12w19a" => ProtocolVersion::legacy(32),
"12w18a" => ProtocolVersion::legacy(32),
"12w17a" => ProtocolVersion::legacy(31),
"12w16a" => ProtocolVersion::legacy(30),
"12w15a" => ProtocolVersion::legacy(29),
"1.2.5" => ProtocolVersion::legacy(29),
"1.2.4" => ProtocolVersion::legacy(29),
"1.2.3" => ProtocolVersion::legacy(28),
"1.2.2" => ProtocolVersion::legacy(28),
"1.2.1" => ProtocolVersion::legacy(28),
"1.2" => ProtocolVersion::legacy(28),
"12w08a" => ProtocolVersion::legacy(28),
"12w07b" => ProtocolVersion::legacy(27),
"12w07a" => ProtocolVersion::legacy(27),
"12w06a" => ProtocolVersion::legacy(25),
"12w05b" => ProtocolVersion::legacy(24),
"12w05a" => ProtocolVersion::legacy(24),
"12w04a" => ProtocolVersion::legacy(24),
"12w03a" => ProtocolVersion::legacy(24),
"1.1" => ProtocolVersion::legacy(23),
"12w01a" => ProtocolVersion::legacy(23),
"11w50a" => ProtocolVersion::legacy(22),
"11w49a" => ProtocolVersion::legacy(22),
"11w48a" => ProtocolVersion::legacy(22),
"11w47a" => ProtocolVersion::legacy(22),
"1.0.1" => ProtocolVersion::legacy(22),
"1.0.0" => ProtocolVersion::legacy(22),
"1.0.0-rc2-1" => ProtocolVersion::legacy(22),
"1.0.0-rc2-2" => ProtocolVersion::legacy(22),
"1.0.0-rc2-3" => ProtocolVersion::legacy(22),
"1.0.0-rc1" => ProtocolVersion::legacy(22),
"b1.9-pre6" => ProtocolVersion::legacy(22),
"b1.9-pre5" => ProtocolVersion::legacy(21),
"b1.9-pre4" => ProtocolVersion::legacy(20),
"b1.9-pre3" => ProtocolVersion::legacy(19),
"b1.9-pre2" => ProtocolVersion::legacy(19),
"b1.9-pre1" => ProtocolVersion::legacy(18),
"b1.8.1" => ProtocolVersion::legacy(17),
"b1.8" => ProtocolVersion::legacy(17),
"b1.8-pre2" => ProtocolVersion::legacy(16),
"b1.8-pre1-1" => ProtocolVersion::legacy(15),
"b1.8-pre1-2" => ProtocolVersion::legacy(15),
};

View File

@ -1,5 +1,6 @@
use crate::ErrorKind; use crate::ErrorKind;
use crate::error::Result; use crate::error::Result;
use crate::util::protocol_version::ProtocolVersion;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::value::RawValue; use serde_json::value::RawValue;
use std::time::Duration; use std::time::Duration;
@ -42,16 +43,23 @@ pub struct ServerGameProfile {
#[derive(Deserialize, Serialize, Debug, Clone)] #[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ServerVersion { pub struct ServerVersion {
pub name: String, pub name: String,
pub protocol: i32, pub protocol: u32,
#[serde(skip_deserializing)]
pub legacy: bool,
} }
pub async fn get_server_status( pub async fn get_server_status(
address: &impl ToSocketAddrs, address: &impl ToSocketAddrs,
original_address: (&str, u16), original_address: (&str, u16),
protocol_version: Option<i32>, protocol_version: Option<ProtocolVersion>,
) -> Result<ServerStatus> { ) -> Result<ServerStatus> {
select! { select! {
res = modern::status(address, original_address, protocol_version) => res, res = async {
match protocol_version {
Some(ProtocolVersion { legacy: true, version }) => legacy::status(address, original_address, Some(version as u8)).await,
protocol => modern::status(address, original_address, protocol.map(|v| v.version)).await,
}
} => res,
_ = tokio::time::sleep(Duration::from_secs(30)) => Err(ErrorKind::OtherError( _ = tokio::time::sleep(Duration::from_secs(30)) => Err(ErrorKind::OtherError(
format!("Ping of {}:{} timed out", original_address.0, original_address.1) format!("Ping of {}:{} timed out", original_address.0, original_address.1)
).into()) ).into())
@ -68,7 +76,7 @@ mod modern {
pub async fn status( pub async fn status(
address: &impl ToSocketAddrs, address: &impl ToSocketAddrs,
original_address: (&str, u16), original_address: (&str, u16),
protocol_version: Option<i32>, protocol_version: Option<u32>,
) -> crate::Result<ServerStatus> { ) -> crate::Result<ServerStatus> {
let mut stream = TcpStream::connect(address).await?; let mut stream = TcpStream::connect(address).await?;
handshake(&mut stream, original_address, protocol_version).await?; handshake(&mut stream, original_address, protocol_version).await?;
@ -80,10 +88,10 @@ mod modern {
async fn handshake( async fn handshake(
stream: &mut TcpStream, stream: &mut TcpStream,
original_address: (&str, u16), original_address: (&str, u16),
protocol_version: Option<i32>, protocol_version: Option<u32>,
) -> crate::Result<()> { ) -> crate::Result<()> {
let (host, port) = original_address; let (host, port) = original_address;
let protocol_version = protocol_version.unwrap_or(-1); let protocol_version = protocol_version.map_or(-1, |x| x as i32);
const PACKET_ID: i32 = 0; const PACKET_ID: i32 = 0;
const NEXT_STATE: i32 = 1; const NEXT_STATE: i32 = 1;
@ -221,3 +229,95 @@ mod modern {
} }
} }
} }
mod legacy {
use super::ServerStatus;
use crate::worlds::{ServerPlayers, ServerVersion};
use crate::{Error, ErrorKind};
use serde_json::value::to_raw_value;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, ToSocketAddrs};
pub async fn status(
address: &impl ToSocketAddrs,
original_address: (&str, u16),
protocol_version: Option<u8>,
) -> crate::Result<ServerStatus> {
let protocol_version = protocol_version.unwrap_or(74);
let mut packet = vec![0xfe];
if protocol_version >= 47 {
packet.push(0x01);
}
if protocol_version >= 73 {
packet.push(0xfa);
write_legacy(&mut packet, "MC|PingHost");
let (host, port) = original_address;
let len_index = packet.len();
packet.push(protocol_version);
write_legacy(&mut packet, host);
packet.extend_from_slice(&(port as u32).to_be_bytes());
packet.splice(
len_index..len_index,
((packet.len() - len_index) as u16).to_be_bytes(),
);
}
let mut stream = TcpStream::connect(address).await?;
stream.write_all(&packet).await?;
stream.flush().await?;
let packet_id = stream.read_u8().await?;
if packet_id != 0xff {
return Err(Error::from(ErrorKind::InputError(
"Unexpected legacy status response".to_string(),
)));
}
let data_length = stream.read_u16().await?;
let mut data = vec![0u8; data_length as usize * 2];
stream.read_exact(&mut data).await?;
drop(stream);
let data = String::from_utf16_lossy(
&data
.chunks_exact(2)
.map(|a| u16::from_be_bytes([a[0], a[1]]))
.collect::<Vec<u16>>(),
);
let mut ancient_server = false;
let mut parts = data.split('\0');
if parts.next() != Some("§1") {
ancient_server = true;
parts = data.split('§');
}
Ok(ServerStatus {
version: (!ancient_server).then(|| ServerVersion {
protocol: parts
.next()
.and_then(|x| x.parse().ok())
.unwrap_or(0),
name: parts.next().unwrap_or("").to_owned(),
legacy: true,
}),
description: parts.next().and_then(|x| to_raw_value(x).ok()),
players: Some(ServerPlayers {
online: parts.next().and_then(|x| x.parse().ok()).unwrap_or(-1),
max: parts.next().and_then(|x| x.parse().ok()).unwrap_or(-1),
sample: vec![],
}),
favicon: None,
enforces_secure_chat: false,
ping: None,
})
}
fn write_legacy(out: &mut Vec<u8>, text: &str) {
let encoded = text.encode_utf16().collect::<Vec<_>>();
out.extend_from_slice(&(encoded.len() as u16).to_be_bytes());
out.extend(encoded.into_iter().flat_map(u16::to_be_bytes));
}
}

View File

@ -38,6 +38,7 @@ import _CodeIcon from './icons/code.svg?component'
import _CoffeeIcon from './icons/coffee.svg?component' import _CoffeeIcon from './icons/coffee.svg?component'
import _CogIcon from './icons/cog.svg?component' import _CogIcon from './icons/cog.svg?component'
import _CoinsIcon from './icons/coins.svg?component' import _CoinsIcon from './icons/coins.svg?component'
import _CollapseIcon from './icons/collapse.svg?component'
import _CollectionIcon from './icons/collection.svg?component' import _CollectionIcon from './icons/collection.svg?component'
import _CompassIcon from './icons/compass.svg?component' import _CompassIcon from './icons/compass.svg?component'
import _ContractIcon from './icons/contract.svg?component' import _ContractIcon from './icons/contract.svg?component'
@ -52,6 +53,7 @@ import _DatabaseIcon from './icons/database.svg?component'
import _DownloadIcon from './icons/download.svg?component' import _DownloadIcon from './icons/download.svg?component'
import _DropdownIcon from './icons/dropdown.svg?component' import _DropdownIcon from './icons/dropdown.svg?component'
import _EditIcon from './icons/edit.svg?component' import _EditIcon from './icons/edit.svg?component'
import _EllipsisVerticalIcon from './icons/ellipsis-vertical.svg?component'
import _ExpandIcon from './icons/expand.svg?component' import _ExpandIcon from './icons/expand.svg?component'
import _ExternalIcon from './icons/external.svg?component' import _ExternalIcon from './icons/external.svg?component'
import _EyeOffIcon from './icons/eye-off.svg?component' import _EyeOffIcon from './icons/eye-off.svg?component'
@ -229,6 +231,7 @@ export const CodeIcon = _CodeIcon
export const CoffeeIcon = _CoffeeIcon export const CoffeeIcon = _CoffeeIcon
export const CogIcon = _CogIcon export const CogIcon = _CogIcon
export const CoinsIcon = _CoinsIcon export const CoinsIcon = _CoinsIcon
export const CollapseIcon = _CollapseIcon
export const CollectionIcon = _CollectionIcon export const CollectionIcon = _CollectionIcon
export const CompassIcon = _CompassIcon export const CompassIcon = _CompassIcon
export const ContractIcon = _ContractIcon export const ContractIcon = _ContractIcon
@ -243,6 +246,7 @@ export const DatabaseIcon = _DatabaseIcon
export const DownloadIcon = _DownloadIcon export const DownloadIcon = _DownloadIcon
export const DropdownIcon = _DropdownIcon export const DropdownIcon = _DropdownIcon
export const EditIcon = _EditIcon export const EditIcon = _EditIcon
export const EllipsisVerticalIcon = _EllipsisVerticalIcon
export const ExpandIcon = _ExpandIcon export const ExpandIcon = _ExpandIcon
export const ExternalIcon = _ExternalIcon export const ExternalIcon = _ExternalIcon
export const EyeOffIcon = _EyeOffIcon export const EyeOffIcon = _EyeOffIcon

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-minimize-icon lucide-minimize">
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
</svg>

After

Width:  |  Height:  |  Size: 414 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis-vertical-icon lucide-ellipsis-vertical"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>

After

Width:  |  Height:  |  Size: 349 B

View File

@ -1,4 +1,4 @@
**Discord:** %PROJECT_DISCORD_URL% \
**Issues:** %PROJECT_ISSUES_URL% \ **Issues:** %PROJECT_ISSUES_URL% \
**Source:** %PROJECT_SOURCE_URL% \ **Source:** %PROJECT_SOURCE_URL% \
**Wiki:** %PROJECT_WIKI_URL% **Wiki:** %PROJECT_WIKI_URL% \
**Discord:** %PROJECT_DISCORD_URL%

View File

@ -0,0 +1,2 @@
**Client:** `%PROJECT_CLIENT_SIDE%` \
**Server:** `%PROJECT_SERVER_SIDE%`

Some files were not shown because too many files have changed in this diff Show More