Compare commits

...

52 Commits

Author SHA1 Message Date
Josiah Glosson
41cd6feff2 Misc Cargo.lock updates 2025-08-07 22:19:05 -05:00
Josiah Glosson
2443a96d1e Update zip 4.2.0 -> 4.3.0 2025-08-07 22:07:05 -05:00
Josiah Glosson
3962f338a5 Update tracing-actix-web 0.7.18 -> 0.7.19 2025-08-07 22:03:05 -05:00
Josiah Glosson
67e48331a1 Update tokio 1.45.1 -> 1.47.1 and tokio-util 0.7.15 -> 0.7.16 2025-08-07 21:59:41 -05:00
Josiah Glosson
a9bfba9d9e Fix build by updating mappings 2025-08-07 21:53:27 -05:00
Josiah Glosson
26dd65d7a3 Update tauri suite 2025-08-07 21:51:19 -05:00
Josiah Glosson
b1093af893 Update sysinfo 0.35.2 -> 0.36.1 2025-08-07 21:43:55 -05:00
Josiah Glosson
b19ad9ccad Update spdx 0.10.8 -> 0.10.9 2025-08-07 21:40:15 -05:00
Josiah Glosson
4aae445e4a Update serde_with 3.13.0 -> 3.14.0 2025-08-07 21:37:04 -05:00
Josiah Glosson
58f1e2c585 Update serde_json 1.0.140 -> 1.0.142 2025-08-07 21:33:49 -05:00
Josiah Glosson
89901de4bd Update sentry 0.41.0 -> 0.42.0 and sentry-actix 0.41.0 -> 0.42.0 2025-08-07 21:29:18 -05:00
Josiah Glosson
2e18d5023e Update rgb 0.8.50 -> 0.8.52 2025-08-07 21:23:35 -05:00
Josiah Glosson
d501f1fec6 Cargo fmt in theseus 2025-08-07 21:18:52 -05:00
Josiah Glosson
2217da078a Update reqwest 0.12.20 -> 0.12.22 2025-08-07 21:18:18 -05:00
Josiah Glosson
fadbf80093 Fix theseus lint 2025-08-07 21:13:27 -05:00
Josiah Glosson
9cb1ad8024 Update quick-xml 0.37.5 -> 0.38.1 2025-08-07 20:56:28 -05:00
Josiah Glosson
14b27552db Update notify 8.0.0 -> 8.2.0 and notify-debouncer-mini 0.6.0 -> 0.7.0 2025-08-07 20:43:55 -05:00
Josiah Glosson
1c940a0d25 Update meilisearch-sdk 0.28.0 -> 0.29.1 2025-08-07 20:39:22 -05:00
Josiah Glosson
1a7b2f1806 Update lettre 0.11.17 -> 0.11.18 2025-08-07 20:31:17 -05:00
Josiah Glosson
39aea6545d Update jemalloc_pprof 0.7.0 -> 0.8.1 2025-08-07 20:27:40 -05:00
Josiah Glosson
86c408c700 Update indicatif 0.17.11 -> 0.18.0 2025-08-07 20:22:53 -05:00
Josiah Glosson
211cd05750 Update indexmap 2.9.0 -> 2.10.0 2025-08-07 16:51:25 -05:00
Josiah Glosson
b7f0ec3199 Update hyper-util 0.1.14 -> 0.1.16 2025-08-07 16:44:42 -05:00
Josiah Glosson
f1825fb9fa Update enumset 1.1.6 -> 1.1.7 2025-08-07 16:38:08 -05:00
Josiah Glosson
719aba383b Update deadpool-redis 0.21.1 -> 0.22.0 and redis 0.31.0 -> 0.32.4 2025-08-07 16:26:24 -05:00
Josiah Glosson
dc33b4b05c Update clap 4.5.40 -> 4.5.43 2025-08-07 12:31:44 -05:00
Josiah Glosson
97a6e94d32 Update bytemuck 1.23.0 -> 1.23.1 2025-08-07 12:25:53 -05:00
Josiah Glosson
37a10c76ab Update async-tungstenite 0.29.1 -> 0.30.0 2025-08-07 12:21:16 -05:00
Josiah Glosson
3ebdac4df9 Update async-compression 0.4.25 -> 0.4.27 2025-08-07 12:11:38 -05:00
Josiah Glosson
8d16834e39 Update Rust version 2025-08-07 11:59:00 -05:00
Alejandro González
d22c9e24f4 tweak(frontend): improve Nuxt build state generation logging and caching (#4133) 2025-08-06 22:05:33 +00:00
fishstiz
e31197f649 feat(app): pass selected version to incompatibility warning modal (#4115)
Co-authored-by: IMB11 <hendersoncal117@gmail.com>
2025-08-05 11:10:02 +00:00
Emma Alexia
0dee21814d Change "Billing" link on dashboard for admins (#3951)
* Change "Billing" link on dashboard for admins

Requires an archon change before merging

* change order

* steal changes from prospector's old PR

supersedes #3234

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

* lint?

---------

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

* Implement direct server joining for versions before 1.6.2

* Ignore methods with a $ in them

* Run intl:extract

* Improve code of MinecraftTransformer

* Support showing last played time for profiles before 1.7

* Reorganize QuickPlayVersion a bit to prepare for singleplayer

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

* Optimize agent some and fix error on NeoForge

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

* Invert the default hasServerQuickPlaySupport return value

* Remove Play Anyway button

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

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

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

* Only show slug stuff when needed.

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

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

* Update versions.ts

remove unnecessary import

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

* Tweak summary formatting msg

* Update license messages to use flink

* reorder link text to match the settings page

* add Description clarity button

---------

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

* fix: lint issues

* fix: issues

* fix: report layout

* fix: lint

* fix: impl quick replies

* fix: remove test qr

* feat: individual report page + use new backend

* feat: memoize filtering

* feat: apply optimizations to moderation queue

* fix: lint issues

* feat: impl quick reply functionality

* fix: top level await

* fix: dep issue

* fix: dep issue x2

* fix: dep issue

* feat: intl extract

* fix: dev-187

* fix: dev-186 & review project btn

* fix: dev-176

* remove redundant moderation button from user dropdown

* correct a msg and add admin to read filter

---------

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

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

* Refactor old_protocol_versions into protocol_version

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

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

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

* show optimization button when present in additional categories

* add more formatted link shortcuts

* Add info text to env info stage

* Only show gallery relevancy button when relevant.

* add unsupported project type message to versions stage

* Fix misuse of slug message.

* Update unsupported_project.md

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

Follow up to #3408 and #3864

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

Allows cancelling a failed subscription and forcing another charge attempt

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

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

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

3
.idea/code.iml generated
View File

@@ -10,11 +10,10 @@
<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>

1518
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,31 +25,31 @@ actix-ws = "0.3.0"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async_zip = "0.0.17"
async-compression = { version = "0.4.25", default-features = false }
async-compression = { version = "0.4.27", default-features = false }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
] }
async-trait = "0.1.88"
async-tungstenite = { version = "0.29.1", default-features = false, features = [
async-tungstenite = { version = "0.30.0", default-features = false, features = [
"futures-03-sink",
] }
async-walkdir = "2.1.0"
base64 = "0.22.1"
bitflags = "2.9.1"
bytemuck = "1.23.0"
bytemuck = "1.23.1"
bytes = "1.10.1"
censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.41"
clap = "4.5.40"
clap = "4.5.43"
clickhouse = "0.13.3"
color-thief = "0.2.2"
console-subscriber = "0.4.1"
daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0"
data-url = "0.3.1"
deadpool-redis = "0.21.1"
deadpool-redis = "0.22.0"
dirs = "6.0.0"
discord-rich-presence = "0.2.5"
dotenv-build = "0.1.1"
@@ -57,7 +57,7 @@ dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
encoding_rs = "0.8.35"
enumset = "1.1.6"
enumset = "1.1.7"
flate2 = "1.1.2"
fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false }
@@ -74,15 +74,15 @@ hyper-rustls = { version = "0.27.7", default-features = false, features = [
"ring",
"tls12",
] }
hyper-util = "0.1.14"
hyper-util = "0.1.16"
iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
indexmap = "2.9.0"
indicatif = "0.17.11"
indexmap = "2.10.0"
indicatif = "0.18.0"
itertools = "0.14.0"
jemalloc_pprof = "0.7.0"
jemalloc_pprof = "0.8.1"
json-patch = { version = "4.0.0", default-features = false }
lettre = { version = "0.11.17", default-features = false, features = [
lettre = { version = "0.11.18", default-features = false, features = [
"builder",
"hostname",
"pool",
@@ -92,23 +92,24 @@ lettre = { version = "0.11.17", default-features = false, features = [
"smtp-transport",
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.28.0", default-features = false }
meilisearch-sdk = { version = "0.29.1", default-features = false }
murmur2 = "0.1.0"
native-dialog = "0.9.0"
notify = { version = "8.0.0", default-features = false }
notify-debouncer-mini = { version = "0.6.0", default-features = false }
notify = { version = "8.2.0", default-features = false }
notify-debouncer-mini = { version = "0.7.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"
quick-xml = "0.37.5"
quick-xml = "0.38.1"
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
redis = "0.32.4"
regex = "1.11.1"
reqwest = { version = "0.12.20", default-features = false }
rgb = "0.8.50"
reqwest = { version = "0.12.22", default-features = false }
rgb = "0.8.52"
rust_decimal = { version = "1.37.2", features = [
"serde-with-float",
"serde-with-str",
@@ -120,7 +121,7 @@ rust-s3 = { version = "0.35.1", default-features = false, features = [
"tokio-rustls-tls",
] }
rusty-money = "0.4.1"
sentry = { version = "0.41.0", default-features = false, features = [
sentry = { version = "0.42.0", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
@@ -128,45 +129,45 @@ sentry = { version = "0.41.0", default-features = false, features = [
"reqwest",
"rustls",
] }
sentry-actix = "0.41.0"
sentry-actix = "0.42.0"
serde = "1.0.219"
serde_bytes = "0.11.17"
serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.140"
serde_with = "3.13.0"
serde_json = "1.0.142"
serde_with = "3.14.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
spdx = "0.10.8"
spdx = "0.10.9"
sqlx = { version = "0.8.6", default-features = false }
sysinfo = { version = "0.35.2", default-features = false }
sysinfo = { version = "0.36.1", default-features = false }
tar = "0.4.44"
tauri = "2.6.1"
tauri-build = "2.3.0"
tauri-plugin-deep-link = "2.4.0"
tauri-plugin-dialog = "2.3.0"
tauri-plugin-http = "2.5.0"
tauri = "2.7.0"
tauri-build = "2.3.1"
tauri-plugin-deep-link = "2.4.1"
tauri-plugin-dialog = "2.3.2"
tauri-plugin-http = "2.5.1"
tauri-plugin-opener = "2.4.0"
tauri-plugin-os = "2.3.0"
tauri-plugin-single-instance = "2.3.0"
tauri-plugin-single-instance = "2.3.2"
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
"rustls-tls",
"zip",
] }
tauri-plugin-window-state = "2.3.0"
tauri-plugin-window-state = "2.4.0"
tempfile = "3.20.0"
theseus = { path = "packages/app-lib" }
thiserror = "2.0.12"
tikv-jemalloc-ctl = "0.6.0"
tikv-jemallocator = "0.6.0"
tokio = "1.45.1"
tokio = "1.47.1"
tokio-stream = "0.1.17"
tokio-util = "0.7.15"
tokio-util = "0.7.16"
totp-rs = "5.7.0"
tracing = "0.1.41"
tracing-actix-web = "0.7.18"
tracing-actix-web = "0.7.19"
tracing-error = "0.2.1"
tracing-subscriber = "0.3.19"
url = "2.5.4"
@@ -178,7 +179,7 @@ whoami = "1.6.0"
winreg = "0.55.0"
woothee = "0.13.0"
yaserde = "0.12.0"
zip = { version = "4.2.0", default-features = false, features = [
zip = { version = "4.3.0", default-features = false, features = [
"bzip2",
"deflate",
"deflate64",
@@ -225,7 +226,7 @@ wildcard_dependencies = "warn"
warnings = "deny"
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "21db186" }
wry = { git = "https://github.com/modrinth/wry", rev = "f2ce0b0" }
# Optimize for speed and reduce size on release builds
[profile.release]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,7 @@ export type ServerStatus = {
version?: {
name: string
protocol: number
legacy: boolean
}
favicon?: string
enforces_secure_chat: boolean
@@ -70,11 +71,17 @@ 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[],
@@ -156,13 +163,13 @@ export async function remove_server_from_profile(path: string, index: number): P
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
}
export async function get_profile_protocol_version(path: string): Promise<number | null> {
export async function get_profile_protocol_version(path: string): Promise<ProtocolVersion | null> {
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
}
export async function get_server_status(
address: string,
protocolVersion: number | null = null,
protocolVersion: ProtocolVersion | null = null,
): Promise<ServerStatus> {
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
}
@@ -206,30 +213,39 @@ export function isServerWorld(world: World): world is ServerWorld {
export async function refreshServerData(
serverData: ServerData,
protocolVersion: number | null,
protocolVersion: ProtocolVersion | 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 async function refreshServers(
export function refreshServers(
worlds: World[],
serverData: Record<string, ServerData>,
protocolVersion: number | null,
protocolVersion: ProtocolVersion | null,
) {
const servers = worlds.filter(isServerWorld)
servers.forEach((server) => {
@@ -243,10 +259,8 @@ export async function refreshServers(
})
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
Promise.all(
Object.keys(serverData).map((address) =>
refreshServerData(serverData[address], protocolVersion, address),
),
Object.keys(serverData).forEach((address) =>
refreshServerData(serverData[address], protocolVersion, address),
)
}
@@ -297,15 +311,24 @@ export async function refreshWorlds(instancePath: string): Promise<World[]> {
return worlds ?? []
}
const FIRST_QUICK_PLAY_VERSION = '23w14a'
export function hasServerQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) {
return true
}
export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
const targetIndex = gameVersions.findIndex((v) => v.version === 'a1.0.5_01')
return versionIndex === -1 || targetIndex === -1 || versionIndex <= targetIndex
}
export function hasWorldQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) {
return false
}
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION)
const targetIndex = gameVersions.findIndex((v) => v.version === '23w14a')
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
}

View File

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

View File

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

View File

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

View File

@@ -197,15 +197,13 @@ pub async fn open_link<R: Runtime>(
if url::Url::parse(&path).is_ok()
&& !state.malicious_origins.contains(&origin)
&& let Some(last_click) = state.last_click
&& last_click.elapsed() < Duration::from_millis(100)
{
if let Some(last_click) = state.last_click {
if last_click.elapsed() < Duration::from_millis(100) {
let _ = app.opener().open_url(&path, None::<String>);
state.last_click = None;
let _ = app.opener().open_url(&path, None::<String>);
state.last_click = None;
return Ok(());
}
}
return Ok(());
}
tracing::info!("Malicious click: {path} origin {origin}");

View File

@@ -59,16 +59,13 @@ pub async fn login<R: Runtime>(
.url()?
.as_str()
.starts_with("https://login.live.com/oauth20_desktop.srf")
{
if let Some((_, code)) =
&& let Some((_, code)) =
window.url()?.query_pairs().find(|x| x.0 == "code")
{
window.close()?;
let val =
minecraft_auth::finish_login(&code.clone(), flow).await?;
{
window.close()?;
let val = minecraft_auth::finish_login(&code.clone(), flow).await?;
return Ok(Some(val));
}
return Ok(Some(val));
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;

View File

@@ -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)
}

View File

@@ -63,11 +63,11 @@ pub async fn should_disable_mouseover() -> bool {
// We try to match version to 12.2 or higher. If unrecognizable to pattern or lower, we default to the css with disabled mouseover for safety
if let tauri_plugin_os::Version::Semantic(major, minor, _) =
tauri_plugin_os::version()
&& major >= 12
&& minor >= 3
{
if major >= 12 && minor >= 3 {
// Mac os version is 12.3 or higher, we allow mouseover
return false;
}
// Mac os version is 12.3 or higher, we allow mouseover
return false;
}
true
} else {

View File

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

View File

@@ -233,10 +233,10 @@ fn main() {
});
#[cfg(not(target_os = "linux"))]
if let Some(window) = app.get_window("main") {
if let Err(e) = window.set_shadow(true) {
tracing::warn!("Failed to set window shadow: {e}");
}
if let Some(window) = app.get_window("main")
&& let Err(e) = window.set_shadow(true)
{
tracing::warn!("Failed to set window shadow: {e}");
}
Ok(())

View File

@@ -506,27 +506,25 @@ async fn fetch(
return Ok(lib);
}
} else if let Some(url) = &lib.url {
if !url.is_empty() {
insert_mirrored_artifact(
&lib.name,
None,
vec![
url.clone(),
"https://libraries.minecraft.net/"
.to_string(),
"https://maven.creeperhost.net/"
.to_string(),
maven_url.to_string(),
],
false,
mirror_artifacts,
)?;
} else if let Some(url) = &lib.url
&& !url.is_empty()
{
insert_mirrored_artifact(
&lib.name,
None,
vec![
url.clone(),
"https://libraries.minecraft.net/".to_string(),
"https://maven.creeperhost.net/".to_string(),
maven_url.to_string(),
],
false,
mirror_artifacts,
)?;
lib.url = Some(format_url("maven/"));
lib.url = Some(format_url("maven/"));
return Ok(lib);
}
return Ok(lib);
}
// Other libraries are generally available in the "maven" directory of the installer. If they are

View File

@@ -93,22 +93,22 @@ async fn main() -> Result<()> {
.ok()
.and_then(|x| x.parse::<bool>().ok())
.unwrap_or(false)
&& let Ok(token) = dotenvy::var("CLOUDFLARE_TOKEN")
&& let Ok(zone_id) = dotenvy::var("CLOUDFLARE_ZONE_ID")
{
if let Ok(token) = dotenvy::var("CLOUDFLARE_TOKEN") {
if let Ok(zone_id) = dotenvy::var("CLOUDFLARE_ZONE_ID") {
let cache_clears = upload_files
let cache_clears = upload_files
.into_iter()
.map(|x| format_url(&x.0))
.chain(
mirror_artifacts
.into_iter()
.map(|x| format_url(&x.0))
.chain(
mirror_artifacts
.into_iter()
.map(|x| format_url(&format!("maven/{}", x.0))),
)
.collect::<Vec<_>>();
.map(|x| format_url(&format!("maven/{}", x.0))),
)
.collect::<Vec<_>>();
// Cloudflare ratelimits cache clears to 500 files per request
for chunk in cache_clears.chunks(500) {
REQWEST_CLIENT.post(format!("https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache"))
// Cloudflare ratelimits cache clears to 500 files per request
for chunk in cache_clears.chunks(500) {
REQWEST_CLIENT.post(format!("https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache"))
.bearer_auth(&token)
.json(&serde_json::json!({
"files": chunk
@@ -128,8 +128,6 @@ async fn main() -> Result<()> {
item: "cloudflare clear cache".to_string(),
}
})?;
}
}
}
}

View File

@@ -167,20 +167,18 @@ pub async fn download_file(
let bytes = x.bytes().await;
if let Ok(bytes) = bytes {
if let Some(sha1) = sha1 {
if &*sha1_async(bytes.clone()).await? != sha1 {
if attempt <= 3 {
continue;
} else {
return Err(
crate::ErrorKind::ChecksumFailure {
hash: sha1.to_string(),
url: url.to_string(),
tries: attempt,
}
.into(),
);
if let Some(sha1) = sha1
&& &*sha1_async(bytes.clone()).await? != sha1
{
if attempt <= 3 {
continue;
} else {
return Err(crate::ErrorKind::ChecksumFailure {
hash: sha1.to_string(),
url: url.to_string(),
tries: attempt,
}
.into());
}
}

View File

@@ -143,8 +143,13 @@ 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
state.apiUrl === API_URL &&
// ...and if no errors were caught during the last generation
(state.errors ?? []).length === 0
) {
console.log(
"Tags already recently generated. Delete apps/frontend/generated/state.json to force regeneration.",
);
return;
}

View File

@@ -59,10 +59,12 @@
"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",

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { ref } 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().indexOf("MAC") >= 0;
return navigator.platform.toUpperCase().includes("MAC");
}
function show(event?: MouseEvent) {

View File

@@ -42,9 +42,9 @@
<div v-if="done">
<p>
You are done moderating this project!
<template v-if="futureProjectCount > 0">
<template v-if="moderationStore.hasItems">
There are
{{ futureProjectCount }} left.
{{ moderationStore.queueLength }} 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="actionStates[getActionId(action)]?.selected ?? false"
:model-value="isActionSelected(action)"
: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 && futureProjectCount > 0">
<button @click="goToNextProject">
<ButtonStyled v-if="!done && !generatedMessage && moderationStore.hasItems">
<button @click="skipCurrentProject">
<XIcon aria-hidden="true" />
Skip
Skip ({{ moderationStore.queueLength }} left)
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<div v-if="done">
<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
<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>
</button>
</ButtonStyled>
</div>
@@ -259,7 +259,7 @@
</button>
</ButtonStyled>
<ButtonStyled color="green">
<button @click="sendMessage('approved')">
<button @click="sendMessage(project.requested_status ?? 'approved')">
<CheckIcon aria-hidden="true" />
Approve
</button>
@@ -355,6 +355,7 @@ import {
renderHighlightedString,
type ModerationJudgements,
type ModerationModpackItem,
type ProjectStatus,
} from "@modrinth/utils";
import { computedAsync, useLocalStorage } from "@vueuse/core";
import {
@@ -370,29 +371,21 @@ 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 = withDefaults(
defineProps<{
project: Project;
futureProjectIds?: string[];
collapsed: boolean;
}>(),
{
futureProjectIds: () => [] as string[],
},
);
const props = defineProps<{
project: Project;
collapsed: boolean;
}>();
const moderationStore = useModerationStore();
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(() => {
@@ -516,7 +509,7 @@ function handleKeybinds(event: KeyboardEvent) {
isLoadingMessage: loadingMessage.value,
isModpackPermissionsStage: isModpackPermissionsStage.value,
futureProjectCount: futureProjectCount.value,
futureProjectCount: moderationStore.queueLength,
visibleActionsCount: visibleActions.value.length,
focusedActionIndex: focusedActionIndex.value,
@@ -529,13 +522,13 @@ function handleKeybinds(event: KeyboardEvent) {
tryGoNext: nextStage,
tryGoBack: previousStage,
tryGenerateMessage: generateMessage,
trySkipProject: goToNextProject,
trySkipProject: skipCurrentProject,
tryToggleCollapse: () => emit("toggleCollapsed"),
tryResetProgress: resetProgress,
tryExitModeration: () => emit("exit"),
tryApprove: () => sendMessage("approved"),
tryApprove: () => sendMessage(props.project.requested_status),
tryReject: () => sendMessage("rejected"),
tryWithhold: () => sendMessage("withheld"),
tryEditMessage: goBackToStages,
@@ -652,12 +645,17 @@ 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 {
const index = visibleActions.value.indexOf(action);
return `${currentStage.value}-${index}-${getActionId(action)}`;
// 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 visibleActions = computed(() => {
@@ -727,7 +725,8 @@ const multiSelectActions = computed(() =>
);
function getDropdownValue(action: DropdownAction) {
const actionId = getActionId(action);
const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const visibleOptions = getVisibleDropdownOptions(action);
const currentValue = actionStates.value[actionId]?.value ?? action.defaultOption ?? 0;
@@ -742,12 +741,14 @@ function getDropdownValue(action: DropdownAction) {
}
function isActionSelected(action: Action): boolean {
const actionId = getActionId(action);
const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
return actionStates.value[actionId]?.selected || false;
}
function toggleAction(action: Action) {
const actionId = getActionId(action);
const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const state = actionStates.value[actionId];
if (state) {
state.selected = !state.selected;
@@ -756,7 +757,8 @@ function toggleAction(action: Action) {
}
function selectDropdownOption(action: DropdownAction, selected: any) {
const actionId = getActionId(action);
const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const state = actionStates.value[actionId];
if (state && selected !== undefined && selected !== null) {
const optionIndex = action.options.findIndex(
@@ -772,7 +774,8 @@ function selectDropdownOption(action: DropdownAction, selected: any) {
}
function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): boolean {
const actionId = getActionId(action);
const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const selectedSet = actionStates.value[actionId]?.value as Set<number> | undefined;
const visibleOptions = getVisibleMultiSelectOptions(action);
@@ -783,7 +786,8 @@ function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): bo
}
function toggleChip(action: MultiSelectChipsAction, optionIndex: number) {
const actionId = getActionId(action);
const actionIndex = currentStageObj.value.actions.indexOf(action);
const actionId = getActionId(action, actionIndex);
const state = actionStates.value[actionId];
if (state && state.value instanceof Set) {
const visibleOptions = getVisibleMultiSelectOptions(action);
@@ -1056,7 +1060,7 @@ function nextStage() {
if (isModpackPermissionsStage.value && !modpackPermissionsComplete.value) {
addNotification({
title: "Modpack permissions stage unfinished",
message: "Please complete the modpack permissions stage before proceeding.",
text: "Please complete the modpack permissions stage before proceeding.",
type: "error",
});
@@ -1133,7 +1137,7 @@ async function generateMessage() {
console.error("Error generating message:", error);
addNotification({
title: "Error generating message",
message: "Failed to generate moderation message. Please try again.",
text: "Failed to generate moderation message. Please try again.",
type: "error",
});
} finally {
@@ -1161,6 +1165,8 @@ 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);
}
});
@@ -1202,7 +1208,8 @@ function generateModpackMessage(allFiles: {
return issues.join("\n\n");
}
async function sendMessage(status: "approved" | "rejected" | "withheld") {
const hasNextProject = ref(false);
async function sendMessage(status: ProjectStatus) {
try {
await useBaseFetch(`project/${props.project.id}`, {
method: "PATCH",
@@ -1236,55 +1243,73 @@ async function sendMessage(status: "approved" | "rejected" | "withheld") {
done.value = true;
// 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",
});
hasNextProject.value = await moderationStore.completeCurrentProject(
props.project.id,
"completed",
);
} catch (error) {
console.error("Error submitting moderation:", error);
addNotification({
title: "Error submitting moderation",
message: "Failed to submit moderation decision. Please try again.",
text: "Failed to submit moderation decision. Please try again.",
type: "error",
});
}
}
async function goToNextProject() {
const currentIds = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
async function endChecklist(status?: string) {
clearProjectLocalStorage();
if (currentIds.length === 0) {
await navigateTo("/moderation/review");
return;
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,
},
});
}
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 exitModeration() {
await navigateTo("/moderation/review");
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 = {};
}
const isLastVisibleStage = computed(() => {

View File

@@ -66,6 +66,27 @@
<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"
@@ -87,7 +108,8 @@ import { Avatar, CopyCode } from "@modrinth/ui";
const props = defineProps<Partial<Server>>();
if (props.server_id) {
if (props.server_id && props.status === "available") {
// Necessary only to get server icon
await useModrinthServers(props.server_id, ["general"]);
}
@@ -109,11 +131,6 @@ 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>

View File

@@ -2,7 +2,10 @@
<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" :key="link.label">
<div
v-for="link in navLinks.filter((x) => x.shown === undefined || x.shown)"
:key="link.label"
>
<NuxtLink
:to="link.href"
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
@@ -40,7 +43,7 @@ import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const emit = defineEmits(["reinstall"]);
defineProps<{
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
navLinks: { label: string; href: string; icon: Component; external?: boolean; shown?: boolean }[];
route: RouteLocationNormalized;
server: ModrinthServer;
backupInProgress?: BackupInProgressReason;

View File

@@ -34,6 +34,38 @@
</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" />
@@ -71,12 +103,17 @@
v-if="sortedMessages.length > 0"
class="btn btn-primary"
:disabled="!replyBody"
@click="sendReply()"
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
>
<ReplyIcon aria-hidden="true" />
Reply
</button>
<button v-else class="btn btn-primary" :disabled="!replyBody" @click="sendReply()">
<button
v-else
class="btn btn-primary"
:disabled="!replyBody"
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
>
<SendIcon aria-hidden="true" />
Send
</button>
@@ -289,6 +326,7 @@ const sortedMessages = computed(() => {
});
const modalSubmit = ref(null);
const modalReply = ref(null);
async function updateThreadLocal() {
let threadId = null;
@@ -316,6 +354,11 @@ 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 = {
@@ -398,6 +441,7 @@ async function reopenReport() {
const replyWithSubmission = ref(false);
const submissionConfirmation = ref(false);
const replyConfirmation = ref(false);
function openResubmitModal(reply) {
submissionConfirmation.value = false;
@@ -405,6 +449,11 @@ function openResubmitModal(reply) {
modalSubmit.value.show();
}
function openReplyModal(reply) {
replyConfirmation.value = false;
modalReply.value.show();
}
async function resubmit() {
if (replyWithSubmission.value) {
await sendReply("processing");

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ 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;

View File

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

View File

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

View File

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

View File

@@ -182,9 +182,6 @@
"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."
},
@@ -479,6 +476,30 @@
"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"
},

View File

@@ -689,7 +689,10 @@
},
{
id: 'moderation-checklist',
action: () => (showModerationChecklist = true),
action: () => {
moderationStore.setSingleProject(project.id);
showModerationChecklist = true;
},
color: 'orange',
hoverOnly: true,
shown:
@@ -870,19 +873,6 @@
@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>
@@ -890,9 +880,8 @@
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
class="moderation-checklist"
>
<NewModerationChecklist
<ModerationChecklist
:project="project"
:future-project-ids="futureProjectIds"
:collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
@@ -951,14 +940,7 @@ import {
useRelativeTime,
} from "@modrinth/ui";
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
import {
formatCategory,
formatProjectType,
isRejected,
isStaff,
isUnderReview,
renderString,
} from "@modrinth/utils";
import { formatCategory, formatProjectType, renderString } from "@modrinth/utils";
import dayjs from "dayjs";
import { Tooltip } from "floating-vue";
import { useLocalStorage } from "@vueuse/core";
@@ -976,11 +958,13 @@ 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 NewModerationChecklist from "~/components/ui/moderation/NewModerationChecklist.vue";
import ModerationChecklist from "~/components/ui/moderation/checklist/ModerationChecklist.vue";
import { useModerationStore } from "~/store/moderation.ts";
const data = useNuxtApp();
const route = useNativeRoute();
const config = useRuntimeConfig();
const moderationStore = useModerationStore();
const auth = await useAuth();
const user = await useUser();
@@ -1568,12 +1552,6 @@ 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) => {
@@ -1646,9 +1624,7 @@ const navLinks = computed(() => {
{
label: formatMessage(messages.moderationTab),
href: `${projectUrl}/moderation`,
shown:
!!currentMember.value &&
(isRejected(project.value) || isUnderReview(project.value) || isStaff(auth.value.user)),
shown: !!currentMember.value,
},
];
});

View File

@@ -365,8 +365,10 @@ 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();
}
}

View File

@@ -76,8 +76,15 @@
<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, contact
<a href="https://support.modrinth.com">Modrinth Support</a>.
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.
</p>
<ConversationThread
v-if="thread"

View File

@@ -58,6 +58,41 @@
</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"
@@ -201,6 +236,12 @@
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>
@@ -234,7 +275,6 @@ 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;
@@ -304,6 +344,10 @@ 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";
@@ -312,6 +356,12 @@ 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 {
@@ -327,8 +377,7 @@ async function refundCharge() {
await refreshCharges();
refundModal.value.hide();
} catch (err) {
data.$notify({
group: "main",
addNotification({
title: "Error refunding",
text: err.data?.description ?? err,
type: "error",
@@ -337,6 +386,32 @@ 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",

View File

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

View File

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

View File

@@ -1,33 +1,84 @@
<template>
<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
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>
<NuxtPage />
</div>
</template>
<script setup>
import { ModrinthIcon, ScaleIcon, ReportIcon } from "@modrinth/assets";
import NavStack from "~/components/ui/NavStack.vue";
import NavStackItem from "~/components/ui/NavStackItem.vue";
<script setup lang="ts">
import { defineMessages, useVIntl } from "@vintl/vintl";
import { Chips } from "@modrinth/ui";
import NavTabs from "@/components/ui/NavTabs.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>

View File

@@ -1,42 +1,339 @@
<template>
<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>
<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>
</section>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<div class="flex flex-col gap-2 sm:flex-row">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredProjects.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
<ButtonStyled color="orange" class="w-full sm:w-auto">
<button
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
@click="moderateAllInFilter()"
>
<ScaleIcon class="size-4 flex-shrink-0" />
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
<span class="sm:hidden">Moderate</span>
</button>
</ButtonStyled>
</div>
</div>
<div 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>
</div>
</template>
<script setup>
import { formatNumber } from "@modrinth/utils";
<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";
useHead({
title: "Staff overview - Modrinth",
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",
},
});
const { data: stats } = await useAsyncData("statistics", () => useBaseFetch("statistics"));
const { data: allProjects } = await useLazyAsyncData("moderation-projects", async () => {
const startTime = performance.now();
let currentOffset = 0;
const PROJECT_ENDPOINT_COUNT = 350;
const allProjects: ModerationProject[] = [];
const enrichmentPromises: Promise<ModerationProject[]>[] = [];
while (true) {
const projects = (await useBaseFetch(
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ internal: true },
)) as any[];
if (projects.length === 0) break;
const enrichmentPromise = enrichProjectBatch(projects);
enrichmentPromises.push(enrichmentPromise);
currentOffset += projects.length;
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
allProjects.push(...completed.flat());
}
if (projects.length < PROJECT_ENDPOINT_COUNT) break;
}
const remainingBatches = await Promise.all(enrichmentPromises);
allProjects.push(...remainingBatches.flat());
const endTime = performance.now();
const duration = endTime - startTime;
console.debug(
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
);
return allProjects;
});
const query = ref(route.query.q?.toString() || "");
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query };
if (newQuery) {
currentQuery.q = newQuery;
} else {
delete currentQuery.q;
}
router.replace({
path: route.path,
query: currentQuery,
});
},
{ immediate: false },
);
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || "";
if (query.value !== newValue) {
query.value = newValue;
}
},
);
const currentFilterType = useLocalStorage("moderation-current-filter-type", () => "All projects");
const filterTypes: readonly string[] = readonly([
"All projects",
"Modpacks",
"Mods",
"Resource Packs",
"Data Packs",
"Plugins",
"Shaders",
]);
const currentSortType = useLocalStorage("moderation-current-sort-type", () => "Oldest");
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
const currentPage = ref(1);
const itemsPerPage = 15;
const totalPages = computed(() => Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage));
const fuse = computed(() => {
if (!allProjects.value || allProjects.value.length === 0) return null;
return new Fuse(allProjects.value, {
keys: [
{
name: "project.title",
weight: 3,
},
{
name: "project.slug",
weight: 2,
},
{
name: "project.description",
weight: 2,
},
{
name: "project.project_type",
weight: 1,
},
"owner.user.username",
"org.name",
"org.slug",
],
includeScore: true,
threshold: 0.4,
});
});
const searchResults = computed(() => {
if (!query.value || !fuse.value) return null;
return fuse.value.search(query.value).map((result) => result.item);
});
const baseFiltered = computed(() => {
if (!allProjects.value) return [];
return query.value && searchResults.value ? searchResults.value : [...allProjects.value];
});
const typeFiltered = computed(() => {
if (currentFilterType.value === "All projects") return baseFiltered.value;
const filterMap: Record<string, string> = {
Modpacks: "modpack",
Mods: "mod",
"Resource Packs": "resourcepack",
"Data Packs": "datapack",
Plugins: "plugin",
Shaders: "shader",
};
const projectType = filterMap[currentFilterType.value];
if (!projectType) return baseFiltered.value;
return baseFiltered.value.filter((queueItem) =>
queueItem.project.project_types.includes(projectType),
);
});
const filteredProjects = computed(() => {
const filtered = [...typeFiltered.value];
if (currentSortType.value === "Oldest") {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime();
const dateB = new Date(b.project.queued || b.project.published || 0).getTime();
return dateA - dateB;
});
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime();
const dateB = new Date(b.project.queued || b.project.published || 0).getTime();
return dateB - dateA;
});
}
return filtered;
});
const paginatedProjects = computed(() => {
if (!filteredProjects.value) return [];
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredProjects.value.slice(start, end);
});
function goToPage(page: number) {
currentPage.value = page;
}
function moderateAllInFilter() {
moderationStore.setQueue(filteredProjects.value.map((queueItem) => queueItem.project.id));
navigateTo({
name: "type-id",
params: {
type: "project",
id: moderationStore.getCurrentProjectId(),
},
state: {
showChecklist: true,
},
});
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,12 +16,15 @@ import {
CardIcon,
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;
@@ -32,7 +35,11 @@ useHead({
title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`,
});
const navLinks = [
const ownerId = computed(() => props.server.general?.owner_id ?? "Ghost");
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value);
const isAdmin = computed(() => isUserAdmin(auth.value?.user));
const navLinks = computed(() => [
{ icon: SettingsIcon, label: "General", href: `/servers/manage/${serverId}/options` },
{ icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` },
{ icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` },
@@ -48,7 +55,15 @@ const navLinks = [
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>

View File

@@ -42,7 +42,7 @@
</label>
<ButtonStyled>
<button
:disabled="invocation === startupSettings?.original_invocation"
:disabled="invocation === originalInvocation"
class="!w-full sm:!w-auto"
@click="resetToDefault"
>
@@ -120,8 +120,9 @@ 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 = [
@@ -137,33 +138,15 @@ const jdkBuildMap = [
{ value: "graal", label: "GraalVM" },
];
const invocation = ref("");
const jdkVersion = ref("");
const jdkBuild = ref("");
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 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 originalInvocation = ref(invocation.value);
const originalJdkVersion = ref(jdkVersion.value);
const originalJdkBuild = ref(jdkBuild.value);
const hasUnsavedChanges = computed(
() =>
@@ -195,7 +178,7 @@ const displayedJavaVersions = computed(() => {
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
});
const saveStartup = async () => {
async function saveStartup() {
try {
isUpdating.value = true;
const invocationValue = invocation.value ?? "";
@@ -232,17 +215,17 @@ const saveStartup = async () => {
} finally {
isUpdating.value = false;
}
};
}
const resetStartup = () => {
function resetStartup() {
invocation.value = originalInvocation.value;
jdkVersion.value = originalJdkVersion.value;
jdkBuild.value = originalJdkBuild.value;
};
}
const resetToDefault = () => {
invocation.value = startupSettings.value?.original_invocation ?? "";
};
function resetToDefault() {
invocation.value = originalInvocation.value ?? "";
}
</script>
<style scoped>

View File

@@ -96,16 +96,7 @@
<UiServersServerListing
v-for="server in filteredData"
:key="server.server_id"
: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"
v-bind="server"
/>
<LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" />
</ul>

View File

@@ -208,15 +208,7 @@
<div class="flex flex-col gap-2">
<UiServersServerListing
v-if="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"
v-bind="subscription.serverInfo"
/>
<div v-else class="w-fit">
<p>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -315,15 +315,18 @@ 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 = !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())
{
authorized = true;
}
let mut authorized = (if hide_unlisted {
collection_data.status.is_searchable()
} else {
!collection_data.status.is_hidden()
}) && !collection_data.projects.is_empty();
if let Some(user) = &user_option
&& !authorized
&& (user.role.is_mod() || user.id == collection_data.user_id.into())
{
authorized = true;
}
Ok(authorized)
}
@@ -331,12 +334,17 @@ 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 (!collection.status.is_hidden() && !collection.projects.is_empty())
if ((if hide_unlisted {
collection.status.is_searchable()
} else {
!collection.status.is_hidden()
}) && !collection.projects.is_empty())
|| user_option.as_ref().is_some_and(|x| x.role.is_mod())
{
return_collections.push(collection.into());
@@ -347,10 +355,10 @@ pub async fn filter_visible_collections(
for collection in check_collections {
// Collections are simple- if we are the owner or a mod, we can see it
if let Some(user) = user_option {
if user.role.is_mod() || user.id == collection.user_id.into() {
return_collections.push(collection.into());
}
if let Some(user) = user_option
&& (user.role.is_mod() || user.id == collection.user_id.into())
{
return_collections.push(collection.into());
}
}

View File

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

View File

@@ -95,10 +95,10 @@ impl DBFlow {
redis: &RedisPool,
) -> Result<Option<DBFlow>, DatabaseError> {
let flow = Self::get(id, redis).await?;
if let Some(flow) = flow.as_ref() {
if predicate(flow) {
Self::remove(id, redis).await?;
}
if let Some(flow) = flow.as_ref()
&& predicate(flow)
{
Self::remove(id, redis).await?;
}
Ok(flow)
}

View File

@@ -801,24 +801,24 @@ impl VersionField {
};
if let Some(count) = countable {
if let Some(min) = loader_field.min_val {
if count < min {
return Err(format!(
"Provided value '{v}' for {field_name} is less than the minimum of {min}",
v = serde_json::to_string(&value).unwrap_or_default(),
field_name = loader_field.field,
));
}
if let Some(min) = loader_field.min_val
&& count < min
{
return Err(format!(
"Provided value '{v}' for {field_name} is less than the minimum of {min}",
v = serde_json::to_string(&value).unwrap_or_default(),
field_name = loader_field.field,
));
}
if let Some(max) = loader_field.max_val {
if count > max {
return Err(format!(
"Provided value '{v}' for {field_name} is greater than the maximum of {max}",
v = serde_json::to_string(&value).unwrap_or_default(),
field_name = loader_field.field,
));
}
if let Some(max) = loader_field.max_val
&& count > max
{
return Err(format!(
"Provided value '{v}' for {field_name} is greater than the maximum of {max}",
v = serde_json::to_string(&value).unwrap_or_default(),
field_name = loader_field.field,
));
}
}

View File

@@ -483,20 +483,20 @@ impl DBTeamMember {
.await?;
}
if let Some(accepted) = new_accepted {
if accepted {
sqlx::query!(
"
if let Some(accepted) = new_accepted
&& accepted
{
sqlx::query!(
"
UPDATE team_members
SET accepted = TRUE
WHERE (team_id = $1 AND user_id = $2)
",
id as DBTeamId,
user_id as DBUserId,
)
.execute(&mut **transaction)
.await?;
}
id as DBTeamId,
user_id as DBUserId,
)
.execute(&mut **transaction)
.await?;
}
if let Some(payouts_split) = new_payouts_split {

View File

@@ -49,6 +49,8 @@ pub struct DBUser {
pub badges: Badges,
pub allow_friend_requests: bool,
pub is_subscribed_to_newsletter: bool,
}
impl DBUser {
@@ -63,13 +65,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
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7,
$8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21
$14, $15, $16, $17, $18, $19, $20, $21, $22
)
",
self.id as DBUserId,
@@ -93,6 +95,7 @@ impl DBUser {
self.venmo_handle,
self.stripe_customer_id,
self.allow_friend_requests,
self.is_subscribed_to_newsletter,
)
.execute(&mut **transaction)
.await?;
@@ -178,7 +181,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
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
FROM users
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
",
@@ -212,6 +215,7 @@ 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));

View File

@@ -353,10 +353,10 @@ impl RedisPool {
};
for (idx, key) in fetch_ids.into_iter().enumerate() {
if let Some(locked) = results.get(idx) {
if locked.is_none() {
continue;
}
if let Some(locked) = results.get(idx)
&& locked.is_none()
{
continue;
}
if let Some((key, raw_key)) = ids.remove(&key) {

View File

@@ -334,18 +334,14 @@ impl From<Version> for LegacyVersion {
// the v2 loaders are whatever the corresponding loader fields are
let mut loaders =
data.loaders.into_iter().map(|l| l.0).collect::<Vec<_>>();
if loaders.contains(&"mrpack".to_string()) {
if let Some((_, mrpack_loaders)) = data
if loaders.contains(&"mrpack".to_string())
&& let Some((_, mrpack_loaders)) = data
.fields
.into_iter()
.find(|(key, _)| key == "mrpack_loaders")
{
if let Ok(mrpack_loaders) =
serde_json::from_value(mrpack_loaders)
{
loaders = mrpack_loaders;
}
}
&& let Ok(mrpack_loaders) = serde_json::from_value(mrpack_loaders)
{
loaders = mrpack_loaders;
}
let loaders = loaders.into_iter().map(Loader).collect::<Vec<_>>();

View File

@@ -43,35 +43,33 @@ impl LegacyResultSearchProject {
pub fn from(result_search_project: ResultSearchProject) -> Self {
let mut categories = result_search_project.categories;
categories.extend(result_search_project.loaders.clone());
if categories.contains(&"mrpack".to_string()) {
if let Some(mrpack_loaders) = result_search_project
if categories.contains(&"mrpack".to_string())
&& let Some(mrpack_loaders) = result_search_project
.project_loader_fields
.get("mrpack_loaders")
{
categories.extend(
mrpack_loaders
.iter()
.filter_map(|c| c.as_str())
.map(String::from),
);
categories.retain(|c| c != "mrpack");
}
{
categories.extend(
mrpack_loaders
.iter()
.filter_map(|c| c.as_str())
.map(String::from),
);
categories.retain(|c| c != "mrpack");
}
let mut display_categories = result_search_project.display_categories;
display_categories.extend(result_search_project.loaders);
if display_categories.contains(&"mrpack".to_string()) {
if let Some(mrpack_loaders) = result_search_project
if display_categories.contains(&"mrpack".to_string())
&& let Some(mrpack_loaders) = result_search_project
.project_loader_fields
.get("mrpack_loaders")
{
categories.extend(
mrpack_loaders
.iter()
.filter_map(|c| c.as_str())
.map(String::from),
);
display_categories.retain(|c| c != "mrpack");
}
{
categories.extend(
mrpack_loaders
.iter()
.filter_map(|c| c.as_str())
.map(String::from),
);
display_categories.retain(|c| c != "mrpack");
}
// Sort then remove duplicates

View File

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

View File

@@ -166,10 +166,10 @@ impl From<ProjectQueryResult> for Project {
Ok(spdx_expr) => {
let mut vec: Vec<&str> = Vec::new();
for node in spdx_expr.iter() {
if let spdx::expression::ExprNode::Req(req) = node {
if let Some(id) = req.req.license.id() {
vec.push(id.full_name);
}
if let spdx::expression::ExprNode::Req(req) = node
&& let Some(id) = req.req.license.id()
{
vec.push(id.full_name);
}
}
// spdx crate returns AND/OR operations in postfix order

View File

@@ -51,16 +51,16 @@ impl ProjectPermissions {
return Some(ProjectPermissions::all());
}
if let Some(member) = project_team_member {
if member.accepted {
return Some(member.permissions);
}
if let Some(member) = project_team_member
&& member.accepted
{
return Some(member.permissions);
}
if let Some(member) = organization_team_member {
if member.accepted {
return Some(member.permissions);
}
if let Some(member) = organization_team_member
&& member.accepted
{
return Some(member.permissions);
}
if role.is_mod() {
@@ -107,10 +107,10 @@ impl OrganizationPermissions {
return Some(OrganizationPermissions::all());
}
if let Some(member) = team_member {
if member.accepted {
return member.organization_permissions;
}
if let Some(member) = team_member
&& member.accepted
{
return member.organization_permissions;
}
if role.is_mod() {
return Some(

View File

@@ -45,17 +45,15 @@ impl MaxMindIndexer {
if let Ok(entries) = archive.entries() {
for mut file in entries.flatten() {
if let Ok(path) = file.header().path() {
if path.extension().and_then(|x| x.to_str()) == Some("mmdb")
{
let mut buf = Vec::new();
file.read_to_end(&mut buf).unwrap();
if let Ok(path) = file.header().path()
&& path.extension().and_then(|x| x.to_str()) == Some("mmdb")
{
let mut buf = Vec::new();
file.read_to_end(&mut buf).unwrap();
let reader =
maxminddb::Reader::from_source(buf).unwrap();
let reader = maxminddb::Reader::from_source(buf).unwrap();
return Ok(Some(reader));
}
return Ok(Some(reader));
}
}
}

View File

@@ -371,8 +371,8 @@ impl AutomatedModerationQueue {
for file in
files.iter().filter(|x| x.version_id == version.id.into())
{
if let Some(hash) = file.hashes.get("sha1") {
if let Some((index, (sha1, _, file_name, _))) = hashes
if let Some(hash) = file.hashes.get("sha1")
&& let Some((index, (sha1, _, file_name, _))) = hashes
.iter()
.enumerate()
.find(|(_, (value, _, _, _))| value == hash)
@@ -382,7 +382,6 @@ impl AutomatedModerationQueue {
hashes.remove(index);
}
}
}
}
@@ -420,12 +419,11 @@ impl AutomatedModerationQueue {
.await?;
for row in rows {
if let Some(sha1) = row.sha1 {
if let Some((index, (sha1, _, file_name, _))) = hashes.iter().enumerate().find(|(_, (value, _, _, _))| value == &sha1) {
if let Some(sha1) = row.sha1
&& let Some((index, (sha1, _, file_name, _))) = hashes.iter().enumerate().find(|(_, (value, _, _, _))| value == &sha1) {
final_hashes.insert(sha1.clone(), IdentifiedFile { file_name: file_name.clone(), status: ApprovalType::from_string(&row.status).unwrap_or(ApprovalType::Unidentified) });
hashes.remove(index);
}
}
}
if hashes.is_empty() {
@@ -499,8 +497,8 @@ impl AutomatedModerationQueue {
let mut insert_ids = Vec::new();
for row in rows {
if let Some((curse_index, (hash, _flame_id))) = flame_files.iter().enumerate().find(|(_, x)| Some(x.1 as i32) == row.flame_project_id) {
if let Some((index, (sha1, _, file_name, _))) = hashes.iter().enumerate().find(|(_, (value, _, _, _))| value == hash) {
if let Some((curse_index, (hash, _flame_id))) = flame_files.iter().enumerate().find(|(_, x)| Some(x.1 as i32) == row.flame_project_id)
&& let Some((index, (sha1, _, file_name, _))) = hashes.iter().enumerate().find(|(_, (value, _, _, _))| value == hash) {
final_hashes.insert(sha1.clone(), IdentifiedFile {
file_name: file_name.clone(),
status: ApprovalType::from_string(&row.status).unwrap_or(ApprovalType::Unidentified),
@@ -512,7 +510,6 @@ impl AutomatedModerationQueue {
hashes.remove(index);
flame_files.remove(curse_index);
}
}
}
if !insert_ids.is_empty() && !insert_hashes.is_empty() {
@@ -581,8 +578,8 @@ impl AutomatedModerationQueue {
for (sha1, _pack_file, file_name, _mumur2) in hashes {
let flame_file = flame_files.iter().find(|x| x.0 == sha1);
if let Some((_, flame_project_id)) = flame_file {
if let Some(project) = flame_projects.iter().find(|x| &x.id == flame_project_id) {
if let Some((_, flame_project_id)) = flame_file
&& let Some(project) = flame_projects.iter().find(|x| &x.id == flame_project_id) {
missing_metadata.flame_files.insert(sha1, MissingMetadataFlame {
title: project.name.clone(),
file_name,
@@ -592,7 +589,6 @@ impl AutomatedModerationQueue {
continue;
}
}
missing_metadata.unknown_files.insert(sha1, file_name);
}

View File

@@ -257,31 +257,30 @@ impl PayoutsQueue {
)
})?;
if !status.is_success() {
if let Some(obj) = value.as_object() {
if let Some(array) = obj.get("errors") {
#[derive(Deserialize)]
struct TremendousError {
message: String,
}
let err = serde_json::from_value::<TremendousError>(
array.clone(),
)
.map_err(|_| {
ApiError::Payments(
"could not retrieve Tremendous error json body"
.to_string(),
)
})?;
return Err(ApiError::Payments(err.message));
if !status.is_success()
&& let Some(obj) = value.as_object()
{
if let Some(array) = obj.get("errors") {
#[derive(Deserialize)]
struct TremendousError {
message: String,
}
return Err(ApiError::Payments(
"could not retrieve Tremendous error body".to_string(),
));
let err =
serde_json::from_value::<TremendousError>(array.clone())
.map_err(|_| {
ApiError::Payments(
"could not retrieve Tremendous error json body"
.to_string(),
)
})?;
return Err(ApiError::Payments(err.message));
}
return Err(ApiError::Payments(
"could not retrieve Tremendous error body".to_string(),
));
}
Ok(serde_json::from_value(value)?)
@@ -449,10 +448,10 @@ impl PayoutsQueue {
};
// we do not support interval gift cards with non US based currencies since we cannot do currency conversions properly
if let PayoutInterval::Fixed { .. } = method.interval {
if !product.currency_codes.contains(&"USD".to_string()) {
continue;
}
if let PayoutInterval::Fixed { .. } = method.interval
&& !product.currency_codes.contains(&"USD".to_string())
{
continue;
}
methods.push(method);

View File

@@ -276,23 +276,27 @@ pub async fn refund_charge(
subscription_interval: charge.subscription_interval,
payment_platform: charge.payment_platform,
payment_platform_id: id,
parent_charge_id: Some(charge.id),
parent_charge_id: if refund_amount != 0 {
Some(charge.id)
} else {
None
},
net,
}
.upsert(&mut transaction)
.await?;
if body.0.unprovision.unwrap_or(false) {
if let Some(subscription_id) = charge.subscription_id {
let open_charge =
DBCharge::get_open_subscription(subscription_id, &**pool)
.await?;
if let Some(mut open_charge) = open_charge {
open_charge.status = ChargeStatus::Cancelled;
open_charge.due = Utc::now();
if body.0.unprovision.unwrap_or(false)
&& let Some(subscription_id) = charge.subscription_id
{
let open_charge =
DBCharge::get_open_subscription(subscription_id, &**pool)
.await?;
if let Some(mut open_charge) = open_charge {
open_charge.status = ChargeStatus::Cancelled;
open_charge.due = Utc::now();
open_charge.upsert(&mut transaction).await?;
}
open_charge.upsert(&mut transaction).await?;
}
}
@@ -388,17 +392,16 @@ pub async fn edit_subscription(
}
}
if let Some(interval) = &edit_subscription.interval {
if let Price::Recurring { intervals } = &current_price.prices {
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(interval) = &edit_subscription.interval
&& let Price::Recurring { intervals } = &current_price.prices
{
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(),
));
}
}
@@ -1221,38 +1224,36 @@ pub async fn initiate_payment(
}
};
if let Price::Recurring { .. } = price_item.prices {
if product.unitary {
let user_subscriptions =
if let Price::Recurring { .. } = price_item.prices
&& product.unitary
{
let user_subscriptions =
user_subscription_item::DBUserSubscription::get_all_user(
user.id.into(),
&**pool,
)
.await?;
let user_products =
product_item::DBProductPrice::get_many(
&user_subscriptions
.iter()
.filter(|x| {
x.status
== SubscriptionStatus::Provisioned
})
.map(|x| x.price_id)
.collect::<Vec<_>>(),
&**pool,
)
.await?;
let user_products = product_item::DBProductPrice::get_many(
&user_subscriptions
.iter()
.filter(|x| {
x.status == SubscriptionStatus::Provisioned
})
.map(|x| x.price_id)
.collect::<Vec<_>>(),
&**pool,
)
.await?;
if user_products
.into_iter()
.any(|x| x.product_id == product.id)
{
return Err(ApiError::InvalidInput(
"You are already subscribed to this product!"
.to_string(),
));
}
if user_products
.into_iter()
.any(|x| x.product_id == product.id)
{
return Err(ApiError::InvalidInput(
"You are already subscribed to this product!"
.to_string(),
));
}
}
@@ -2000,38 +2001,36 @@ pub async fn stripe_webhook(
EventType::PaymentMethodAttached => {
if let EventObject::PaymentMethod(payment_method) =
event.data.object
{
if let Some(customer_id) =
&& let Some(customer_id) =
payment_method.customer.map(|x| x.id())
{
let customer = stripe::Customer::retrieve(
&stripe_client,
&customer_id,
&[],
)
.await?;
if customer
.invoice_settings
.is_none_or(|x| x.default_payment_method.is_none())
{
let customer = stripe::Customer::retrieve(
stripe::Customer::update(
&stripe_client,
&customer_id,
&[],
UpdateCustomer {
invoice_settings: Some(
CustomerInvoiceSettings {
default_payment_method: Some(
payment_method.id.to_string(),
),
..Default::default()
},
),
..Default::default()
},
)
.await?;
if customer
.invoice_settings
.is_none_or(|x| x.default_payment_method.is_none())
{
stripe::Customer::update(
&stripe_client,
&customer_id,
UpdateCustomer {
invoice_settings: Some(
CustomerInvoiceSettings {
default_payment_method: Some(
payment_method.id.to_string(),
),
..Default::default()
},
),
..Default::default()
},
)
.await?;
}
}
}
}

View File

@@ -1,5 +1,7 @@
use crate::auth::email::send_email;
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::auth::validate::{
get_full_user_from_headers, get_user_record_from_bearer_token,
};
use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers};
use crate::database::models::DBUser;
use crate::database::models::flow_item::DBFlow;
@@ -77,13 +79,12 @@ impl TempUser {
file_host: &Arc<dyn FileHost + Send + Sync>,
redis: &RedisPool,
) -> Result<crate::database::models::DBUserId, AuthenticationError> {
if let Some(email) = &self.email {
if crate::database::models::DBUser::get_by_email(email, client)
if let Some(email) = &self.email
&& crate::database::models::DBUser::get_by_email(email, client)
.await?
.is_some()
{
return Err(AuthenticationError::DuplicateUser);
}
{
return Err(AuthenticationError::DuplicateUser);
}
let user_id =
@@ -232,6 +233,7 @@ impl TempUser {
role: Role::Developer.to_string(),
badges: Badges::default(),
allow_friend_requests: true,
is_subscribed_to_newsletter: false,
}
.insert(transaction)
.await?;
@@ -1266,19 +1268,19 @@ pub async fn delete_auth_provider(
.update_user_id(user.id.into(), None, &mut transaction)
.await?;
if delete_provider.provider != AuthProvider::PayPal {
if let Some(email) = user.email {
send_email(
email,
"Authentication method removed",
&format!(
"When logging into Modrinth, you can no longer log in using the {} authentication provider.",
delete_provider.provider.as_str()
),
"If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).",
None,
)?;
}
if delete_provider.provider != AuthProvider::PayPal
&& let Some(email) = user.email
{
send_email(
email,
"Authentication method removed",
&format!(
"When logging into Modrinth, you can no longer log in using the {} authentication provider.",
delete_provider.provider.as_str()
),
"If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).",
None,
)?;
}
transaction.commit().await?;
@@ -1291,37 +1293,6 @@ 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> {
@@ -1456,6 +1427,9 @@ 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?;
@@ -1476,10 +1450,6 @@ 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))
@@ -2420,15 +2390,24 @@ pub async fn subscribe_newsletter(
.await?
.1;
if let Some(email) = user.email {
sign_up_sendy(&email).await?;
sqlx::query!(
"
UPDATE users
SET is_subscribed_to_newsletter = TRUE
WHERE id = $1
",
user.id.0 as i64,
)
.execute(&**pool)
.await?;
Ok(HttpResponse::NoContent().finish())
} else {
Err(ApiError::InvalidInput(
"User does not have an email.".to_string(),
))
}
crate::database::models::DBUser::clear_caches(
&[(user.id.into(), None)],
&redis,
)
.await?;
Ok(HttpResponse::NoContent().finish())
}
#[get("email/subscribe")]
@@ -2438,7 +2417,7 @@ pub async fn get_newsletter_subscription_status(
redis: Data<RedisPool>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
let user = get_full_user_from_headers(
&req,
&**pool,
&redis,
@@ -2448,16 +2427,16 @@ pub async fn get_newsletter_subscription_status(
.await?
.1;
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
})))
}
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
})))
}
fn send_email_verify(

View File

@@ -18,12 +18,14 @@ pub fn config(cfg: &mut web::ServiceConfig) {
}
#[derive(Deserialize)]
pub struct ResultCount {
pub struct ProjectsRequestOptions {
#[serde(default = "default_count")]
pub count: i16,
pub count: u16,
#[serde(default)]
pub offset: u32,
}
fn default_count() -> i16 {
fn default_count() -> u16 {
100
}
@@ -31,7 +33,7 @@ pub async fn get_projects(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
count: web::Query<ResultCount>,
request_opts: web::Query<ProjectsRequestOptions>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
check_is_moderator_from_headers(
@@ -50,10 +52,12 @@ pub async fn get_projects(
SELECT id FROM mods
WHERE status = $1
ORDER BY queued ASC
LIMIT $2;
OFFSET $3
LIMIT $2
",
ProjectStatus::Processing.as_str(),
count.count as i64
request_opts.count as i64,
request_opts.offset as i64
)
.fetch(&**pool)
.map_ok(|m| database::models::DBProjectId(m.id))
@@ -185,17 +189,16 @@ pub async fn get_project_meta(
.iter()
.find(|x| Some(x.1.id as i32) == row.flame_project_id)
.map(|x| x.0.clone())
&& let Some(val) = merged.flame_files.remove(&sha1)
{
if let Some(val) = merged.flame_files.remove(&sha1) {
merged.identified.insert(
sha1,
IdentifiedFile {
file_name: val.file_name.clone(),
status: ApprovalType::from_string(&row.status)
.unwrap_or(ApprovalType::Unidentified),
},
);
}
merged.identified.insert(
sha1,
IdentifiedFile {
file_name: val.file_name.clone(),
status: ApprovalType::from_string(&row.status)
.unwrap_or(ApprovalType::Unidentified),
},
);
}
}

View File

@@ -185,69 +185,69 @@ pub async fn edit_pat(
)
.await?;
if let Some(pat) = pat {
if pat.user_id == user.id.into() {
let mut transaction = pool.begin().await?;
if let Some(pat) = pat
&& pat.user_id == user.id.into()
{
let mut transaction = pool.begin().await?;
if let Some(scopes) = &info.scopes {
if scopes.is_restricted() {
return Err(ApiError::InvalidInput(
"Invalid scopes requested!".to_string(),
));
}
if let Some(scopes) = &info.scopes {
if scopes.is_restricted() {
return Err(ApiError::InvalidInput(
"Invalid scopes requested!".to_string(),
));
}
sqlx::query!(
"
sqlx::query!(
"
UPDATE pats
SET scopes = $1
WHERE id = $2
",
scopes.bits() as i64,
pat.id.0
)
.execute(&mut *transaction)
.await?;
}
if let Some(name) = &info.name {
sqlx::query!(
"
scopes.bits() as i64,
pat.id.0
)
.execute(&mut *transaction)
.await?;
}
if let Some(name) = &info.name {
sqlx::query!(
"
UPDATE pats
SET name = $1
WHERE id = $2
",
name,
pat.id.0
)
.execute(&mut *transaction)
.await?;
name,
pat.id.0
)
.execute(&mut *transaction)
.await?;
}
if let Some(expires) = &info.expires {
if expires < &Utc::now() {
return Err(ApiError::InvalidInput(
"Expire date must be in the future!".to_string(),
));
}
if let Some(expires) = &info.expires {
if expires < &Utc::now() {
return Err(ApiError::InvalidInput(
"Expire date must be in the future!".to_string(),
));
}
sqlx::query!(
"
sqlx::query!(
"
UPDATE pats
SET expires = $1
WHERE id = $2
",
expires,
pat.id.0
)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
database::models::pat_item::DBPersonalAccessToken::clear_cache(
vec![(Some(pat.id), Some(pat.access_token), Some(pat.user_id))],
&redis,
expires,
pat.id.0
)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
database::models::pat_item::DBPersonalAccessToken::clear_cache(
vec![(Some(pat.id), Some(pat.access_token), Some(pat.user_id))],
&redis,
)
.await?;
}
Ok(HttpResponse::NoContent().finish())
@@ -276,21 +276,21 @@ pub async fn delete_pat(
)
.await?;
if let Some(pat) = pat {
if pat.user_id == user.id.into() {
let mut transaction = pool.begin().await?;
database::models::pat_item::DBPersonalAccessToken::remove(
pat.id,
&mut transaction,
)
.await?;
transaction.commit().await?;
database::models::pat_item::DBPersonalAccessToken::clear_cache(
vec![(Some(pat.id), Some(pat.access_token), Some(pat.user_id))],
&redis,
)
.await?;
}
if let Some(pat) = pat
&& pat.user_id == user.id.into()
{
let mut transaction = pool.begin().await?;
database::models::pat_item::DBPersonalAccessToken::remove(
pat.id,
&mut transaction,
)
.await?;
transaction.commit().await?;
database::models::pat_item::DBPersonalAccessToken::clear_cache(
vec![(Some(pat.id), Some(pat.access_token), Some(pat.user_id))],
&redis,
)
.await?;
}
Ok(HttpResponse::NoContent().finish())

View File

@@ -185,21 +185,21 @@ pub async fn delete(
let session = DBSession::get(info.into_inner().0, &**pool, &redis).await?;
if let Some(session) = session {
if session.user_id == current_user.id.into() {
let mut transaction = pool.begin().await?;
DBSession::remove(session.id, &mut transaction).await?;
transaction.commit().await?;
DBSession::clear_cache(
vec![(
Some(session.id),
Some(session.session),
Some(session.user_id),
)],
&redis,
)
.await?;
}
if let Some(session) = session
&& session.user_id == current_user.id.into()
{
let mut transaction = pool.begin().await?;
DBSession::remove(session.id, &mut transaction).await?;
transaction.commit().await?;
DBSession::clear_cache(
vec![(
Some(session.id),
Some(session.session),
Some(session.user_id),
)],
&redis,
)
.await?;
}
Ok(HttpResponse::NoContent().body(""))

View File

@@ -401,14 +401,13 @@ async fn broadcast_to_known_local_friends(
friend.user_id
};
if friend.accepted {
if let Some(socket_ids) =
if friend.accepted
&& let Some(socket_ids) =
sockets.sockets_by_user_id.get(&friend_id.into())
{
for socket_id in socket_ids.iter() {
if let Some(socket) = sockets.sockets.get(&socket_id) {
let _ = send_message(socket.value(), &message).await;
}
{
for socket_id in socket_ids.iter() {
if let Some(socket) = sockets.sockets.get(&socket_id) {
let _ = send_message(socket.value(), &message).await;
}
}
}

View File

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

View File

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

View File

@@ -387,17 +387,16 @@ pub async fn revenue_get(
.map(|x| (x.to_string(), HashMap::new()))
.collect::<HashMap<_, _>>();
for value in payouts_values {
if let Some(mod_id) = value.mod_id {
if let Some(amount) = value.amount_sum {
if let Some(interval_start) = value.interval_start {
let id_string = to_base62(mod_id as u64);
if !hm.contains_key(&id_string) {
hm.insert(id_string.clone(), HashMap::new());
}
if let Some(hm) = hm.get_mut(&id_string) {
hm.insert(interval_start.timestamp(), amount);
}
}
if let Some(mod_id) = value.mod_id
&& let Some(amount) = value.amount_sum
&& let Some(interval_start) = value.interval_start
{
let id_string = to_base62(mod_id as u64);
if !hm.contains_key(&id_string) {
hm.insert(id_string.clone(), HashMap::new());
}
if let Some(hm) = hm.get_mut(&id_string) {
hm.insert(interval_start.timestamp(), amount);
}
}
}

View File

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

View File

@@ -536,11 +536,9 @@ pub async fn create_payout(
Some(true),
)
.await
&& let Some(data) = res.items.first()
{
if let Some(data) = res.items.first() {
payout_item.platform_id =
Some(data.payout_item_id.clone());
}
payout_item.platform_id = Some(data.payout_item_id.clone());
}
}

View File

@@ -182,10 +182,10 @@ pub async fn project_get(
.map(|x| x.1)
.ok();
if let Some(data) = project_data {
if is_visible_project(&data.inner, &user_option, &pool, false).await? {
return Ok(HttpResponse::Ok().json(Project::from(data)));
}
if let Some(data) = project_data
&& is_visible_project(&data.inner, &user_option, &pool, false).await?
{
return Ok(HttpResponse::Ok().json(Project::from(data)));
}
Err(ApiError::NotFound)
}
@@ -403,34 +403,36 @@ pub async fn project_edit(
.await?;
}
if status.is_searchable() && !project_item.inner.webhook_sent {
if let Ok(webhook_url) = dotenvy::var("PUBLIC_DISCORD_WEBHOOK") {
crate::util::webhook::send_discord_webhook(
project_item.inner.id.into(),
&pool,
&redis,
webhook_url,
None,
)
.await
.ok();
if status.is_searchable()
&& !project_item.inner.webhook_sent
&& let Ok(webhook_url) = dotenvy::var("PUBLIC_DISCORD_WEBHOOK")
{
crate::util::webhook::send_discord_webhook(
project_item.inner.id.into(),
&pool,
&redis,
webhook_url,
None,
)
.await
.ok();
sqlx::query!(
"
sqlx::query!(
"
UPDATE mods
SET webhook_sent = TRUE
WHERE id = $1
",
id as db_ids::DBProjectId,
)
.execute(&mut *transaction)
.await?;
}
id as db_ids::DBProjectId,
)
.execute(&mut *transaction)
.await?;
}
if user.role.is_mod() {
if let Ok(webhook_url) = dotenvy::var("MODERATION_SLACK_WEBHOOK") {
crate::util::webhook::send_slack_webhook(
if user.role.is_mod()
&& let Ok(webhook_url) = dotenvy::var("MODERATION_SLACK_WEBHOOK")
{
crate::util::webhook::send_slack_webhook(
project_item.inner.id.into(),
&pool,
&redis,
@@ -449,7 +451,6 @@ pub async fn project_edit(
)
.await
.ok();
}
}
if team_member.is_none_or(|x| !x.accepted) {
@@ -692,45 +693,45 @@ pub async fn project_edit(
.await?;
}
if let Some(links) = &new_project.link_urls {
if !links.is_empty() {
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthentication(
if let Some(links) = &new_project.link_urls
&& !links.is_empty()
{
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
return Err(ApiError::CustomAuthentication(
"You do not have the permissions to edit the links of this project!"
.to_string(),
));
}
}
let ids_to_delete = links.keys().cloned().collect::<Vec<String>>();
// Deletes all links from hashmap- either will be deleted or be replaced
sqlx::query!(
"
let ids_to_delete = links.keys().cloned().collect::<Vec<String>>();
// Deletes all links from hashmap- either will be deleted or be replaced
sqlx::query!(
"
DELETE FROM mods_links
WHERE joining_mod_id = $1 AND joining_platform_id IN (
SELECT id FROM link_platforms WHERE name = ANY($2)
)
",
id as db_ids::DBProjectId,
&ids_to_delete
)
.execute(&mut *transaction)
.await?;
id as db_ids::DBProjectId,
&ids_to_delete
)
.execute(&mut *transaction)
.await?;
for (platform, url) in links {
if let Some(url) = url {
let platform_id =
db_models::categories::LinkPlatform::get_id(
platform,
&mut *transaction,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"Platform {} does not exist.",
platform.clone()
))
})?;
sqlx::query!(
for (platform, url) in links {
if let Some(url) = url {
let platform_id = db_models::categories::LinkPlatform::get_id(
platform,
&mut *transaction,
)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"Platform {} does not exist.",
platform.clone()
))
})?;
sqlx::query!(
"
INSERT INTO mods_links (joining_mod_id, joining_platform_id, url)
VALUES ($1, $2, $3)
@@ -741,7 +742,6 @@ pub async fn project_edit(
)
.execute(&mut *transaction)
.await?;
}
}
}
}
@@ -2430,7 +2430,7 @@ pub async fn project_get_organization(
organization,
team_members,
);
return Ok(HttpResponse::Ok().json(organization));
Ok(HttpResponse::Ok().json(organization))
} else {
Err(ApiError::NotFound)
}

View File

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

View File

@@ -767,12 +767,13 @@ pub async fn edit_team_member(
));
}
if let Some(new_permissions) = edit_member.permissions {
if !permissions.contains(new_permissions) {
return Err(ApiError::InvalidInput(
"The new permissions have permissions that you don't have".to_string(),
));
}
if let Some(new_permissions) = edit_member.permissions
&& !permissions.contains(new_permissions)
{
return Err(ApiError::InvalidInput(
"The new permissions have permissions that you don't have"
.to_string(),
));
}
if edit_member.organization_permissions.is_some() {
@@ -800,13 +801,12 @@ pub async fn edit_team_member(
}
if let Some(new_permissions) = edit_member.organization_permissions
&& !organization_permissions.contains(new_permissions)
{
if !organization_permissions.contains(new_permissions) {
return Err(ApiError::InvalidInput(
return Err(ApiError::InvalidInput(
"The new organization permissions have permissions that you don't have"
.to_string(),
));
}
}
if edit_member.permissions.is_some()
@@ -822,13 +822,13 @@ pub async fn edit_team_member(
}
}
if let Some(payouts_split) = edit_member.payouts_split {
if payouts_split < Decimal::ZERO || payouts_split > Decimal::from(5000)
{
return Err(ApiError::InvalidInput(
"Payouts split must be between 0 and 5000!".to_string(),
));
}
if let Some(payouts_split) = edit_member.payouts_split
&& (payouts_split < Decimal::ZERO
|| payouts_split > Decimal::from(5000))
{
return Err(ApiError::InvalidInput(
"Payouts split must be between 0 and 5000!".to_string(),
));
}
DBTeamMember::edit_team_member(
@@ -883,13 +883,13 @@ pub async fn transfer_ownership(
DBTeam::get_association(id.into(), &**pool).await?;
if let Some(TeamAssociationId::Project(pid)) = team_association_id {
let result = DBProject::get_id(pid, &**pool, &redis).await?;
if let Some(project_item) = result {
if project_item.inner.organization_id.is_some() {
return Err(ApiError::InvalidInput(
if let Some(project_item) = result
&& project_item.inner.organization_id.is_some()
{
return Err(ApiError::InvalidInput(
"You cannot transfer ownership of a project team that is owend by an organization"
.to_string(),
));
}
}
}

View File

@@ -289,36 +289,33 @@ pub async fn thread_get(
.await?
.1;
if let Some(mut data) = thread_data {
if is_authorized_thread(&data, &user, &pool).await? {
let authors = &mut data.members;
if let Some(mut data) = thread_data
&& is_authorized_thread(&data, &user, &pool).await?
{
let authors = &mut data.members;
authors.append(
&mut data
.messages
.iter()
.filter_map(|x| {
if x.hide_identity && !user.role.is_mod() {
None
} else {
x.author_id
}
})
.collect::<Vec<_>>(),
);
authors.append(
&mut data
.messages
.iter()
.filter_map(|x| {
if x.hide_identity && !user.role.is_mod() {
None
} else {
x.author_id
}
})
.collect::<Vec<_>>(),
);
let users: Vec<User> = database::models::DBUser::get_many_ids(
authors, &**pool, &redis,
)
.await?
.into_iter()
.map(From::from)
.collect();
let users: Vec<User> =
database::models::DBUser::get_many_ids(authors, &**pool, &redis)
.await?
.into_iter()
.map(From::from)
.collect();
return Ok(
HttpResponse::Ok().json(Thread::from(data, users, &user))
);
}
return Ok(HttpResponse::Ok().json(Thread::from(data, users, &user)));
}
Err(ApiError::NotFound)
}
@@ -454,33 +451,32 @@ pub async fn thread_send_message(
)
.await?;
if let Some(project) = project {
if project.inner.status != ProjectStatus::Processing
&& user.role.is_mod()
{
let members =
database::models::DBTeamMember::get_from_team_full(
project.inner.team_id,
&**pool,
&redis,
)
.await?;
NotificationBuilder {
body: NotificationBody::ModeratorMessage {
thread_id: thread.id.into(),
message_id: id.into(),
project_id: Some(project.inner.id.into()),
report_id: None,
},
}
.insert_many(
members.into_iter().map(|x| x.user_id).collect(),
&mut transaction,
if let Some(project) = project
&& project.inner.status != ProjectStatus::Processing
&& user.role.is_mod()
{
let members =
database::models::DBTeamMember::get_from_team_full(
project.inner.team_id,
&**pool,
&redis,
)
.await?;
NotificationBuilder {
body: NotificationBody::ModeratorMessage {
thread_id: thread.id.into(),
message_id: id.into(),
project_id: Some(project.inner.id.into()),
report_id: None,
},
}
.insert_many(
members.into_iter().map(|x| x.user_id).collect(),
&mut transaction,
&redis,
)
.await?;
}
} else if let Some(report_id) = thread.report_id {
let report = database::models::report_item::DBReport::get(

View File

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

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