Compare commits
10 Commits
fetch/offe
...
coolbot/mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54b79148aa | ||
|
|
b5589adb8a | ||
|
|
68fafa8e63 | ||
|
|
473fe52865 | ||
|
|
1032b521bf | ||
|
|
61d535707d | ||
|
|
da41659666 | ||
|
|
8db23d08ea | ||
|
|
95393eca1f | ||
|
|
dd06bb0a50 |
3
.idea/code.iml
generated
3
.idea/code.iml
generated
@@ -10,10 +10,11 @@
|
||||
<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$/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" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
</module>
|
||||
|
||||
44
Cargo.lock
generated
44
Cargo.lock
generated
@@ -5731,17 +5731,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "phf_codegen"
|
||||
version = "0.8.0"
|
||||
@@ -5792,16 +5781,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "phf_macros"
|
||||
version = "0.10.0"
|
||||
@@ -5829,19 +5808,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "phf_shared"
|
||||
version = "0.8.0"
|
||||
@@ -5869,15 +5835,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "pin-project"
|
||||
version = "1.1.10"
|
||||
@@ -8989,7 +8946,6 @@ dependencies = [
|
||||
"notify-debouncer-mini",
|
||||
"p256",
|
||||
"paste",
|
||||
"phf 0.12.1",
|
||||
"png",
|
||||
"quartz_nbt",
|
||||
"quick-xml 0.37.5",
|
||||
|
||||
@@ -99,7 +99,6 @@ notify = { version = "8.0.0", default-features = false }
|
||||
notify-debouncer-mini = { version = "0.6.0", default-features = false }
|
||||
p256 = "0.13.2"
|
||||
paste = "1.0.15"
|
||||
phf = { version = "0.12.1", features = ["macros"] }
|
||||
png = "0.17.16"
|
||||
prometheus = "0.14.0"
|
||||
quartz_nbt = "0.2.9"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"tsc:check": "vue-tsc --noEmit",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"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",
|
||||
"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",
|
||||
"test": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -21,11 +21,14 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const featuredCategory = computed(() => {
|
||||
if (props.project.display_categories.includes('optimization')) {
|
||||
if (props.project.categories.includes('optimization')) {
|
||||
return 'optimization'
|
||||
}
|
||||
|
||||
return props.project.display_categories[0] ?? props.project.categories[0]
|
||||
if (props.project.categories.length > 0) {
|
||||
return props.project.categories[0]
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const toColor = computed(() => {
|
||||
|
||||
@@ -76,10 +76,10 @@ const installing = ref(false)
|
||||
const onInstall = ref(() => {})
|
||||
|
||||
defineExpose({
|
||||
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
|
||||
show: (instanceVal, projectVal, projectVersions, callback) => {
|
||||
instance.value = instanceVal
|
||||
versions.value = projectVersions
|
||||
selectedVersion.value = selected ?? projectVersions[0]
|
||||
selectedVersion.value = projectVersions[0]
|
||||
|
||||
project.value = projectVal
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type ProtocolVersion,
|
||||
type ServerWorld,
|
||||
type ServerData,
|
||||
type WorldWithProfile,
|
||||
@@ -34,7 +33,7 @@ const theme = useTheming()
|
||||
|
||||
const jumpBackInItems = ref<JumpBackInItem[]>([])
|
||||
const serverData = ref<Record<string, ServerData>>({})
|
||||
const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
|
||||
const protocolVersions = ref<Record<string, number | null>>({})
|
||||
|
||||
const MIN_JUMP_BACK_IN = 3
|
||||
const MAX_JUMP_BACK_IN = 6
|
||||
@@ -122,8 +121,11 @@ async function populateJumpBackIn() {
|
||||
}
|
||||
})
|
||||
|
||||
servers.forEach(({ instancePath, address }) =>
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
||||
// fetch each server's data
|
||||
Promise.all(
|
||||
servers.map(({ instancePath, address }) =>
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -148,8 +150,8 @@ async function populateJumpBackIn() {
|
||||
.slice(0, MAX_JUMP_BACK_IN)
|
||||
}
|
||||
|
||||
function refreshServer(address: string, instancePath: string) {
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
|
||||
async function refreshServer(address: string, instancePath: string) {
|
||||
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
|
||||
}
|
||||
|
||||
async function joinWorld(world: WorldWithProfile) {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import type {
|
||||
ProtocolVersion,
|
||||
ServerStatus,
|
||||
ServerWorld,
|
||||
SingleplayerWorld,
|
||||
World,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts'
|
||||
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
|
||||
import { formatNumber, getPingLevel } from '@modrinth/utils'
|
||||
import {
|
||||
@@ -60,9 +54,8 @@ const props = withDefaults(
|
||||
playingInstance?: boolean
|
||||
playingWorld?: boolean
|
||||
startingInstance?: boolean
|
||||
supportsServerQuickPlay?: boolean
|
||||
supportsWorldQuickPlay?: boolean
|
||||
currentProtocol?: ProtocolVersion | null
|
||||
supportsQuickPlay?: boolean
|
||||
currentProtocol?: number | null
|
||||
highlighted?: boolean
|
||||
|
||||
// Server only
|
||||
@@ -85,8 +78,7 @@ const props = withDefaults(
|
||||
playingInstance: false,
|
||||
playingWorld: false,
|
||||
startingInstance: false,
|
||||
supportsServerQuickPlay: true,
|
||||
supportsWorldQuickPlay: false,
|
||||
supportsQuickPlay: false,
|
||||
currentProtocol: null,
|
||||
|
||||
refreshing: false,
|
||||
@@ -110,8 +102,7 @@ const serverIncompatible = computed(
|
||||
!!props.serverStatus &&
|
||||
!!props.serverStatus.version?.protocol &&
|
||||
!!props.currentProtocol &&
|
||||
(props.serverStatus.version.protocol !== props.currentProtocol.version ||
|
||||
props.serverStatus.version.legacy !== props.currentProtocol.legacy),
|
||||
props.serverStatus.version.protocol !== props.currentProtocol,
|
||||
)
|
||||
|
||||
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
||||
@@ -129,13 +120,9 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.a_minecraft_server',
|
||||
defaultMessage: 'A Minecraft Server',
|
||||
},
|
||||
noServerQuickPlay: {
|
||||
id: 'instance.worlds.no_server_quick_play',
|
||||
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+',
|
||||
noQuickPlay: {
|
||||
id: 'instance.worlds.no_quick_play',
|
||||
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+',
|
||||
},
|
||||
gameAlreadyOpen: {
|
||||
id: 'instance.worlds.game_already_open',
|
||||
@@ -157,6 +144,10 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.view_instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
playAnyway: {
|
||||
id: 'instance.worlds.play_anyway',
|
||||
defaultMessage: 'Play anyway',
|
||||
},
|
||||
playInstance: {
|
||||
id: 'instance.worlds.play_instance',
|
||||
defaultMessage: 'Play instance',
|
||||
@@ -331,24 +322,17 @@ const messages = defineMessages({
|
||||
<ButtonStyled v-else>
|
||||
<button
|
||||
v-tooltip="
|
||||
world.type == 'server' && !supportsServerQuickPlay
|
||||
? formatMessage(messages.noServerQuickPlay)
|
||||
: world.type == 'singleplayer' && !supportsWorldQuickPlay
|
||||
? formatMessage(messages.noSingleplayerQuickPlay)
|
||||
: playingOtherWorld || locked
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: !serverStatus
|
||||
? formatMessage(messages.noContact)
|
||||
: serverIncompatible
|
||||
? formatMessage(messages.incompatibleServer)
|
||||
: null
|
||||
"
|
||||
:disabled="
|
||||
playingOtherWorld ||
|
||||
startingInstance ||
|
||||
(world.type == 'server' && !supportsServerQuickPlay) ||
|
||||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
|
||||
!serverStatus
|
||||
? formatMessage(messages.noContact)
|
||||
: serverIncompatible
|
||||
? formatMessage(messages.incompatibleServer)
|
||||
: !supportsQuickPlay
|
||||
? formatMessage(messages.noQuickPlay)
|
||||
: playingOtherWorld || locked
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: null
|
||||
"
|
||||
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||
@@ -365,6 +349,11 @@ const messages = defineMessages({
|
||||
disabled: playingInstance,
|
||||
action: () => emit('play-instance'),
|
||||
},
|
||||
{
|
||||
id: 'play-anyway',
|
||||
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
|
||||
action: () => emit('play'),
|
||||
},
|
||||
{
|
||||
id: 'open-instance',
|
||||
shown: !!instancePath,
|
||||
@@ -430,6 +419,10 @@ const messages = defineMessages({
|
||||
<PlayIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.playInstance) }}
|
||||
</template>
|
||||
<template #play-anyway>
|
||||
<PlayIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.playAnyway) }}
|
||||
</template>
|
||||
<template #open-instance>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.viewInstance) }}
|
||||
|
||||
@@ -51,7 +51,6 @@ export type ServerStatus = {
|
||||
version?: {
|
||||
name: string
|
||||
protocol: number
|
||||
legacy: boolean
|
||||
}
|
||||
favicon?: string
|
||||
enforces_secure_chat: boolean
|
||||
@@ -71,17 +70,11 @@ export interface Chat {
|
||||
|
||||
export type ServerData = {
|
||||
refreshing: boolean
|
||||
lastSuccessfulRefresh?: number
|
||||
status?: ServerStatus
|
||||
rawMotd?: string | Chat
|
||||
renderedMotd?: string
|
||||
}
|
||||
|
||||
export type ProtocolVersion = {
|
||||
version: number
|
||||
legacy: boolean
|
||||
}
|
||||
|
||||
export async function get_recent_worlds(
|
||||
limit: number,
|
||||
displayStatuses?: DisplayStatus[],
|
||||
@@ -163,13 +156,13 @@ export async function remove_server_from_profile(path: string, index: number): P
|
||||
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
|
||||
}
|
||||
|
||||
export async function get_profile_protocol_version(path: string): Promise<ProtocolVersion | null> {
|
||||
export async function get_profile_protocol_version(path: string): Promise<number | null> {
|
||||
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
|
||||
}
|
||||
|
||||
export async function get_server_status(
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
protocolVersion: number | null = null,
|
||||
): Promise<ServerStatus> {
|
||||
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
|
||||
}
|
||||
@@ -213,39 +206,30 @@ export function isServerWorld(world: World): world is ServerWorld {
|
||||
|
||||
export async function refreshServerData(
|
||||
serverData: ServerData,
|
||||
protocolVersion: ProtocolVersion | null,
|
||||
protocolVersion: number | null,
|
||||
address: string,
|
||||
): Promise<void> {
|
||||
const refreshTime = Date.now()
|
||||
serverData.refreshing = true
|
||||
await get_server_status(address, protocolVersion)
|
||||
.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
|
||||
if (status.description) {
|
||||
serverData.rawMotd = status.description
|
||||
serverData.renderedMotd = autoToHTML(status.description)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Refreshing addr: ${address}`, err)
|
||||
})
|
||||
.finally(() => {
|
||||
serverData.refreshing = false
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Refreshing addr ${address}`, protocolVersion, err)
|
||||
if (!protocolVersion?.legacy) {
|
||||
refreshServerData(serverData, { version: 74, legacy: true }, address)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function refreshServers(
|
||||
export async function refreshServers(
|
||||
worlds: World[],
|
||||
serverData: Record<string, ServerData>,
|
||||
protocolVersion: ProtocolVersion | null,
|
||||
protocolVersion: number | null,
|
||||
) {
|
||||
const servers = worlds.filter(isServerWorld)
|
||||
servers.forEach((server) => {
|
||||
@@ -259,8 +243,10 @@ export function refreshServers(
|
||||
})
|
||||
|
||||
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
|
||||
Object.keys(serverData).forEach((address) =>
|
||||
refreshServerData(serverData[address], protocolVersion, address),
|
||||
Promise.all(
|
||||
Object.keys(serverData).map((address) =>
|
||||
refreshServerData(serverData[address], protocolVersion, address),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -311,24 +297,15 @@ export async function refreshWorlds(instancePath: string): Promise<World[]> {
|
||||
return worlds ?? []
|
||||
}
|
||||
|
||||
export function hasServerQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
|
||||
if (!gameVersions.length) {
|
||||
return true
|
||||
}
|
||||
const FIRST_QUICK_PLAY_VERSION = '23w14a'
|
||||
|
||||
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) {
|
||||
export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
|
||||
if (!gameVersions.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
|
||||
const targetIndex = gameVersions.findIndex((v) => v.version === '23w14a')
|
||||
const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION)
|
||||
|
||||
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
|
||||
}
|
||||
|
||||
@@ -383,11 +383,11 @@
|
||||
"instance.worlds.no_contact": {
|
||||
"message": "Server couldn't be contacted"
|
||||
},
|
||||
"instance.worlds.no_server_quick_play": {
|
||||
"message": "You can only jump straight into servers on Minecraft Alpha 1.0.5+"
|
||||
"instance.worlds.no_quick_play": {
|
||||
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
||||
},
|
||||
"instance.worlds.no_singleplayer_quick_play": {
|
||||
"message": "You can only jump straight into singleplayer worlds on Minecraft 1.20+"
|
||||
"instance.worlds.play_anyway": {
|
||||
"message": "Play anyway"
|
||||
},
|
||||
"instance.worlds.play_instance": {
|
||||
"message": "Play instance"
|
||||
|
||||
@@ -67,8 +67,7 @@
|
||||
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
||||
:world="world"
|
||||
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
||||
:supports-server-quick-play="supportsServerQuickPlay"
|
||||
:supports-world-quick-play="supportsWorldQuickPlay"
|
||||
:supports-quick-play="supportsQuickPlay"
|
||||
:current-protocol="protocolVersion"
|
||||
:playing-instance="playing"
|
||||
:playing-world="worldsMatch(world, worldPlaying)"
|
||||
@@ -135,7 +134,6 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
type ProtocolVersion,
|
||||
type SingleplayerWorld,
|
||||
type World,
|
||||
type ServerWorld,
|
||||
@@ -151,11 +149,10 @@ import {
|
||||
refreshWorld,
|
||||
sortWorlds,
|
||||
refreshServers,
|
||||
hasWorldQuickPlaySupport,
|
||||
hasQuickPlaySupport,
|
||||
refreshWorlds,
|
||||
handleDefaultProfileUpdateEvent,
|
||||
showWorldInFolder,
|
||||
hasServerQuickPlaySupport,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
|
||||
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
||||
@@ -213,9 +210,7 @@ const worldPlaying = ref<World>()
|
||||
const worlds = ref<World[]>([])
|
||||
const serverData = ref<Record<string, ServerData>>({})
|
||||
|
||||
const protocolVersion = ref<ProtocolVersion | null>(
|
||||
await get_profile_protocol_version(instance.value.path),
|
||||
)
|
||||
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path))
|
||||
|
||||
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
|
||||
if (e.profile_path_id !== instance.value.path) return
|
||||
@@ -251,7 +246,7 @@ async function refreshAllWorlds() {
|
||||
worlds.value = await refreshWorlds(instance.value.path).finally(
|
||||
() => (refreshingAll.value = false),
|
||||
)
|
||||
refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
||||
await refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
||||
|
||||
const hasNoWorlds = worlds.value.length === 0
|
||||
|
||||
@@ -357,11 +352,8 @@ function worldsMatch(world: World, other: World | undefined) {
|
||||
}
|
||||
|
||||
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
|
||||
const supportsServerQuickPlay = computed(() =>
|
||||
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
)
|
||||
const supportsWorldQuickPlay = computed(() =>
|
||||
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
const supportsQuickPlay = computed(() =>
|
||||
hasQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
)
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
|
||||
@@ -29,8 +29,8 @@ export const useInstall = defineStore('installStore', {
|
||||
setIncompatibilityWarningModal(ref) {
|
||||
this.incompatibilityWarningModal = ref
|
||||
},
|
||||
showIncompatibilityWarningModal(instance, project, versions, selected, onInstall) {
|
||||
this.incompatibilityWarningModal.show(instance, project, versions, selected, onInstall)
|
||||
showIncompatibilityWarningModal(instance, project, versions, onInstall) {
|
||||
this.incompatibilityWarningModal.show(instance, project, versions, onInstall)
|
||||
},
|
||||
setModInstallModal(ref) {
|
||||
this.modInstallModal = ref
|
||||
@@ -133,13 +133,7 @@ export const install = async (
|
||||
callback(version.id)
|
||||
} else {
|
||||
const install = useInstall()
|
||||
install.showIncompatibilityWarningModal(
|
||||
instance,
|
||||
project,
|
||||
projectVersions,
|
||||
version,
|
||||
callback,
|
||||
)
|
||||
install.showIncompatibilityWarningModal(instance, project, projectVersions, callback)
|
||||
}
|
||||
} else {
|
||||
const versions = (await get_version_many(project.versions).catch(handleError)).sort(
|
||||
|
||||
@@ -250,7 +250,7 @@ pub async fn profile_get_pack_export_candidates(
|
||||
// invoke('plugin:profile|profile_run', path)
|
||||
#[tauri::command]
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ use enumset::EnumSet;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use theseus::prelude::ProcessMetadata;
|
||||
use theseus::profile::{QuickPlayType, get_full_path};
|
||||
use theseus::server_address::ServerAddress;
|
||||
use theseus::worlds::{
|
||||
DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
|
||||
WorldType, WorldWithProfile,
|
||||
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType,
|
||||
WorldWithProfile,
|
||||
};
|
||||
use theseus::{profile, worlds};
|
||||
|
||||
@@ -184,16 +183,14 @@ pub async fn remove_server_from_profile(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profile_protocol_version(
|
||||
path: &str,
|
||||
) -> Result<Option<ProtocolVersion>> {
|
||||
pub async fn get_profile_protocol_version(path: &str) -> Result<Option<i32>> {
|
||||
Ok(worlds::get_profile_protocol_version(path).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_server_status(
|
||||
address: &str,
|
||||
protocol_version: Option<ProtocolVersion>,
|
||||
protocol_version: Option<i32>,
|
||||
) -> Result<ServerStatus> {
|
||||
Ok(worlds::get_server_status(address, protocol_version).await?)
|
||||
}
|
||||
@@ -204,7 +201,7 @@ pub async fn start_join_singleplayer_world(
|
||||
world: String,
|
||||
) -> Result<ProcessMetadata> {
|
||||
let process =
|
||||
profile::run(path, QuickPlayType::Singleplayer(world)).await?;
|
||||
profile::run(path, &QuickPlayType::Singleplayer(world)).await?;
|
||||
|
||||
Ok(process)
|
||||
}
|
||||
@@ -214,11 +211,8 @@ pub async fn start_join_server(
|
||||
path: &str,
|
||||
address: &str,
|
||||
) -> Result<ProcessMetadata> {
|
||||
let process = profile::run(
|
||||
path,
|
||||
QuickPlayType::Server(ServerAddress::Unresolved(address.to_owned())),
|
||||
)
|
||||
.await?;
|
||||
let process =
|
||||
profile::run(path, &QuickPlayType::Server(address.to_owned())).await?;
|
||||
|
||||
Ok(process)
|
||||
}
|
||||
|
||||
@@ -143,13 +143,8 @@ export default defineNuxtConfig({
|
||||
state.lastGenerated &&
|
||||
new Date(state.lastGenerated).getTime() + TTL > new Date().getTime() &&
|
||||
// ...but only if the API URL is the same
|
||||
state.apiUrl === API_URL &&
|
||||
// ...and if no errors were caught during the last generation
|
||||
(state.errors ?? []).length === 0
|
||||
state.apiUrl === API_URL
|
||||
) {
|
||||
console.log(
|
||||
"Tags already recently generated. Delete apps/frontend/generated/state.json to force regeneration.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,12 +59,10 @@
|
||||
"markdown-it": "14.1.0",
|
||||
"pathe": "^1.1.2",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persistedstate": "^4.4.1",
|
||||
"prettier": "^3.6.2",
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"semver": "^7.5.4",
|
||||
"three": "^0.172.0",
|
||||
"vue-confetti-explosion": "^1.0.2",
|
||||
"vue-multiselect": "3.0.0-alpha.2",
|
||||
"vue-typed-virtual-list": "^1.0.10",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
|
||||
1133
apps/frontend/src/components/ui/ModerationChecklist.vue
Normal file
1133
apps/frontend/src/components/ui/ModerationChecklist.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { MailIcon, CheckIcon } from "@modrinth/assets";
|
||||
import { ref } from "vue";
|
||||
import { ref, watchEffect } from "vue";
|
||||
import { useBaseFetch } from "~/composables/fetch.js";
|
||||
|
||||
const auth = await useAuth();
|
||||
const showSubscriptionConfirmation = ref(false);
|
||||
const showSubscribeButton = useAsyncData(
|
||||
async () => {
|
||||
if (auth.value?.user) {
|
||||
try {
|
||||
const { subscribed } = await useBaseFetch("auth/email/subscribe", {
|
||||
method: "GET",
|
||||
});
|
||||
return !subscribed;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
const subscribed = ref(false);
|
||||
|
||||
async function checkSubscribed() {
|
||||
if (auth.value?.user) {
|
||||
try {
|
||||
const { data } = await useBaseFetch("auth/email/subscribe", {
|
||||
method: "GET",
|
||||
});
|
||||
subscribed.value = data?.subscribed || false;
|
||||
} catch {
|
||||
subscribed.value = false;
|
||||
}
|
||||
},
|
||||
{ watch: [auth], server: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
checkSubscribed();
|
||||
});
|
||||
|
||||
async function subscribe() {
|
||||
try {
|
||||
@@ -34,19 +35,14 @@ async function subscribe() {
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
showSubscriptionConfirmation.value = false;
|
||||
showSubscribeButton.status.value = "success";
|
||||
showSubscribeButton.data.value = false;
|
||||
subscribed.value = true;
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonStyled
|
||||
v-if="showSubscribeButton.status.value === 'success' && showSubscribeButton.data.value"
|
||||
color="brand"
|
||||
type="outlined"
|
||||
>
|
||||
<ButtonStyled v-if="auth?.user && !subscribed" color="brand" type="outlined">
|
||||
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
|
||||
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
|
||||
<template v-else> <CheckIcon /> Subscribed! </template>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
|
||||
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
|
||||
|
||||
@@ -64,7 +64,7 @@ function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
|
||||
}
|
||||
|
||||
function isMac() {
|
||||
return navigator.platform.toUpperCase().includes("MAC");
|
||||
return navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
@@ -1,179 +0,0 @@
|
||||
<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>
|
||||
@@ -1,204 +0,0 @@
|
||||
<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">•</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">•</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>
|
||||
@@ -1,275 +0,0 @@
|
||||
<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>
|
||||
@@ -42,9 +42,9 @@
|
||||
<div v-if="done">
|
||||
<p>
|
||||
You are done moderating this project!
|
||||
<template v-if="moderationStore.hasItems">
|
||||
<template v-if="futureProjectCount > 0">
|
||||
There are
|
||||
{{ moderationStore.queueLength }} left.
|
||||
{{ futureProjectCount }} left.
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
<div v-if="toggleActions.length > 0" class="toggle-actions-group space-y-3">
|
||||
<template v-for="action in toggleActions" :key="getActionKey(action)">
|
||||
<Checkbox
|
||||
:model-value="isActionSelected(action)"
|
||||
:model-value="actionStates[getActionId(action)]?.selected ?? false"
|
||||
:label="action.label"
|
||||
:description="action.description"
|
||||
: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"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled v-if="!done && !generatedMessage && moderationStore.hasItems">
|
||||
<button @click="skipCurrentProject">
|
||||
<ButtonStyled v-if="!done && !generatedMessage && futureProjectCount > 0">
|
||||
<button @click="goToNextProject">
|
||||
<XIcon aria-hidden="true" />
|
||||
Skip ({{ moderationStore.queueLength }} left)
|
||||
Skip
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="done">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="endChecklist(undefined)">
|
||||
<template v-if="hasNextProject">
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
Next Project ({{ moderationStore.queueLength }} left)
|
||||
</template>
|
||||
<template v-else>
|
||||
<CheckIcon aria-hidden="true" />
|
||||
All Done!
|
||||
</template>
|
||||
<ButtonStyled v-if="futureProjectCount > 0" color="brand">
|
||||
<button @click="goToNextProject">
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
Next Project
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand">
|
||||
<button @click="exitModeration">
|
||||
<CheckIcon aria-hidden="true" />
|
||||
Done
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -259,7 +259,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="green">
|
||||
<button @click="sendMessage(project.requested_status ?? 'approved')">
|
||||
<button @click="sendMessage('approved')">
|
||||
<CheckIcon aria-hidden="true" />
|
||||
Approve
|
||||
</button>
|
||||
@@ -355,7 +355,6 @@ import {
|
||||
renderHighlightedString,
|
||||
type ModerationJudgements,
|
||||
type ModerationModpackItem,
|
||||
type ProjectStatus,
|
||||
} from "@modrinth/utils";
|
||||
import { computedAsync, useLocalStorage } from "@vueuse/core";
|
||||
import {
|
||||
@@ -371,21 +370,29 @@ import {
|
||||
import * as prettier from "prettier";
|
||||
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
|
||||
import KeybindsModal from "./ChecklistKeybindsModal.vue";
|
||||
import { useModerationStore } from "~/store/moderation.ts";
|
||||
|
||||
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>();
|
||||
|
||||
const props = defineProps<{
|
||||
project: Project;
|
||||
collapsed: boolean;
|
||||
}>();
|
||||
|
||||
const moderationStore = useModerationStore();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
project: Project;
|
||||
futureProjectIds?: string[];
|
||||
collapsed: boolean;
|
||||
}>(),
|
||||
{
|
||||
futureProjectIds: () => [] as string[],
|
||||
},
|
||||
);
|
||||
|
||||
const variables = computed(() => {
|
||||
return flattenProjectVariables(props.project);
|
||||
});
|
||||
|
||||
const futureProjectCount = computed(() => {
|
||||
const ids = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
|
||||
return ids.length;
|
||||
});
|
||||
|
||||
const modpackPermissionsComplete = ref(false);
|
||||
const modpackJudgements = ref<ModerationJudgements>({});
|
||||
const isModpackPermissionsStage = computed(() => {
|
||||
@@ -509,7 +516,7 @@ function handleKeybinds(event: KeyboardEvent) {
|
||||
isLoadingMessage: loadingMessage.value,
|
||||
isModpackPermissionsStage: isModpackPermissionsStage.value,
|
||||
|
||||
futureProjectCount: moderationStore.queueLength,
|
||||
futureProjectCount: futureProjectCount.value,
|
||||
visibleActionsCount: visibleActions.value.length,
|
||||
|
||||
focusedActionIndex: focusedActionIndex.value,
|
||||
@@ -522,13 +529,13 @@ function handleKeybinds(event: KeyboardEvent) {
|
||||
tryGoNext: nextStage,
|
||||
tryGoBack: previousStage,
|
||||
tryGenerateMessage: generateMessage,
|
||||
trySkipProject: skipCurrentProject,
|
||||
trySkipProject: goToNextProject,
|
||||
|
||||
tryToggleCollapse: () => emit("toggleCollapsed"),
|
||||
tryResetProgress: resetProgress,
|
||||
tryExitModeration: () => emit("exit"),
|
||||
|
||||
tryApprove: () => sendMessage(props.project.requested_status),
|
||||
tryApprove: () => sendMessage("approved"),
|
||||
tryReject: () => sendMessage("rejected"),
|
||||
tryWithhold: () => sendMessage("withheld"),
|
||||
tryEditMessage: goBackToStages,
|
||||
@@ -645,17 +652,12 @@ function initializeStageActions(stage: Stage, stageIndex: number) {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function getActionKey(action: Action): string {
|
||||
// Find the actual index of this action in the current stage's actions array
|
||||
const index = currentStageObj.value.actions.indexOf(action);
|
||||
return `${currentStage.value}-${index}-${getActionId(action, index)}`;
|
||||
const index = visibleActions.value.indexOf(action);
|
||||
return `${currentStage.value}-${index}-${getActionId(action)}`;
|
||||
}
|
||||
|
||||
const visibleActions = computed(() => {
|
||||
@@ -725,8 +727,7 @@ const multiSelectActions = computed(() =>
|
||||
);
|
||||
|
||||
function getDropdownValue(action: DropdownAction) {
|
||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||
const actionId = getActionId(action, actionIndex);
|
||||
const actionId = getActionId(action);
|
||||
const visibleOptions = getVisibleDropdownOptions(action);
|
||||
const currentValue = actionStates.value[actionId]?.value ?? action.defaultOption ?? 0;
|
||||
|
||||
@@ -741,14 +742,12 @@ function getDropdownValue(action: DropdownAction) {
|
||||
}
|
||||
|
||||
function isActionSelected(action: Action): boolean {
|
||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||
const actionId = getActionId(action, actionIndex);
|
||||
const actionId = getActionId(action);
|
||||
return actionStates.value[actionId]?.selected || false;
|
||||
}
|
||||
|
||||
function toggleAction(action: Action) {
|
||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||
const actionId = getActionId(action, actionIndex);
|
||||
const actionId = getActionId(action);
|
||||
const state = actionStates.value[actionId];
|
||||
if (state) {
|
||||
state.selected = !state.selected;
|
||||
@@ -757,8 +756,7 @@ function toggleAction(action: Action) {
|
||||
}
|
||||
|
||||
function selectDropdownOption(action: DropdownAction, selected: any) {
|
||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||
const actionId = getActionId(action, actionIndex);
|
||||
const actionId = getActionId(action);
|
||||
const state = actionStates.value[actionId];
|
||||
if (state && selected !== undefined && selected !== null) {
|
||||
const optionIndex = action.options.findIndex(
|
||||
@@ -774,8 +772,7 @@ function selectDropdownOption(action: DropdownAction, selected: any) {
|
||||
}
|
||||
|
||||
function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): boolean {
|
||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||
const actionId = getActionId(action, actionIndex);
|
||||
const actionId = getActionId(action);
|
||||
const selectedSet = actionStates.value[actionId]?.value as Set<number> | undefined;
|
||||
|
||||
const visibleOptions = getVisibleMultiSelectOptions(action);
|
||||
@@ -786,8 +783,7 @@ function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): bo
|
||||
}
|
||||
|
||||
function toggleChip(action: MultiSelectChipsAction, optionIndex: number) {
|
||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||
const actionId = getActionId(action, actionIndex);
|
||||
const actionId = getActionId(action);
|
||||
const state = actionStates.value[actionId];
|
||||
if (state && state.value instanceof Set) {
|
||||
const visibleOptions = getVisibleMultiSelectOptions(action);
|
||||
@@ -1060,7 +1056,7 @@ function nextStage() {
|
||||
if (isModpackPermissionsStage.value && !modpackPermissionsComplete.value) {
|
||||
addNotification({
|
||||
title: "Modpack permissions stage unfinished",
|
||||
text: "Please complete the modpack permissions stage before proceeding.",
|
||||
message: "Please complete the modpack permissions stage before proceeding.",
|
||||
type: "error",
|
||||
});
|
||||
|
||||
@@ -1137,7 +1133,7 @@ async function generateMessage() {
|
||||
console.error("Error generating message:", error);
|
||||
addNotification({
|
||||
title: "Error generating message",
|
||||
text: "Failed to generate moderation message. Please try again.",
|
||||
message: "Failed to generate moderation message. Please try again.",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
@@ -1165,8 +1161,6 @@ function generateModpackMessage(allFiles: {
|
||||
attributeMods.push(file.file_name);
|
||||
} else if (file.status === "no" && file.approved === "no") {
|
||||
noMods.push(file.file_name);
|
||||
} else if (file.status === "permanent-no") {
|
||||
permanentNoMods.push(file.file_name);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1208,8 +1202,7 @@ function generateModpackMessage(allFiles: {
|
||||
return issues.join("\n\n");
|
||||
}
|
||||
|
||||
const hasNextProject = ref(false);
|
||||
async function sendMessage(status: ProjectStatus) {
|
||||
async function sendMessage(status: "approved" | "rejected" | "withheld") {
|
||||
try {
|
||||
await useBaseFetch(`project/${props.project.id}`, {
|
||||
method: "PATCH",
|
||||
@@ -1243,73 +1236,55 @@ async function sendMessage(status: ProjectStatus) {
|
||||
|
||||
done.value = true;
|
||||
|
||||
hasNextProject.value = await moderationStore.completeCurrentProject(
|
||||
props.project.id,
|
||||
"completed",
|
||||
);
|
||||
// Clear local storage for future reviews
|
||||
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}`);
|
||||
actionStates.value = {};
|
||||
|
||||
addNotification({
|
||||
title: "Moderation submitted",
|
||||
message: `Project ${status} successfully.`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error submitting moderation:", error);
|
||||
addNotification({
|
||||
title: "Error submitting moderation",
|
||||
text: "Failed to submit moderation decision. Please try again.",
|
||||
message: "Failed to submit moderation decision. Please try again.",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function endChecklist(status?: string) {
|
||||
clearProjectLocalStorage();
|
||||
async function goToNextProject() {
|
||||
const currentIds = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
|
||||
|
||||
if (!hasNextProject.value) {
|
||||
await navigateTo({
|
||||
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 {
|
||||
navigateTo({
|
||||
name: "type-id",
|
||||
params: {
|
||||
type: "project",
|
||||
id: moderationStore.getCurrentProjectId(),
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
},
|
||||
});
|
||||
if (currentIds.length === 0) {
|
||||
await navigateTo("/moderation/review");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextProjectId = currentIds[0];
|
||||
const remainingIds = currentIds.slice(1);
|
||||
|
||||
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingIds));
|
||||
|
||||
await router.push({
|
||||
name: "type-id",
|
||||
params: {
|
||||
type: "project",
|
||||
id: nextProjectId,
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function skipCurrentProject() {
|
||||
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 = {};
|
||||
async function exitModeration() {
|
||||
await navigateTo("/moderation/review");
|
||||
}
|
||||
|
||||
const isLastVisibleStage = computed(() => {
|
||||
@@ -66,27 +66,6 @@
|
||||
<UiServersPanelSpinner />
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
</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
|
||||
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"
|
||||
@@ -108,8 +87,7 @@ import { Avatar, CopyCode } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps<Partial<Server>>();
|
||||
|
||||
if (props.server_id && props.status === "available") {
|
||||
// Necessary only to get server icon
|
||||
if (props.server_id) {
|
||||
await useModrinthServers(props.server_id, ["general"]);
|
||||
}
|
||||
|
||||
@@ -131,6 +109,11 @@ if (props.upstream) {
|
||||
}
|
||||
|
||||
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 isConfiguring = computed(() => props.flows?.intro);
|
||||
</script>
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
<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="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.filter((x) => x.shown === undefined || x.shown)"
|
||||
:key="link.label"
|
||||
>
|
||||
<div v-for="link in navLinks" :key="link.label">
|
||||
<NuxtLink
|
||||
:to="link.href"
|
||||
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
|
||||
@@ -43,7 +40,7 @@ import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
const emit = defineEmits(["reinstall"]);
|
||||
|
||||
defineProps<{
|
||||
navLinks: { label: string; href: string; icon: Component; external?: boolean; shown?: boolean }[];
|
||||
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
|
||||
route: RouteLocationNormalized;
|
||||
server: ModrinthServer;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
|
||||
@@ -34,38 +34,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
Thread ID:
|
||||
<CopyCode :text="thread.id" />
|
||||
@@ -103,17 +71,12 @@
|
||||
v-if="sortedMessages.length > 0"
|
||||
class="btn btn-primary"
|
||||
:disabled="!replyBody"
|
||||
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
|
||||
@click="sendReply()"
|
||||
>
|
||||
<ReplyIcon aria-hidden="true" />
|
||||
Reply
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-primary"
|
||||
:disabled="!replyBody"
|
||||
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
|
||||
>
|
||||
<button v-else class="btn btn-primary" :disabled="!replyBody" @click="sendReply()">
|
||||
<SendIcon aria-hidden="true" />
|
||||
Send
|
||||
</button>
|
||||
@@ -326,7 +289,6 @@ const sortedMessages = computed(() => {
|
||||
});
|
||||
|
||||
const modalSubmit = ref(null);
|
||||
const modalReply = ref(null);
|
||||
|
||||
async function updateThreadLocal() {
|
||||
let threadId = null;
|
||||
@@ -354,11 +316,6 @@ async function onUploadImage(file) {
|
||||
return response.url;
|
||||
}
|
||||
|
||||
async function sendReplyFromModal(status = null, privateMessage = false) {
|
||||
modalReply.value.hide();
|
||||
await sendReply(status, privateMessage);
|
||||
}
|
||||
|
||||
async function sendReply(status = null, privateMessage = false) {
|
||||
try {
|
||||
const body = {
|
||||
@@ -441,7 +398,6 @@ async function reopenReport() {
|
||||
|
||||
const replyWithSubmission = ref(false);
|
||||
const submissionConfirmation = ref(false);
|
||||
const replyConfirmation = ref(false);
|
||||
|
||||
function openResubmitModal(reply) {
|
||||
submissionConfirmation.value = false;
|
||||
@@ -449,11 +405,6 @@ function openResubmitModal(reply) {
|
||||
modalSubmit.value.show();
|
||||
}
|
||||
|
||||
function openReplyModal(reply) {
|
||||
replyConfirmation.value = false;
|
||||
modalReply.value.show();
|
||||
}
|
||||
|
||||
async function resubmit() {
|
||||
if (replyWithSubmission.value) {
|
||||
await sendReply("processing");
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
<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>
|
||||
@@ -36,7 +36,7 @@
|
||||
v-tooltip="'Modrinth Team'"
|
||||
/>
|
||||
<MicrophoneIcon
|
||||
v-if="report && message.author_id === report.reporter_user?.id"
|
||||
v-if="report && message.author_id === report.reporterUser.id"
|
||||
v-tooltip="'Reporter'"
|
||||
class="reporter-icon"
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { ServerModule } from "./base.ts";
|
||||
export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||
server_id!: string;
|
||||
name!: string;
|
||||
owner_id!: string;
|
||||
net!: { ip: string; port: number; domain: string };
|
||||
game!: string;
|
||||
backup_quota!: number;
|
||||
|
||||
@@ -147,7 +147,7 @@ export async function useServersFetch<T>(
|
||||
404: "Not Found",
|
||||
405: "Method Not Allowed",
|
||||
408: "Request Timeout",
|
||||
429: "You're making requests too quickly. Please wait a moment and try again.",
|
||||
429: "Too Many Requests",
|
||||
500: "Internal Server Error",
|
||||
502: "Bad Gateway",
|
||||
503: "Service Unavailable",
|
||||
@@ -167,17 +167,11 @@ export async function useServersFetch<T>(
|
||||
console.error("Fetch error:", error);
|
||||
|
||||
const fetchError = new ModrinthServersFetchError(
|
||||
`[Modrinth Servers] ${error.message}`,
|
||||
`[Modrinth Servers] ${message}`,
|
||||
statusCode,
|
||||
error,
|
||||
);
|
||||
throw new ModrinthServerError(
|
||||
`[Modrinth Servers] ${message}`,
|
||||
statusCode,
|
||||
fetchError,
|
||||
module,
|
||||
v1Error,
|
||||
);
|
||||
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
|
||||
}
|
||||
|
||||
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000;
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -295,7 +295,7 @@
|
||||
{
|
||||
id: 'review-projects',
|
||||
color: 'orange',
|
||||
link: '/moderation/',
|
||||
link: '/moderation/review',
|
||||
},
|
||||
{
|
||||
id: 'review-reports',
|
||||
@@ -981,6 +981,23 @@ 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,
|
||||
{
|
||||
|
||||
@@ -182,6 +182,9 @@
|
||||
"collection.button.unfollow-project": {
|
||||
"message": "Unfollow project"
|
||||
},
|
||||
"collection.button.upload-icon": {
|
||||
"message": "Upload icon"
|
||||
},
|
||||
"collection.delete-modal.description": {
|
||||
"message": "This will remove this collection forever. This action cannot be undone."
|
||||
},
|
||||
@@ -476,30 +479,6 @@
|
||||
"layout.nav.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": {
|
||||
"message": "Manage user billing"
|
||||
},
|
||||
|
||||
@@ -689,10 +689,7 @@
|
||||
},
|
||||
{
|
||||
id: 'moderation-checklist',
|
||||
action: () => {
|
||||
moderationStore.setSingleProject(project.id);
|
||||
showModerationChecklist = true;
|
||||
},
|
||||
action: () => (showModerationChecklist = true),
|
||||
color: 'orange',
|
||||
hoverOnly: true,
|
||||
shown:
|
||||
@@ -873,6 +870,19 @@
|
||||
@delete-version="deleteVersion"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -880,8 +890,9 @@
|
||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||
class="moderation-checklist"
|
||||
>
|
||||
<ModerationChecklist
|
||||
<NewModerationChecklist
|
||||
:project="project"
|
||||
:future-project-ids="futureProjectIds"
|
||||
:collapsed="collapsedModerationChecklist"
|
||||
@exit="showModerationChecklist = false"
|
||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||
@@ -940,7 +951,14 @@ import {
|
||||
useRelativeTime,
|
||||
} from "@modrinth/ui";
|
||||
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
||||
import { formatCategory, formatProjectType, renderString } from "@modrinth/utils";
|
||||
import {
|
||||
formatCategory,
|
||||
formatProjectType,
|
||||
isRejected,
|
||||
isStaff,
|
||||
isUnderReview,
|
||||
renderString,
|
||||
} from "@modrinth/utils";
|
||||
import dayjs from "dayjs";
|
||||
import { Tooltip } from "floating-vue";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
@@ -958,13 +976,11 @@ import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
||||
import { userCollectProject } from "~/composables/user.js";
|
||||
import { reportProject } from "~/utils/report-helpers.ts";
|
||||
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
||||
import ModerationChecklist from "~/components/ui/moderation/checklist/ModerationChecklist.vue";
|
||||
import { useModerationStore } from "~/store/moderation.ts";
|
||||
import NewModerationChecklist from "~/components/ui/moderation/NewModerationChecklist.vue";
|
||||
|
||||
const data = useNuxtApp();
|
||||
const route = useNativeRoute();
|
||||
const config = useRuntimeConfig();
|
||||
const moderationStore = useModerationStore();
|
||||
|
||||
const auth = await useAuth();
|
||||
const user = await useUser();
|
||||
@@ -1552,6 +1568,12 @@ const showModerationChecklist = useLocalStorage(
|
||||
);
|
||||
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
|
||||
|
||||
const futureProjectIds = useLocalStorage("moderation-future-projects", []);
|
||||
|
||||
watch(futureProjectIds, (newValue) => {
|
||||
console.log("Future project IDs updated:", newValue);
|
||||
});
|
||||
|
||||
watch(
|
||||
showModerationChecklist,
|
||||
(newValue) => {
|
||||
@@ -1624,7 +1646,9 @@ const navLinks = computed(() => {
|
||||
{
|
||||
label: formatMessage(messages.moderationTab),
|
||||
href: `${projectUrl}/moderation`,
|
||||
shown: !!currentMember.value,
|
||||
shown:
|
||||
!!currentMember.value &&
|
||||
(isRejected(project.value) || isUnderReview(project.value) || isStaff(auth.value.user)),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -365,10 +365,8 @@ export default defineNuxtComponent({
|
||||
if (e.key === "Escape") {
|
||||
this.expandedGalleryItem = null;
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
e.stopPropagation();
|
||||
this.previousImage();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.stopPropagation();
|
||||
this.nextImage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,15 +76,8 @@
|
||||
<p>
|
||||
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
|
||||
project for review. For additional inquiries, please go to the
|
||||
<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.
|
||||
project for review. For additional inquiries, contact
|
||||
<a href="https://support.modrinth.com">Modrinth Support</a>.
|
||||
</p>
|
||||
<ConversationThread
|
||||
v-if="thread"
|
||||
|
||||
@@ -58,41 +58,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</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="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
|
||||
@@ -236,12 +201,6 @@
|
||||
Refund options
|
||||
</button>
|
||||
</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>
|
||||
@@ -275,6 +234,7 @@ import { products } from "~/generated/state.json";
|
||||
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const data = useNuxtApp();
|
||||
const vintl = useVIntl();
|
||||
|
||||
const { formatMessage } = vintl;
|
||||
@@ -344,10 +304,6 @@ const refundTypes = ref(["full", "partial", "none"]);
|
||||
const refundAmount = ref(0);
|
||||
const unprovision = ref(true);
|
||||
|
||||
const modifying = ref(false);
|
||||
const modifyModal = ref();
|
||||
const cancel = ref(false);
|
||||
|
||||
function showRefundModal(charge) {
|
||||
selectedCharge.value = charge;
|
||||
refundType.value = "full";
|
||||
@@ -356,12 +312,6 @@ function showRefundModal(charge) {
|
||||
refundModal.value.show();
|
||||
}
|
||||
|
||||
function showModifyModal(charge) {
|
||||
selectedCharge.value = charge;
|
||||
cancel.value = false;
|
||||
modifyModal.value.show();
|
||||
}
|
||||
|
||||
async function refundCharge() {
|
||||
refunding.value = true;
|
||||
try {
|
||||
@@ -377,7 +327,8 @@ async function refundCharge() {
|
||||
await refreshCharges();
|
||||
refundModal.value.hide();
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "Error refunding",
|
||||
text: err.data?.description ?? err,
|
||||
type: "error",
|
||||
@@ -386,32 +337,6 @@ async function refundCharge() {
|
||||
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 = {
|
||||
open: {
|
||||
color: "bg-blue",
|
||||
|
||||
@@ -218,7 +218,7 @@ const username = ref("");
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const token = ref("");
|
||||
const subscribe = ref(false);
|
||||
const subscribe = ref(true);
|
||||
|
||||
async function createAccount() {
|
||||
startLoading();
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
@change="showPreviewImage"
|
||||
>
|
||||
<UploadIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.uploadIconButton) }}
|
||||
</FileInput>
|
||||
<Button
|
||||
v-if="!deletedIcon && (previewImage || collection.icon_url)"
|
||||
@@ -478,6 +479,10 @@ const messages = defineMessages({
|
||||
id: "collection.label.updated-at",
|
||||
defaultMessage: "Updated {ago}",
|
||||
},
|
||||
uploadIconButton: {
|
||||
id: "collection.button.upload-icon",
|
||||
defaultMessage: "Upload icon",
|
||||
},
|
||||
});
|
||||
|
||||
const data = useNuxtApp();
|
||||
|
||||
@@ -1,84 +1,33 @@
|
||||
<template>
|
||||
<div
|
||||
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
|
||||
>
|
||||
<h1>Moderation</h1>
|
||||
<NavTabs :links="moderationLinks" class="mb-4 hidden sm:flex" />
|
||||
<div class="mb-4 sm:hidden">
|
||||
<Chips
|
||||
v-model="selectedChip"
|
||||
:items="mobileNavOptions"
|
||||
:never-empty="true"
|
||||
@change="navigateToPage"
|
||||
/>
|
||||
<div class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<aside class="universal-card">
|
||||
<h1>Moderation</h1>
|
||||
<NavStack>
|
||||
<NavStackItem link="/moderation" label="Overview">
|
||||
<ModrinthIcon aria-hidden="true" />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/moderation/review" label="Review projects">
|
||||
<ScaleIcon aria-hidden="true" />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/moderation/reports" label="Reports">
|
||||
<ReportIcon aria-hidden="true" />
|
||||
</NavStackItem>
|
||||
</NavStack>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||
import { Chips } from "@modrinth/ui";
|
||||
import NavTabs from "@/components/ui/NavTabs.vue";
|
||||
<script setup>
|
||||
import { ModrinthIcon, ScaleIcon, ReportIcon } from "@modrinth/assets";
|
||||
import NavStack from "~/components/ui/NavStack.vue";
|
||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||
|
||||
definePageMeta({
|
||||
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>
|
||||
|
||||
@@ -1,339 +1,42 @@
|
||||
<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" />
|
||||
<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>
|
||||
<section class="universal-card">
|
||||
<h2>Statistics</h2>
|
||||
<div class="grid-display">
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Projects</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.projects, false) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Versions</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.versions, false) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Files</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.files, false) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Authors</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.authors, false) }}
|
||||
</div>
|
||||
</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 v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
<ConfettiExplosion v-if="visible" />
|
||||
</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 v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
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";
|
||||
<script setup>
|
||||
import { formatNumber } from "@modrinth/utils";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
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",
|
||||
},
|
||||
useHead({
|
||||
title: "Staff overview - Modrinth",
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
const { data: stats } = await useAsyncData("statistics", () => useBaseFetch("statistics"));
|
||||
</script>
|
||||
|
||||
17
apps/frontend/src/pages/moderation/report/[id].vue
Normal file
17
apps/frontend/src/pages/moderation/report/[id].vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
16
apps/frontend/src/pages/moderation/reports.vue
Normal file
16
apps/frontend/src/pages/moderation/reports.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
@@ -1,28 +0,0 @@
|
||||
<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>
|
||||
@@ -1,290 +0,0 @@
|
||||
<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>
|
||||
304
apps/frontend/src/pages/moderation/review.vue
Normal file
304
apps/frontend/src/pages/moderation/review.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<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>
|
||||
@@ -1,386 +0,0 @@
|
||||
<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>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<p>Not yet implemented.</p>
|
||||
</template>
|
||||
@@ -16,15 +16,12 @@ import {
|
||||
CardIcon,
|
||||
UserIcon,
|
||||
WrenchIcon,
|
||||
ModrinthIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { isAdmin as isUserAdmin, type User } from "@modrinth/utils";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
|
||||
const route = useRoute();
|
||||
const serverId = route.params.id as string;
|
||||
const auth = await useAuth();
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer;
|
||||
@@ -35,11 +32,7 @@ useHead({
|
||||
title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`,
|
||||
});
|
||||
|
||||
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(() => [
|
||||
const navLinks = [
|
||||
{ icon: SettingsIcon, label: "General", href: `/servers/manage/${serverId}/options` },
|
||||
{ icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` },
|
||||
{ icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` },
|
||||
@@ -55,15 +48,7 @@ const navLinks = computed(() => [
|
||||
label: "Billing",
|
||||
href: `/settings/billing#server-${serverId}`,
|
||||
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` },
|
||||
]);
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
</label>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
:disabled="invocation === originalInvocation"
|
||||
:disabled="invocation === startupSettings?.original_invocation"
|
||||
class="!w-full sm:!w-auto"
|
||||
@click="resetToDefault"
|
||||
>
|
||||
@@ -120,9 +120,8 @@ const props = defineProps<{
|
||||
server: ModrinthServer;
|
||||
}>();
|
||||
|
||||
await props.server.startup.fetch();
|
||||
|
||||
const data = computed(() => props.server.general);
|
||||
const startupSettings = computed(() => props.server.startup);
|
||||
const showAllVersions = ref(false);
|
||||
|
||||
const jdkVersionMap = [
|
||||
@@ -138,15 +137,33 @@ const jdkBuildMap = [
|
||||
{ value: "graal", label: "GraalVM" },
|
||||
];
|
||||
|
||||
const invocation = ref(props.server.startup.invocation);
|
||||
const jdkVersion = ref(
|
||||
jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label,
|
||||
);
|
||||
const jdkBuild = ref(jdkBuildMap.find((v) => v.value === props.server.startup.jdk_build)?.label);
|
||||
const invocation = ref("");
|
||||
const jdkVersion = ref("");
|
||||
const jdkBuild = ref("");
|
||||
|
||||
const originalInvocation = ref(invocation.value);
|
||||
const originalJdkVersion = ref(jdkVersion.value);
|
||||
const originalJdkBuild = ref(jdkBuild.value);
|
||||
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 hasUnsavedChanges = computed(
|
||||
() =>
|
||||
@@ -178,7 +195,7 @@ const displayedJavaVersions = computed(() => {
|
||||
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
|
||||
});
|
||||
|
||||
async function saveStartup() {
|
||||
const saveStartup = async () => {
|
||||
try {
|
||||
isUpdating.value = true;
|
||||
const invocationValue = invocation.value ?? "";
|
||||
@@ -215,17 +232,17 @@ async function saveStartup() {
|
||||
} finally {
|
||||
isUpdating.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function resetStartup() {
|
||||
const resetStartup = () => {
|
||||
invocation.value = originalInvocation.value;
|
||||
jdkVersion.value = originalJdkVersion.value;
|
||||
jdkBuild.value = originalJdkBuild.value;
|
||||
}
|
||||
};
|
||||
|
||||
function resetToDefault() {
|
||||
invocation.value = originalInvocation.value ?? "";
|
||||
}
|
||||
const resetToDefault = () => {
|
||||
invocation.value = startupSettings.value?.original_invocation ?? "";
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -96,7 +96,16 @@
|
||||
<UiServersServerListing
|
||||
v-for="server in filteredData"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
:server_id="server.server_id"
|
||||
: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" />
|
||||
</ul>
|
||||
|
||||
@@ -208,7 +208,15 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<UiServersServerListing
|
||||
v-if="subscription.serverInfo"
|
||||
v-bind="subscription.serverInfo"
|
||||
:server_id="subscription.serverInfo.server_id"
|
||||
: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">
|
||||
<p>
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE metadata ->> 'type' = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "metadata",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "unitary",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE users_redeemals\n SET status = $1\n WHERE\n status = $2\n AND NOW() - last_attempt > INTERVAL '5 minutes'\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE\n ORDER BY created ASC\n OFFSET $2\n LIMIT $1\n ",
|
||||
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE\n ORDER BY created ASC\n LIMIT $1;\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -11,7 +11,6 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
@@ -19,5 +18,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1aea0d5e6936b043cb7727b779d60598aa812c8ef0f5895fa740859321092a1c"
|
||||
"hash": "29e171bd746ac5dc1fabae4c9f81c3d1df4e69c860b7d0f6a907377664199217"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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, 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 ",
|
||||
"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 ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -25,11 +25,10 @@
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e"
|
||||
"hash": "32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id FROM mods\n WHERE status = $1\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
|
||||
"query": "\n SELECT id FROM mods\n WHERE status = $1\n ORDER BY queued ASC\n LIMIT $2;\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -12,7 +12,6 @@
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
@@ -20,5 +19,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ccb0315ff52ea4402f53508334a7288fc9f8e77ffd7bce665441ff682384cbf9"
|
||||
"hash": "3baabc9f08401801fa290866888c540746fc50c1d79911f08f3322b605ce5c30"
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT * FROM users_redeemals WHERE status = $1 LIMIT $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "offer",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "redeemed",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "last_attempt",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "n_attempts",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286"
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO users_redeemals\n (user_id, offer, redeemed, status, last_attempt, n_attempts)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7adff98b270adc4a48e2c8a89a32ca1b83104102190597f4cda05e6f1c1e8f26"
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE users_redeemals\n SET\n offer = $2,\n status = $3,\n redeemed = $4,\n last_attempt = $5,\n n_attempts = $6\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8d61d1ecc5321e2ac8932ef99de0f77e49cced9c7726ea746392a5fcbe75f2f5"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n users.id,\n users_redeemals.status AS \"status: Option<String>\"\n FROM\n users\n LEFT JOIN\n users_redeemals ON users_redeemals.user_id = users.id\n AND users_redeemals.offer = $2\n WHERE\n users.username = $1\n ORDER BY\n users_redeemals.redeemed DESC\n LIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "status: Option<String>",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "949da1b1e3c772f79dd1248f99774fa39f140d3943f975067799f46f2cb48a0f"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n EXISTS (\n SELECT\n 1\n FROM\n users_redeemals\n WHERE\n user_id = $1\n AND offer = $2\n ) AS \"exists!\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9898e9962ba497ef8482ffa57d6590f7933e9f2465e9458fab005fe33d96ec7a"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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, is_subscribed_to_newsletter\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\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -122,11 +122,6 @@
|
||||
"ordinal": 23,
|
||||
"name": "allow_friend_requests",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "is_subscribed_to_newsletter",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -159,9 +154,8 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4"
|
||||
"hash": "b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'expiring' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -102,5 +102,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9"
|
||||
"hash": "bfcbcadda1e323d56b6a21fc060c56bff2f38a54cf65dd1cc21f209240c7091b"
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, product_id, prices, currency_code\n FROM products_prices\n WHERE product_id = ANY($1::bigint[])\n AND public = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "product_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "prices",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "currency_code",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c37fc91df7619ac5c10fd04fdc2556aa98b80ccbfc53813659464a0e5e09fae8"
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -102,5 +102,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7"
|
||||
"hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE users_redeemals\n SET\n status = $3,\n last_attempt = $4,\n n_attempts = $5\n WHERE id = $1 AND status = $2\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e3f6fa7e5ec6dee4fcdff904b3e692dccd55372d9cc827a1d68361fd036bc183"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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 ",
|
||||
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE AND reporter = $1\n ORDER BY created ASC\n LIMIT $2;\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -11,7 +11,6 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
@@ -20,5 +19,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "be8a5dd2b71fdc279a6fa68fe5384da31afd91d4b480527e2dd8402aef36f12c"
|
||||
"hash": "f17a109913015a7a5ab847bb2e73794d6261a08d450de24b450222755e520881"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
CREATE INDEX reports_closed ON reports (closed);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE users ADD COLUMN is_subscribed_to_newsletter BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Add migration script here
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users_redeemals (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
offer VARCHAR NOT NULL,
|
||||
redeemed TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
status VARCHAR NOT NULL,
|
||||
last_attempt TIMESTAMP WITH TIME ZONE,
|
||||
n_attempts INTEGER NOT NULL
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Add migration script here
|
||||
|
||||
ALTER TABLE
|
||||
products_prices
|
||||
ADD COLUMN
|
||||
public BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -315,13 +315,9 @@ pub async fn filter_enlisted_version_ids(
|
||||
pub async fn is_visible_collection(
|
||||
collection_data: &DBCollection,
|
||||
user_option: &Option<User>,
|
||||
hide_unlisted: bool,
|
||||
) -> Result<bool, ApiError> {
|
||||
let mut authorized = (if hide_unlisted {
|
||||
collection_data.status.is_searchable()
|
||||
} else {
|
||||
!collection_data.status.is_hidden()
|
||||
}) && !collection_data.projects.is_empty();
|
||||
let mut authorized = !collection_data.status.is_hidden()
|
||||
&& !collection_data.projects.is_empty();
|
||||
if let Some(user) = &user_option {
|
||||
if !authorized
|
||||
&& (user.role.is_mod() || user.id == collection_data.user_id.into())
|
||||
@@ -335,17 +331,12 @@ pub async fn is_visible_collection(
|
||||
pub async fn filter_visible_collections(
|
||||
collections: Vec<DBCollection>,
|
||||
user_option: &Option<User>,
|
||||
hide_unlisted: bool,
|
||||
) -> Result<Vec<crate::models::collections::Collection>, ApiError> {
|
||||
let mut return_collections = Vec::new();
|
||||
let mut check_collections = Vec::new();
|
||||
|
||||
for collection in collections {
|
||||
if ((if hide_unlisted {
|
||||
collection.status.is_searchable()
|
||||
} else {
|
||||
!collection.status.is_hidden()
|
||||
}) && !collection.projects.is_empty())
|
||||
if (!collection.status.is_hidden() && !collection.projects.is_empty())
|
||||
|| user_option.as_ref().is_some_and(|x| x.role.is_mod())
|
||||
{
|
||||
return_collections.push(collection.into());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::AuthProvider;
|
||||
use crate::auth::AuthenticationError;
|
||||
use crate::database::models::{DBUser, user_item};
|
||||
use crate::database::models::user_item;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::users::User;
|
||||
@@ -44,33 +44,6 @@ where
|
||||
Ok(Some((scopes, User::from_full(db_user))))
|
||||
}
|
||||
|
||||
pub async fn get_full_user_from_headers<'a, E>(
|
||||
req: &HttpRequest,
|
||||
executor: E,
|
||||
redis: &RedisPool,
|
||||
session_queue: &AuthQueue,
|
||||
required_scopes: Scopes,
|
||||
) -> Result<(Scopes, DBUser), AuthenticationError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let (scopes, db_user) = get_user_record_from_bearer_token(
|
||||
req,
|
||||
None,
|
||||
executor,
|
||||
redis,
|
||||
session_queue,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
if !scopes.contains(required_scopes) {
|
||||
return Err(AuthenticationError::InvalidCredentials);
|
||||
}
|
||||
|
||||
Ok((scopes, db_user))
|
||||
}
|
||||
|
||||
pub async fn get_user_from_headers<'a, E>(
|
||||
req: &HttpRequest,
|
||||
executor: E,
|
||||
@@ -81,16 +54,24 @@ pub async fn get_user_from_headers<'a, E>(
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let (scopes, db_user) = get_full_user_from_headers(
|
||||
// Fetch DB user record and minos user from headers
|
||||
let (scopes, db_user) = get_user_record_from_bearer_token(
|
||||
req,
|
||||
None,
|
||||
executor,
|
||||
redis,
|
||||
session_queue,
|
||||
required_scopes,
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
Ok((scopes, User::from_full(db_user)))
|
||||
let user = User::from_full(db_user);
|
||||
|
||||
if !scopes.contains(required_scopes) {
|
||||
return Err(AuthenticationError::InvalidCredentials);
|
||||
}
|
||||
|
||||
Ok((scopes, user))
|
||||
}
|
||||
|
||||
pub async fn get_user_record_from_bearer_token<'a, 'b, E>(
|
||||
|
||||
@@ -197,7 +197,7 @@ impl DBCharge {
|
||||
) -> Result<Option<DBCharge>, DatabaseError> {
|
||||
let user_subscription_id = user_subscription_id.0;
|
||||
let res = select_charges_with_predicate!(
|
||||
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')",
|
||||
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
|
||||
user_subscription_id
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
@@ -240,7 +240,6 @@ impl DBCharge {
|
||||
charge_type = $1 AND
|
||||
(
|
||||
(status = 'cancelled' AND due < NOW()) OR
|
||||
(status = 'expiring' AND due < NOW()) OR
|
||||
(status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')
|
||||
)
|
||||
"#,
|
||||
|
||||
@@ -25,7 +25,6 @@ pub mod team_item;
|
||||
pub mod thread_item;
|
||||
pub mod user_item;
|
||||
pub mod user_subscription_item;
|
||||
pub mod users_redeemals;
|
||||
pub mod version_item;
|
||||
|
||||
pub use collection_item::DBCollection;
|
||||
|
||||
@@ -57,26 +57,6 @@ impl DBProduct {
|
||||
Ok(Self::get_many(&[id], exec).await?.into_iter().next())
|
||||
}
|
||||
|
||||
pub async fn get_by_type<'a, E>(
|
||||
exec: E,
|
||||
r#type: &str,
|
||||
) -> Result<Vec<Self>, DatabaseError>
|
||||
where
|
||||
E: sqlx::PgExecutor<'a>,
|
||||
{
|
||||
let maybe_row = select_products_with_predicate!(
|
||||
"WHERE metadata ->> 'type' = $1",
|
||||
r#type
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
maybe_row
|
||||
.into_iter()
|
||||
.map(|r| r.try_into().map_err(Into::into))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn get_many(
|
||||
ids: &[DBProductId],
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||
@@ -120,11 +100,10 @@ pub struct QueryProductWithPrices {
|
||||
}
|
||||
|
||||
impl QueryProductWithPrices {
|
||||
/// Lists products with at least one public price.
|
||||
pub async fn list_purchaseable<'a, E>(
|
||||
pub async fn list<'a, E>(
|
||||
exec: E,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Vec<Self>, DatabaseError>
|
||||
) -> Result<Vec<QueryProductWithPrices>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
@@ -139,51 +118,7 @@ impl QueryProductWithPrices {
|
||||
}
|
||||
|
||||
let all_products = product_item::DBProduct::get_all(exec).await?;
|
||||
let prices =
|
||||
product_item::DBProductPrice::get_all_public_products_prices(
|
||||
&all_products.iter().map(|x| x.id).collect::<Vec<_>>(),
|
||||
exec,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let products = all_products
|
||||
.into_iter()
|
||||
.filter_map(|x| {
|
||||
Some(QueryProductWithPrices {
|
||||
id: x.id,
|
||||
metadata: x.metadata,
|
||||
prices: prices
|
||||
.remove(&x.id)
|
||||
.map(|x| x.1)?
|
||||
.into_iter()
|
||||
.map(|x| DBProductPrice {
|
||||
id: x.id,
|
||||
product_id: x.product_id,
|
||||
prices: x.prices,
|
||||
currency_code: x.currency_code,
|
||||
})
|
||||
.collect(),
|
||||
unitary: x.unitary,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(PRODUCTS_NAMESPACE, "all", &products, None)
|
||||
.await?;
|
||||
|
||||
Ok(products)
|
||||
}
|
||||
|
||||
pub async fn list_by_product_type<'a, E>(
|
||||
exec: E,
|
||||
r#type: &str,
|
||||
) -> Result<Vec<Self>, DatabaseError>
|
||||
where
|
||||
E: sqlx::PgExecutor<'a> + Copy,
|
||||
{
|
||||
let all_products = DBProduct::get_by_type(exec, r#type).await?;
|
||||
let prices = DBProductPrice::get_all_products_prices(
|
||||
let prices = product_item::DBProductPrice::get_all_products_prices(
|
||||
&all_products.iter().map(|x| x.id).collect::<Vec<_>>(),
|
||||
exec,
|
||||
)
|
||||
@@ -191,26 +126,29 @@ impl QueryProductWithPrices {
|
||||
|
||||
let products = all_products
|
||||
.into_iter()
|
||||
.filter_map(|x| {
|
||||
Some(QueryProductWithPrices {
|
||||
id: x.id,
|
||||
metadata: x.metadata,
|
||||
prices: prices
|
||||
.remove(&x.id)
|
||||
.map(|x| x.1)?
|
||||
.into_iter()
|
||||
.map(|x| DBProductPrice {
|
||||
id: x.id,
|
||||
product_id: x.product_id,
|
||||
prices: x.prices,
|
||||
currency_code: x.currency_code,
|
||||
})
|
||||
.collect(),
|
||||
unitary: x.unitary,
|
||||
})
|
||||
.map(|x| QueryProductWithPrices {
|
||||
id: x.id,
|
||||
metadata: x.metadata,
|
||||
prices: prices
|
||||
.remove(&x.id)
|
||||
.map(|x| x.1)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|x| DBProductPrice {
|
||||
id: x.id,
|
||||
product_id: x.product_id,
|
||||
prices: x.prices,
|
||||
currency_code: x.currency_code,
|
||||
})
|
||||
.collect(),
|
||||
unitary: x.unitary,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
redis
|
||||
.set_serialized_to_json(PRODUCTS_NAMESPACE, "all", &products, None)
|
||||
.await?;
|
||||
|
||||
Ok(products)
|
||||
}
|
||||
}
|
||||
@@ -231,11 +169,7 @@ struct ProductPriceQueryResult {
|
||||
}
|
||||
|
||||
macro_rules! select_prices_with_predicate {
|
||||
($predicate:tt, $param1:ident) => {
|
||||
select_prices_with_predicate!($predicate, $param1, )
|
||||
};
|
||||
|
||||
($predicate:tt, $($param:ident,)+) => {
|
||||
($predicate:tt, $param:ident) => {
|
||||
sqlx::query_as!(
|
||||
ProductPriceQueryResult,
|
||||
r#"
|
||||
@@ -243,7 +177,7 @@ macro_rules! select_prices_with_predicate {
|
||||
FROM products_prices
|
||||
"#
|
||||
+ $predicate,
|
||||
$($param),+
|
||||
$param
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -297,82 +231,33 @@ impl DBProductPrice {
|
||||
Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn get_all_public_product_prices(
|
||||
product_id: DBProductId,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<Vec<DBProductPrice>, DatabaseError> {
|
||||
let res =
|
||||
Self::get_all_public_products_prices(&[product_id], exec).await?;
|
||||
|
||||
Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Gets all public prices for the given products. If a product has no public price,
|
||||
/// it won't be included in the resulting map.
|
||||
pub async fn get_all_public_products_prices(
|
||||
product_ids: &[DBProductId],
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
|
||||
Self::get_all_products_prices_with_visibility(
|
||||
product_ids,
|
||||
Some(true),
|
||||
exec,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_all_products_prices(
|
||||
product_ids: &[DBProductId],
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
|
||||
Self::get_all_products_prices_with_visibility(product_ids, None, exec)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_all_products_prices_with_visibility(
|
||||
product_ids: &[DBProductId],
|
||||
public_filter: Option<bool>,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
|
||||
let ids = product_ids.iter().map(|id| id.0).collect_vec();
|
||||
let ids_ref: &[i64] = &ids;
|
||||
|
||||
use futures_util::TryStreamExt;
|
||||
let prices = select_prices_with_predicate!(
|
||||
"WHERE product_id = ANY($1::bigint[])",
|
||||
ids_ref
|
||||
)
|
||||
.fetch(exec)
|
||||
.try_fold(
|
||||
DashMap::new(),
|
||||
|acc: DashMap<DBProductId, Vec<DBProductPrice>>, x| {
|
||||
if let Ok(item) = <ProductPriceQueryResult as TryInto<
|
||||
DBProductPrice,
|
||||
>>::try_into(x)
|
||||
{
|
||||
acc.entry(item.product_id).or_default().push(item);
|
||||
}
|
||||
|
||||
let predicate = |acc: DashMap<DBProductId, Vec<DBProductPrice>>, x| {
|
||||
if let Ok(item) = <ProductPriceQueryResult as TryInto<
|
||||
DBProductPrice,
|
||||
>>::try_into(x)
|
||||
{
|
||||
acc.entry(item.product_id).or_default().push(item);
|
||||
}
|
||||
|
||||
async move { Ok(acc) }
|
||||
};
|
||||
|
||||
let prices = match public_filter {
|
||||
None => {
|
||||
select_prices_with_predicate!(
|
||||
"WHERE product_id = ANY($1::bigint[])",
|
||||
ids_ref,
|
||||
)
|
||||
.fetch(exec)
|
||||
.try_fold(DashMap::new(), predicate)
|
||||
.await?
|
||||
}
|
||||
|
||||
Some(public) => {
|
||||
select_prices_with_predicate!(
|
||||
"WHERE product_id = ANY($1::bigint[])
|
||||
AND public = $2",
|
||||
ids_ref,
|
||||
public,
|
||||
)
|
||||
.fetch(exec)
|
||||
.try_fold(DashMap::new(), predicate)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
async move { Ok(acc) }
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(prices)
|
||||
}
|
||||
|
||||
@@ -49,8 +49,6 @@ pub struct DBUser {
|
||||
pub badges: Badges,
|
||||
|
||||
pub allow_friend_requests: bool,
|
||||
|
||||
pub is_subscribed_to_newsletter: bool,
|
||||
}
|
||||
|
||||
impl DBUser {
|
||||
@@ -65,13 +63,13 @@ impl DBUser {
|
||||
avatar_url, raw_avatar_url, bio, created,
|
||||
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
||||
email_verified, password, paypal_id, paypal_country, paypal_email,
|
||||
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
|
||||
venmo_handle, stripe_customer_id, allow_friend_requests
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7,
|
||||
$8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19, $20, $21, $22
|
||||
$14, $15, $16, $17, $18, $19, $20, $21
|
||||
)
|
||||
",
|
||||
self.id as DBUserId,
|
||||
@@ -95,7 +93,6 @@ impl DBUser {
|
||||
self.venmo_handle,
|
||||
self.stripe_customer_id,
|
||||
self.allow_friend_requests,
|
||||
self.is_subscribed_to_newsletter,
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
@@ -181,7 +178,7 @@ impl DBUser {
|
||||
created, role, badges,
|
||||
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
||||
email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,
|
||||
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
|
||||
venmo_handle, stripe_customer_id, allow_friend_requests
|
||||
FROM users
|
||||
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
|
||||
",
|
||||
@@ -215,7 +212,6 @@ impl DBUser {
|
||||
stripe_customer_id: u.stripe_customer_id,
|
||||
totp_secret: u.totp_secret,
|
||||
allow_friend_requests: u.allow_friend_requests,
|
||||
is_subscribed_to_newsletter: u.is_subscribed_to_newsletter,
|
||||
};
|
||||
|
||||
acc.insert(u.id, (Some(u.username), user));
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
use crate::database::models::DBUserId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{query, query_scalar};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(
|
||||
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Offer {
|
||||
#[default]
|
||||
Medal,
|
||||
}
|
||||
|
||||
impl Offer {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Offer::Medal => "medal",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str_or_default(s: &str) -> Self {
|
||||
match s {
|
||||
"medal" => Offer::Medal,
|
||||
_ => Offer::Medal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Offer {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Status {
|
||||
#[default]
|
||||
Pending,
|
||||
Processing,
|
||||
Processed,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Status::Pending => "pending",
|
||||
Status::Processing => "processing",
|
||||
Status::Processed => "processed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str_or_default(s: &str) -> Self {
|
||||
match s {
|
||||
"pending" => Status::Pending,
|
||||
"processing" => Status::Processing,
|
||||
"processed" => Status::Processed,
|
||||
_ => Status::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Status {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UserRedeemal {
|
||||
pub id: i32,
|
||||
pub user_id: DBUserId,
|
||||
pub offer: Offer,
|
||||
pub redeemed: DateTime<Utc>,
|
||||
pub last_attempt: Option<DateTime<Utc>>,
|
||||
pub n_attempts: i32,
|
||||
pub status: Status,
|
||||
}
|
||||
|
||||
impl UserRedeemal {
|
||||
pub async fn get_pending<'a, E>(
|
||||
exec: E,
|
||||
limit: i64,
|
||||
) -> sqlx::Result<Vec<UserRedeemal>>
|
||||
where
|
||||
E: sqlx::PgExecutor<'a>,
|
||||
{
|
||||
let redeemals = query!(
|
||||
r#"SELECT * FROM users_redeemals WHERE status = $1 LIMIT $2"#,
|
||||
Status::Pending.as_str(),
|
||||
limit
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| UserRedeemal {
|
||||
id: row.id,
|
||||
user_id: DBUserId(row.user_id),
|
||||
offer: Offer::from_str_or_default(&row.offer),
|
||||
redeemed: row.redeemed,
|
||||
last_attempt: row.last_attempt,
|
||||
n_attempts: row.n_attempts,
|
||||
status: Status::from_str_or_default(&row.status),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(redeemals)
|
||||
}
|
||||
|
||||
pub async fn update_stuck_5_minutes<'a, E>(exec: E) -> sqlx::Result<()>
|
||||
where
|
||||
E: sqlx::PgExecutor<'a>,
|
||||
{
|
||||
query!(
|
||||
r#"
|
||||
UPDATE users_redeemals
|
||||
SET status = $1
|
||||
WHERE
|
||||
status = $2
|
||||
AND NOW() - last_attempt > INTERVAL '5 minutes'
|
||||
"#,
|
||||
Status::Pending.as_str(),
|
||||
Status::Processing.as_str(),
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn exists_by_user_and_offer<'a, E>(
|
||||
exec: E,
|
||||
user_id: DBUserId,
|
||||
offer: Offer,
|
||||
) -> sqlx::Result<bool>
|
||||
where
|
||||
E: sqlx::PgExecutor<'a>,
|
||||
{
|
||||
query_scalar!(
|
||||
r#"SELECT
|
||||
EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
users_redeemals
|
||||
WHERE
|
||||
user_id = $1
|
||||
AND offer = $2
|
||||
) AS "exists!"
|
||||
"#,
|
||||
user_id.0,
|
||||
offer.as_str(),
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert<'a, E>(&mut self, exec: E) -> sqlx::Result<()>
|
||||
where
|
||||
E: sqlx::PgExecutor<'a>,
|
||||
{
|
||||
let query = query_scalar!(
|
||||
r#"
|
||||
INSERT INTO users_redeemals
|
||||
(user_id, offer, redeemed, status, last_attempt, n_attempts)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id"#,
|
||||
self.user_id.0,
|
||||
self.offer.as_str(),
|
||||
self.redeemed,
|
||||
self.status.as_str(),
|
||||
self.last_attempt,
|
||||
self.n_attempts,
|
||||
);
|
||||
|
||||
let id = query.fetch_one(exec).await?;
|
||||
|
||||
self.id = id;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates `status`, `last_attempt`, and `n_attempts` only if `status` is currently pending.
|
||||
/// Returns `true` if the status was updated, `false` otherwise.
|
||||
pub async fn update_status_if_pending<'a, E>(
|
||||
&self,
|
||||
exec: E,
|
||||
) -> sqlx::Result<bool>
|
||||
where
|
||||
E: sqlx::PgExecutor<'a>,
|
||||
{
|
||||
let query = query!(
|
||||
r#"
|
||||
UPDATE users_redeemals
|
||||
SET
|
||||
status = $3,
|
||||
last_attempt = $4,
|
||||
n_attempts = $5
|
||||
WHERE id = $1 AND status = $2
|
||||
"#,
|
||||
self.id,
|
||||
Status::Pending.as_str(),
|
||||
self.status.as_str(),
|
||||
self.last_attempt,
|
||||
self.n_attempts,
|
||||
);
|
||||
|
||||
let query_result = query.execute(exec).await?;
|
||||
|
||||
Ok(query_result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn update<'a, E>(&self, exec: E) -> sqlx::Result<()>
|
||||
where
|
||||
E: sqlx::PgExecutor<'a>,
|
||||
{
|
||||
let query = query!(
|
||||
r#"
|
||||
UPDATE users_redeemals
|
||||
SET
|
||||
offer = $2,
|
||||
status = $3,
|
||||
redeemed = $4,
|
||||
last_attempt = $5,
|
||||
n_attempts = $6
|
||||
WHERE id = $1
|
||||
"#,
|
||||
self.id,
|
||||
self.offer.as_str(),
|
||||
self.status.as_str(),
|
||||
self.redeemed,
|
||||
self.last_attempt,
|
||||
self.n_attempts,
|
||||
);
|
||||
|
||||
query.execute(exec).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RedeemalLookupFields {
|
||||
pub user_id: DBUserId,
|
||||
pub redeemal_status: Option<Status>,
|
||||
}
|
||||
|
||||
impl RedeemalLookupFields {
|
||||
/// Returns the redeemal status of a user for an offer, while looking up the user
|
||||
/// itself. **This expects a single redeemal per user/offer pair**.
|
||||
///
|
||||
/// If the returned value is `Ok(None)`, the user doesn't exist.
|
||||
///
|
||||
/// If the returned value is `Ok(Some(fields))`, but `redeemal_status` is `None`,
|
||||
/// the user exists and has not redeemed the offer.
|
||||
pub async fn redeemal_status_by_username_and_offer<'a, E>(
|
||||
exec: E,
|
||||
user_username: &str,
|
||||
offer: Offer,
|
||||
) -> sqlx::Result<Option<RedeemalLookupFields>>
|
||||
where
|
||||
E: sqlx::PgExecutor<'a>,
|
||||
{
|
||||
let maybe_row = query!(
|
||||
r#"
|
||||
SELECT
|
||||
users.id,
|
||||
users_redeemals.status AS "status: Option<String>"
|
||||
FROM
|
||||
users
|
||||
LEFT JOIN
|
||||
users_redeemals ON users_redeemals.user_id = users.id
|
||||
AND users_redeemals.offer = $2
|
||||
WHERE
|
||||
users.username = $1
|
||||
ORDER BY
|
||||
users_redeemals.redeemed DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
user_username,
|
||||
offer.as_str(),
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
// If no row was returned, the user doesn't exist.
|
||||
// If a row NULL status was returned, the user exists but has no redeemed the offer.
|
||||
|
||||
Ok(maybe_row.map(|row| RedeemalLookupFields {
|
||||
user_id: DBUserId(row.id),
|
||||
redeemal_status: row
|
||||
.status
|
||||
.as_deref()
|
||||
.map(Status::from_str_or_default),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -24,13 +24,6 @@ pub enum ProductMetadata {
|
||||
swap: u32,
|
||||
storage: u32,
|
||||
},
|
||||
Medal {
|
||||
cpu: u32,
|
||||
ram: u32,
|
||||
swap: u32,
|
||||
storage: u32,
|
||||
region: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -55,7 +48,6 @@ pub enum Price {
|
||||
#[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Debug, Copy, Clone)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PriceDuration {
|
||||
FiveDays,
|
||||
Monthly,
|
||||
Quarterly,
|
||||
Yearly,
|
||||
@@ -64,7 +56,6 @@ pub enum PriceDuration {
|
||||
impl PriceDuration {
|
||||
pub fn duration(&self) -> chrono::Duration {
|
||||
match self {
|
||||
PriceDuration::FiveDays => chrono::Duration::days(5),
|
||||
PriceDuration::Monthly => chrono::Duration::days(30),
|
||||
PriceDuration::Quarterly => chrono::Duration::days(90),
|
||||
PriceDuration::Yearly => chrono::Duration::days(365),
|
||||
@@ -73,7 +64,6 @@ impl PriceDuration {
|
||||
|
||||
pub fn from_string(string: &str) -> PriceDuration {
|
||||
match string {
|
||||
"five-days" => PriceDuration::FiveDays,
|
||||
"monthly" => PriceDuration::Monthly,
|
||||
"quarterly" => PriceDuration::Quarterly,
|
||||
"yearly" => PriceDuration::Yearly,
|
||||
@@ -86,7 +76,6 @@ impl PriceDuration {
|
||||
PriceDuration::Monthly => "monthly",
|
||||
PriceDuration::Quarterly => "quarterly",
|
||||
PriceDuration::Yearly => "yearly",
|
||||
PriceDuration::FiveDays => "five-days",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +84,6 @@ impl PriceDuration {
|
||||
PriceDuration::Monthly,
|
||||
PriceDuration::Quarterly,
|
||||
PriceDuration::Yearly,
|
||||
PriceDuration::FiveDays,
|
||||
]
|
||||
.into_iter()
|
||||
}
|
||||
@@ -158,7 +146,6 @@ impl SubscriptionStatus {
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum SubscriptionMetadata {
|
||||
Pyro { id: String, region: Option<String> },
|
||||
Medal { id: String },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -220,10 +207,6 @@ pub enum ChargeStatus {
|
||||
Succeeded,
|
||||
Failed,
|
||||
Cancelled,
|
||||
// Expiring charges are charges that aren't expected to be processed
|
||||
// but can be promoted to a full charge, like for trials/freebies. When
|
||||
// due, the underlying subscription is unprovisioned.
|
||||
Expiring,
|
||||
}
|
||||
|
||||
impl ChargeStatus {
|
||||
@@ -234,7 +217,6 @@ impl ChargeStatus {
|
||||
"failed" => ChargeStatus::Failed,
|
||||
"open" => ChargeStatus::Open,
|
||||
"cancelled" => ChargeStatus::Cancelled,
|
||||
"expiring" => ChargeStatus::Expiring,
|
||||
_ => ChargeStatus::Failed,
|
||||
}
|
||||
}
|
||||
@@ -246,7 +228,6 @@ impl ChargeStatus {
|
||||
ChargeStatus::Failed => "failed",
|
||||
ChargeStatus::Open => "open",
|
||||
ChargeStatus::Cancelled => "cancelled",
|
||||
ChargeStatus::Expiring => "expiring",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,14 +235,12 @@ impl ChargeStatus {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PaymentPlatform {
|
||||
Stripe,
|
||||
None,
|
||||
}
|
||||
|
||||
impl PaymentPlatform {
|
||||
pub fn from_string(string: &str) -> PaymentPlatform {
|
||||
match string {
|
||||
"stripe" => PaymentPlatform::Stripe,
|
||||
"none" => PaymentPlatform::None,
|
||||
_ => PaymentPlatform::Stripe,
|
||||
}
|
||||
}
|
||||
@@ -269,7 +248,6 @@ impl PaymentPlatform {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
PaymentPlatform::Stripe => "stripe",
|
||||
PaymentPlatform::None => "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ impl CollectionStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// Collection pages + info cannot be viewed
|
||||
// Project pages + info cannot be viewed
|
||||
pub fn is_hidden(&self) -> bool {
|
||||
match self {
|
||||
CollectionStatus::Rejected => true,
|
||||
@@ -103,11 +103,6 @@ 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 {
|
||||
match self {
|
||||
CollectionStatus::Listed => true,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
use crate::auth::{get_user_from_headers, send_email};
|
||||
use crate::database::models::charge_item::DBCharge;
|
||||
use crate::database::models::user_item::DBUser;
|
||||
use crate::database::models::user_subscription_item::DBUserSubscription;
|
||||
use crate::database::models::users_redeemals::{self, UserRedeemal};
|
||||
use crate::database::models::{
|
||||
generate_charge_id, generate_user_subscription_id, product_item,
|
||||
user_subscription_item,
|
||||
@@ -17,7 +14,6 @@ use crate::models::pats::Scopes;
|
||||
use crate::models::users::Badges;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::archon::{ArchonClient, CreateServerRequest, Specs};
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
|
||||
use ariadne::ids::base62_impl::{parse_base62, to_base62};
|
||||
use chrono::{Duration, Utc};
|
||||
@@ -63,10 +59,8 @@ pub async fn products(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let products = product_item::QueryProductWithPrices::list_purchaseable(
|
||||
&**pool, &redis,
|
||||
)
|
||||
.await?;
|
||||
let products =
|
||||
product_item::QueryProductWithPrices::list(&**pool, &redis).await?;
|
||||
|
||||
let products = products
|
||||
.into_iter()
|
||||
@@ -188,9 +182,7 @@ pub async fn refund_charge(
|
||||
ChargeStatus::Open
|
||||
| ChargeStatus::Processing
|
||||
| ChargeStatus::Succeeded => Some(x.amount),
|
||||
ChargeStatus::Failed
|
||||
| ChargeStatus::Cancelled
|
||||
| ChargeStatus::Expiring => None,
|
||||
ChargeStatus::Failed | ChargeStatus::Cancelled => None,
|
||||
})
|
||||
.sum::<i64>();
|
||||
|
||||
@@ -264,12 +256,6 @@ pub async fn refund_charge(
|
||||
));
|
||||
}
|
||||
}
|
||||
PaymentPlatform::None => {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"This charge was not processed via a payment platform."
|
||||
.to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -290,11 +276,7 @@ pub async fn refund_charge(
|
||||
subscription_interval: charge.subscription_interval,
|
||||
payment_platform: charge.payment_platform,
|
||||
payment_platform_id: id,
|
||||
parent_charge_id: if refund_amount != 0 {
|
||||
Some(charge.id)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
parent_charge_id: Some(charge.id),
|
||||
net,
|
||||
}
|
||||
.upsert(&mut transaction)
|
||||
@@ -384,7 +366,6 @@ pub async fn edit_subscription(
|
||||
})?;
|
||||
|
||||
if let Some(cancelled) = &edit_subscription.cancelled {
|
||||
// Notably, cannot cancel/uncancel expiring charges.
|
||||
if !matches!(
|
||||
open_charge.status,
|
||||
ChargeStatus::Open
|
||||
@@ -409,17 +390,14 @@ pub async fn edit_subscription(
|
||||
|
||||
if let Some(interval) = &edit_subscription.interval {
|
||||
if let Price::Recurring { intervals } = ¤t_price.prices {
|
||||
// For expiring charges, the interval is handled in the Product branch.
|
||||
if open_charge.status != ChargeStatus::Expiring {
|
||||
if let Some(price) = intervals.get(interval) {
|
||||
open_charge.subscription_interval = Some(*interval);
|
||||
open_charge.amount = *price as i64;
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Interval is not valid for this subscription!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(price) = intervals.get(interval) {
|
||||
open_charge.subscription_interval = Some(*interval);
|
||||
open_charge.amount = *price as i64;
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Interval is not valid for this subscription!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,14 +425,48 @@ pub async fn edit_subscription(
|
||||
));
|
||||
}
|
||||
|
||||
// If the charge is an expiring charge, we need to create a payment
|
||||
// intent as if the user was subscribing to the product, as opposed
|
||||
// to a proration.
|
||||
if open_charge.status == ChargeStatus::Expiring {
|
||||
// We need a new interval when promoting the charge.
|
||||
let interval = edit_subscription.interval
|
||||
.ok_or_else(|| ApiError::InvalidInput("You need to specify an interval when promoting an expiring charge.".to_owned()))?;
|
||||
let interval = open_charge.due - Utc::now();
|
||||
let duration = PriceDuration::Monthly;
|
||||
|
||||
let current_amount = match ¤t_price.prices {
|
||||
Price::OneTime { price } => *price,
|
||||
Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Could not find a valid price for the user's duration".to_string(),
|
||||
)
|
||||
})?,
|
||||
};
|
||||
|
||||
let amount = match &product_price.prices {
|
||||
Price::OneTime { price } => *price,
|
||||
Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Could not find a valid price for the user's duration".to_string(),
|
||||
)
|
||||
})?,
|
||||
};
|
||||
|
||||
let complete = Decimal::from(interval.num_seconds())
|
||||
/ Decimal::from(duration.duration().num_seconds());
|
||||
let proration = (Decimal::from(amount - current_amount) * complete)
|
||||
.floor()
|
||||
.to_i32()
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Could not convert proration to i32".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// First branch: Plan downgrade, update future charge
|
||||
// Second branch: For small transactions (under 30 cents), we make a loss on the
|
||||
// proration due to fees. In these situations, just give it to them for free, because
|
||||
// their next charge will be in a day or two anyway.
|
||||
if current_amount > amount || proration < 30 {
|
||||
open_charge.price_id = product_price.id;
|
||||
open_charge.amount = amount as i64;
|
||||
|
||||
None
|
||||
} else {
|
||||
let charge_id = generate_charge_id(&mut transaction).await?;
|
||||
|
||||
let customer_id = get_or_create_customer(
|
||||
@@ -467,15 +479,6 @@ pub async fn edit_subscription(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let new_price_value = match product_price.prices {
|
||||
Price::OneTime { ref price } => *price,
|
||||
Price::Recurring { ref intervals } => {
|
||||
*intervals
|
||||
.get(&interval)
|
||||
.ok_or_else(|| ApiError::InvalidInput("Could not find a valid price for the specified duration".to_owned()))?
|
||||
}
|
||||
};
|
||||
|
||||
let currency = Currency::from_str(
|
||||
¤t_price.currency_code.to_lowercase(),
|
||||
)
|
||||
@@ -484,7 +487,7 @@ pub async fn edit_subscription(
|
||||
})?;
|
||||
|
||||
let mut intent =
|
||||
CreatePaymentIntent::new(new_price_value as i64, currency);
|
||||
CreatePaymentIntent::new(proration as i64, currency);
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert(
|
||||
@@ -505,11 +508,15 @@ pub async fn edit_subscription(
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_subscription_interval".to_string(),
|
||||
interval.as_str().to_string(),
|
||||
open_charge
|
||||
.subscription_interval
|
||||
.unwrap_or(PriceDuration::Monthly)
|
||||
.as_str()
|
||||
.to_string(),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_charge_type".to_string(),
|
||||
ChargeType::Subscription.as_str().to_string(),
|
||||
ChargeType::Proration.as_str().to_string(),
|
||||
);
|
||||
|
||||
intent.customer = Some(customer_id);
|
||||
@@ -534,139 +541,7 @@ pub async fn edit_subscription(
|
||||
stripe::PaymentIntent::create(&stripe_client, intent)
|
||||
.await?;
|
||||
|
||||
// We do NOT update the open charge here. It will be patched to be the next
|
||||
// charge of the subscription in the stripe webhook.
|
||||
//
|
||||
// We also shouldn't delete it, because if the payment fails, the expiring
|
||||
// charge will be gone and the preview subscription will never be unprovisioned.
|
||||
|
||||
Some((new_price_value, 0, intent))
|
||||
} else {
|
||||
// The charge is not an expiring charge, need to prorate.
|
||||
|
||||
let interval = open_charge.due - Utc::now();
|
||||
let duration = PriceDuration::Monthly;
|
||||
|
||||
let current_amount = match ¤t_price.prices {
|
||||
Price::OneTime { price } => *price,
|
||||
Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Could not find a valid price for the user's duration".to_string(),
|
||||
)
|
||||
})?,
|
||||
};
|
||||
|
||||
let amount = match &product_price.prices {
|
||||
Price::OneTime { price } => *price,
|
||||
Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Could not find a valid price for the user's duration".to_string(),
|
||||
)
|
||||
})?,
|
||||
};
|
||||
|
||||
let complete = Decimal::from(interval.num_seconds())
|
||||
/ Decimal::from(duration.duration().num_seconds());
|
||||
let proration = (Decimal::from(amount - current_amount)
|
||||
* complete)
|
||||
.floor()
|
||||
.to_i32()
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Could not convert proration to i32".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// First condition: Plan downgrade, update future charge
|
||||
// Second condition: For small transactions (under 30 cents), we make a loss on the
|
||||
// proration due to fees. In these situations, just give it to them for free, because
|
||||
// their next charge will be in a day or two anyway.
|
||||
if current_amount > amount || proration < 30 {
|
||||
open_charge.price_id = product_price.id;
|
||||
open_charge.amount = amount as i64;
|
||||
|
||||
None
|
||||
} else {
|
||||
let charge_id =
|
||||
generate_charge_id(&mut transaction).await?;
|
||||
|
||||
let customer_id = get_or_create_customer(
|
||||
user.id,
|
||||
user.stripe_customer_id.as_deref(),
|
||||
user.email.as_deref(),
|
||||
&stripe_client,
|
||||
&pool,
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let currency = Currency::from_str(
|
||||
¤t_price.currency_code.to_lowercase(),
|
||||
)
|
||||
.map_err(|_| {
|
||||
ApiError::InvalidInput(
|
||||
"Invalid currency code".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut intent =
|
||||
CreatePaymentIntent::new(proration as i64, currency);
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert(
|
||||
"modrinth_user_id".to_string(),
|
||||
to_base62(user.id.0),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_charge_id".to_string(),
|
||||
to_base62(charge_id.0 as u64),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_subscription_id".to_string(),
|
||||
to_base62(subscription.id.0 as u64),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_price_id".to_string(),
|
||||
to_base62(product_price.id.0 as u64),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_subscription_interval".to_string(),
|
||||
open_charge
|
||||
.subscription_interval
|
||||
.unwrap_or(PriceDuration::Monthly)
|
||||
.as_str()
|
||||
.to_string(),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_charge_type".to_string(),
|
||||
ChargeType::Proration.as_str().to_string(),
|
||||
);
|
||||
|
||||
intent.customer = Some(customer_id);
|
||||
intent.metadata = Some(metadata);
|
||||
intent.receipt_email = user.email.as_deref();
|
||||
intent.setup_future_usage =
|
||||
Some(PaymentIntentSetupFutureUsage::OffSession);
|
||||
|
||||
if let Some(payment_method) =
|
||||
&edit_subscription.payment_method
|
||||
{
|
||||
let Ok(payment_method_id) =
|
||||
PaymentMethodId::from_str(payment_method)
|
||||
else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Invalid payment method id".to_string(),
|
||||
));
|
||||
};
|
||||
intent.payment_method = Some(payment_method_id);
|
||||
}
|
||||
|
||||
let intent =
|
||||
stripe::PaymentIntent::create(&stripe_client, intent)
|
||||
.await?;
|
||||
|
||||
Some((proration, 0, intent))
|
||||
}
|
||||
Some((proration, 0, intent))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
@@ -1069,17 +944,14 @@ pub async fn active_servers(
|
||||
let server_ids = servers
|
||||
.into_iter()
|
||||
.filter_map(|x| {
|
||||
x.metadata.as_ref().and_then(|metadata| match metadata {
|
||||
SubscriptionMetadata::Pyro { id, region } => {
|
||||
Some(ActiveServer {
|
||||
user_id: x.user_id.into(),
|
||||
server_id: id.clone(),
|
||||
price_id: x.price_id.into(),
|
||||
interval: x.interval,
|
||||
region: region.clone(),
|
||||
})
|
||||
}
|
||||
SubscriptionMetadata::Medal { .. } => None,
|
||||
x.metadata.as_ref().map(|metadata| match metadata {
|
||||
SubscriptionMetadata::Pyro { id, region } => ActiveServer {
|
||||
user_id: x.user_id.into(),
|
||||
server_id: id.clone(),
|
||||
price_id: x.price_id.into(),
|
||||
interval: x.interval,
|
||||
region: region.clone(),
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect::<Vec<ActiveServer>>();
|
||||
@@ -1311,7 +1183,7 @@ pub async fn initiate_payment(
|
||||
})?;
|
||||
|
||||
let mut product_prices =
|
||||
product_item::DBProductPrice::get_all_public_product_prices(
|
||||
product_item::DBProductPrice::get_all_product_prices(
|
||||
product.id, &**pool,
|
||||
)
|
||||
.await?;
|
||||
@@ -1828,13 +1700,6 @@ pub async fn stripe_webhook(
|
||||
|
||||
// Provision subscription
|
||||
match metadata.product_item.metadata {
|
||||
// A payment shouldn't be processed for Medal subscriptions.
|
||||
ProductMetadata::Medal { .. } => {
|
||||
warn!(
|
||||
"A payment processed for a free subscription"
|
||||
);
|
||||
}
|
||||
|
||||
ProductMetadata::Midas => {
|
||||
let badges =
|
||||
metadata.user_item.badges | Badges::MIDAS;
|
||||
@@ -1964,7 +1829,6 @@ pub async fn stripe_webhook(
|
||||
"region": server_region,
|
||||
"source": source,
|
||||
"payment_interval": metadata.charge_item.subscription_interval.map(|x| match x {
|
||||
PriceDuration::FiveDays => 1,
|
||||
PriceDuration::Monthly => 1,
|
||||
PriceDuration::Quarterly => 3,
|
||||
PriceDuration::Yearly => 12,
|
||||
@@ -2011,32 +1875,10 @@ pub async fn stripe_webhook(
|
||||
}
|
||||
};
|
||||
|
||||
// If the next open charge is actually an expiring charge,
|
||||
// this means the subscription was promoted from a temporary
|
||||
// free subscription to a paid subscription.
|
||||
//
|
||||
// In this case, we need to modify this expiring charge to be the
|
||||
// next charge of the subscription, turn it into a normal open charge.
|
||||
//
|
||||
// Otherwise, if there *is* an open charge, the subscription was upgraded
|
||||
// and the just-processed payment was the proration charge. In this case,
|
||||
// the existing open charge must be updated to reflect the new product's price.
|
||||
//
|
||||
// If there are no open charges, the just-processed payment was a recurring
|
||||
// or initial subscription charge, and we need to create the next charge.
|
||||
if let Some(mut charge) = open_charge {
|
||||
if charge.status == ChargeStatus::Expiring {
|
||||
charge.status = ChargeStatus::Open;
|
||||
charge.due = Utc::now()
|
||||
+ subscription.interval.duration();
|
||||
charge.payment_platform =
|
||||
PaymentPlatform::Stripe;
|
||||
charge.last_attempt = None;
|
||||
} else {
|
||||
charge.price_id =
|
||||
metadata.product_price_item.id;
|
||||
charge.amount = new_price as i64;
|
||||
}
|
||||
charge.price_id = metadata.product_price_item.id;
|
||||
charge.amount = new_price as i64;
|
||||
|
||||
charge.upsert(&mut transaction).await?;
|
||||
} else if metadata.charge_item.status
|
||||
!= ChargeStatus::Cancelled
|
||||
@@ -2259,11 +2101,7 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) {
|
||||
let mut transaction = pool.begin().await?;
|
||||
let mut clear_cache_users = Vec::new();
|
||||
|
||||
// If an active subscription has:
|
||||
// - A canceled charge due now
|
||||
// - An expiring charge due now
|
||||
// - A failed charge more than two days ago
|
||||
// It should be unprovisioned.
|
||||
// If an active subscription has a canceled charge OR a failed charge more than two days ago, it should be cancelled
|
||||
let all_charges = DBCharge::get_unprovision(&pool).await?;
|
||||
|
||||
let mut all_subscriptions =
|
||||
@@ -2359,37 +2197,33 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) {
|
||||
|
||||
true
|
||||
}
|
||||
ProductMetadata::Pyro { .. } => {
|
||||
if let Some(SubscriptionMetadata::Pyro { id, region: _ }) =
|
||||
&subscription.metadata
|
||||
{
|
||||
let res = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{}/modrinth/v0/servers/{}/suspend",
|
||||
dotenvy::var("ARCHON_URL")?,
|
||||
id
|
||||
))
|
||||
.header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?)
|
||||
.json(&serde_json::json!({
|
||||
"reason": if charge.status == ChargeStatus::Cancelled {
|
||||
"cancelled"
|
||||
} else {
|
||||
"paymentfailed"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
ProductMetadata::Pyro { .. }
|
||||
| ProductMetadata::Medal { .. } => 'server: {
|
||||
let server_id = match &subscription.metadata {
|
||||
Some(SubscriptionMetadata::Pyro { id, region: _ }) => {
|
||||
id
|
||||
if let Err(e) = res {
|
||||
warn!("Error suspending pyro server: {:?}", e);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
Some(SubscriptionMetadata::Medal { id }) => id,
|
||||
_ => break 'server true,
|
||||
};
|
||||
|
||||
let res = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{}/modrinth/v0/servers/{}/suspend",
|
||||
dotenvy::var("ARCHON_URL")?,
|
||||
server_id
|
||||
))
|
||||
.header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?)
|
||||
.json(&serde_json::json!({
|
||||
"reason": if charge.status == ChargeStatus::Cancelled || charge.status == ChargeStatus::Expiring {
|
||||
"cancelled"
|
||||
} else {
|
||||
"paymentfailed"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if let Err(e) = res {
|
||||
warn!("Error suspending pyro server: {:?}", e);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
@@ -2414,20 +2248,6 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) {
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
// If an offer redeemal has been processing for over 5 minutes, it should be set pending.
|
||||
UserRedeemal::update_stuck_5_minutes(&pool).await?;
|
||||
|
||||
// If an offer redeemal is pending, try processing it.
|
||||
// Try processing it.
|
||||
let pending_redeemals = UserRedeemal::get_pending(&pool, 100).await?;
|
||||
for redeemal in pending_redeemals {
|
||||
if let Err(error) =
|
||||
try_process_user_redeemal(&pool, &redis, redeemal).await
|
||||
{
|
||||
warn!(%error, "Failed to process a redeemal.")
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<(), ApiError>(())
|
||||
};
|
||||
|
||||
@@ -2438,161 +2258,6 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) {
|
||||
info!("Done indexing subscriptions");
|
||||
}
|
||||
|
||||
/// Attempts to process a user redeemal.
|
||||
///
|
||||
/// Returns `Ok` if the entry has been succesfully processed, or will not be processed.
|
||||
pub async fn try_process_user_redeemal(
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
mut user_redeemal: UserRedeemal,
|
||||
) -> Result<(), ApiError> {
|
||||
// Immediately update redeemal row
|
||||
user_redeemal.last_attempt = Some(Utc::now());
|
||||
user_redeemal.n_attempts += 1;
|
||||
user_redeemal.status = users_redeemals::Status::Processing;
|
||||
let updated = user_redeemal.update_status_if_pending(pool).await?;
|
||||
|
||||
if !updated {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let user_id = user_redeemal.user_id;
|
||||
|
||||
// Find the Medal product's price & metadata
|
||||
|
||||
let mut medal_products =
|
||||
product_item::QueryProductWithPrices::list_by_product_type(
|
||||
pool, "medal",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(product_item::QueryProductWithPrices {
|
||||
id: _product_id,
|
||||
metadata,
|
||||
mut prices,
|
||||
unitary: _,
|
||||
}) = medal_products.pop()
|
||||
else {
|
||||
return Err(ApiError::Conflict(
|
||||
"Missing Medal subscription product".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
let ProductMetadata::Medal {
|
||||
cpu,
|
||||
ram,
|
||||
swap,
|
||||
storage,
|
||||
region,
|
||||
} = metadata
|
||||
else {
|
||||
return Err(ApiError::Conflict(
|
||||
"Missing or incorrect metadata for Medal subscription".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
let Some(medal_price) = prices.pop() else {
|
||||
return Err(ApiError::Conflict(
|
||||
"Missing price for Medal subscription".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
let (price_duration, price_amount) = match medal_price.prices {
|
||||
Price::OneTime { price: _ } => {
|
||||
return Err(ApiError::Conflict(
|
||||
"Unexpected metadata for Medal subscription price".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
Price::Recurring { intervals } => {
|
||||
let Some((price_duration, price_amount)) =
|
||||
intervals.into_iter().next()
|
||||
else {
|
||||
return Err(ApiError::Conflict(
|
||||
"Missing price interval for Medal subscription".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
(price_duration, price_amount)
|
||||
}
|
||||
};
|
||||
|
||||
let price_id = medal_price.id;
|
||||
|
||||
// Get the user's username
|
||||
|
||||
let user = DBUser::get_id(user_id, pool, redis)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
// Send the provision request to Archon. On failure, the redeemal will be "stuck" processing,
|
||||
// and moved back to pending by `index_subscriptions`.
|
||||
|
||||
let archon_client = ArchonClient::from_env()?;
|
||||
let server_id = archon_client
|
||||
.create_server(&CreateServerRequest {
|
||||
user_id: to_base62(user_id.0 as u64),
|
||||
name: format!("{}'s Medal server", user.username),
|
||||
specs: Specs {
|
||||
memory_mb: ram,
|
||||
cpu,
|
||||
swap_mb: swap,
|
||||
storage_mb: storage,
|
||||
},
|
||||
source: crate::util::archon::Empty::default(),
|
||||
region,
|
||||
tags: vec!["medal".to_owned()],
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut txn = pool.begin().await?;
|
||||
|
||||
// Build a subscription using this price ID.
|
||||
let subscription = DBUserSubscription {
|
||||
id: generate_user_subscription_id(&mut txn).await?,
|
||||
user_id,
|
||||
price_id,
|
||||
interval: PriceDuration::FiveDays,
|
||||
created: Utc::now(),
|
||||
status: SubscriptionStatus::Provisioned,
|
||||
metadata: Some(SubscriptionMetadata::Medal {
|
||||
id: server_id.to_string(),
|
||||
}),
|
||||
};
|
||||
|
||||
subscription.upsert(&mut txn).await?;
|
||||
|
||||
// Insert an expiring charge, `index_subscriptions` will unprovision the
|
||||
// subscription when expired.
|
||||
DBCharge {
|
||||
id: generate_charge_id(&mut txn).await?,
|
||||
user_id,
|
||||
price_id,
|
||||
amount: price_amount.into(),
|
||||
currency_code: medal_price.currency_code,
|
||||
status: ChargeStatus::Expiring,
|
||||
due: Utc::now() + price_duration.duration(),
|
||||
last_attempt: None,
|
||||
type_: ChargeType::Subscription,
|
||||
subscription_id: Some(subscription.id),
|
||||
subscription_interval: Some(subscription.interval),
|
||||
payment_platform: PaymentPlatform::None,
|
||||
payment_platform_id: None,
|
||||
parent_charge_id: None,
|
||||
net: None,
|
||||
}
|
||||
.upsert(&mut txn)
|
||||
.await?;
|
||||
|
||||
// Update `users_redeemal`, mark subscription as redeemed.
|
||||
user_redeemal.status = users_redeemals::Status::Processed;
|
||||
user_redeemal.update(&mut *txn).await?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn index_billing(
|
||||
stripe_client: stripe::Client,
|
||||
pool: PgPool,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use crate::auth::email::send_email;
|
||||
use crate::auth::validate::{
|
||||
get_full_user_from_headers, get_user_record_from_bearer_token,
|
||||
};
|
||||
use crate::auth::validate::get_user_record_from_bearer_token;
|
||||
use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers};
|
||||
use crate::database::models::DBUser;
|
||||
use crate::database::models::flow_item::DBFlow;
|
||||
@@ -234,7 +232,6 @@ impl TempUser {
|
||||
role: Role::Developer.to_string(),
|
||||
badges: Badges::default(),
|
||||
allow_friend_requests: true,
|
||||
is_subscribed_to_newsletter: false,
|
||||
}
|
||||
.insert(transaction)
|
||||
.await?;
|
||||
@@ -1294,6 +1291,37 @@ pub async fn delete_auth_provider(
|
||||
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(
|
||||
email: &str,
|
||||
) -> Result<bool, AuthenticationError> {
|
||||
@@ -1428,9 +1456,6 @@ pub async fn create_account_with_password(
|
||||
role: Role::Developer.to_string(),
|
||||
badges: Badges::default(),
|
||||
allow_friend_requests: true,
|
||||
is_subscribed_to_newsletter: new_account
|
||||
.sign_up_newsletter
|
||||
.unwrap_or(false),
|
||||
}
|
||||
.insert(&mut transaction)
|
||||
.await?;
|
||||
@@ -1451,6 +1476,10 @@ pub async fn create_account_with_password(
|
||||
&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?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(res))
|
||||
@@ -2391,24 +2420,15 @@ pub async fn subscribe_newsletter(
|
||||
.await?
|
||||
.1;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET is_subscribed_to_newsletter = TRUE
|
||||
WHERE id = $1
|
||||
",
|
||||
user.id.0 as i64,
|
||||
)
|
||||
.execute(&**pool)
|
||||
.await?;
|
||||
if let Some(email) = user.email {
|
||||
sign_up_sendy(&email).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")]
|
||||
@@ -2418,7 +2438,7 @@ pub async fn get_newsletter_subscription_status(
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_full_user_from_headers(
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
@@ -2428,16 +2448,16 @@ pub async fn get_newsletter_subscription_status(
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let is_subscribed = user.is_subscribed_to_newsletter
|
||||
|| if let Some(email) = user.email {
|
||||
check_sendy_subscription(&email).await?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"subscribed": is_subscribed
|
||||
})))
|
||||
if let Some(email) = user.email {
|
||||
let is_subscribed = check_sendy_subscription(&email).await?;
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"subscribed": is_subscribed
|
||||
})))
|
||||
} else {
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"subscribed": false
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
fn send_email_verify(
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
use actix_web::{HttpResponse, post, web};
|
||||
use ariadne::ids::UserId;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::database::models::users_redeemals::{
|
||||
Offer, RedeemalLookupFields, Status, UserRedeemal,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::routes::ApiError;
|
||||
use crate::routes::internal::billing::try_process_user_redeemal;
|
||||
use crate::util::guards::medal_key_guard;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::scope("medal").service(verify).service(redeem));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MedalQuery {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[post("verify", guard = "medal_key_guard")]
|
||||
pub async fn verify(
|
||||
pool: web::Data<PgPool>,
|
||||
web::Query(MedalQuery { username }): web::Query<MedalQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let maybe_fields =
|
||||
RedeemalLookupFields::redeemal_status_by_username_and_offer(
|
||||
&**pool,
|
||||
&username,
|
||||
Offer::Medal,
|
||||
)
|
||||
.await?;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct VerifyResponse {
|
||||
user_id: UserId,
|
||||
redeemed: bool,
|
||||
}
|
||||
|
||||
match maybe_fields {
|
||||
None => Err(ApiError::NotFound),
|
||||
Some(fields) => Ok(HttpResponse::Ok().json(VerifyResponse {
|
||||
user_id: fields.user_id.into(),
|
||||
redeemed: fields.redeemal_status.is_some(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[post("redeem", guard = "medal_key_guard")]
|
||||
pub async fn redeem(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
web::Query(MedalQuery { username }): web::Query<MedalQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Check the offer hasn't been redeemed yet, then insert into the table.
|
||||
// In a transaction to avoid double inserts.
|
||||
|
||||
let mut txn = pool.begin().await?;
|
||||
|
||||
let maybe_fields =
|
||||
RedeemalLookupFields::redeemal_status_by_username_and_offer(
|
||||
&mut *txn,
|
||||
&username,
|
||||
Offer::Medal,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user_id = match maybe_fields {
|
||||
None => return Err(ApiError::NotFound),
|
||||
Some(fields) => {
|
||||
if fields.redeemal_status.is_some() {
|
||||
return Err(ApiError::Conflict(
|
||||
"User already redeemed this offer".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
fields.user_id
|
||||
}
|
||||
};
|
||||
|
||||
// Link user to offer redeemal.
|
||||
let mut redeemal = UserRedeemal {
|
||||
id: 0,
|
||||
user_id,
|
||||
offer: Offer::Medal,
|
||||
redeemed: Utc::now(),
|
||||
status: Status::Pending,
|
||||
last_attempt: None,
|
||||
n_attempts: 0,
|
||||
};
|
||||
|
||||
redeemal.insert(&mut *txn).await?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
// Immediately try to process the redeemal
|
||||
if let Err(error) = try_process_user_redeemal(&pool, &redis, redeemal).await
|
||||
{
|
||||
warn!(%error, "Medal redeemal processing failed");
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
} else {
|
||||
Ok(HttpResponse::Created().finish())
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ pub(crate) mod admin;
|
||||
pub mod billing;
|
||||
pub mod flows;
|
||||
pub mod gdpr;
|
||||
pub mod medal;
|
||||
pub mod moderation;
|
||||
pub mod pats;
|
||||
pub mod session;
|
||||
@@ -25,7 +24,6 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
.configure(moderation::config)
|
||||
.configure(billing::config)
|
||||
.configure(gdpr::config)
|
||||
.configure(statuses::config)
|
||||
.configure(medal::config),
|
||||
.configure(statuses::config),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,14 +18,12 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ProjectsRequestOptions {
|
||||
pub struct ResultCount {
|
||||
#[serde(default = "default_count")]
|
||||
pub count: u16,
|
||||
#[serde(default)]
|
||||
pub offset: u32,
|
||||
pub count: i16,
|
||||
}
|
||||
|
||||
fn default_count() -> u16 {
|
||||
fn default_count() -> i16 {
|
||||
100
|
||||
}
|
||||
|
||||
@@ -33,7 +31,7 @@ pub async fn get_projects(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
request_opts: web::Query<ProjectsRequestOptions>,
|
||||
count: web::Query<ResultCount>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
check_is_moderator_from_headers(
|
||||
@@ -52,12 +50,10 @@ pub async fn get_projects(
|
||||
SELECT id FROM mods
|
||||
WHERE status = $1
|
||||
ORDER BY queued ASC
|
||||
OFFSET $3
|
||||
LIMIT $2
|
||||
LIMIT $2;
|
||||
",
|
||||
ProjectStatus::Processing.as_str(),
|
||||
request_opts.count as i64,
|
||||
request_opts.offset as i64
|
||||
count.count as i64
|
||||
)
|
||||
.fetch(&**pool)
|
||||
.map_ok(|m| database::models::DBProjectId(m.id))
|
||||
|
||||
@@ -137,8 +137,6 @@ pub enum ApiError {
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Resource not found")]
|
||||
NotFound,
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error(
|
||||
"You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
|
||||
)]
|
||||
@@ -174,7 +172,6 @@ impl ApiError {
|
||||
ApiError::Clickhouse(..) => "clickhouse_error",
|
||||
ApiError::Reroute(..) => "reroute_error",
|
||||
ApiError::NotFound => "not_found",
|
||||
ApiError::Conflict(..) => "conflict",
|
||||
ApiError::Zip(..) => "zip_error",
|
||||
ApiError::Io(..) => "io_error",
|
||||
ApiError::RateLimitError(..) => "ratelimit_error",
|
||||
@@ -211,7 +208,6 @@ impl actix_web::ResponseError for ApiError {
|
||||
ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::NotFound => StatusCode::NOT_FOUND,
|
||||
ApiError::Conflict(..) => StatusCode::CONFLICT,
|
||||
ApiError::Zip(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::Io(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
||||
|
||||
@@ -15,10 +15,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResultCount {
|
||||
#[serde(default = "default_count")]
|
||||
pub count: u16,
|
||||
pub count: i16,
|
||||
}
|
||||
|
||||
fn default_count() -> u16 {
|
||||
fn default_count() -> i16 {
|
||||
100
|
||||
}
|
||||
|
||||
@@ -34,10 +34,7 @@ pub async fn get_projects(
|
||||
req,
|
||||
pool.clone(),
|
||||
redis.clone(),
|
||||
web::Query(internal::moderation::ProjectsRequestOptions {
|
||||
count: count.count,
|
||||
offset: 0,
|
||||
}),
|
||||
web::Query(internal::moderation::ResultCount { count: count.count }),
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -43,12 +43,12 @@ pub async fn report_create(
|
||||
#[derive(Deserialize)]
|
||||
pub struct ReportsRequestOptions {
|
||||
#[serde(default = "default_count")]
|
||||
count: u16,
|
||||
count: i16,
|
||||
#[serde(default = "default_all")]
|
||||
all: bool,
|
||||
}
|
||||
|
||||
fn default_count() -> u16 {
|
||||
fn default_count() -> i16 {
|
||||
100
|
||||
}
|
||||
fn default_all() -> bool {
|
||||
@@ -60,7 +60,7 @@ pub async fn reports(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
request_opts: web::Query<ReportsRequestOptions>,
|
||||
count: web::Query<ReportsRequestOptions>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let response = v3::reports::reports(
|
||||
@@ -68,9 +68,8 @@ pub async fn reports(
|
||||
pool,
|
||||
redis,
|
||||
web::Query(v3::reports::ReportsRequestOptions {
|
||||
count: request_opts.count,
|
||||
offset: 0,
|
||||
all: request_opts.all,
|
||||
count: count.count,
|
||||
all: count.all,
|
||||
}),
|
||||
session_queue,
|
||||
)
|
||||
|
||||
@@ -163,8 +163,7 @@ pub async fn collections_get(
|
||||
.ok();
|
||||
|
||||
let collections =
|
||||
filter_visible_collections(collections_data, &user_option, false)
|
||||
.await?;
|
||||
filter_visible_collections(collections_data, &user_option).await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(collections))
|
||||
}
|
||||
@@ -193,7 +192,7 @@ pub async fn collection_get(
|
||||
.ok();
|
||||
|
||||
if let Some(data) = collection_data {
|
||||
if is_visible_collection(&data, &user_option, false).await? {
|
||||
if is_visible_collection(&data, &user_option).await? {
|
||||
return Ok(HttpResponse::Ok().json(Collection::from(data)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,14 +222,12 @@ pub async fn report_create(
|
||||
#[derive(Deserialize)]
|
||||
pub struct ReportsRequestOptions {
|
||||
#[serde(default = "default_count")]
|
||||
pub count: u16,
|
||||
#[serde(default)]
|
||||
pub offset: u32,
|
||||
pub count: i16,
|
||||
#[serde(default = "default_all")]
|
||||
pub all: bool,
|
||||
}
|
||||
|
||||
fn default_count() -> u16 {
|
||||
fn default_count() -> i16 {
|
||||
100
|
||||
}
|
||||
fn default_all() -> bool {
|
||||
@@ -240,7 +238,7 @@ pub async fn reports(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
request_opts: web::Query<ReportsRequestOptions>,
|
||||
count: web::Query<ReportsRequestOptions>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
@@ -255,17 +253,15 @@ pub async fn reports(
|
||||
|
||||
use futures::stream::TryStreamExt;
|
||||
|
||||
let report_ids = if user.role.is_mod() && request_opts.all {
|
||||
let report_ids = if user.role.is_mod() && count.all {
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT id FROM reports
|
||||
WHERE closed = FALSE
|
||||
ORDER BY created ASC
|
||||
OFFSET $2
|
||||
LIMIT $1
|
||||
LIMIT $1;
|
||||
",
|
||||
request_opts.count as i64,
|
||||
request_opts.offset as i64
|
||||
count.count as i64
|
||||
)
|
||||
.fetch(&**pool)
|
||||
.map_ok(|m| crate::database::models::ids::DBReportId(m.id))
|
||||
@@ -277,12 +273,10 @@ pub async fn reports(
|
||||
SELECT id FROM reports
|
||||
WHERE closed = FALSE AND reporter = $1
|
||||
ORDER BY created ASC
|
||||
OFFSET $3
|
||||
LIMIT $2
|
||||
LIMIT $2;
|
||||
",
|
||||
user.id.0 as i64,
|
||||
request_opts.count as i64,
|
||||
request_opts.offset as i64
|
||||
count.count as i64
|
||||
)
|
||||
.fetch(&**pool)
|
||||
.map_ok(|m| crate::database::models::ids::DBReportId(m.id))
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use super::{ApiError, oauth_clients::get_user_clients};
|
||||
use crate::file_hosting::FileHostPublicity;
|
||||
use crate::util::img::delete_old_images;
|
||||
use crate::{
|
||||
auth::{
|
||||
filter_visible_collections, filter_visible_projects,
|
||||
get_user_from_headers,
|
||||
},
|
||||
auth::{filter_visible_projects, get_user_from_headers},
|
||||
database::{models::DBUser, redis::RedisPool},
|
||||
file_hosting::{FileHost, FileHostPublicity},
|
||||
file_hosting::FileHost,
|
||||
models::{
|
||||
collections::{Collection, CollectionStatus},
|
||||
notifications::Notification,
|
||||
pats::Scopes,
|
||||
projects::Project,
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
util::{
|
||||
img::delete_old_images, routes::read_limited_from_payload,
|
||||
routes::read_limited_from_payload,
|
||||
validate::validation_errors_to_string,
|
||||
},
|
||||
};
|
||||
@@ -244,19 +244,27 @@ pub async fn collections_list(
|
||||
let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?;
|
||||
|
||||
if let Some(id) = id_option.map(|x| x.id) {
|
||||
let collection_data = DBUser::get_collections(id, &**pool).await?;
|
||||
let user_id: UserId = id.into();
|
||||
|
||||
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(
|
||||
&collection_data,
|
||||
&project_data,
|
||||
&**pool,
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|x| {
|
||||
can_view_private || matches!(x.status, CollectionStatus::Listed)
|
||||
})
|
||||
.map(Collection::from)
|
||||
.collect();
|
||||
|
||||
let collections =
|
||||
filter_visible_collections(response, &user, true).await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(collections))
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
use reqwest::header::HeaderName;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::routes::ApiError;
|
||||
|
||||
const X_MASTER_KEY: HeaderName = HeaderName::from_static("x-master-key");
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Empty {}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Specs {
|
||||
pub memory_mb: u32,
|
||||
pub cpu: u32,
|
||||
pub swap_mb: u32,
|
||||
pub storage_mb: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateServerRequest {
|
||||
pub user_id: String,
|
||||
pub name: String,
|
||||
pub specs: Specs,
|
||||
// Must be included because archon doesn't accept null values, only
|
||||
// an empty struct, as a source.
|
||||
pub source: Empty,
|
||||
pub region: String,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ArchonClient {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
pyro_api_key: String,
|
||||
}
|
||||
|
||||
impl ArchonClient {
|
||||
/// Builds an Archon client from environment variables. Returns `None` if the
|
||||
/// required environment variables are not set.
|
||||
pub fn from_env() -> Result<Self, ApiError> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let base_url =
|
||||
dotenvy::var("ARCHON_URL")?.trim_end_matches('/').to_owned();
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
base_url,
|
||||
pyro_api_key: dotenvy::var("PYRO_API_KEY")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_server(
|
||||
&self,
|
||||
request: &CreateServerRequest,
|
||||
) -> Result<Uuid, reqwest::Error> {
|
||||
#[derive(Deserialize)]
|
||||
struct CreateServerResponse {
|
||||
uuid: Uuid,
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("{}/modrinth/v0/servers/create", self.base_url))
|
||||
.header(X_MASTER_KEY, &self.pyro_api_key)
|
||||
.json(request)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
Ok(response.json::<CreateServerResponse>().await?.uuid)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
use actix_web::guard::GuardContext;
|
||||
|
||||
pub const ADMIN_KEY_HEADER: &str = "Modrinth-Admin";
|
||||
pub const MEDAL_KEY_HEADER: &str = "X-Medal-Access-Key";
|
||||
|
||||
pub fn admin_key_guard(ctx: &GuardContext) -> bool {
|
||||
let admin_key = std::env::var("LABRINTH_ADMIN_KEY").expect(
|
||||
"No admin key provided, this should have been caught by check_env_vars",
|
||||
@@ -12,16 +10,3 @@ pub fn admin_key_guard(ctx: &GuardContext) -> bool {
|
||||
.get(ADMIN_KEY_HEADER)
|
||||
.is_some_and(|it| it.as_bytes() == admin_key.as_bytes())
|
||||
}
|
||||
|
||||
pub fn medal_key_guard(ctx: &GuardContext) -> bool {
|
||||
let maybe_medal_key = dotenvy::var("LABRINTH_MEDAL_KEY").ok();
|
||||
|
||||
match maybe_medal_key {
|
||||
None => false,
|
||||
Some(medal_key) => ctx
|
||||
.head()
|
||||
.headers()
|
||||
.get(MEDAL_KEY_HEADER)
|
||||
.is_some_and(|it| it.as_bytes() == medal_key.as_bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod actix;
|
||||
pub mod archon;
|
||||
pub mod bitflag;
|
||||
pub mod captcha;
|
||||
pub mod cors;
|
||||
|
||||
@@ -27,7 +27,6 @@ hashlink.workspace = true
|
||||
png.workspace = true
|
||||
bytemuck.workspace = true
|
||||
rgb.workspace = true
|
||||
phf.workspace = true
|
||||
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
daedalus.workspace = true
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user