Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) b8f6fa9eb3 fix: link 2026-06-04 14:17:04 +01:00
Calum H. (IMB11) ea4013949d fix: lint 2026-06-04 00:25:25 +01:00
Calum H. (IMB11) d1da60ce59 fix: lint 2026-06-04 00:24:25 +01:00
Calum H. (IMB11) 1a98120a6b fix: split up access 2026-06-04 00:23:59 +01:00
Calum H. (IMB11) 2a59a0b265 fix: notif sizing 2026-06-03 23:59:51 +01:00
Calum H. (IMB11) b93d355de4 fix: alignment of save bar 2026-06-03 23:30:10 +01:00
Calum H. (IMB11) 5426f581ab fix: lint 2026-06-03 23:12:35 +01:00
Calum H. (IMB11) b6b9a2e2f6 fix: reinvite stuff 2026-06-03 23:10:49 +01:00
Prospector 050b4c5c2f update images 2026-06-03 14:47:46 -07:00
Calum H. (IMB11) a99b6ba99a Merge remote-tracking branch 'origin/main' into cal/sharing-tab-panel 2026-06-03 22:22:02 +01:00
Calum H. (IMB11) c36055bf56 Merge branch 'cal/sharing-tab-panel' of https://github.com/modrinth/code into cal/sharing-tab-panel 2026-06-03 22:21:32 +01:00
Calum H. (IMB11) ee9674ca9d feat: use reinvite route 2026-06-03 22:21:31 +01:00
Prospector 82a30bd203 prepr 2026-06-03 14:12:05 -07:00
Prospector 968eb52254 thumbnail update one last time 2026-06-03 14:10:33 -07:00
Prospector 45050c002e blog images 2026-06-03 14:07:21 -07:00
Calum H. (IMB11) 5692cd66e4 Merge branch 'cal/sharing-tab-panel' of https://github.com/modrinth/code into cal/sharing-tab-panel 2026-06-03 21:42:14 +01:00
Calum H. (IMB11) e5196a6a06 feat: toast shit 2026-06-03 21:42:12 +01:00
Prospector 6707a70324 blog + prepr 2026-06-03 13:20:40 -07:00
Calum H. (IMB11) 56a0897f95 feat: qa 2026-06-03 21:12:18 +01:00
58ad58f958 feat: user search (#6302)
* feat: user search

* refactor: use sqlx macro

* refactor: better name for escaped query

* style: cleanup unused import

* chore: update sqlx query cache

* fix: tests

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
2026-06-03 19:38:24 +00:00
Prospector d907083d83 changelog 2026-06-03 11:38:23 -07:00
Calum H. (IMB11) d212fa799c fix: qa 2026-06-03 19:35:44 +01:00
Calum H. (IMB11) 7275b20c57 fix: qa 2026-06-03 19:35:39 +01:00
Truman GaoandGitHub 8371ff641a fix: analytics post release bugs (#6291)
* fix: previous period data was included in the table

* fix: revenue displaying stale data when viewing it from different metric and grouped by 6 hour or 1 hour

* fix: remove staletime on analytics query so switching tabs does not refersh query

* feat: add monetization alert

* fix-small: missing space in tooltip

* fix: incorrect y-axis formatting for trailing decimal 0s

* fix: switching tabs resets table series selection due to other refetches

* fix: always show month first in chart tooltip

* fix: change all time start date to be project published date

* fix: increase length on project name column

* fix: unknown download source data points not showing for download source breakdown

* fix: double unknown for loader

* fix: no data on country labeling incorrectly as "Unknown" instead of "Other"

* fix: date picker number inputs showing arrows

* fix: stat card showing enormous percentage for prev period by switching it to absolute value difference after 1000%

* fix: decimal values for playtime being rounded badly, resulting in 0.04 becoming 0.0

* fix: chips having stroke

* refactor: pnpm prepr

* fix: spacing in annoucement link

* fix: legend scroll shadow on top of event tooltip
2026-06-03 18:27:31 +00:00
Prospector b1cd16f966 update ads.txt 2026-06-03 10:23:11 -07:00
Prospector 40a06921ea changelog 2026-06-03 07:00:46 -07:00
Aaron CorreaandGitHub a7dc063e08 fix: use correct organization route parameter (#6294) 2026-06-03 13:41:02 +00:00
ThatGravyBoatandGitHub 64b61d8fd0 chore: misc moderation changes (#6296)
* feat: report filter by target, issue type, project type

* fix: reply modal showing up for staff

* chore: change minimum class source from 10 to 2
2026-06-03 13:14:17 +00:00
5e7d4cc838 fix: notifs not live (#6299)
* fix: notifs not live

* Update apps/labrinth/src/routes/internal/external_notifications.rs

Co-authored-by: Sychic <47618543+Sychic@users.noreply.github.com>
Signed-off-by: Calum H. <hendersoncal117@gmail.com>

* fix: fmt

---------

Signed-off-by: Calum H. <hendersoncal117@gmail.com>
Co-authored-by: Sychic <47618543+Sychic@users.noreply.github.com>
2026-06-03 13:00:12 +00:00
Calum H.andGitHub fcddaf8e08 Merge branch 'main' into cal/sharing-tab-panel 2026-06-03 12:03:35 +01:00
Calum H. (IMB11) 03935b2469 fix: notification sync not happening immediately 2026-06-03 12:03:11 +01:00
Calum H. (IMB11) d9d58eef3c feat: qa 2026-06-03 11:40:51 +01:00
ProspectorandGitHub a0c80b13a4 fix: no mention of Australia on hosting landing page (#6288) 2026-06-03 02:25:16 +00:00
Calum H.andGitHub a7fb4e6429 Merge branch 'main' into cal/sharing-tab-panel 2026-06-02 21:15:36 +01:00
Calum H.andGitHub 3c051f5b1d feat: add notifs onto friends ws temporarily (#6290)
* feat: add notifs onto friends ws temporarily

* fix: lint + styling

* fix: regressions
2026-06-02 19:47:37 +00:00
Calum H.andGitHub c0cce3b1a2 Merge branch 'main' into cal/sharing-tab-panel 2026-06-02 17:42:12 +01:00
Calum H.andGitHub 940a796ba5 chore: changelog (#6286) 2026-06-02 18:39:29 +02:00
6ee5e4df19 feat: access labrinth backend (#6284)
* feat: redirect `/hosting` to archon

* feat: server invite notification type

* feat: direct email notification endpoint

* feat: revoke notification endpoint

* feat: specify users to remove notifications from

* refactor: insert notifications before sending emails

* refactor: rename endpoint

* refactor: remove archon redirect

* style: mark field unused

* feat: dedup external notifications

* feat: add server invite email templates

* style: remove unnecessary format

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
2026-06-02 16:34:04 +00:00
Calum H. (IMB11) b3d0478501 fix: notifications 2026-06-02 17:07:56 +01:00
Calum H. (IMB11) 84062e3107 Merge remote-tracking branch 'origin/main' into cal/sharing-tab-panel 2026-06-02 17:07:56 +01:00
Calum H. (IMB11) 65ba9625af feat: fixes + notif fix 2026-06-02 17:07:39 +01:00
Calum H. (IMB11) 87cfb88d58 fix: pin nuxt 2026-06-02 17:07:39 +01:00
Calum H. (IMB11) f4c7dfe179 fix: safari cookies in dev 2026-06-02 17:07:39 +01:00
Calum H. (IMB11) e6c2ddf3ec fix: utils barrel 2026-06-02 17:07:39 +01:00
Calum H. (IMB11) 7b137df0db Merge remote-tracking branch 'origin/main' into cal/sharing-tab-panel 2026-06-02 17:07:39 +01:00
aecsocketandGitHub d61397097c Tilitfy webhook changes (#6267)
* Tilitfy webhook changes

* add env

* fix
2026-06-02 15:14:02 +02:00
Calum H.andGitHub cfe45b368c fix: memory issues when importing giant mrpack files (#6278)
* feat: dont load mrpacks into memory if they are local imports

* fix: frontend
2026-06-02 12:59:41 +00:00
Calum H. (IMB11) 418a35fcd3 Merge branch 'cal/sharing-tab-panel' of https://github.com/modrinth/code into cal/sharing-tab-panel 2026-05-25 15:40:38 +01:00
Calum H. (IMB11) 185d4e3f31 fix: lint 2026-05-25 15:40:37 +01:00
Calum H. (IMB11) 34c1a22227 fix: qa 2026-05-25 15:39:47 +01:00
Calum H.andGitHub fdecfeebe1 Merge branch 'main' into cal/sharing-tab-panel 2026-05-25 14:23:46 +01:00
Calum H. (IMB11) 803aa49560 feat: qa 2026-05-25 14:05:42 +01:00
Calum H. (IMB11) 9ed9873629 fix: supress friend errors 2026-05-22 19:44:47 +01:00
Calum H.andGitHub 0e0dda5857 Merge branch 'main' into cal/sharing-tab-panel 2026-05-22 19:24:11 +01:00
Calum H. (IMB11) a30546461f fix: lint 2026-05-22 19:21:46 +01:00
Calum H. (IMB11) a4d93c2c18 fix: backend alignment 2026-05-22 19:18:05 +01:00
Calum H. (IMB11) 5380ae5732 fix: lint 2026-05-22 18:48:29 +01:00
Calum H. (IMB11) 5c6846354e feat: finish frontend panel stuff 2026-05-22 18:45:09 +01:00
Calum H. (IMB11) a0b644fc63 feat: audit log table sort by time 2026-05-22 18:22:23 +01:00
Calum H. (IMB11) d7b66254fc feat: qa + lint 2026-05-22 17:53:30 +01:00
Calum H. (IMB11) fb4d3f8d89 fix: lint 2026-05-22 17:10:45 +01:00
Calum H. (IMB11) aff6d1d36c fix: hosting manage page shared server detection 2026-05-22 16:28:23 +01:00
Calum H. (IMB11) 43128d473d fix: permisison audit log entries 2026-05-22 14:29:03 +01:00
Calum H. (IMB11) 747c0c6cbd fix: spacing 2026-05-22 14:21:06 +01:00
Calum H. (IMB11) 498a719e05 feat: QA 2026-05-22 14:00:52 +01:00
Calum H. (IMB11) ec17c40eaf feat: time frame picker from analytics 2026-05-22 13:38:11 +01:00
Calum H. (IMB11) 39a529f556 fix: desync path 2026-05-22 13:32:15 +01:00
Calum H. (IMB11) 9db8c55fa0 feat: sse impl 2026-05-22 13:32:15 +01:00
Calum H. (IMB11) 13eb82a369 impl: sse support in api-client 2026-05-22 13:32:15 +01:00
Calum H. (IMB11) 4db623623e feat: qa 2026-05-22 13:32:15 +01:00
Calum H. (IMB11) 6612695365 fix: tooltip bug 2026-05-22 13:32:15 +01:00
Calum H. (IMB11) 1ea2a75411 feat: qa 2026-05-22 13:32:15 +01:00
Calum H. (IMB11) 645b6327b3 feat: email template start 2026-05-22 13:31:43 +01:00
Calum H. (IMB11) c7d95c30fe feat: enforce permissions 2026-05-22 13:31:43 +01:00
Calum H. (IMB11) 740331f439 feat: qa 2026-05-22 13:29:01 +01:00
Calum H. (IMB11) 96d0898141 feat: i18n 2026-05-22 13:29:01 +01:00
Calum H. (IMB11) cdced210ee feat: better mobile mode for audit log table 2026-05-22 13:29:01 +01:00
Calum H. (IMB11) a94e49899a feat: audit log table event start 2026-05-22 13:29:01 +01:00
Calum H. (IMB11) 7606329ea8 feat: fix permissions 2026-05-22 13:29:01 +01:00
Calum H. (IMB11) bb8e85e076 feat: hook up api 2026-05-22 13:29:01 +01:00
Calum H. (IMB11) 3687adeb09 fix: lint 2026-05-22 13:29:01 +01:00
Calum H. (IMB11) c4f093b820 fix: lint 2026-05-22 13:29:01 +01:00
Calum H. (IMB11) 054a9c4b1f feat: fix fields 2026-05-22 13:29:01 +01:00
Calum H. (IMB11) cf25e6e7d2 feat: change to bitfield 2026-05-22 13:29:01 +01:00
Calum H. (IMB11) 773f1423cc fix: lint 2026-05-22 13:29:01 +01:00
Calum H. (IMB11) a1ebba5ea9 feat: fix user "search" 2026-05-22 13:25:05 +01:00
Calum H. (IMB11) 33400c7b19 qa: qa pass 2026-05-22 13:23:56 +01:00
Calum H. (IMB11) 224befa6c2 feat: implement backend 2026-05-22 13:23:19 +01:00
Calum H. (IMB11) 5689c2baed feat: qa 2026-05-22 13:23:19 +01:00
Calum H. (IMB11) 7ca96c39bc fix: spacing 2026-05-22 13:22:53 +01:00
Calum H. (IMB11) b97f338d54 feat: implement access tab with dummy data 2026-05-22 13:22:52 +01:00
293 changed files with 17635 additions and 2356 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
"files.insertFinalNewline": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "always"
"source.organizeImports": "never"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[vue]": {
+1 -1
View File
@@ -64,7 +64,7 @@ The website and app `prepr` commands
Each project may have its own `CLAUDE.md` with detailed instructions:
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
## Code Guidelines
Generated
+1
View File
@@ -10514,6 +10514,7 @@ dependencies = [
"flate2",
"fs4",
"futures",
"futures-lite 2.6.1",
"heck 0.5.0",
"hickory-resolver 0.25.2",
"indicatif",
+1
View File
@@ -85,6 +85,7 @@ eyre = "0.6.12"
flate2 = "1.1.4"
fs4 = { version = "0.13.1", default-features = false }
futures = "0.3.31"
futures-lite = "2.6.1"
futures-util = "0.3.31"
heck = "0.5.0"
hex = "0.4.3"
+82 -1
View File
@@ -95,7 +95,7 @@ import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { create_profile_and_install_from_file } from '@/helpers/pack'
import { list } from '@/helpers/profile.js'
@@ -241,6 +241,7 @@ const {
const news = ref([])
const availableSurvey = ref(false)
const displayedServerInviteNotifications = new Set()
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
@@ -752,6 +753,86 @@ const accounts = ref(null)
provide('accountsCard', accounts)
command_listener(handleCommand)
notification_listener(handleLiveNotification)
async function markLiveNotificationRead(notification) {
try {
await tauriApiClient.labrinth.notifications_v2.markAsRead(notification.id)
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 404) {
console.warn(`notification ${notification.id} could not be marked as read`, error)
return
}
throw error
}
}
async function respondToServerInvite(notification, action) {
const serverId = notification.body?.server_id
if (typeof serverId !== 'string') {
throw new Error('Missing server ID for invite notification.')
}
await tauriApiClient.request(`/servers/${serverId}/invites/${action}`, {
api: 'archon',
version: 1,
method: 'POST',
})
await markLiveNotificationRead(notification)
return serverId
}
async function acceptServerInviteNotification(notification) {
try {
const serverId = await respondToServerInvite(notification, 'accept')
await router.push(`/hosting/manage/${encodeURIComponent(serverId)}`)
queryClient.invalidateQueries({ queryKey: ['servers'] })
} catch (error) {
handleError(error)
}
}
async function declineServerInviteNotification(notification) {
try {
await respondToServerInvite(notification, 'decline')
} catch (error) {
handleError(error)
}
}
function openServerInviteInviterProfile(inviterName) {
if (!inviterName) return
openUrl(`${config.siteUrl}/user/${encodeURIComponent(inviterName)}`)
}
async function handleLiveNotification(notification) {
if (notification?.body?.type !== 'server_invite' || notification.read) return
if (displayedServerInviteNotifications.has(notification.id)) return
displayedServerInviteNotifications.add(notification.id)
const serverName =
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
const inviterId = notification.body.invited_by
const invitedBy =
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
addPopupNotification({
title: serverName,
autoCloseMs: null,
toast: {
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
},
})
}
async function handleCommand(e) {
if (!e) return
@@ -133,6 +133,7 @@ import {
type PopupNotificationProgressItem,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Dropdown } from 'floating-vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -284,6 +285,7 @@ function goToTerminal(path?: string) {
}
const currentLoadingBars = ref<LoadingBar[]>([])
const currentLoadingBarIconUrls = ref<Record<string, string | null>>({})
const notificationId = ref<string | number | null>(null)
const dismissed = ref(false)
@@ -303,6 +305,16 @@ function getLoadingText(loadingBar: LoadingBar): string {
return loadingBar.message ? `${percent}% ${loadingBar.message}` : `${percent}%`
}
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) {
return null
}
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) {
return icon
}
return convertFileSrc(icon)
}
function getNotification(): PopupNotification | null {
if (!notificationId.value) {
return null
@@ -326,6 +338,7 @@ function buildDownloadItems(): PopupNotificationProgressItem[] {
id: getLoadingBarKey(bar),
title: bar.title ?? '',
text: getLoadingText(bar),
iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null,
progress: getLoadingProgress(bar),
waiting: !bar.total || bar.total <= 0,
}))
@@ -400,6 +413,32 @@ async function refreshLoadingBars() {
.map(formatLoadingBars)
.filter((bar) => bar?.bar_type?.type !== 'launcher_update')
const profilePaths = Array.from(
new Set(
currentLoadingBars.value
.map((bar) => bar.bar_type?.profile_path)
.filter((path): path is string => !!path),
),
)
const profiles = profilePaths.length
? await getInstances(profilePaths).catch((error) => {
handleError(error)
return []
})
: []
const profileIconUrls = new Map(
profiles.map((profile) => [profile.path, getDisplayIconUrl(profile.icon_path)]),
)
currentLoadingBarIconUrls.value = Object.fromEntries(
currentLoadingBars.value.map((bar) => {
const barIconUrl = getDisplayIconUrl(bar.bar_type?.icon)
const profileIconUrl = bar.bar_type?.profile_path
? profileIconUrls.get(bar.bar_type.profile_path)
: null
return [getLoadingBarKey(bar), barIconUrl ?? profileIconUrl ?? null]
}),
)
currentLoadingBars.value.sort((a, b) => {
const aKey = `${a.loading_bar_uuid ?? a.id ?? ''}`
const bKey = `${b.loading_bar_uuid ?? b.id ?? ''}`
@@ -8,6 +8,7 @@ import {
InstallationSettingsLayout,
provideAppBackup,
provideInstallationSettings,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
@@ -34,9 +35,17 @@ import type { Manifest } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const debug = useDebugLogger('AppInstallationSettings')
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
debug('metadata load: start', {
instancePath: instance.value.path,
loader: instance.value.loader,
gameVersion: instance.value.game_version,
installStage: instance.value.install_stage,
})
const [
fabric_versions,
forge_versions,
@@ -72,6 +81,15 @@ const [
.catch(handleError),
])
debug('metadata load: done', {
hasFabricManifest: !!fabric_versions?.value,
hasForgeManifest: !!forge_versions?.value,
hasQuiltManifest: !!quilt_versions?.value,
hasNeoforgeManifest: !!neoforge_versions?.value,
gameVersions: all_game_versions?.value?.length ?? 0,
availablePlatforms: loaders?.value?.map((loader) => loader.name) ?? [],
})
const { data: modpackInfo } = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.value.path]),
queryFn: () => get_linked_modpack_info(instance.value.path, 'must_revalidate'),
@@ -95,11 +113,21 @@ function getManifest(loader: string) {
quilt: quilt_versions,
neoforge: neoforge_versions,
}
return map[loader]
const manifest = map[loader]
debug('getManifest:', {
loader,
hasManifest: !!manifest?.value,
gameVersions: manifest?.value?.gameVersions?.length ?? 0,
})
return manifest
}
provideAppBackup({
async createBackup() {
debug('createBackup: start', {
instancePath: instance.value.path,
instanceName: instance.value.name,
})
const allProfiles = await list()
const prefix = `${instance.value.name} - Backup #`
const existingNums = allProfiles
@@ -109,6 +137,7 @@ provideAppBackup({
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const newPath = await duplicate(instance.value.path)
await edit(newPath, { name: `${prefix}${nextNum}` })
debug('createBackup: done', { newPath, backupName: `${prefix}${nextNum}` })
},
})
@@ -165,32 +194,72 @@ provideInstallationSettings({
const manifest = getManifest(loader)
return !!manifest?.value?.gameVersions?.some((x) => item.version === x.id)
})
return (showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')).map(
(x) => ({ value: x.version, label: x.version }),
)
const result = (
showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')
).map((x) => ({ value: x.version, label: x.version }))
debug('resolveGameVersions:', {
loader,
showSnapshots,
totalVersions: versions.length,
filteredVersions: filtered.length,
resultVersions: result.length,
})
return result
},
resolveLoaderVersions(loader, gameVersion) {
if (loader === 'vanilla' || !gameVersion) return []
const manifest = getManifest(loader)
if (!manifest?.value) return []
if (loader === 'fabric' || loader === 'quilt') {
return manifest.value.gameVersions[0]?.loaders ?? []
if (loader === 'vanilla' || !gameVersion) {
debug('resolveLoaderVersions: skipped', { loader, gameVersion })
return []
}
return manifest.value.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
const manifest = getManifest(loader)
if (!manifest?.value) {
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
return []
}
if (loader === 'fabric' || loader === 'quilt') {
const result = manifest.value.gameVersions[0]?.loaders ?? []
debug('resolveLoaderVersions: fabric/quilt result', {
loader,
gameVersion,
count: result.length,
})
return result
}
const result =
manifest.value.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
return result
},
resolveHasSnapshots(loader) {
const versions = all_game_versions?.value ?? []
if (loader === 'vanilla') return versions.some((x) => x.version_type !== 'release')
if (loader === 'vanilla') {
const result = versions.some((x) => x.version_type !== 'release')
debug('resolveHasSnapshots: vanilla', { loader, result })
return result
}
const manifest = getManifest(loader)
const supported = versions.filter(
(item) => !!manifest?.value?.gameVersions?.some((x) => item.version === x.id),
)
return supported.some((x) => x.version_type !== 'release')
const result = supported.some((x) => x.version_type !== 'release')
debug('resolveHasSnapshots:', {
loader,
totalVersions: versions.length,
supportedVersions: supported.length,
result,
})
return result
},
async save(platform, gameVersion, loaderVersionId) {
debug('save: called', {
instancePath: instance.value.path,
platform,
gameVersion,
loaderVersionId,
})
const editProfile: Record<string, string | undefined> = {
loader: platform,
game_version: gameVersion,
@@ -199,17 +268,21 @@ provideInstallationSettings({
editProfile.loader_version = loaderVersionId
}
await edit(instance.value.path, editProfile).catch(handleError)
debug('save: edit complete', { editProfile })
},
afterSave: async () => {
debug('afterSave: installing', { instancePath: instance.value.path })
await install(instance.value.path, false).catch(handleError)
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('afterSave: done')
},
async repair() {
debug('repair: called', { instancePath: instance.value.path })
repairing.value = true
await install(instance.value.path, true).catch(handleError)
repairing.value = false
@@ -217,9 +290,11 @@ provideInstallationSettings({
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('repair: done')
},
async reinstallModpack() {
debug('reinstallModpack: called', { instancePath: instance.value.path })
reinstalling.value = true
await update_repair_modrinth(instance.value.path).catch(handleError)
reinstalling.value = false
@@ -227,9 +302,11 @@ provideInstallationSettings({
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('reinstallModpack: done')
},
async unlinkModpack() {
debug('unlinkModpack: called', { instancePath: instance.value.path })
await edit(instance.value.path, {
linked_data: null as unknown as undefined,
})
@@ -237,27 +314,38 @@ provideInstallationSettings({
queryKey: ['linkedModpackInfo', instance.value.path],
})
onUnlinked()
debug('unlinkModpack: done')
},
getCachedModpackVersions: () => null,
async fetchModpackVersions() {
debug('fetchModpackVersions: called', {
projectId: instance.value.linked_data?.project_id,
})
const versions = await get_project_versions(instance.value.linked_data!.project_id!).catch(
handleError,
)
debug('fetchModpackVersions: done', { count: versions?.length ?? 0 })
return (versions ?? []) as Labrinth.Versions.v2.Version[]
},
async getVersionChangelog(versionId: string) {
debug('getVersionChangelog: called', { versionId })
return (await get_version(versionId, 'must_revalidate').catch(
() => null,
)) as Labrinth.Versions.v2.Version | null
},
async onModpackVersionConfirm(version) {
debug('onModpackVersionConfirm: called', {
versionId: version.id,
instancePath: instance.value.path,
})
await update_managed_modrinth_version(instance.value.path, version.id)
await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', instance.value.path],
})
debug('onModpackVersionConfirm: done')
},
updaterModalProps: computed(() => ({
+4
View File
@@ -98,6 +98,10 @@ export async function friend_listener(callback) {
return await listen('friend', (event) => callback(event.payload))
}
export async function notification_listener(callback) {
return await listen('notification', (event) => callback(event.payload))
}
/// Payload for the 'log' event
/*
LogPayload {
+1
View File
@@ -10,6 +10,7 @@ export interface LoadingBarType {
version?: string
profile_path?: string
pack_name?: string
icon?: string | null
}
export interface LoadingBar {
@@ -0,0 +1,33 @@
<script setup lang="ts">
import {
injectModrinthClient,
injectModrinthServerContext,
ServersManageAccessPage,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const client = injectModrinthClient()
const { serverId } = injectModrinthServerContext()
const queryClient = useQueryClient()
try {
await Promise.all([
queryClient.ensureQueryData({
queryKey: ['servers', 'users', 'v1', serverId],
queryFn: () => client.archon.server_users_v1.list(serverId),
staleTime: 30_000,
}),
queryClient.ensureQueryData({
queryKey: ['servers', 'v1', 'detail', serverId],
queryFn: () => client.archon.servers_v1.get(serverId),
staleTime: 30_000,
}),
])
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
</script>
<template>
<ServersManageAccessPage />
</template>
@@ -1,7 +1,8 @@
import Access from './Access.vue'
import Backups from './Backups.vue'
import Content from './Content.vue'
import Files from './Files.vue'
import Index from './Index.vue'
import Overview from './Overview.vue'
export { Backups, Content, Files, Index, Overview }
export { Access, Backups, Content, Files, Index, Overview }
@@ -26,7 +26,7 @@ export function setupFilePickerProvider() {
const file = await createFileFromPath(path, 'icon')
return { file, path, previewUrl: convertFileSrc(path) }
},
async pickModpackFile() {
async pickModpackFile(options) {
const result = await open({
multiple: false,
filters: [{ name: 'Modpack', extensions: ['mrpack'] }],
@@ -34,12 +34,19 @@ export function setupFilePickerProvider() {
if (!result) return null
const path = result.path ?? result
if (!path) return null
const file = await createFileFromPath(
if (options?.readFile === false) {
// Instance imports stream from the native path, keeping large packs out of JS memory.
return { path, previewUrl: '' }
}
return {
file: await createFileFromPath(
path,
'modpack.mrpack',
'application/x-modrinth-modpack+zip',
),
path,
'modpack.mrpack',
'application/x-modrinth-modpack+zip',
)
return { file, path, previewUrl: '' }
previewUrl: '',
}
},
})
}
+8
View File
@@ -73,6 +73,14 @@ export default new createRouter({
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'access',
name: 'ServerManageAccess',
component: Hosting.Access,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
],
},
{
+6
View File
@@ -42,6 +42,12 @@
{
"url": "https://api.purpurmc.org/*"
},
{
"url": "http://localhost:8000/*"
},
{
"url": "http://127.0.0.1:8000/*"
},
{
"url": "http://*.taila228c5.ts.net/*"
},
-1
View File
@@ -40,4 +40,3 @@ These composables are deprecated and should not be used in new code:
- **`useAsyncData`** - we use tanstack, not nuxt's built in async data utility.
- **`useBaseFetch`** (`src/composables/fetch.js`) — legacy Labrinth fetch wrapper. Use `client.labrinth.*` modules instead.
- **`useServersFetch`** (`src/composables/servers/servers-fetch.ts`) — legacy Archon fetch wrapper with manual retry/circuit-breaker. Use `client.archon.*` modules instead — refer to the `packages/api-client/CLAUDE.md` for more information.
+1
View File
@@ -224,6 +224,7 @@ export default defineNuxtConfig({
globalThis.INTERCOM_APP_ID ||
'ykeritl9',
production: isProduction(),
cookieSecure: isProduction(),
buildEnv: process.env.BUILD_ENV,
preview: process.env.PREVIEW === 'true',
featureFlagOverrides: getFeatureFlagOverrides(),
+1 -1
View File
@@ -26,7 +26,7 @@
"@types/semver": "^7.7.1",
"autoprefixer": "^10.4.19",
"glob": "^10.2.7",
"nuxt": "^3.20.2",
"nuxt": "=3.20.2",
"postcss": "^8.4.39",
"prettier-plugin-tailwindcss": "^0.6.5",
"sass": "^1.58.0",
@@ -10,7 +10,7 @@
>
<div
v-if="showLegendTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-5 bg-gradient-to-b from-surface-3 to-transparent"
class="z-1 pointer-events-none absolute left-0 right-0 top-0 h-5 bg-gradient-to-b from-surface-3 to-transparent"
/>
</Transition>
@@ -99,7 +99,7 @@
>
<div
v-if="showLegendBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-5 bg-gradient-to-t from-surface-3 to-transparent"
class="z-1 pointer-events-none absolute bottom-0 left-0 right-0 h-5 bg-gradient-to-t from-surface-3 to-transparent"
/>
</Transition>
</div>
@@ -132,7 +132,7 @@
:href="event.announcement_url"
target="_blank"
rel="noopener noreferrer"
class="mt-1.5 inline-flex items-center gap-1 text-sm font-medium text-primary underline !transition-all hover:text-contrast"
class="my-0.5 inline-flex items-center gap-1 text-xs font-medium text-primary underline !transition-all hover:text-contrast"
>
{{ formatMessage(analyticsChartMessages.seeAnnouncement) }}
<ExternalIcon class="size-3.5" aria-hidden="true" />
@@ -18,7 +18,7 @@
({{ durationLabel }})
</span>
</span>
<span v-if="previousRangeLabel" class="min-w-0 truncate text-xs text-primary">
<span v-if="previousRangeLabel" class="min-w-0 space-x-1 truncate text-xs text-primary">
<span class="font-medium">{{ previousRangeLabel }}</span>
<span class="font-normal text-secondary">
{{ formatMessage(analyticsChartMessages.previousPeriodShort) }}
@@ -197,6 +197,7 @@ function getEntryAriaLabel(entry: AnalyticsChartTooltipEntry) {
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const ONE_HOUR_MS = 60 * 60 * 1000
const ONE_MINUTE_MS = 60 * 1000
const DATE_LOCALE = 'en-US'
function formatRangeLabel(
start: Date,
@@ -225,13 +226,13 @@ function formatRangeLabel(
}
if (includeTime) {
const startLabel = new Intl.DateTimeFormat(undefined, startOptions).format(start)
const endLabel = new Intl.DateTimeFormat(undefined, timeOptions).format(end)
const startLabel = new Intl.DateTimeFormat(DATE_LOCALE, startOptions).format(start)
const endLabel = new Intl.DateTimeFormat(DATE_LOCALE, timeOptions).format(end)
const range = `${startLabel}${endLabel}`
if (!showTrailingYear) return range
const yearLabel = new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(end)
const yearLabel = new Intl.DateTimeFormat(DATE_LOCALE, { year: 'numeric' }).format(end)
return `${range}, ${yearLabel}`
}
@@ -244,13 +245,13 @@ function formatRangeLabel(
endOptions = { day: 'numeric' }
}
const startLabel = new Intl.DateTimeFormat(undefined, startOptions).format(start)
const endLabel = new Intl.DateTimeFormat(undefined, endOptions).format(end)
const startLabel = new Intl.DateTimeFormat(DATE_LOCALE, startOptions).format(start)
const endLabel = new Intl.DateTimeFormat(DATE_LOCALE, endOptions).format(end)
const range = `${startLabel}${endLabel}`
if (!showTrailingYear) return range
const yearLabel = new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(end)
const yearLabel = new Intl.DateTimeFormat(DATE_LOCALE, { year: 'numeric' }).format(end)
return `${range}, ${yearLabel}`
}
@@ -40,7 +40,6 @@ export function useAnalyticsChartEvents(
placeholderData: [],
refetchOnMount: 'always',
retry: false,
staleTime: 0,
})
const localAnalyticsChartEvents = computed(() => analyticsEvents.value ?? [])
@@ -109,7 +109,7 @@ function getRegionDisplayNames(locale: string): Intl.DisplayNames | null {
function formatCountryCode(countryCode: string, formatMessage: FormatMessage): string {
const normalized = countryCode.trim().toUpperCase()
if (normalized === OTHER_COUNTRY_CODE) {
return formatMessage(analyticsMessages.unknown)
return formatMessage(analyticsMessages.other)
}
if (!REGION_CODE_PATTERN.test(normalized)) {
@@ -146,6 +146,9 @@ export function formatBreakdownLabel(
normalizedLowercaseValue === 'other' ||
normalizedLowercaseValue === 'unknown'
) {
if (selectedBreakdown === 'country') {
return formatMessage(analyticsMessages.other)
}
return formatMessage(analyticsMessages.unknown)
}
if (selectedBreakdown === 'country') {
@@ -753,7 +756,7 @@ export function formatMetricValue(
case 'playtime': {
const hours = value / 3600
return formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: hours.toFixed(1),
hours: Math.abs(hours) < 1 ? hours.toFixed(2) : hours.toFixed(1),
})
}
case 'views':
@@ -770,7 +773,11 @@ function formatSmallAxisNumber(value: number): string {
}
const formattedValue = Math.abs(value) < 1 ? value.toFixed(2) : value.toFixed(1)
return formattedValue.replace(/\.?0+$/, '')
return trimTrailingFractionZeros(formattedValue)
}
function trimTrailingFractionZeros(value: string): string {
return value.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
}
const COMPACT_AXIS_UNITS = [
@@ -814,7 +821,7 @@ function formatCompactAxisValue(value: number): string {
return String(truncatedValue)
}
return roundedValue.toFixed(fractionDigitCount).replace(/\.?0+$/, '')
return trimTrailingFractionZeros(roundedValue.toFixed(fractionDigitCount))
}
export function formatAxisValue(
@@ -224,6 +224,19 @@ export const analyticsGraphTitleMessages = defineMessages({
})
export const analyticsStatCardMessages = defineMessages({
monetizationBannerTitle: {
id: 'analytics.stat.monetization-banner.title',
defaultMessage: 'How does monetization work?',
},
monetizationBannerBody: {
id: 'analytics.stat.monetization-banner.body',
defaultMessage:
'Only views and downloads made through Modrinth are eligible for monetization and must pass fraud-prevention filtering. Modrinth App downloads also require the user to be logged in. Because all projects have a similar ratio of monetized downloads, your revenue would not meaningfully change if all downloads were counted.',
},
monetizationBannerLearnMore: {
id: 'analytics.stat.monetization-banner.learn-more',
defaultMessage: 'Learn more',
},
revenueValue: {
id: 'analytics.stat.revenue-value',
defaultMessage: '${value}',
@@ -66,6 +66,7 @@ export function buildAnalyticsTableColumns({
key: getAnalyticsTableBreakdownColumnKey(breakdown),
label: getAnalyticsTableBreakdownColumnLabel(breakdown, formatMessage),
enableSorting: true,
width: breakdown === 'project' ? '25%' : undefined,
})
}
}
@@ -75,6 +76,7 @@ export function buildAnalyticsTableColumns({
key: 'project',
label: formatAnalyticsBreakdownLabel('project', formatMessage),
enableSorting: true,
width: '25%',
})
}
@@ -40,10 +40,12 @@ export function formatAnalyticsTableCompactPlaytime(
formatMessage: FormatMessage,
): string {
const totalSeconds = Math.max(0, Math.round(value))
const hours = totalSeconds / SECONDS_PER_HOUR
const fractionDigits = hours < 1 ? 2 : 1
return formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: (totalSeconds / SECONDS_PER_HOUR).toLocaleString(undefined, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
hours: hours.toLocaleString(undefined, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}),
})
}
@@ -12,6 +12,7 @@ import {
getSliceCount,
} from '../analytics-chart/analytics-chart-utils'
import type { FormatMessage } from '../analytics-messages'
import { analyticsMessages } from '../analytics-messages'
import {
ALL_BREAKDOWN_VALUE,
COMBINED_BREAKDOWN_LABEL_SEPARATOR,
@@ -64,6 +65,8 @@ export function buildAnalyticsTableRows({
const timeRange = fetchRequest.time_range
const sliceCount = getSliceCount(timeRange, timeSlices.length)
const currentTimeSlices =
timeSlices.length > sliceCount ? timeSlices.slice(timeSlices.length - sliceCount) : timeSlices
const includeDate = mode === 'date_breakdown'
const breakdownDisplayValues = new Map<string, string>()
const projectDisplayValues = new Map<string, string>()
@@ -119,9 +122,22 @@ export function buildAnalyticsTableRows({
}
function getCombinedBreakdownDisplay(displays: AnalyticsTableBreakdownDisplayValues) {
const unknownBreakdownLabel = formatMessage(analyticsMessages.unknown)
let hasUnknownBreakdownLabel = false
return selectedBreakdowns
.map((breakdown) => displays[breakdown])
.filter((displayValue): displayValue is string => Boolean(displayValue))
.filter((displayValue) => {
if (displayValue !== unknownBreakdownLabel) {
return true
}
if (hasUnknownBreakdownLabel) {
return false
}
hasUnknownBreakdownLabel = true
return true
})
.join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
}
@@ -184,7 +200,7 @@ export function buildAnalyticsTableRows({
}
}
timeSlices.forEach((slice, sliceIndex) => {
currentTimeSlices.forEach((slice, sliceIndex) => {
const bucketLabel = includeDate ? getBucketLabel(sliceIndex) : undefined
for (const point of slice) {
@@ -30,9 +30,10 @@ export function getAnalyticsBreakdownValue(
case 'user_agent': {
const downloadSource = normalizeBreakdownValue(
'user_agent' in point ? point.user_agent : undefined,
UNKNOWN_BREAKDOWN_VALUE,
)
return downloadSource === ALL_BREAKDOWN_VALUE
? ALL_BREAKDOWN_VALUE
return downloadSource === UNKNOWN_BREAKDOWN_VALUE
? UNKNOWN_BREAKDOWN_VALUE
: getDownloadSourceLabel(downloadSource, formatMessage)
}
case 'download_reason':
@@ -98,5 +99,12 @@ function normalizeBreakdownValue(
fallback = ALL_BREAKDOWN_VALUE,
): string {
const normalized = value?.trim()
const normalizedLowercase = normalized?.toLowerCase()
if (
fallback === UNKNOWN_BREAKDOWN_VALUE &&
(normalizedLowercase === 'unknown' || normalizedLowercase === 'other')
) {
return fallback
}
return normalized && normalized.length > 0 ? normalized : fallback
}
@@ -52,6 +52,7 @@ const {
selectedCustomTimeframeEndDate,
selectedGroupBy,
queryRefreshTimestamp,
analyticsAllTimeStartDate,
refreshAnalyticsQuery,
} = injectAnalyticsDashboardContext()
@@ -107,6 +108,7 @@ function handleTimeframeDraftChange(selection: TimeFramePickerSelection) {
customStartDate: selection.customStartDate,
customEndDate: selection.customEndDate,
nowTimestamp: queryRefreshTimestamp.value,
allTimeStartDate: analyticsAllTimeStartDate.value,
})
const { start, end } = ensureMinimumTimeRange(range.start, range.end)
const durationMinutes = Math.max(1, Math.floor((end.getTime() - start.getTime()) / 60000))
@@ -894,12 +894,6 @@ function isRevenueHourlyGroupBy(groupBy: AnalyticsGroupByPreset): boolean {
return groupBy === '1h' || groupBy === '6h'
}
function getAllTimeYearGroupStart(end: Date): Date {
const start = new Date(end)
start.setFullYear(2021)
return start
}
const groupByOptions = computed<ComboboxOption<AnalyticsGroupByPreset>[]>(() => {
const timeframeMinutes = selectedTimeframeDurationMinutes.value
const options = groupByPresetOptions.map((option) => {
@@ -1159,13 +1153,7 @@ function buildMetricFilters(
const fetchRequest = computed<Labrinth.Analytics.v3.FetchRequest>(() => {
const rawRange = selectedTimeRange.value
const rawStart =
selectedTimeframeMode.value === 'preset' &&
selectedTimeframe.value === 'all_time' &&
selectedGroupBy.value === 'year'
? getAllTimeYearGroupStart(rawRange.end)
: rawRange.start
const { start, end } = ensureMinimumTimeRange(rawStart, rawRange.end)
const { start, end } = ensureMinimumTimeRange(rawRange.start, rawRange.end)
const groupByMs = getAnalyticsGroupByPresetMinutes(selectedGroupBy.value) * 60 * 1000
const desiredSlices = Math.max(1, Math.floor((end.getTime() - start.getTime()) / groupByMs))
@@ -84,6 +84,7 @@ function subtractCalendarMonths(date: Date, months: number): Date {
export function getTimeRangeForPreset(
preset: AnalyticsTimeframePreset,
nowTimestamp: number,
allTimeStartDate: Date = new Date(Date.UTC(2023, 0, 1, 0, 0, 0, 0)),
): AnalyticsTimeRange {
const now = getRoundedNow(nowTimestamp)
const end = new Date(now)
@@ -130,7 +131,7 @@ export function getTimeRangeForPreset(
}
case 'all_time':
return {
start: new Date(Date.UTC(2023, 0, 1, 0, 0, 0, 0)),
start: new Date(allTimeStartDate),
end,
}
default:
@@ -193,6 +194,7 @@ export function getAnalyticsTimeRange({
customStartDate,
customEndDate,
nowTimestamp,
allTimeStartDate,
}: {
mode: AnalyticsTimeframeMode
preset: AnalyticsTimeframePreset
@@ -201,6 +203,7 @@ export function getAnalyticsTimeRange({
customStartDate: string
customEndDate: string
nowTimestamp: number
allTimeStartDate?: Date
}): AnalyticsTimeRange {
switch (mode) {
case 'last':
@@ -211,7 +214,7 @@ export function getAnalyticsTimeRange({
return getTimeRangeForCustomDateTimeRange(customStartDate, customEndDate)
case 'preset':
default:
return getTimeRangeForPreset(preset, nowTimestamp)
return getTimeRangeForPreset(preset, nowTimestamp, allTimeStartDate)
}
}
@@ -269,6 +272,7 @@ export function useSelectedAnalyticsTimeRange() {
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
queryRefreshTimestamp,
analyticsAllTimeStartDate,
} = injectAnalyticsDashboardContext()
const selectedTimeRange = computed(() =>
@@ -280,6 +284,7 @@ export function useSelectedAnalyticsTimeRange() {
customStartDate: selectedCustomTimeframeStartDate.value,
customEndDate: selectedCustomTimeframeEndDate.value,
nowTimestamp: queryRefreshTimestamp.value,
allTimeStartDate: analyticsAllTimeStartDate.value,
}),
)
@@ -1,21 +1,45 @@
<template>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
v-for="card in statCards"
:key="card.key"
:label="card.label"
:stat-label="card.statLabel"
:vs-prev-period-percent="card.vsPrevPeriodPercent"
:icon="card.icon"
:active="activeStat === card.key"
:disabled="card.disabled"
@click="setActiveStat(card.key)"
/>
<div class="flex w-full flex-col gap-3">
<Admonition
v-if="showMonetizationBanner"
type="info"
:header="formatMessage(analyticsStatCardMessages.monetizationBannerTitle)"
show-actions-underneath
dismissible
@dismiss="dismissMonetizationBanner"
>
<div class="text-primary">
{{ formatMessage(analyticsStatCardMessages.monetizationBannerBody) }}
</div>
<template #actions>
<ButtonStyled color="blue">
<a href="https://modrinth.com/legal/cmp-info" target="_blank" class="w-fit !px-4">
{{ formatMessage(analyticsStatCardMessages.monetizationBannerLearnMore) }}
<RightArrowIcon aria-hidden="true" />
</a>
</ButtonStyled>
</template>
</Admonition>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard
v-for="card in statCards"
:key="card.key"
:label="card.label"
:stat-label="card.statLabel"
:vs-prev-period-percent="card.vsPrevPeriodPercent"
:icon="card.icon"
:active="activeStat === card.key"
:disabled="card.disabled"
@click="setActiveStat(card.key)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useFormatNumber, useVIntl } from '@modrinth/ui'
import { RightArrowIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, useFormatNumber, useVIntl } from '@modrinth/ui'
import { useLocalStorage } from '@vueuse/core'
import {
type AnalyticsDashboardStat,
@@ -25,10 +49,13 @@ import {
import { analyticsStatCardMessages, formatAnalyticsStatLabel } from '../analytics-messages.ts'
import StatCard from './StatCard.vue'
const MONETIZATION_BANNER_DISMISSED_KEY = 'analytics-monetization-banner-dismissed'
const {
activeStat,
setActiveStat,
currentTotals,
previousTotals,
percentChanges,
hasPreviousPeriodComparison,
selectedBreakdowns,
@@ -36,6 +63,11 @@ const {
} = injectAnalyticsDashboardContext()
const formatNumber = useFormatNumber()
const { formatMessage } = useVIntl()
const monetizationBannerDismissed = useLocalStorage(MONETIZATION_BANNER_DISMISSED_KEY, false)
const showMonetizationBanner = computed(
() => selectedBreakdowns.value.includes('monetization') && !monetizationBannerDismissed.value,
)
const MAX_PREVIOUS_PERIOD_PERCENT_DISPLAY = 1000
const compactNumberFormatter = computed(
() =>
@@ -57,16 +89,79 @@ function formatStatNumber(value: number): string {
function formatPercent(value: number): string {
const rounded = Math.round(value * 10) / 10
if (rounded === 0) {
return '0%'
}
const signPrefix = rounded > 0 ? '+' : ''
return `${signPrefix}${rounded.toFixed(1)}%`
}
function formatPreviousPeriodPercent(value: number): string | null {
function formatSignedStatNumber(value: number): string {
const signPrefix = value > 0 ? '+' : ''
return `${signPrefix}${formatStatNumber(value)}`
}
function formatSignedRevenue(value: number): string {
const signPrefix = value > 0 ? '+' : value < 0 ? '-' : ''
return `${signPrefix}${formatMessage(analyticsStatCardMessages.revenueValue, {
value: formatStatNumber(Math.abs(value)),
})}`
}
function formatSignedPlaytimeHours(value: number): string {
const rounded = Math.round(value * 10) / 10
if (rounded === 0) {
return '0'
}
if (Math.abs(rounded) >= 1000) {
const signPrefix = rounded > 0 ? '+' : ''
return `${signPrefix}${compactNumberFormatter.value.format(rounded)}`
}
const signPrefix = rounded > 0 ? '+' : ''
return `${signPrefix}${rounded.toFixed(1)}`
}
function formatSignedPlaytime(value: number): string {
return formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: formatSignedPlaytimeHours(value / 3600),
})
}
function formatPreviousPeriodComparison(
stat: AnalyticsDashboardStat,
percentChange: number,
currentValue: number,
previousValue: number,
): string | null {
if (!hasPreviousPeriodComparison.value) {
return null
}
return formatPercent(value)
const delta = currentValue - previousValue
if (previousValue === 0 && currentValue === 0) {
return formatPercent(percentChange)
}
if (previousValue !== 0 && Math.abs(percentChange) <= MAX_PREVIOUS_PERIOD_PERCENT_DISPLAY) {
return formatPercent(percentChange)
}
switch (stat) {
case 'revenue':
return formatSignedRevenue(delta)
case 'playtime':
return formatSignedPlaytime(delta)
case 'views':
case 'downloads':
return formatSignedStatNumber(delta)
}
}
function dismissMonetizationBanner() {
monetizationBannerDismissed.value = true
}
const statCards = computed<
@@ -83,7 +178,12 @@ const statCards = computed<
key: 'views',
label: formatAnalyticsStatLabel('views', formatMessage),
statLabel: formatStatNumber(currentTotals.value.views),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.views),
vsPrevPeriodPercent: formatPreviousPeriodComparison(
'views',
percentChanges.value.views,
currentTotals.value.views,
previousTotals.value.views,
),
icon: 'eye',
disabled: !isAnalyticsDashboardStatRelevant('views', selectedBreakdowns.value),
},
@@ -91,7 +191,12 @@ const statCards = computed<
key: 'downloads',
label: formatAnalyticsStatLabel('downloads', formatMessage),
statLabel: formatStatNumber(currentTotals.value.downloads),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.downloads),
vsPrevPeriodPercent: formatPreviousPeriodComparison(
'downloads',
percentChanges.value.downloads,
currentTotals.value.downloads,
previousTotals.value.downloads,
),
icon: 'download',
disabled: !isAnalyticsDashboardStatRelevant('downloads', selectedBreakdowns.value),
},
@@ -101,7 +206,12 @@ const statCards = computed<
statLabel: formatMessage(analyticsStatCardMessages.revenueValue, {
value: formatStatNumber(currentTotals.value.revenue),
}),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.revenue),
vsPrevPeriodPercent: formatPreviousPeriodComparison(
'revenue',
percentChanges.value.revenue,
currentTotals.value.revenue,
previousTotals.value.revenue,
),
icon: 'dollar',
disabled: !isAnalyticsDashboardStatRelevant('revenue', selectedBreakdowns.value),
},
@@ -111,7 +221,12 @@ const statCards = computed<
statLabel: formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: formatStatNumber(currentTotals.value.playtime / 3600),
}),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.playtime),
vsPrevPeriodPercent: formatPreviousPeriodComparison(
'playtime',
percentChanges.value.playtime,
currentTotals.value.playtime,
previousTotals.value.playtime,
),
icon: 'clock',
disabled: !isAnalyticsDashboardStatRelevant('playtime', selectedBreakdowns.value),
},
@@ -1,244 +1,284 @@
<template>
<div
class="notification"
:class="{
'has-body': hasBody,
compact: compact,
read: notification.read,
}"
:class="
type === 'server_invite'
? { read: notification.read }
: {
notification: true,
'has-body': hasBody,
compact: compact,
read: notification.read,
}
"
>
<nuxt-link
v-if="!type"
:to="notification.link"
class="notification__icon backed-svg"
:class="{ raised: raised }"
>
<BellIcon />
</nuxt-link>
<DoubleIcon v-else class="notification__icon">
<template #primary>
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link
v-else-if="organization"
:to="`/organization/${organization.slug}`"
tabindex="-1"
>
<Avatar size="xs" :src="organization.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
</nuxt-link>
<Avatar v-else size="xs" :raised="raised" no-shadow />
</template>
<template #secondary>
<ScaleIcon
v-if="type === 'moderator_message' || type === 'status_change'"
class="moderation-color"
/>
<UserPlusIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
<UserPlusIcon
v-else-if="type === 'organization_invite' && organization"
class="creator-color"
/>
<VersionIcon v-else-if="type === 'project_update' && project && version" />
<BellIcon v-else />
</template>
</DoubleIcon>
<div class="notification__title">
<template v-if="type === 'project_update' && project && version">
A project you follow,
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link>
, has been updated:
</template>
<template v-else-if="type === 'team_invite' && project">
<nuxt-link
:to="`/user/${invitedBy.username}`"
class="iconified-link title-link inline-flex"
>
<Avatar
:src="invitedBy.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
class="inline-flex"
/>
<span class="space">&nbsp;</span>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<span>
has invited you to join
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }} </nuxt-link
>.
</span>
</template>
<template v-else-if="type === 'organization_invite' && organization">
<nuxt-link
:to="`/user/${invitedBy.username}`"
class="iconified-link title-link inline-flex"
>
<Avatar
:src="invitedBy.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
class="inline-flex"
/>
<span class="space">&nbsp;</span>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<span>
has invited you to join
<nuxt-link :to="`/organization/${organization.slug}`" class="title-link">
{{ organization.name }} </nuxt-link
>.
</span>
</template>
<template v-else-if="type === 'status_change' && project">
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<template v-if="tags.rejectedStatuses.includes(notification.body.new_status)">
has been
<ProjectStatusBadge :status="notification.body.new_status" />
</template>
<template v-else>
updated from
<ProjectStatusBadge :status="notification.body.old_status" />
to
<ProjectStatusBadge :status="notification.body.new_status" />
</template>
by the moderators.
</template>
<template v-else-if="type === 'moderator_message' && thread && project && !report">
Your project,
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link>
, has received
<template v-if="notification.grouped_notifs"> messages</template>
<template v-else>a message</template>
from the moderators.
</template>
<template v-else-if="type === 'moderator_message' && thread && report">
A moderator replied to your report of
<template v-if="version">
version
<nuxt-link :to="getVersionLink(project, version)" class="title-link">
{{ version.name }}
</nuxt-link>
of project
</template>
<nuxt-link v-if="project" :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" class="title-link">
{{ user.username }}
</nuxt-link>
.
</template>
<nuxt-link v-else :to="notification.link" class="title-link">
<span v-html="renderString(notification.title)" />
</nuxt-link>
<!-- <span v-else class="known-errors">Error reading notification.</span>-->
</div>
<div v-if="hasBody" class="notification__body">
<ThreadSummary
v-if="type === 'moderator_message' && thread"
:thread="thread"
:link="threadLink"
:raised="raised"
:messages="getMessages()"
class="thread-summary"
:auth="auth"
/>
<div v-else-if="type === 'project_update'" class="version-list">
<template v-if="type === 'server_invite'">
<div class="flex flex-col gap-4">
<ModrinthServersIcon class="h-auto w-56 max-w-full text-[var(--color-heading)]" />
<div
v-for="notif in (notification.grouped_notifs
? [notification, ...notification.grouped_notifs]
: [notification]
).filter((x) => x.extra_data.version)"
:key="notif.id"
class="version-link"
class="flex flex-wrap items-center gap-x-1.5 gap-y-2 text-lg leading-tight text-[var(--color-heading)]"
>
<VersionIcon />
<nuxt-link
:to="getVersionLink(notif.extra_data.project, notif.extra_data.version)"
class="text-link"
v-if="invitedBy"
:to="`/user/${invitedBy.username}`"
class="inline-flex items-center font-bold text-[var(--color-heading)] hover:underline"
>
{{ notif.extra_data.version.name }}
</nuxt-link>
<span class="version-info">
for
<Categories
:categories="getLoaderCategories(notif.extra_data.version)"
:type="notif.extra_data.project.project_type"
class="categories"
<Avatar
:src="invitedBy.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
class="mr-1.5 inline-flex"
/>
{{ $formatVersion(notif.extra_data.version.game_versions) }}
<span v-tooltip="formatDateTime(notif.extra_data.version.date_published)" class="date">
{{ formatRelativeTime(notif.extra_data.version.date_published) }}
</span>
</span>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<span v-if="invitedBy">has invited you to manage</span>
<span v-else>You have been invited to manage</span>
<span
><strong class="font-bold text-[var(--color-heading)]">{{
notification.body.server_name
}}</strong
>.</span
>
</div>
</div>
<template v-else>
{{ notification.text }}
</template>
</div>
<span class="notification__date">
<span v-if="notification.read" class="read-badge inline-flex">
<CheckCircleIcon /> Read
</span>
<span v-tooltip="formatDateTime(notification.created)" class="inline-flex">
<CalendarIcon class="mr-1" /> Received
{{ formatRelativeTime(notification.created) }}
</span>
</span>
<div v-if="compact" class="notification__actions">
<template v-if="type === 'team_invite' || type === 'organization_invite'">
<ButtonStyled circular color="brand" type="transparent">
<button
v-tooltip="`Accept`"
@click="
() => {
acceptTeamInvite(notification.body.team_id)
read()
}
"
>
<CheckIcon />
</button>
</ButtonStyled>
<ButtonStyled circular color="red" type="transparent">
<button
v-tooltip="`Decline`"
@click="
() => {
removeSelfFromTeam(notification.body.team_id)
read()
}
"
>
<XIcon />
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else-if="!notification.read" circular type="transparent">
<button v-tooltip="`Mark as read`" @click="read()">
<XIcon />
</button>
</ButtonStyled>
</div>
<div v-else class="notification__actions">
<div v-if="type !== null" class="input-group">
<template
v-if="(type === 'team_invite' || type === 'organization_invite') && !notification.read"
<div
v-if="!notification.read"
class="flex flex-wrap items-center gap-3"
:class="{ 'gap-2': compact }"
>
<ButtonStyled color="brand">
<button @click="performActionByTitle(notification, 'Accept')">
<CheckIcon />
Accept
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="performActionByTitle(notification, 'Deny')">
<XIcon />
Decline
</button>
</ButtonStyled>
</div>
<div
class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[var(--color-text-secondary)]"
>
<span
v-if="notification.read"
class="inline-flex items-center font-bold text-[var(--color-text)]"
>
<CheckCircleIcon /> Read
</span>
<span v-tooltip="formatDateTime(notification.created)" class="inline-flex items-center">
<CalendarIcon class="mr-1" /> Received
{{ formatRelativeTime(notification.created) }}
</span>
<CopyCode v-if="flags.developerMode" :text="notification.id" />
</div>
</div>
</template>
<template v-else>
<nuxt-link
v-if="!type"
:to="notification.link"
class="notification__icon backed-svg"
:class="{ raised: raised }"
>
<BellIcon />
</nuxt-link>
<DoubleIcon v-else class="notification__icon">
<template #primary>
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link
v-else-if="organization"
:to="`/organization/${organization.slug}`"
tabindex="-1"
>
<Avatar size="xs" :src="organization.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
</nuxt-link>
<Avatar v-else size="xs" :raised="raised" no-shadow />
</template>
<template #secondary>
<ScaleIcon
v-if="type === 'moderator_message' || type === 'status_change'"
class="moderation-color"
/>
<UserPlusIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
<UserPlusIcon
v-else-if="type === 'organization_invite' && organization"
class="creator-color"
/>
<VersionIcon v-else-if="type === 'project_update' && project && version" />
<BellIcon v-else />
</template>
</DoubleIcon>
<div class="notification__title">
<template v-if="type === 'project_update' && project && version">
A project you follow,
<nuxt-link :to="getProjectLink(project)" class="title-link">{{
project.title
}}</nuxt-link>
, has been updated:
</template>
<template v-else-if="type === 'team_invite' && project">
<nuxt-link
:to="`/user/${invitedBy.username}`"
class="iconified-link title-link inline-flex"
>
<Avatar
:src="invitedBy.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
class="inline-flex"
/>
<span class="space">&nbsp;</span>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<span>
has invited you to join
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }} </nuxt-link
>.
</span>
</template>
<template v-else-if="type === 'organization_invite' && organization">
<nuxt-link
:to="`/user/${invitedBy.username}`"
class="iconified-link title-link inline-flex"
>
<Avatar
:src="invitedBy.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
class="inline-flex"
/>
<span class="space">&nbsp;</span>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<span>
has invited you to join
<nuxt-link :to="`/organization/${organization.slug}`" class="title-link">
{{ organization.name }} </nuxt-link
>.
</span>
</template>
<template v-else-if="type === 'status_change' && project">
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<template v-if="tags.rejectedStatuses.includes(notification.body.new_status)">
has been
<ProjectStatusBadge :status="notification.body.new_status" />
</template>
<template v-else>
updated from
<ProjectStatusBadge :status="notification.body.old_status" />
to
<ProjectStatusBadge :status="notification.body.new_status" />
</template>
by the moderators.
</template>
<template v-else-if="type === 'moderator_message' && thread && project && !report">
Your project,
<nuxt-link :to="getProjectLink(project)" class="title-link">{{
project.title
}}</nuxt-link>
, has received
<template v-if="notification.grouped_notifs"> messages</template>
<template v-else>a message</template>
from the moderators.
</template>
<template v-else-if="type === 'moderator_message' && thread && report">
A moderator replied to your report of
<template v-if="version">
version
<nuxt-link :to="getVersionLink(project, version)" class="title-link">
{{ version.name }}
</nuxt-link>
of project
</template>
<nuxt-link v-if="project" :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" class="title-link">
{{ user.username }}
</nuxt-link>
.
</template>
<nuxt-link v-else :to="notification.link" class="title-link">
<span v-html="renderString(notification.title)" />
</nuxt-link>
<!-- <span v-else class="known-errors">Error reading notification.</span>-->
</div>
<div v-if="hasBody" class="notification__body">
<ThreadSummary
v-if="type === 'moderator_message' && thread"
:thread="thread"
:link="threadLink"
:raised="raised"
:messages="getMessages()"
class="thread-summary"
:auth="auth"
/>
<div v-else-if="type === 'project_update'" class="version-list">
<div
v-for="notif in (notification.grouped_notifs
? [notification, ...notification.grouped_notifs]
: [notification]
).filter((x) => x.extra_data.version)"
:key="notif.id"
class="version-link"
>
<VersionIcon />
<nuxt-link
:to="getVersionLink(notif.extra_data.project, notif.extra_data.version)"
class="text-link"
>
{{ notif.extra_data.version.name }}
</nuxt-link>
<span class="version-info">
for
<Categories
:categories="getLoaderCategories(notif.extra_data.version)"
:type="notif.extra_data.project.project_type"
class="categories"
/>
{{ $formatVersion(notif.extra_data.version.game_versions) }}
<span
v-tooltip="formatDateTime(notif.extra_data.version.date_published)"
class="date"
>
{{ formatRelativeTime(notif.extra_data.version.date_published) }}
</span>
</span>
</div>
</div>
<template v-else>
{{ notification.text }}
</template>
</div>
<span class="notification__date">
<span v-if="notification.read" class="read-badge inline-flex">
<CheckCircleIcon /> Read
</span>
<span v-tooltip="formatDateTime(notification.created)" class="inline-flex">
<CalendarIcon class="mr-1" /> Received
{{ formatRelativeTime(notification.created) }}
</span>
</span>
<div v-if="compact" class="notification__actions">
<template v-if="type === 'team_invite' || type === 'organization_invite'">
<ButtonStyled circular color="brand" type="transparent">
<button
v-tooltip="`Accept`"
@click="
() => {
acceptTeamInvite(notification.body.team_id)
@@ -247,11 +287,11 @@
"
>
<CheckIcon />
Accept
</button>
</ButtonStyled>
<ButtonStyled color="red">
<ButtonStyled circular color="red" type="transparent">
<button
v-tooltip="`Decline`"
@click="
() => {
removeSelfFromTeam(notification.body.team_id)
@@ -260,41 +300,79 @@
"
>
<XIcon />
Decline
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else-if="!notification.read">
<button @click="read()">
<CheckIcon />
Mark as read
<ButtonStyled v-else-if="!notification.read" circular type="transparent">
<button v-tooltip="`Mark as read`" @click="read()">
<XIcon />
</button>
</ButtonStyled>
<CopyCode v-if="flags.developerMode" :text="notification.id" />
</div>
<div v-else class="input-group">
<ButtonStyled v-if="notification.link && notification.link !== '#'">
<nuxt-link :to="notification.link" target="_blank">
<ExternalIcon />
Open link
</nuxt-link>
</ButtonStyled>
<ButtonStyled v-for="(action, actionIndex) in notification.actions" :key="actionIndex">
<button @click="performAction(notification, actionIndex)">
<CheckIcon v-if="action.title === 'Accept'" />
<XIcon v-else-if="action.title === 'Deny'" />
{{ action.title }}
</button>
</ButtonStyled>
<ButtonStyled v-if="notification.actions.length === 0 && !notification.read">
<button @click="performAction(notification, null)">
<CheckIcon />
Mark as read
</button>
</ButtonStyled>
<CopyCode v-if="flags.developerMode" :text="notification.id" />
<div v-else class="notification__actions">
<div v-if="type !== null" class="input-group">
<template
v-if="(type === 'team_invite' || type === 'organization_invite') && !notification.read"
>
<ButtonStyled color="brand">
<button
@click="
() => {
acceptTeamInvite(notification.body.team_id)
read()
}
"
>
<CheckIcon />
Accept
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button
@click="
() => {
removeSelfFromTeam(notification.body.team_id)
read()
}
"
>
<XIcon />
Decline
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else-if="!notification.read">
<button @click="read()">
<CheckIcon />
Mark as read
</button>
</ButtonStyled>
<CopyCode v-if="flags.developerMode" :text="notification.id" />
</div>
<div v-else class="input-group">
<ButtonStyled v-if="notification.link && notification.link !== '#'">
<nuxt-link :to="notification.link" target="_blank">
<ExternalIcon />
Open link
</nuxt-link>
</ButtonStyled>
<ButtonStyled v-for="(action, actionIndex) in notification.actions" :key="actionIndex">
<button @click="performAction(notification, actionIndex)">
<CheckIcon v-if="action.title === 'Accept'" />
<XIcon v-else-if="action.title === 'Deny'" />
{{ action.title }}
</button>
</ButtonStyled>
<ButtonStyled v-if="notification.actions.length === 0 && !notification.read">
<button @click="performAction(notification, null)">
<CheckIcon />
Mark as read
</button>
</ButtonStyled>
<CopyCode v-if="flags.developerMode" :text="notification.id" />
</div>
</div>
</div>
</template>
</div>
</template>
@@ -328,11 +406,13 @@ import { markAsRead } from '~/helpers/platform-notifications'
import { getProjectLink, getVersionLink } from '~/helpers/projects'
import { acceptTeamInvite, removeSelfFromTeam } from '~/helpers/teams'
import ModrinthServersIcon from '../brand/ModrinthServersIcon.vue'
import ThreadSummary from './thread/ThreadSummary.vue'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const emit = defineEmits(['update:notifications'])
const router = useRouter()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
@@ -415,9 +495,29 @@ async function performAction(notification, actionIndex) {
await read()
if (actionIndex !== null) {
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
})
const action = notification.actions[actionIndex]
if (type.value === 'server_invite') {
const actionName = action.title.toLowerCase()
const inviteAction = actionName === 'accept' ? 'accept' : 'decline'
const serverId = notification.body.server_id
await client.request(`/servers/${serverId}/invites/${inviteAction}`, {
api: 'archon',
version: 1,
method: 'POST',
})
if (inviteAction === 'accept') {
await router.push(`/hosting/manage/${encodeURIComponent(serverId)}`)
}
} else {
const [method, route] = action.action_route
await useBaseFetch(route, {
method: method.toUpperCase(),
})
}
}
} catch (err) {
addNotification({
@@ -429,6 +529,20 @@ async function performAction(notification, actionIndex) {
stopLoading()
}
function performActionByTitle(notification, title) {
const actionIndex = notification.actions.findIndex((action) => action.title === title)
if (actionIndex === -1) {
addNotification({
title: 'An error occurred',
text: `Missing ${title.toLowerCase()} action for notification.`,
type: 'error',
})
return
}
return performAction(notification, actionIndex)
}
function getMessages() {
const messages = []
if (props.notification.body.message_id) {
@@ -1,20 +1,22 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { PlusIcon, XIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
injectModrinthClient,
injectNotificationManager,
NewModal,
ServerNotice,
StyledInput,
TagItem,
} from '@modrinth/ui'
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
import { ref } from 'vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
type ServerNoticeType = Archon.Notices.v0.ListedNotice
const modal = ref<InstanceType<typeof NewModal>>()
@@ -32,28 +34,23 @@ const assignedNodes = computed(() => assigned.value.filter((n) => n.kind === 'no
const inputField = ref('')
async function refresh() {
await useServersFetch('notices').then((res) => {
const notices = res as ServerNoticeType[]
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? []
})
const notices = await client.archon.notices_v0.list()
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? []
}
async function assign(server: boolean = true) {
const input = inputField.value.trim()
if (input !== '' && notice.value) {
await useServersFetch(
`notices/${notice.value.id}/assign?${server ? 'server' : 'node'}=${input}`,
{
method: 'PUT',
},
).catch((err) => {
addNotification({
title: 'Error assigning notice',
text: err,
type: 'error',
await client.archon.notices_v0
.assign(notice.value.id, server ? { server: input } : { node: input })
.catch((err) => {
addNotification({
title: 'Error assigning notice',
text: err,
type: 'error',
})
})
})
} else {
addNotification({
title: 'Error assigning notice',
@@ -84,18 +81,15 @@ async function unassignDetect() {
async function unassign(id: string, server: boolean = true) {
if (notice.value) {
await useServersFetch(
`notices/${notice.value.id}/unassign?${server ? 'server' : 'node'}=${id}`,
{
method: 'PUT',
},
).catch((err) => {
addNotification({
title: 'Error unassigning notice',
text: err,
type: 'error',
await client.archon.notices_v0
.unassign(notice.value.id, server ? { server: id } : { node: id })
.catch((err) => {
addNotification({
title: 'Error unassigning notice',
text: err,
type: 'error',
})
})
})
}
await refresh()
}
@@ -125,7 +119,7 @@ defineExpose({ show, hide })
:level="notice.level"
:message="notice.message"
:dismissable="notice.dismissable"
:title="notice.title"
:title="notice.title ?? undefined"
preview
/>
<div class="flex flex-col gap-2">
@@ -133,6 +133,7 @@ import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
injectModrinthClient,
injectNotificationManager,
NewModal,
StyledInput,
@@ -143,9 +144,9 @@ import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
import { computed, ref } from 'vue'
import { useBaseFetch } from '#imports'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const modal = ref<InstanceType<typeof NewModal>>()
@@ -205,12 +206,12 @@ const applyDisabled = computed(() => {
async function ensureOverview() {
if (regions.value.length || nodeHostnames.value.length) return
try {
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
regions.value = (data.regions || []).map((r: any) => ({
const data = await client.archon.nodes_internal.overview()
regions.value = data.regions.map((r) => ({
value: r.key,
label: `${r.display_name} (${r.key})`,
}))
nodeHostnames.value = data.node_hostnames || []
nodeHostnames.value = data.node_hostnames
if (!selectedRegion.value && regions.value.length) selectedRegion.value = regions.value[0].value
} catch (err) {
addNotification({ title: 'Failed to load nodes overview', text: String(err), type: 'error' })
@@ -198,6 +198,7 @@ import {
ButtonStyled,
Chips,
Combobox,
injectModrinthClient,
injectNotificationManager,
NewModal,
StyledInput,
@@ -207,13 +208,12 @@ import {
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const emit = defineEmits<{
success: []
}>()
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const modal = ref<InstanceType<typeof NewModal>>()
@@ -341,12 +341,12 @@ const submitDisabled = computed(() => {
async function ensureOverview() {
if (regions.value.length || nodeHostnames.value.length) return
try {
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
regions.value = (data.regions || []).map((r: any) => ({
const data = await client.archon.nodes_internal.overview()
regions.value = data.regions.map((r) => ({
value: r.key,
label: `${r.display_name} (${r.key})`,
}))
nodeHostnames.value = data.node_hostnames || []
nodeHostnames.value = data.node_hostnames
if (!selectedRegion.value && regions.value.length) {
selectedRegion.value = regions.value[0].value
}
@@ -364,30 +364,22 @@ async function submit() {
scheduleOption.value === 'now' ? undefined : dayjs(scheduledDate.value).toISOString()
if (mode.value === 'servers') {
await useServersFetch('/transfers/schedule/servers', {
version: 'internal',
method: 'POST',
body: {
server_ids: parsedServerIds.value,
scheduled_at: scheduledAt,
target_region: selectedRegion.value || undefined,
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
reason: reason.value.trim(),
},
await client.archon.transfers_internal.scheduleServers({
server_ids: parsedServerIds.value,
scheduled_at: scheduledAt,
target_region: selectedRegion.value || undefined,
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
reason: reason.value.trim(),
})
} else {
await useServersFetch('/transfers/schedule/nodes', {
version: 'internal',
method: 'POST',
body: {
node_hostnames: selectedNodes.value.slice(),
scheduled_at: scheduledAt,
target_region: selectedRegion.value || undefined,
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
reason: reason.value.trim(),
cordon_nodes: cordonNodes.value,
tag_nodes: tagNodes.value.trim() || undefined,
},
await client.archon.transfers_internal.scheduleNodes({
node_hostnames: selectedNodes.value.slice(),
scheduled_at: scheduledAt,
target_region: selectedRegion.value || undefined,
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
reason: reason.value.trim(),
cordon_nodes: cordonNodes.value,
tag_nodes: tagNodes.value.trim() || undefined,
})
}
@@ -48,7 +48,12 @@
>{{ isIncome ? '' : '-' }}{{ formatMoney(transaction.amount) }}</span
>
<template v-if="transaction.type === 'withdrawal' && transaction.status === 'in-transit'">
<Tooltip theme="dismissable-prompt" :triggers="['hover', 'focus']" no-auto-focus>
<Tooltip
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
no-auto-focus
>
<span class="my-auto align-middle"
><ButtonStyled circular type="outlined" size="small">
<button class="align-middle" @click="cancelPayout">
@@ -565,7 +565,7 @@ const expandedClasses = reactive<Set<string>>(new Set())
const autoExpandedFileIds = reactive<Set<string>>(new Set())
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
const highlightedSourceCache = reactive<Map<string, { source: string; lines: string[] }>>(new Map())
const LAZY_LOAD_CLASS_SOURCE_MINIMUM = 10
const LAZY_LOAD_CLASS_SOURCE_MINIMUM = 2
interface ClassGroup {
key: string
@@ -152,7 +152,7 @@
v-if="sortedMessages.length > 0"
:disabled="!replyBody || isLoading"
@click="
isApproved(project)
isApproved(project) && !isStaff(auth.user)
? openReplyModal()
: runBlockingAction('reply', () => sendReply())
"
@@ -169,7 +169,7 @@
v-else
:disabled="!replyBody || isLoading"
@click="
isApproved(project)
isApproved(project) && !isStaff(auth.user)
? openReplyModal()
: runBlockingAction('send', () => sendReply())
"
+2 -1
View File
@@ -1,8 +1,9 @@
export const useAffiliates = () => {
const config = useRuntimeConfig()
const affiliateCookie = useCookie('mrs_afl', {
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: 'lax',
secure: true,
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
})
+2 -1
View File
@@ -30,10 +30,11 @@ export const initAuth = async (oldToken = null) => {
}
const route = useRoute()
const config = useRuntimeConfig()
const authCookie = useCookie('auth-token', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
})
+12 -9
View File
@@ -54,6 +54,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showViewProdRouteBanner: false,
showModeratorProjectMemberUi: false,
showModeratorPrivateMessageHighlight: true,
archonApiStaging: false,
showHostingAccessInstanceAuditLog: false,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
@@ -64,19 +66,20 @@ export type AllFeatureFlags = {
export type PartialFeatureFlags = Partial<AllFeatureFlags>
const COOKIE_OPTIONS = {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
httpOnly: false,
path: '/',
} satisfies CookieOptions<PartialFeatureFlags>
const getCookieOptions = () =>
({
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: useRuntimeConfig().public.cookieSecure,
httpOnly: false,
path: '/',
}) satisfies CookieOptions<PartialFeatureFlags>
export const useFeatureFlags = () =>
useState<AllFeatureFlags>('featureFlags', () => {
const config = useRuntimeConfig()
const savedFlags = useCookie<PartialFeatureFlags>('featureFlags', COOKIE_OPTIONS)
const savedFlags = useCookie<PartialFeatureFlags>('featureFlags', getCookieOptions())
if (!savedFlags.value) {
savedFlags.value = {}
@@ -106,6 +109,6 @@ export const useFeatureFlags = () =>
export const saveFeatureFlags = () => {
const flags = useFeatureFlags()
const cookie = useCookie<PartialFeatureFlags>('featureFlags', COOKIE_OPTIONS)
const cookie = useCookie<PartialFeatureFlags>('featureFlags', getCookieOptions())
cookie.value = flags.value
}
@@ -1,264 +0,0 @@
/**
* @deprecated Use `@modrinth/api-client` via `injectModrinthClient()` instead.
* The api-client's archon modules (`client.archon.servers_v0`, etc.) handle auth,
* retry, and circuit breaking automatically. This composable is kept for legacy
* code that hasn't been migrated yet.
*/
import { PANEL_VERSION } from '@modrinth/api-client'
import type { V1ErrorInfo } from '@modrinth/utils'
import { ModrinthServerError, ModrinthServersFetchError } from '@modrinth/utils'
import { $fetch, FetchError } from 'ofetch'
export interface ServersFetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
contentType?: string
body?: Record<string, any>
version?: number | 'internal'
override?: {
url?: string
token?: string
}
retry?: number | boolean
bypassAuth?: boolean
}
export async function useServersFetch<T>(
path: string,
options: ServersFetchOptions = {},
module?: string,
errorContext?: string,
): Promise<T> {
const config = useRuntimeConfig()
const auth = await useAuth()
const authToken = auth.value?.token
if (!authToken && !options.bypassAuth) {
const error = new ModrinthServersFetchError(
'[Modrinth Hosting] Cannot fetch without auth',
10000,
)
throw new ModrinthServerError('Missing auth token', 401, error, module, undefined, undefined)
}
const {
method = 'GET',
contentType = 'application/json',
body,
version = 0,
override,
retry = method === 'GET' ? 3 : 0,
} = options
const circuitBreakerKey = `${module || 'default'}_${path}`
const failureCount = useState<number>(`fetch_failures_${circuitBreakerKey}`, () => 0)
const lastFailureTime = useState<number>(`last_failure_${circuitBreakerKey}`, () => 0)
const now = Date.now()
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
const error = new ModrinthServersFetchError(
'[Modrinth Hosting] Circuit breaker open - too many recent failures',
503,
)
throw new ModrinthServerError(
'Service temporarily unavailable',
503,
error,
module,
undefined,
undefined,
)
}
if (now - lastFailureTime.value > 30000) {
failureCount.value = 0
}
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/,
'',
)
if (!base) {
const error = new ModrinthServersFetchError(
'[Modrinth Hosting] Cannot fetch without base url. Make sure to set a PYRO_BASE_URL in environment variables',
10001,
)
throw new ModrinthServerError(
'Configuration error: Missing PYRO_BASE_URL',
500,
error,
module,
undefined,
undefined,
)
}
const versionString = `v${version}`
let newOverrideUrl = override?.url
if (newOverrideUrl && newOverrideUrl.includes('v0') && version !== 0) {
newOverrideUrl = newOverrideUrl.replace('v0', versionString)
}
const fullUrl = newOverrideUrl
? `https://${newOverrideUrl}/${path.replace(/^\//, '')}`
: version === 0
? `${base}/modrinth/v${version}/${path.replace(/^\//, '')}`
: version === 'internal'
? `${base}/_internal/${path.replace(/^\//, '')}`
: `${base}/v${version}/${path.replace(/^\//, '')}`
const headers: Record<string, string> = {
'User-Agent': 'Modrinth/1.0 (https://modrinth.com)',
'X-Archon-Request': 'true',
'X-Panel-Version': String(PANEL_VERSION),
Vary: 'Accept, Origin',
}
if (!options.bypassAuth) {
headers.Authorization = `Bearer ${override?.token ?? authToken}`
headers['Access-Control-Allow-Headers'] = 'Authorization'
}
if (contentType !== 'none') {
headers['Content-Type'] = contentType
}
if (import.meta.client && typeof window !== 'undefined') {
headers.Origin = window.location.origin
}
let attempts = 0
const maxAttempts = (typeof retry === 'boolean' ? (retry ? 3 : 1) : retry) + 1
let lastError: Error | null = null
while (attempts < maxAttempts) {
try {
const response = await $fetch<T>(fullUrl, {
method,
headers,
body:
body && contentType === 'application/json' ? JSON.stringify(body) : (body ?? undefined),
timeout: 10000,
})
failureCount.value = 0
return response
} catch (error) {
lastError = error as Error
attempts++
if (error instanceof FetchError) {
const statusCode = error.response?.status
const statusText = error.response?.statusText || 'Unknown error'
if (statusCode && statusCode >= 500) {
failureCount.value++
lastFailureTime.value = now
}
let v1Error: V1ErrorInfo | undefined
if (error.data?.error && error.data?.description) {
v1Error = {
context: errorContext,
...error.data,
}
}
const errorMessages: { [key: number]: string } = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
408: 'Request Timeout',
429: "You're making requests too quickly. Please wait a moment and try again.",
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
}
const message =
statusCode && statusCode in errorMessages
? errorMessages[statusCode]
: `HTTP Error: ${statusCode || 'unknown'} ${statusText}`
const isRetryable = statusCode ? [408, 429].includes(statusCode) : false
const is5xxRetryable =
statusCode && statusCode >= 500 && statusCode < 600 && method === 'GET' && attempts === 1
if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) {
console.error('Fetch error:', error)
const fetchError = new ModrinthServersFetchError(
`[Modrinth Hosting] ${error.message}`,
statusCode,
error,
)
throw new ModrinthServerError(
`[Modrinth Hosting] ${message}`,
statusCode,
fetchError,
module,
v1Error,
error.data,
)
}
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000
const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000)
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`)
await new Promise((resolve) => setTimeout(resolve, delay))
continue
}
console.error('Unexpected fetch error:', error)
const fetchError = new ModrinthServersFetchError(
'[Modrinth Hosting] An unexpected error occurred during the fetch operation.',
undefined,
error as Error,
)
throw new ModrinthServerError(
'Unexpected error during fetch operation',
undefined,
fetchError,
module,
undefined,
undefined,
)
}
}
console.error('All retry attempts failed:', lastError)
if (lastError instanceof FetchError) {
const statusCode = lastError.response?.status
const pyroError = new ModrinthServersFetchError(
'Maximum retry attempts reached',
statusCode,
lastError,
)
throw new ModrinthServerError(
'Maximum retry attempts reached',
statusCode,
pyroError,
module,
undefined,
lastError.data,
)
}
const fetchError = new ModrinthServersFetchError(
'Maximum retry attempts reached',
undefined,
lastError || undefined,
)
throw new ModrinthServerError(
'Maximum retry attempts reached',
undefined,
fetchError,
module,
undefined,
undefined,
)
}
@@ -17,7 +17,6 @@ export type FilterSelection = {
const cookieDefaults = {
maxAge: TEN_MINUTES,
sameSite: 'lax' as const,
secure: true,
path: '/',
httpOnly: false,
}
@@ -52,13 +51,19 @@ function newFilterSelection(
}
export function useCdnDownloadContext() {
const filterGameVersionCookie = useCookie<string | null>('mr_download_filter_game_version', {
const config = useRuntimeConfig()
const cookieOptions = {
...cookieDefaults,
secure: config.public.cookieSecure,
}
const filterGameVersionCookie = useCookie<string | null>('mr_download_filter_game_version', {
...cookieOptions,
default: () => null,
})
const filterLoaderCookie = useCookie<string | null>('mr_download_filter_loader', {
...cookieDefaults,
...cookieOptions,
default: () => null,
})
+3 -1
View File
@@ -14,6 +14,7 @@ import {
import type { Ref } from 'vue'
import { useFeatureFlags } from '~/composables/featureFlags.ts'
import { withStagingArchonBaseUrl } from '~/helpers/archon.ts'
async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
try {
@@ -37,7 +38,8 @@ export function createModrinthClient(
const clientConfig: NuxtClientConfig = {
labrinthBaseUrl: config.apiBaseUrl,
archonBaseUrl: config.archonBaseUrl,
archonBaseUrl: () =>
withStagingArchonBaseUrl(config.archonBaseUrl, flags.value.archonApiStaging),
archonSentryCapture: () => flags.value.archonSentryCapture,
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
features: [
+12
View File
@@ -0,0 +1,12 @@
export const STAGING_ARCHON_BASE_URL = 'https://staging-archon.modrinth.com/'
export function withStagingArchonBaseUrl(
baseUrl: string,
useStaging = useFeatureFlags().value.archonApiStaging,
) {
if (!useStaging) {
return baseUrl
}
return STAGING_ARCHON_BASE_URL
}
+11 -2
View File
@@ -368,6 +368,15 @@
"analytics.stat.downloads": {
"message": "Downloads"
},
"analytics.stat.monetization-banner.body": {
"message": "Only views and downloads made through Modrinth are eligible for monetization and must pass fraud-prevention filtering. Modrinth App downloads also require the user to be logged in. Because all projects have a similar ratio of monetized downloads, your revenue would not meaningfully change if all downloads were counted."
},
"analytics.stat.monetization-banner.learn-more": {
"message": "Learn more"
},
"analytics.stat.monetization-banner.title": {
"message": "How does monetization work?"
},
"analytics.stat.playtime": {
"message": "Playtime"
},
@@ -1944,7 +1953,7 @@
"message": "Frog"
},
"hosting-marketing.available-locations": {
"message": "Available in North America, Europe, and Southeast Asia for wide coverage."
"message": "Available in North America, Europe, Southeast Asia, and Australia for wide coverage."
},
"hosting-marketing.billing.monthly": {
"message": "Pay monthly"
@@ -2007,7 +2016,7 @@
"message": "Where are Modrinth Hosting servers located? Can I choose a region?"
},
"hosting-marketing.faq.location.answer": {
"message": "We have servers available across North America, Europe, and Southeast Asia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support."
"message": "We have servers available across North America, Europe, Southeast Asia, and Australia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support."
},
"hosting-marketing.faq.versions-loaders": {
"message": "What Minecraft versions and loaders can be used?"
+6 -2
View File
@@ -23,6 +23,10 @@ function copy(id: string) {
navigator.clipboard?.writeText(`/_internal/templates/email/${id}`).catch(() => {})
}
function uncachedPreviewUrl(id: string) {
return `/_internal/templates/email/${id}?preview=${Date.now()}`
}
const previewModal = ref<{ hide: () => void; show: () => void } | null>(null)
const previewTemplate = ref<string | null>(null)
const previewLoading = ref(false)
@@ -73,7 +77,7 @@ async function openPreview(id: string, event?: MouseEvent) {
variableValues.value = {}
try {
const response = await fetch(`/_internal/templates/email/${id}`)
const response = await fetch(uncachedPreviewUrl(id), { cache: 'no-store' })
previewHtml.value = await response.text()
if (!response.ok) {
@@ -103,7 +107,7 @@ function openPopupPreview(id: string, offset = 0) {
const left = window.screenX + (window.outerWidth - width) / 2 + ((offset * 28) % 320)
const top = window.screenY + (window.outerHeight - height) / 2 + ((offset * 28) % 320)
window.open(
`/_internal/templates/email/${id}`,
uncachedPreviewUrl(id),
`email-${id}`,
`popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,menubar=no,toolbar=no,location=no,status=no`,
)
@@ -218,7 +218,7 @@
:level="notice.level"
:message="notice.message"
:dismissable="notice.dismissable"
:title="notice.title"
:title="notice.title ?? undefined"
preview
/>
<div class="mt-4 flex items-center gap-2">
@@ -260,6 +260,7 @@
</div>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { EditIcon, PlusIcon, SaveIcon, SettingsIcon, TrashIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
@@ -267,6 +268,7 @@ import {
commonMessages,
CopyCode,
defineMessages,
injectModrinthClient,
injectNotificationManager,
NewModal,
ServerNotice,
@@ -278,14 +280,13 @@ import {
useVIntl,
} from '@modrinth/ui'
import { NOTICE_LEVELS } from '@modrinth/ui/src/utils/notices.ts'
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import AssignNoticeModal from '~/components/ui/admin/AssignNoticeModal.vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
@@ -297,6 +298,8 @@ const formatDateTimeShortMonth = useFormatDateTime({
dateStyle: 'medium',
})
type ServerNoticeType = Archon.Notices.v0.ListedNotice
const notices = ref<ServerNoticeType[]>([])
const createNoticeModal = ref<InstanceType<typeof NewModal>>()
const assignNoticeModal = ref<InstanceType<typeof AssignNoticeModal>>()
@@ -304,16 +307,14 @@ const assignNoticeModal = ref<InstanceType<typeof AssignNoticeModal>>()
await refreshNotices()
async function refreshNotices() {
await useServersFetch('notices').then((res) => {
notices.value = res as ServerNoticeType[]
notices.value.sort((a, b) => {
const dateDiff = dayjs(b.announce_at).diff(dayjs(a.announce_at))
if (dateDiff === 0) {
return b.id - a.id
}
notices.value = await client.archon.notices_v0.list()
notices.value.sort((a, b) => {
const dateDiff = dayjs(b.announce_at).diff(dayjs(a.announce_at))
if (dateDiff === 0) {
return b.id - a.id
}
return dateDiff
})
return dateDiff
})
}
@@ -347,7 +348,7 @@ function startEditing(notice: ServerNoticeType, assignments: boolean = false) {
newNoticeLevel.value = levelOptions.find((x) => x.id === notice.level) ?? levelOptions[0]
newNoticeDismissable.value = notice.dismissable
newNoticeMessage.value = notice.message
newNoticeTitle.value = notice.title
newNoticeTitle.value = notice.title ?? undefined
newNoticeScheduledDate.value = dayjs(notice.announce_at).format(DATE_TIME_FORMAT)
newNoticeExpiresDate.value = notice.expires
? dayjs(notice.expires).format(DATE_TIME_FORMAT)
@@ -361,9 +362,8 @@ function startEditing(notice: ServerNoticeType, assignments: boolean = false) {
}
async function deleteNotice(notice: ServerNoticeType) {
await useServersFetch(`notices/${notice.id}`, {
method: 'DELETE',
})
await client.archon.notices_v0
.delete(notice.id)
.then(() => {
addNotification({
title: `Successfully deleted notice #${notice.id}`,
@@ -412,9 +412,10 @@ async function saveChanges() {
return
}
await useServersFetch(`notices/${editingNotice.value?.id}`, {
method: 'PATCH',
body: {
if (!editingNotice.value) return
await client.archon.notices_v0
.update(editingNotice.value.id, {
message: newNoticeMessage.value,
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
level: newNoticeLevel.value.id,
@@ -425,14 +426,14 @@ async function saveChanges() {
expires: newNoticeExpiresDate.value
? dayjs(newNoticeExpiresDate.value).toISOString()
: undefined,
},
}).catch((err) => {
addNotification({
title: 'Error saving changes to notice',
text: err,
type: 'error',
})
})
.catch((err) => {
addNotification({
title: 'Error saving changes to notice',
text: err,
type: 'error',
})
})
await refreshNotices()
createNoticeModal.value?.hide()
}
@@ -442,9 +443,8 @@ async function createNotice() {
return
}
await useServersFetch('notices', {
method: 'POST',
body: {
await client.archon.notices_v0
.create({
message: newNoticeMessage.value,
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
level: newNoticeLevel.value.id,
@@ -455,14 +455,14 @@ async function createNotice() {
expires: newNoticeExpiresDate.value
? dayjs(newNoticeExpiresDate.value).toISOString()
: undefined,
},
}).catch((err) => {
addNotification({
title: 'Error creating notice',
text: err,
type: 'error',
})
})
.catch((err) => {
addNotification({
title: 'Error creating notice',
text: err,
type: 'error',
})
})
await refreshNotices()
createNoticeModal.value?.hide()
}
@@ -108,11 +108,13 @@
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { PlusIcon, XCircleIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
ConfirmModal,
injectModrinthClient,
injectNotificationManager,
Pagination,
TagItem,
@@ -124,38 +126,16 @@ import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import TransferModal from '~/components/ui/admin/TransferModal.vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
// Types
interface ProvisionOptions {
region?: string | null
node_tags?: string[]
}
interface TransferBatch {
id: number
created_by: string
created_at: string
reason: string | null
scheduled_at: string
cancelled: boolean
log_count: number
provision_options: ProvisionOptions
}
interface HistoryResponse {
batches: TransferBatch[]
total: number
page: number
page_size: number
}
type TransferBatch = Archon.Transfers.Internal.TransferLogBatchEntry
const transferModal = ref<InstanceType<typeof TransferModal>>()
const cancelModal = ref<InstanceType<typeof ConfirmModal>>()
@@ -178,10 +158,10 @@ async function refreshHistory() {
loading.value = true
error.value = null
try {
const data = await useServersFetch<HistoryResponse>(
`/transfers/history?page=${currentPage.value}&page_size=${pageSize}`,
{ version: 'internal' },
)
const data = await client.archon.transfers_internal.history({
page: currentPage.value,
page_size: pageSize,
})
batches.value = data.batches || []
total.value = data.total || 0
@@ -283,12 +263,8 @@ function showCancelModal(batchId: number) {
async function confirmCancel() {
if (!cancellingBatchId.value) return
try {
await useServersFetch('/transfers/cancel', {
version: 'internal',
method: 'POST',
body: {
batch_ids: [cancellingBatchId.value],
},
await client.archon.transfers_internal.cancel({
batch_ids: [cancellingBatchId.value],
})
addNotification({
title: 'Transfer cancelled',
@@ -72,6 +72,7 @@
}}
<Tooltip
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
no-auto-focus
:aria-id="`${baseId}-date-segment-tooltip-${i}`"
@@ -106,6 +107,7 @@
{{ formatMessage(messages.processing) }}
<Tooltip
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
no-auto-focus
:aria-id="`${baseId}-processing-tooltip`"
+3 -2
View File
@@ -868,7 +868,7 @@ const messages = defineMessages({
faqLocationAnswer: {
id: 'hosting-marketing.faq.location.answer',
defaultMessage:
"We have servers available across North America, Europe, and Southeast Asia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support.",
"We have servers available across North America, Europe, Southeast Asia, and Australia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support.",
},
faqIncreaseStorage: {
id: 'hosting-marketing.faq.increase-storage',
@@ -921,7 +921,8 @@ const messages = defineMessages({
},
availableLocations: {
id: 'hosting-marketing.available-locations',
defaultMessage: 'Available in North America, Europe, and Southeast Asia for wide coverage.',
defaultMessage:
'Available in North America, Europe, Southeast Asia, and Australia for wide coverage.',
},
payMonthly: {
id: 'hosting-marketing.billing.monthly',
@@ -11,6 +11,7 @@
:auth-user="authUser"
:navigate-to-billing="() => router.push('/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
constrain-width
:browse-modpacks="
({ serverId: sid, worldId: wid, from }) => {
navigateTo({
@@ -0,0 +1,82 @@
<script setup lang="ts">
import {
injectModrinthClient,
injectModrinthServerContext,
ServersManageAccessPage,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const client = injectModrinthClient()
const { server, serverId } = injectModrinthServerContext()
const queryClient = useQueryClient()
const flags = useFeatureFlags()
const ACTION_LOG_PAGE_SIZE = 200
const ACTION_LOG_SORT_DIRECTION = 'desc'
const actionLogDateFilter = defaultActionLogDateFilter()
await Promise.allSettled([
queryClient.ensureQueryData({
queryKey: ['servers', 'users', 'v1', serverId],
queryFn: () => client.archon.server_users_v1.list(serverId),
staleTime: 30_000,
}),
queryClient.ensureQueryData({
queryKey: ['servers', 'v1', 'detail', serverId],
queryFn: () => client.archon.servers_v1.get(serverId),
staleTime: 30_000,
}),
queryClient.prefetchInfiniteQuery({
queryKey: [
'servers',
'action-log',
'v1',
'infinite',
serverId,
null,
actionLogDateFilter.min_datetime,
actionLogDateFilter.max_datetime,
ACTION_LOG_SORT_DIRECTION,
],
queryFn: ({ pageParam = 0 }) => {
const offset = typeof pageParam === 'number' ? pageParam : 0
return client.archon.actions_v1.list(serverId, {
limit: ACTION_LOG_PAGE_SIZE,
offset,
order: ACTION_LOG_SORT_DIRECTION,
...actionLogDateFilter,
})
},
getNextPageParam: (lastPage) =>
typeof lastPage.next_offset === 'number' ? lastPage.next_offset : undefined,
initialPageParam: 0,
staleTime: 30_000,
}),
])
useHead({
title: computed(() => `Access - ${server.value?.name ?? 'Server'} - Modrinth`),
})
function defaultActionLogDateFilter() {
const endDate = new Date()
const startDate = new Date(endDate)
startDate.setDate(startDate.getDate() - 6)
return {
min_datetime: startOfDay(startDate).toISOString(),
max_datetime: endOfDay(endDate).toISOString(),
}
}
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
function endOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999)
}
</script>
<template>
<ServersManageAccessPage :show-audit-log-instances="flags.showHostingAccessInstanceAuditLog" />
</template>
@@ -17,42 +17,81 @@
<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">
<div
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
>
<Combobox
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:options="filterTypes"
v-model="currentMessageFilter"
class="!w-full flex-grow sm:!w-[200px] sm:flex-grow-0"
:options="messageFilterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
@select="goToPage(1)"
>
<template #selected>
<template #selected="{ label: messageLabel }">
<span class="flex flex-row gap-2 align-middle font-semibold">
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast"
>{{ currentFilterType }} ({{ filteredReports.length }})</span
>{{ messageLabel }} ({{ sortedReports.length }})</span
>
</span>
</template>
</Combobox>
<Combobox
v-model="currentSortType"
v-model="currentSortTypeSorting"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
@select="goToPage(1)"
>
<template #selected>
<template #selected="{ label: sortingLabel }">
<span class="flex flex-row gap-2 align-middle font-semibold">
<SortAscIcon
v-if="currentSortType === 'Oldest'"
v-if="currentSortTypeSorting === 'oldest'"
class="size-5 flex-shrink-0 text-secondary"
/>
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast">{{ currentSortType }}</span>
<span class="truncate text-contrast">{{ sortingLabel }}</span>
</span>
</template>
</Combobox>
<FloatingPanel button-class="!h-10 !shadow-none !text-contrast" :auto-focus="false">
<BlendIcon class="size-5" /> Advanced filters
<template #panel>
<div class="flex min-w-64 flex-col gap-3">
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-secondary">Report target</span>
<Combobox
v-model="currentReportTargetFilter"
class="!w-full"
:options="reportTargetFilterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
/>
</div>
<div class="flex min-w-64 flex-col gap-3">
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-secondary">Issue type</span>
<Combobox
v-model="currentReportIssueFilter"
class="!w-full"
:options="reportIssueFilterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-secondary">Project type</span>
<Combobox
v-model="currentProjectTypeFilter"
class="!w-full"
:options="projectTypeFilterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
/>
</div>
</div>
</template>
</FloatingPanel>
</div>
</div>
@@ -72,12 +111,13 @@
</template>
<script setup lang="ts">
import { ListFilterIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import { BlendIcon, ListFilterIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import type { ExtendedReport } from '@modrinth/moderation'
import {
Combobox,
type ComboboxOption,
commonMessages,
FloatingPanel,
Pagination,
StyledInput,
useVIntl,
@@ -93,6 +133,7 @@ useHead({ title: 'Reports queue - Modrinth' })
const { formatMessage } = useVIntl()
const route = useRoute()
const router = useRouter()
const auth = await useAuth()
const { data: allReports } = await useLazyAsyncData('new-moderation-reports', async () => {
const startTime = performance.now()
@@ -168,22 +209,54 @@ watch(
},
)
const currentFilterType = ref('All')
const filterTypes: ComboboxOption<string>[] = [
{ value: 'All', label: 'All' },
{ value: 'Unread', label: 'Unread' },
{ value: 'Read', label: 'Read' },
const currentSortTypeSorting = ref('oldest')
const sortTypes: ComboboxOption<string>[] = [
{ value: 'oldest', label: 'Oldest' },
{ value: 'newest', label: 'Newest' },
]
const currentSortType = ref('Oldest')
const sortTypes: ComboboxOption<string>[] = [
{ value: 'Oldest', label: 'Oldest' },
{ value: 'Newest', label: 'Newest' },
const currentMessageFilter = ref('all')
const messageFilterTypes: ComboboxOption<string>[] = [
{ value: 'all', label: 'All' },
{ value: 'unread', label: 'Unread' },
{ value: 'read', label: 'Read' },
{ value: 'involved', label: 'Involved' },
]
const currentProjectTypeFilter = ref('all')
const projectTypeFilterTypes: ComboboxOption<string>[] = [
{ value: 'all', label: 'All project types' },
{ value: 'modpack', label: 'Modpacks' },
{ value: 'mod', label: 'Mods' },
{ value: 'resourcepack', label: 'Resource Packs' },
{ value: 'datapack', label: 'Data Packs' },
{ value: 'plugin', label: 'Plugins' },
{ value: 'shader', label: 'Shaders' },
{ value: 'minecraft_java_server', label: 'Servers' },
]
const currentReportTargetFilter = ref('all')
const reportTargetFilterTypes: ComboboxOption<string>[] = [
{ value: 'all', label: 'All' },
{ value: 'project', label: 'Projects' },
{ value: 'user', label: 'Users' },
{ value: 'version', label: 'Versions' },
]
const currentReportIssueFilter = ref('all')
const reportIssueFilterTypes = computed<ComboboxOption<string>[]>(() => {
const base: ComboboxOption<string>[] = [{ value: 'all', label: 'All' }]
if (!allReports.value) return base
const issueTypes = new Set(allReports.value.map((report) => report.report_type))
const sortedTypes = Array.from(issueTypes).sort()
return [...base, ...sortedTypes.map((type) => ({ value: type, label: type }))]
})
const currentPage = ref(1)
const itemsPerPage = 15
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage))
const totalPages = computed(() => Math.ceil((sortedReports.value?.length || 0) / itemsPerPage))
const fuse = computed(() => {
if (!allReports.value || allReports.value.length === 0) return null
@@ -247,33 +320,68 @@ const baseFiltered = computed(() => {
return query.value && searchResults.value ? searchResults.value : [...allReports.value]
})
const typeFiltered = computed(() => {
if (currentFilterType.value === 'All') return baseFiltered.value
const filteredReports = computed(() => {
const messageFilter = currentMessageFilter.value
const projectTypeFilter = currentProjectTypeFilter.value
const reportTargetFilter = currentReportTargetFilter.value
const reportIssueFilter = currentReportIssueFilter.value
return baseFiltered.value.filter((report) => {
if (
messageFilter === 'all' &&
projectTypeFilter === 'all' &&
reportTargetFilter === 'all' &&
reportIssueFilter === 'all'
) {
return baseFiltered.value
}
const messageFilterPredicate = (report: ExtendedReport) => {
const messages = report.thread?.messages || []
if (messages.length === 0) {
return currentFilterType.value === 'Unread'
}
if (messageFilter === 'all') return true
if (messages.length === 0) return messageFilter === 'Unread'
if (!messages[messages.length - 1].author_id) return false
const lastMessage = messages[messages.length - 1]
if (!lastMessage.author_id) return false
if (messageFilter === 'involved') {
const userId = (auth.value.user as any)?.id
return userId && messages.some((message) => message.author_id === userId)
}
const roleMap = memberRoleMap.value.get(report.id)
if (!roleMap) return false
const authorRole = roleMap.get(lastMessage.author_id)
const authorRole = roleMap.get(messages[messages.length - 1].author_id)
const isModeratorMessage = authorRole === 'moderator' || authorRole === 'admin'
return currentFilterType.value === 'Read' ? isModeratorMessage : !isModeratorMessage
return messageFilter === 'Read' ? isModeratorMessage : !isModeratorMessage
}
const projectTypeFilterPredicate = (report: ExtendedReport) => {
return projectTypeFilter === 'all' || report.project?.project_type === projectTypeFilter
}
const reportTargetFilterPredicate = (report: ExtendedReport) => {
return reportTargetFilter === 'all' || report.item_type === reportTargetFilter
}
const reportIssueFilterPredicate = (report: ExtendedReport) => {
return reportIssueFilter === 'all' || report.report_type === reportIssueFilter
}
return baseFiltered.value.filter((report) => {
return (
messageFilterPredicate(report) &&
projectTypeFilterPredicate(report) &&
reportTargetFilterPredicate(report) &&
reportIssueFilterPredicate(report)
)
})
})
const filteredReports = computed(() => {
const filtered = [...typeFiltered.value]
const sortedReports = computed(() => {
const filtered = [...filteredReports.value]
if (currentSortType.value === 'Oldest') {
if (currentSortTypeSorting.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())
@@ -283,10 +391,10 @@ const filteredReports = computed(() => {
})
const paginatedReports = computed(() => {
if (!filteredReports.value) return []
if (!sortedReports.value) return []
const start = (currentPage.value - 1) * itemsPerPage
const end = start + itemsPerPage
return filteredReports.value.slice(start, end)
return sortedReports.value.slice(start, end)
})
function goToPage(page: number) {
@@ -85,7 +85,7 @@ const showPreviewImage = (files) => {
}
}
const orgId = useRouteId('orgId')
const orgId = useRouteId('organization')
const save = async () => {
// Save field changes via useSavable
+2 -1
View File
@@ -27,10 +27,11 @@ export interface Cosmetics {
export default defineNuxtPlugin({
name: 'cosmetics',
setup() {
const config = useRuntimeConfig()
const cosmetics = useCookie<Cosmetics>('cosmetics', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
default: () => ({
@@ -8,10 +8,11 @@ interface ThemeSettings {
export function useThemeSettings(getDefaultTheme?: () => Theme) {
getDefaultTheme ??= () => 'dark'
const config = useRuntimeConfig()
const $settings = useCookie<ThemeSettings>('color-mode', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
})
@@ -17,7 +17,7 @@ import type {
const ANALYTICS_START_TIMESTAMP = '2023-01-01T00:00:00.000Z'
export const ANALYTICS_START_DATE_INPUT_VALUE = ANALYTICS_START_TIMESTAMP.slice(0, 10)
const ANALYTICS_START_TIME = new Date(ANALYTICS_START_TIMESTAMP).getTime()
export const ANALYTICS_START_TIME = new Date(ANALYTICS_START_TIMESTAMP).getTime()
export const REVENUE_MIN_TIMEFRAME_MS = 1 * 24 * 60 * 60 * 1000 // need at least 1 day in timeframe range to show revenue
const ANALYTICS_DAY_MS = 24 * 60 * 60 * 1000
const ANALYTICS_MAX_TIME_SLICES = 256 // controls granularity allowed in "group by" for timeframe ranges
@@ -32,6 +32,7 @@ function isProjectAnalyticsPoint(
export function buildComparisonFetchRequest(
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
minStartTime = ANALYTICS_START_TIME,
): AnalyticsProjectFetchRequest | null {
if (!isAnalyticsFetchRequestReady(fetchRequest)) {
return null
@@ -47,7 +48,7 @@ export function buildComparisonFetchRequest(
const previousStart = new Date(startTimestamp - duration)
if (previousStart.getTime() < ANALYTICS_START_TIME) {
if (previousStart.getTime() < minStartTime) {
return null
}
@@ -93,8 +94,12 @@ function getAnalyticsTimeSliceCount(
export function splitAnalyticsTimeSlices(
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
minStartTime = ANALYTICS_START_TIME,
): AnalyticsTimeSliceSplit {
if (!isAnalyticsFetchRequestReady(fetchRequest) || !buildComparisonFetchRequest(fetchRequest)) {
if (
!isAnalyticsFetchRequestReady(fetchRequest) ||
!buildComparisonFetchRequest(fetchRequest, minStartTime)
) {
return {
currentTimeSlices: timeSlices,
previousTimeSlices: [],
@@ -339,6 +344,7 @@ export function getAnalyticsTimeframeDurationMs({
customStartDate,
customEndDate,
nowTimestamp,
allTimeStartTimestamp = ANALYTICS_START_TIME,
}: {
mode: AnalyticsTimeframeMode
preset: AnalyticsTimeframePreset
@@ -347,6 +353,7 @@ export function getAnalyticsTimeframeDurationMs({
customStartDate: string
customEndDate: string
nowTimestamp: number
allTimeStartTimestamp?: number
}): number {
if (mode === 'preset') {
switch (preset) {
@@ -370,7 +377,7 @@ export function getAnalyticsTimeframeDurationMs({
return now.getTime() - yearStart.getTime()
}
case 'all_time': {
const allTimeDurationMs = nowTimestamp - ANALYTICS_START_TIME
const allTimeDurationMs = nowTimestamp - allTimeStartTimestamp
return Math.max(0, allTimeDurationMs)
}
}
@@ -46,6 +46,7 @@ export function toAnalyticsDashboardProject(
iconUrl: project.icon_url ?? undefined,
downloads: project.downloads ?? 0,
status: getProjectStatusFilterValue(project.status),
publishedAt: project.published ?? undefined,
}
}
@@ -108,6 +108,7 @@ export type AnalyticsDashboardProjectSource = ProjectTypeMetadata & {
icon_url?: string | null
downloads?: number | null
status?: string | null
published?: string | null
}
export type AnalyticsProjectVersionSource = {
@@ -121,6 +122,7 @@ export interface AnalyticsDashboardProject {
iconUrl?: string
downloads: number
status: ProjectStatusFilterValue
publishedAt?: string
}
export interface AnalyticsDashboardProjectGroup {
@@ -36,6 +36,7 @@ import type { OrganizationContext } from '../organization-context'
import {
addVersionIdsFromTimeSlices,
addVersionProjectNamesFromTimeSlices,
ANALYTICS_START_TIME,
areAnalyticsFetchRequestsEqual,
buildAnalyticsCurrentTimeSlicesQueryKey,
buildAnalyticsFacetsRequest,
@@ -68,6 +69,7 @@ import {
sortStringValues,
} from './analytics-filter-utils'
import {
getProjectIdsMatchingStatusFilter,
getProjectOrganizationId,
getSingleQueryValue,
getUniqueAnalyticsDashboardProjects,
@@ -131,6 +133,17 @@ const ANALYTICS_TIME_SLICES_GC_TIME_MS = 30 * 1000
const ANALYTICS_PREFETCH_GC_TIME_MS = 15 * 1000
const ANALYTICS_FILTER_OPTIONS_GC_TIME_MS = 60 * 1000
const ANALYTICS_MOBILE_LAYOUT_QUERY = '(pointer: coarse), (max-width: 800px)'
const ANALYTICS_ALL_TIME_START_OFFSET_MONTHS = 2
function subtractAnalyticsCalendarMonths(date: Date, months: number): Date {
const nextDate = new Date(date)
const day = nextDate.getDate()
nextDate.setDate(1)
nextDate.setMonth(nextDate.getMonth() - months)
const daysInMonth = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate()
nextDate.setDate(Math.min(day, daysInMonth))
return nextDate
}
function getAnalyticsFetchErrorMessage(error: unknown): string {
if (error && typeof error === 'object') {
@@ -164,6 +177,7 @@ export interface AnalyticsDashboardContextValue {
selectedCustomTimeframeStartDate: Ref<string>
selectedCustomTimeframeEndDate: Ref<string>
selectedGroupBy: Ref<AnalyticsGroupByPreset>
analyticsAllTimeStartDate: ComputedRef<Date>
selectedBreakdowns: Ref<AnalyticsSelectedBreakdowns>
selectedFilters: Ref<AnalyticsSelectedFilters>
queryRefreshTimestamp: Ref<number>
@@ -368,6 +382,7 @@ export function createAnalyticsDashboardContext(
},
enabled: computed(() => shouldFetchEffectiveUser.value && hasCompletedAnalyticsLoading.value),
placeholderData: null,
refetchOnWindowFocus: false,
})
const effectiveUsername = computed(() => {
if (effectiveUserId.value === options.auth.value.user?.id) {
@@ -407,6 +422,7 @@ export function createAnalyticsDashboardContext(
}
},
enabled: shouldFetchDashboardAllProjects,
refetchOnWindowFocus: false,
})
const areProjectsLoaded = computed(() => {
@@ -547,6 +563,63 @@ export function createAnalyticsDashboardContext(
return getAnalyticsVersionIdsFromProjects(projects, sortedSelectedProjectIds.value)
})
const { data: filterOptionProjectVersions, isFetched: hasFetchedFilterOptionProjectVersions } =
useQuery({
queryKey: computed(() => [
'analytics',
'dashboard',
analyticsQueryUserId.value,
'filter-options',
'versions',
filterOptionVersionIds.value,
]),
queryFn: () =>
fetchAnalyticsVersionMetadataByIds(filterOptionVersionIds.value, (ids) =>
client.labrinth.versions_v3.getVersions(ids),
),
enabled: computed(
() =>
filterOptionProjectSources.value !== null && sortedSelectedProjectIds.value.length > 0,
),
placeholderData: [],
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
refetchOnWindowFocus: false,
})
const projectsById = computed(
() => new Map(projects.value.map((project) => [project.id, project])),
)
const analyticsAllTimeStartDate = computed(() => {
const fallbackStartDate = new Date(ANALYTICS_START_TIME)
const filteredProjectIds = getProjectIdsMatchingStatusFilter(
selectedProjectIds.value.length > 0 ? selectedProjectIds.value : availableProjectIds.value,
projectStatusById.value,
selectedFilters.value,
)
let startTime = Number.POSITIVE_INFINITY
for (const projectId of filteredProjectIds) {
const publishedAt = projectsById.value.get(projectId)?.publishedAt
if (!publishedAt) {
continue
}
const projectStartTime = new Date(publishedAt).getTime()
if (Number.isFinite(projectStartTime)) {
startTime = Math.min(startTime, projectStartTime)
}
}
if (!Number.isFinite(startTime)) {
return fallbackStartDate
}
const offsetStartDate = subtractAnalyticsCalendarMonths(
new Date(startTime),
ANALYTICS_ALL_TIME_START_OFFSET_MONTHS,
)
return new Date(Math.max(offsetStartDate.getTime(), ANALYTICS_START_TIME))
})
const hasExplicitProjectSelectionQuery = computed(() =>
hasAnalyticsProjectSelectionQuery(route.query),
)
@@ -598,6 +671,7 @@ export function createAnalyticsDashboardContext(
customStartDate: selectedCustomTimeframeStartDate.value,
customEndDate: selectedCustomTimeframeEndDate.value,
nowTimestamp: queryRefreshTimestamp.value,
allTimeStartTimestamp: analyticsAllTimeStartDate.value.getTime(),
}) > REVENUE_MIN_TIMEFRAME_MS,
)
@@ -867,7 +941,17 @@ export function createAnalyticsDashboardContext(
{ deep: true },
)
const comparisonFetchRequest = computed(() => buildComparisonFetchRequest(fetchRequest.value))
const analyticsComparisonStartTime = computed(() => {
if (selectedTimeframeMode.value === 'preset' && selectedTimeframe.value === 'all_time') {
const fetchRequestStart = fetchRequest.value?.time_range.start
return new Date(fetchRequestStart ?? analyticsAllTimeStartDate.value).getTime()
}
return ANALYTICS_START_TIME
})
const comparisonFetchRequest = computed(() =>
buildComparisonFetchRequest(fetchRequest.value, analyticsComparisonStartTime.value),
)
const analyticsTimeSlicesFetchRequest = computed(
() => comparisonFetchRequest.value ?? fetchRequest.value,
)
@@ -901,6 +985,7 @@ export function createAnalyticsDashboardContext(
)
},
enabled: computed(() => isAnalyticsFetchRequestReady(analyticsTimeSlicesFetchRequest.value)),
refetchOnWindowFocus: false,
gcTime: ANALYTICS_TIME_SLICES_GC_TIME_MS,
})
watch(currentAnalyticsError, (error) => {
@@ -956,7 +1041,10 @@ export function createAnalyticsDashboardContext(
}
const dailyFetchRequest = buildDailyAnalyticsFetchRequest(fetchRequest.value)
return buildComparisonFetchRequest(dailyFetchRequest) ?? dailyFetchRequest
return (
buildComparisonFetchRequest(dailyFetchRequest, analyticsComparisonStartTime.value) ??
dailyFetchRequest
)
})
watch(
@@ -984,7 +1072,7 @@ export function createAnalyticsDashboardContext(
nextQueryRefreshTimestamp,
),
queryFn: () =>
fetchAnalyticsTimeSlices(nextFetchRequest, (request) =>
fetchAnalyticsData(nextFetchRequest, (request) =>
client.labrinth.analytics_v3.fetch(request),
),
gcTime: ANALYTICS_PREFETCH_GC_TIME_MS,
@@ -1057,6 +1145,7 @@ export function createAnalyticsDashboardContext(
isAnalyticsFetchRequestReady(analyticsFacetsRequest.value),
),
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
refetchOnWindowFocus: false,
})
const { data: analyticsDownloadCountTimeSlices } = useQuery({
@@ -1086,30 +1175,9 @@ export function createAnalyticsDashboardContext(
),
placeholderData: [],
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
refetchOnWindowFocus: false,
})
const { data: filterOptionProjectVersions, isFetched: hasFetchedFilterOptionProjectVersions } =
useQuery({
queryKey: computed(() => [
'analytics',
'dashboard',
analyticsQueryUserId.value,
'filter-options',
'versions',
filterOptionVersionIds.value,
]),
queryFn: () =>
fetchAnalyticsVersionMetadataByIds(filterOptionVersionIds.value, (ids) =>
client.labrinth.versions_v3.getVersions(ids),
),
enabled: computed(
() =>
filterOptionProjectSources.value !== null && sortedSelectedProjectIds.value.length > 0,
),
placeholderData: [],
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
})
const analyticsFacetsFilterOptionSummary = computed(() =>
getAnalyticsFacetsFilterOptionSummary(analyticsFacetsData.value?.facets),
)
@@ -1213,6 +1281,7 @@ export function createAnalyticsDashboardContext(
const splitTimeSlices = splitAnalyticsTimeSlices(
nextAnalyticsData.metrics,
fetchRequest.value,
analyticsComparisonStartTime.value,
)
timeSlices.value = splitTimeSlices.currentTimeSlices
previousTimeSlices.value = splitTimeSlices.previousTimeSlices
@@ -1269,6 +1338,7 @@ export function createAnalyticsDashboardContext(
enabled: computed(() => analyticsVersionIds.value.length > 0),
placeholderData: [],
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
refetchOnWindowFocus: false,
})
const allVersionMetadata = computed(() => {
@@ -1488,6 +1558,7 @@ export function createAnalyticsDashboardContext(
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
selectedGroupBy,
analyticsAllTimeStartDate,
selectedBreakdowns,
selectedFilters,
queryRefreshTimestamp,
+5 -1
View File
@@ -522,4 +522,8 @@ mintegral.com, 10003, RESELLER
blis.com, 33, RESELLER, 61453ae19a4b73f4
#Yahoo
yahoo.com, 100033, DIRECT, e1a5b5b6e3255540
yahoo.com, 100033, DIRECT, e1a5b5b6e3255540
#IndexExchange
indexexchange.com, 184169, DIRECT, 50b1c356f2c5c8fc
indexexchange.com, 198988, DIRECT, 50b1c356f2c5c8fc
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

@@ -1,5 +1,12 @@
{
"articles": [
{
"title": "Manage servers together",
"summary": "Add other users to your server, assign roles, and track whats changed.",
"thumbnail": "https://modrinth.com/news/article/server-access/thumbnail.webp",
"date": "2026-06-03T20:10:28.823Z",
"link": "https://modrinth.com/news/article/server-access"
},
{
"title": "Pride 2026 Fundraiser: Matching up to $10,000",
"summary": "Celebrating our community and working together to make a difference.",
+9 -1
View File
@@ -4,9 +4,17 @@
<description><![CDATA[Keep up-to-date on the latest news from Modrinth.]]></description>
<link>https://modrinth.com/news/</link>
<generator>@modrinth/blog</generator>
<lastBuildDate>Sun, 31 May 2026 23:20:41 GMT</lastBuildDate>
<lastBuildDate>Wed, 03 Jun 2026 21:05:41 GMT</lastBuildDate>
<atom:link href="https://modrinth.com/news/feed/rss.xml" rel="self" type="application/rss+xml"/>
<language><![CDATA[en]]></language>
<item>
<title><![CDATA[Manage servers together]]></title>
<description><![CDATA[Add other users to your server, assign roles, and track whats changed.]]></description>
<link>https://modrinth.com/news/article/server-access/</link>
<guid isPermaLink="false">https://modrinth.com/news/article/server-access/</guid>
<pubDate>Wed, 03 Jun 2026 20:10:28 GMT</pubDate>
<content:encoded>&lt;![CDATA[&lt;p&gt;Hey everyone,&lt;/p&gt;&lt;p&gt;With this release, you can now give other users access to your server! This has been one of the most requested features for Modrinth Hosting and were excited to finally get it out.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;/news/article/server-access/server-access.webp&quot; alt=&quot;The new Access tab in the Modrinth Hosting panel, featuring a list of invited users and their permissions, invite new users, and an activity log to see what changes are being made to your server and by whom.&quot;&gt;&lt;/p&gt;&lt;h2&gt;TL;DR&lt;/h2&gt;&lt;ul&gt;&lt;li&gt;Add users to your server&lt;/li&gt;&lt;li&gt;Set permission roles&lt;/li&gt;&lt;li&gt;View activity log&lt;/li&gt;&lt;/ul&gt;&lt;h2&gt;Invite your friends&lt;/h2&gt;&lt;p&gt;You can now give other users access to your server so they can help manage content, start the server, and more. To invite someone, just enter their Modrinth username and theyll receive an invite by email or as a notification in the app if theyre signed in.&lt;/p&gt;&lt;p&gt;Alongside this release, weve also improved state syncing between the website panel, app, and other users, so everything should stay up to date in real time.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;/news/article/server-access/add-user-modal.webp&quot; alt=&quot;A pop-up modal for adding a user to your server. Search by Modrinth username, select their role (editor or limited), and an option to also send them a friend request.&quot;&gt;&lt;/p&gt;&lt;h2&gt;Permission roles&lt;/h2&gt;&lt;p&gt;When adding someone to your server, you can choose what level of access they have. There are three roles, with each role inheriting the permissions of the previous one:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Owner:&lt;/strong&gt; Full access to the server including billing (you)&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Editor:&lt;/strong&gt; Manage content, files, backups, settings, and more&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Limited:&lt;/strong&gt; Start, stop, and view the server without making changes&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;You can find a full permission breakdown below:&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Permission&lt;/th&gt;&lt;th&gt;Owner&lt;/th&gt;&lt;th&gt;Editor&lt;/th&gt;&lt;th&gt;Limited&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Start / stop server&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Execute commands&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Edit settings&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Edit installation&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Manage content&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Manage files&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Create &amp;amp; restore backups&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Invite users&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Reset server&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Manage billing&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;h2&gt;See what changed&lt;/h2&gt;&lt;p&gt;Along with adding users, weve introduced an activity log. This is a chronological history of actions related to your server so you can see what changed, who changed it, and when it happened. Some actions are grouped together, like updating multiple projects at once, to keep things easier to read.&lt;/p&gt;&lt;p&gt;You can select a time timeframe and filter by user or action type if youre looking for something specific.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;/news/article/server-access/activity-log.webp&quot; alt=&quot;The activity log section of the Access tab, where you can see the user that performed an action on the left column, the action that was performed in the center, and the time it happened on the right.&quot;&gt;&lt;/p&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;Thank you for your continued support! 💚&lt;/p&gt;]]&gt;</content:encoded>
</item>
<item>
<title><![CDATA[Pride 2026 Fundraiser: Matching up to $10,000]]></title>
<description><![CDATA[Celebrating our community and working together to make a difference.]]></description>
@@ -32,6 +32,10 @@ export default {
'project-invited': () => import('./project/ProjectInvited.vue'),
'project-transferred': () => import('./project/ProjectTransferred.vue'),
// Server
'server-invited': () => import('./server/ServerInvited.vue'),
'server-invited-no-account': () => import('./server/ServerInvitedNoAccount.vue'),
// Organizations
'organization-invited': () => import('./organization/OrganizationInvited.vue'),
} as Record<string, () => Promise<{ default: Component }>>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { Button, Column, Heading, Link as VLink, Row, Section, Text } from '@vue-email/components'
import StyledEmail from '../shared/StyledEmail.vue'
</script>
<template>
<StyledEmail
title="You've been invited to a server"
:manual-links="[
{ link: 'https://modrinth.com/dashboard/notifications', label: 'Notification dashboard' },
{ link: 'https://support.modrinth.com', label: 'Support Portal' },
]"
>
<Heading as="h1" class="mb-2 text-2xl font-bold">You've been invited to a server</Heading>
<Text class="text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-base">
Modrinth user
<b
><VLink href="https://modrinth.com/user/{inviter.name}" class="text-green underline">
{inviter.name}
</VLink></b
>
has invited you to help manage
<b>{server.name}</b>
on Modrinth Hosting.
</Text>
<Section class="bg-bg-super mb-4 mt-4 rounded-lg border border-divider pb-4 pl-4 pr-4 pt-4">
<Text class="m-0 text-base">
You have been invited with the <b>{server.role}</b> role permission.
</Text>
</Section>
<Button
href="https://modrinth.com/dashboard/notifications"
target="_blank"
class="text-accentContrast inline-block rounded-[12px] bg-brand pb-3 pl-4 pr-4 pt-3 text-[14px] font-bold"
>
Review invitation
</Button>
<VLink href="https://modrinth.com/dashboard/notifications">
<Text class="text-muted mt-2 break-words text-xs font-bold"
>https://modrinth.com/dashboard/notifications</Text
>
</VLink>
<Text class="text-base">
To accept or reject this invitation, open your Modrinth notifications and review the invite.
If you were not expecting this invitation, contact the server owner or reach out to Modrinth
Support
<VLink href="https://support.modrinth.com" class="text-green underline">
through the Support Portal</VLink
>.
</Text>
<Section class="border-0 border-t border-solid border-divider pt-4">
<Text class="mb-3 mt-0 text-base font-bold">What does my role mean?</Text>
<Row>
<Column class="pr-2 align-top">
<Section class="bg-bg-super rounded-lg border border-divider pb-3 pl-3 pr-3 pt-3">
<Text class="m-0 text-xs font-bold uppercase">Editor</Text>
<Text class="mb-0 mt-2 text-base">
Manage instance content, files, backups, and other settings.
</Text>
</Section>
</Column>
<Column class="pl-2 align-top">
<Section class="bg-bg-super rounded-lg border border-divider pb-3 pl-3 pr-3 pt-3">
<Text class="m-0 text-xs font-bold uppercase">Viewer</Text>
<Text class="mb-0 mt-2 text-base">
Start, stop, and view the server without making changes.
</Text>
</Section>
</Column>
</Row>
</Section>
</StyledEmail>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { Button, Column, Heading, Link as VLink, Row, Section, Text } from '@vue-email/components'
import StyledEmail from '../shared/StyledEmail.vue'
</script>
<template>
<StyledEmail
title="You've been invited to a server"
:manual-links="[
{ link: '{serverinvite.url}', label: 'Create account and review invitation' },
{ link: 'https://support.modrinth.com', label: 'Support Portal' },
]"
>
<Heading as="h1" class="mb-2 text-2xl font-bold">You've been invited to a server</Heading>
<Text class="text-base">Hi,</Text>
<Text class="text-base">
Modrinth user
<b
><VLink href="https://modrinth.com/user/{inviter.name}" class="text-green underline">
{inviter.name}
</VLink></b
>
has invited you to help manage
<b>{server.name}</b>
on Modrinth Hosting.
</Text>
<Section class="bg-bg-super mb-4 mt-4 rounded-lg border border-divider pb-4 pl-4 pr-4 pt-4">
<Text class="m-0 text-base">
You have been invited with the <b>{server.role}</b> role permission.
</Text>
</Section>
<Button
href="{serverinvite.url}"
target="_blank"
class="text-accentContrast inline-block rounded-[12px] bg-brand pb-3 pl-4 pr-4 pt-3 text-[14px] font-bold"
>
Create account
</Button>
<VLink href="{serverinvite.url}">
<Text class="text-muted mt-2 break-words text-xs font-bold">{serverinvite.url}</Text>
</VLink>
<Text class="text-base">
To accept or reject this invitation, create a Modrinth account and review the invite from your
notifications dashboard. If you were not expecting this invitation, contact the server owner
or reach out to Modrinth Support
<VLink href="https://support.modrinth.com" class="text-green underline">
through the Support Portal</VLink
>.
</Text>
<Section class="border-0 border-t border-solid border-divider pt-4">
<Text class="mb-3 mt-0 text-base font-bold">What does my role let me do?</Text>
<Row>
<Column class="pr-2 align-top">
<Section class="bg-bg-super rounded-lg border border-divider pb-3 pl-3 pr-3 pt-3">
<Text class="m-0 text-xs font-bold uppercase">Editor</Text>
<Text class="mb-0 mt-2 text-base">
Manage instance content, files, backups, and other settings.
</Text>
</Section>
</Column>
<Column class="pl-2 align-top">
<Section class="bg-bg-super rounded-lg border border-divider pb-3 pl-3 pr-3 pt-3">
<Text class="m-0 text-xs font-bold uppercase">Viewer</Text>
<Text class="mb-0 mt-2 text-base">
Start, stop, and view the server without making changes.
</Text>
</Section>
</Column>
</Row>
</Section>
</StyledEmail>
</template>
+1
View File
@@ -104,6 +104,7 @@ TREMENDOUS_PRIVATE_KEY=none
TREMENDOUS_CAMPAIGN_ID=none
TILTIFY_CLIENT_ID=
TILTIFY_CLIENT_SECRET=
TILTIFY_WEBHOOK_SIGNING_KEY=
TILTIFY_PRIDE_26_CAMPAIGN_ID=
HCAPTCHA_SECRET=none
+1
View File
@@ -125,6 +125,7 @@ TREMENDOUS_PRIVATE_KEY=none
TREMENDOUS_CAMPAIGN_ID=none
TILTIFY_CLIENT_ID=
TILTIFY_CLIENT_SECRET=
TILTIFY_WEBHOOK_SIGNING_KEY=
TILTIFY_PRIDE_26_CAMPAIGN_ID=
HCAPTCHA_SECRET=none
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id\n FROM notifications\n WHERE body @> $1::jsonb\n AND user_id = ANY($2::bigint[])\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Jsonb",
"Int8Array"
]
},
"nullable": [
false
]
},
"hash": "147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, username, avatar_url\n FROM users\n WHERE LOWER(username) LIKE $1 ESCAPE ''\n ORDER BY LOWER(username) = $2 DESC, LOWER(username), username\n LIMIT 25\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "avatar_url",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "8d0ae0da359ebd33801f2796c841b9b3cc1a59f7cdee756ac5ce1c459e69a531"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT user_id\n FROM notifications\n WHERE user_id = ANY($1::bigint[]) AND body = $2::jsonb\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array",
"Jsonb"
]
},
"nullable": [
false
]
},
"hash": "a29d71466a0842f634d0a068a45951273c087bc112a9848e9bdda37c7fe61747"
}
@@ -0,0 +1,28 @@
INSERT INTO notifications_types
(name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications)
VALUES ('server_invite', 1, FALSE, TRUE);
INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
VALUES (NULL, 'email', 'server_invite', FALSE);
INSERT INTO notifications_templates
(channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
VALUES
(
'email',
'server_invite',
'You''ve been invited to a server',
'https://modrinth.com/_internal/templates/email/server-invited',
CONCAT(
'Hi {user.name},',
CHR(10),
CHR(10),
'Modrinth user {inviter.name} has invited you to help manage {server.name} on Modrinth Hosting with the {server.role} role.',
CHR(10),
CHR(10),
'To accept or reject this invitation, open your Modrinth notifications: https://modrinth.com/dashboard/notifications',
CHR(10),
CHR(10),
'If you were not expecting this invitation, contact the server owner or reach out to Modrinth Support at https://support.modrinth.com'
)
);
@@ -0,0 +1,2 @@
CREATE INDEX users_lowercase_username_pattern
ON users (LOWER(username) text_pattern_ops);
@@ -40,7 +40,8 @@ impl NotificationBuilder {
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<(), DatabaseError> {
self.insert_many(vec![user], transaction, redis).await
self.insert_many(vec![user], transaction, redis).await?;
Ok(())
}
pub async fn insert_many_payout_notifications(
@@ -129,12 +130,11 @@ impl NotificationBuilder {
Ok(())
}
pub async fn insert_many(
async fn insert_many_records(
&self,
users: Vec<DBUserId>,
users: &[DBUserId],
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<(), DatabaseError> {
) -> Result<Vec<DBNotificationId>, DatabaseError> {
let notification_ids =
generate_many_notification_ids(users.len(), &mut *transaction)
.await?;
@@ -146,7 +146,7 @@ impl NotificationBuilder {
.collect::<Vec<_>>();
let users_raw_ids = users.iter().map(|x| x.0).collect::<Vec<_>>();
let notification_ids =
let notification_ids_raw =
notification_ids.iter().map(|x| x.0).collect::<Vec<_>>();
sqlx::query!(
@@ -156,13 +156,29 @@ impl NotificationBuilder {
)
SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::jsonb[])
",
&notification_ids[..],
&notification_ids_raw[..],
&users_raw_ids[..],
&bodies[..],
)
.execute(&mut *transaction)
.await?;
Ok(notification_ids)
}
pub async fn insert_many(
&self,
users: Vec<DBUserId>,
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<Vec<DBNotificationId>, DatabaseError> {
let notification_ids =
self.insert_many_records(&users, transaction).await?;
let users_raw_ids = users.iter().map(|x| x.0).collect::<Vec<_>>();
let notification_ids_raw =
notification_ids.iter().map(|x| x.0).collect::<Vec<_>>();
let notification_types = notification_ids
.iter()
.map(|_| self.body.notification_type().as_str())
@@ -171,14 +187,28 @@ impl NotificationBuilder {
NotificationBuilder::insert_many_deliveries(
transaction,
redis,
&notification_ids,
&notification_ids_raw,
&users_raw_ids,
&notification_types,
&users,
)
.await?;
Ok(())
Ok(notification_ids)
}
/// Like [`insert_many`], but skips queuing deliveries so the caller can
/// manually send the notifications.
pub async fn insert_many_without_delivery(
&self,
users: Vec<DBUserId>,
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<Vec<DBNotificationId>, DatabaseError> {
let notification_ids =
self.insert_many_records(&users, transaction).await?;
DBNotification::clear_user_notifications_cache(&users, redis).await?;
Ok(notification_ids)
}
pub async fn insert_many_deliveries(
@@ -571,6 +601,38 @@ impl DBNotification {
Ok(Some(()))
}
pub async fn remove_many_matching_body(
body_filter: &serde_json::Value,
users: &[DBUserId],
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<usize, DatabaseError> {
let user_ids = users.iter().map(|x| x.0).collect::<Vec<i64>>();
let ids = sqlx::query!(
"
SELECT id
FROM notifications
WHERE body @> $1::jsonb
AND user_id = ANY($2::bigint[])
",
body_filter,
&user_ids
)
.fetch(&mut *transaction)
.map_ok(|x| DBNotificationId(x.id))
.try_collect::<Vec<_>>()
.await?;
if ids.is_empty() {
return Ok(0);
}
Self::remove_many(&ids, transaction, redis).await?;
Ok(ids.len())
}
pub async fn clear_user_notifications_cache(
user_ids: impl IntoIterator<Item = &DBUserId>,
redis: &RedisPool,
@@ -57,6 +57,13 @@ pub struct DBUser {
pub is_subscribed_to_newsletter: bool,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct DBSearchUser {
pub id: DBUserId,
pub username: String,
pub avatar_url: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct Pride26CampaignDonation {
pub last_donated_at: DateTime<Utc>,
@@ -264,6 +271,44 @@ impl DBUser {
Ok(val)
}
pub async fn search<'a, E>(
query: &str,
exec: E,
) -> Result<Vec<DBSearchUser>, sqlx::Error>
where
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
if query.is_empty() {
return Ok(Vec::new());
}
let lowercase_query = query.to_lowercase();
let escaped_query = format!("{}%", escape_like(&lowercase_query));
let users = sqlx::query!(
"
SELECT id, username, avatar_url
FROM users
WHERE LOWER(username) LIKE $1 ESCAPE '\'
ORDER BY LOWER(username) = $2 DESC, LOWER(username), username
LIMIT 25
",
escaped_query,
lowercase_query
)
.fetch_all(exec)
.await?
.into_iter()
.map(|row| DBSearchUser {
id: DBUserId(row.id),
username: row.username,
avatar_url: row.avatar_url,
})
.collect();
Ok(users)
}
pub async fn get_by_email<'a, E>(
email: &str,
exec: E,
@@ -1044,3 +1089,14 @@ impl DBUser {
}
}
}
fn escape_like(query: &str) -> String {
let mut escaped = String::with_capacity(query.len());
for ch in query.chars() {
if matches!(ch, '\\' | '%' | '_') {
escaped.push('\\');
}
escaped.push(ch);
}
escaped
}
+1
View File
@@ -298,6 +298,7 @@ vars! {
TILTIFY_CLIENT_ID: String = "";
TILTIFY_CLIENT_SECRET: String = "";
TILTIFY_PRIDE_26_CAMPAIGN_ID: String = "";
TILTIFY_WEBHOOK_SIGNING_KEY: String = "";
// server pinging
SERVER_PING_MAX_CONCURRENT: usize = 16usize;
@@ -11,6 +11,7 @@ use crate::models::{
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize)]
pub struct LegacyNotification {
@@ -66,6 +67,12 @@ pub enum LegacyNotificationBody {
team_id: TeamId,
role: String,
},
ServerInvite {
server_id: Uuid,
server_name: String,
invited_by: UserId,
role: String,
},
StatusChange {
project_id: ProjectId,
old_status: ProjectStatus,
@@ -166,6 +173,9 @@ impl LegacyNotification {
NotificationBody::OrganizationInvite { .. } => {
Some("organization_invite".to_string())
}
NotificationBody::ServerInvite { .. } => {
Some("server_invite".to_string())
}
NotificationBody::StatusChange { .. } => {
Some("status_change".to_string())
}
@@ -269,6 +279,17 @@ impl LegacyNotification {
team_id,
role,
},
NotificationBody::ServerInvite {
server_id,
server_name,
invited_by,
role,
} => LegacyNotificationBody::ServerInvite {
server_id,
server_name,
invited_by,
role,
},
NotificationBody::StatusChange {
project_id,
old_status,
@@ -12,6 +12,7 @@ use crate::routes::ApiError;
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize)]
pub struct Notification {
@@ -34,6 +35,7 @@ pub enum NotificationType {
ProjectUpdate,
TeamInvite,
OrganizationInvite,
ServerInvite,
StatusChange,
ModeratorMessage,
LegacyMarkdown,
@@ -67,6 +69,7 @@ impl NotificationType {
NotificationType::ProjectUpdate => "project_update",
NotificationType::TeamInvite => "team_invite",
NotificationType::OrganizationInvite => "organization_invite",
NotificationType::ServerInvite => "server_invite",
NotificationType::StatusChange => "status_change",
NotificationType::ModeratorMessage => "moderator_message",
NotificationType::LegacyMarkdown => "legacy_markdown",
@@ -104,6 +107,7 @@ impl NotificationType {
"project_update" => NotificationType::ProjectUpdate,
"team_invite" => NotificationType::TeamInvite,
"organization_invite" => NotificationType::OrganizationInvite,
"server_invite" => NotificationType::ServerInvite,
"status_change" => NotificationType::StatusChange,
"moderator_message" => NotificationType::ModeratorMessage,
"legacy_markdown" => NotificationType::LegacyMarkdown,
@@ -156,6 +160,12 @@ pub enum NotificationBody {
team_id: TeamId,
role: String,
},
ServerInvite {
server_id: Uuid,
server_name: String,
invited_by: UserId,
role: String,
},
StatusChange {
project_id: ProjectId,
old_status: ProjectStatus,
@@ -267,6 +277,9 @@ impl NotificationBody {
NotificationBody::OrganizationInvite { .. } => {
NotificationType::OrganizationInvite
}
NotificationBody::ServerInvite { .. } => {
NotificationType::ServerInvite
}
NotificationBody::StatusChange { .. } => {
NotificationType::StatusChange
}
@@ -418,6 +431,34 @@ impl From<DBNotification> for Notification {
},
],
),
NotificationBody::ServerInvite {
server_id: _,
server_name,
role,
..
} => (
"You have been invited to join a server!".to_string(),
format!(
"An invite has been sent for you to be {role} of {server_name}"
),
"#".to_string(),
vec![
NotificationAction {
name: "Accept".to_string(),
action_route: (
"POST".to_string(),
String::new(),
),
},
NotificationAction {
name: "Deny".to_string(),
action_route: (
"POST".to_string(),
String::new(),
),
},
],
),
NotificationBody::StatusChange {
old_status,
new_status,
+20 -1
View File
@@ -73,6 +73,13 @@ pub struct User {
pub github_id: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SearchUser {
pub id: UserId,
pub username: String,
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserCampaigns {
pub pride_26: Option<Pride26CampaignDonation>,
@@ -87,7 +94,9 @@ pub struct UserPayoutData {
pub balance: Decimal,
}
use crate::database::models::user_item::{DBUser, Pride26CampaignDonation};
use crate::database::models::user_item::{
DBSearchUser, DBUser, Pride26CampaignDonation,
};
impl From<DBUser> for User {
fn from(data: DBUser) -> Self {
@@ -116,6 +125,16 @@ impl From<DBUser> for User {
}
}
impl From<DBSearchUser> for SearchUser {
fn from(data: DBSearchUser) -> Self {
Self {
id: data.id.into(),
username: data.username,
avatar_url: data.avatar_url,
}
}
}
impl User {
pub fn from_full(db_user: DBUser) -> Self {
let mut auth_providers = Vec::new();
@@ -61,6 +61,10 @@ const ORGINVITE_INVITER_NAME: &str = "organizationinvite.inviter.name";
const ORGINVITE_ORG_NAME: &str = "organizationinvite.organization.name";
const ORGINVITE_ROLE_NAME: &str = "organizationinvite.role.name";
const SERVERINVITE_INVITER_NAME: &str = "inviter.name";
const SERVERINVITE_SERVER_NAME: &str = "server.name";
const SERVERINVITE_ROLE_NAME: &str = "server.role";
const STATUSCHANGE_PROJECT_NAME: &str = "statuschange.project.name";
const STATUSCHANGE_OLD_STATUS: &str = "statuschange.old.status";
const STATUSCHANGE_NEW_STATUS: &str = "statuschange.new.status";
@@ -735,6 +739,27 @@ async fn collect_template_variables(
title: title.to_string(),
}),
NotificationBody::ServerInvite {
server_name,
invited_by,
role,
..
} => {
let inviter = DBUser::get_id(
DBUserId(invited_by.0 as i64),
&mut *exec,
redis,
)
.await?
.ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?;
map.insert(SERVERINVITE_INVITER_NAME, inviter.username);
map.insert(SERVERINVITE_SERVER_NAME, server_name.clone());
map.insert(SERVERINVITE_ROLE_NAME, role.clone());
Ok(EmailTemplate::Static(map))
}
NotificationBody::ProjectUpdate { .. }
| NotificationBody::ModeratorMessage { .. }
| NotificationBody::LegacyMarkdown { .. }
+58 -3
View File
@@ -1,9 +1,12 @@
use actix_web::{get, post, web};
use chrono::{DateTime, Utc};
use actix_web::{HttpRequest, get, post, web};
use base64::Engine;
use chrono::{DateTime, Duration, Utc};
use eyre::eyre;
use hmac::{Hmac, Mac};
use reqwest::Method;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashSet;
use tracing::{info, warn};
use uuid::Uuid;
@@ -134,11 +137,17 @@ impl CampaignDonation {
#[utoipa::path]
#[post("/webhook")]
pub async fn tiltify_webhook(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
payouts_queue: web::Data<PayoutsQueue>,
web::Json(raw_payload): web::Json<serde_json::Value>,
body: String,
) -> Result<(), ApiError> {
verify_tiltify_webhook_signature(&req, &body)?;
let raw_payload = serde_json::from_str::<serde_json::Value>(&body)
.wrap_internal_err_with(|| eyre!("invalid Tiltify webhook JSON"))?;
// deserialize the JSON in the request handler, not in the params,
// since if the JSON fails to deserialize then it's *our* fault,
// not the caller's.
@@ -237,6 +246,52 @@ pub async fn tiltify_webhook(
Ok(())
}
fn verify_tiltify_webhook_signature(
req: &HttpRequest,
body: &str,
) -> Result<(), ApiError> {
let signature = req
.headers()
.get("X-Tiltify-Signature")
.and_then(|x| x.to_str().ok())
.wrap_request_err("missing Tiltify webhook signature")?;
let signature = base64::engine::general_purpose::STANDARD
.decode(signature)
.wrap_request_err("invalid Tiltify webhook signature")?;
let timestamp = req
.headers()
.get("X-Tiltify-Timestamp")
.and_then(|x| x.to_str().ok())
.wrap_request_err("missing Tiltify webhook timestamp")?;
let parsed_timestamp = DateTime::parse_from_rfc3339(timestamp)
.wrap_request_err("invalid Tiltify webhook timestamp")?;
let parsed_timestamp = parsed_timestamp.with_timezone(&Utc);
let age = Utc::now().signed_duration_since(parsed_timestamp);
if age < -Duration::minutes(1) || age > Duration::minutes(1) {
return Err(ApiError::Request(eyre!(
"expired Tiltify webhook timestamp",
)));
}
if ENV.TILTIFY_WEBHOOK_SIGNING_KEY.is_empty() {
return Err(ApiError::Internal(eyre!(
"TILTIFY_WEBHOOK_SIGNING_KEY must be set"
)));
}
let mut mac: Hmac<Sha256> =
Hmac::new_from_slice(ENV.TILTIFY_WEBHOOK_SIGNING_KEY.as_bytes())
.wrap_internal_err("initializing Tiltify webhook HMAC")?;
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body.as_bytes());
mac.verify_slice(&signature)
.wrap_request_err("invalid Tiltify webhook signature")?;
Ok(())
}
#[utoipa::path]
#[get("/pride-26")]
pub async fn pride_26(
@@ -1,23 +1,36 @@
use crate::auth::get_user_from_headers;
use crate::database::PgPool;
use crate::database::models::ids::DBUserId;
use crate::database::models::notification_item::DBNotification;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::user_item::DBUser;
use crate::database::redis::RedisPool;
use crate::models::users::Role;
use crate::models::v3::notifications::NotificationBody;
use crate::models::v3::notifications::{
Notification, NotificationBody, NotificationDeliveryStatus,
};
use crate::models::v3::pats::Scopes;
use crate::queue::email::EmailQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::routes::internal::statuses::broadcast_friends_message;
use crate::sync::friends::RedisFriendsMessage;
use crate::util::guards::external_notification_key_guard;
use actix_web::HttpRequest;
use actix_web::http::StatusCode;
use actix_web::web;
use actix_web::{HttpResponse, post};
use actix_web::{
CustomizeResponder, HttpRequest, HttpResponse, Responder, delete, post,
};
use ariadne::ids::UserId;
use eyre::eyre;
use lettre::message::Mailbox;
use serde::Deserialize;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(create).service(send_custom_email);
cfg.service(create)
.service(create_email_sync)
.service(remove)
.service(send_custom_email);
}
#[derive(Deserialize)]
@@ -47,15 +60,218 @@ pub async fn create(
));
}
NotificationBuilder { body }
let notification_ids = NotificationBuilder { body }
.insert_many(user_ids, &mut txn, &redis)
.await?;
let notifications = DBNotification::get_many(&notification_ids, &mut txn)
.await?
.into_iter()
.map(Notification::from)
.collect::<Vec<_>>();
txn.commit().await?;
for notification in notifications {
let notification_id = notification.id;
let to_user = notification.user_id;
if let Err(error) = broadcast_friends_message(
&redis,
RedisFriendsMessage::Notification {
to_user,
notification,
},
)
.await
{
tracing::warn!(
?error,
?notification_id,
?to_user,
"failed to broadcast realtime notification"
);
}
}
Ok(HttpResponse::Accepted().finish())
}
/// Inserts notifications for all users and tries to send emails immediately.
///
/// Responds with the user IDs that could not be emailed:
/// - `200` if every recipient was emailed (empty list)
/// - `207` if some recipients could not be emailed (list of failed IDs)
#[post(
"external_notifications/email-sync",
guard = "external_notification_key_guard"
)]
pub async fn create_email_sync(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
email_queue: web::Data<EmailQueue>,
create_notification: web::Json<CreateNotification>,
) -> Result<CustomizeResponder<web::Json<Vec<UserId>>>, ApiError> {
let CreateNotification { body, user_ids } =
create_notification.into_inner();
let raw_user_ids = user_ids.iter().map(|x| x.0 as i64).collect::<Vec<_>>();
let user_ids = raw_user_ids
.iter()
.map(|x| DBUserId(*x))
.collect::<Vec<_>>();
let mut txn = pool.begin().await?;
if !DBUser::exists_many(&user_ids, &mut txn).await? {
return Err(ApiError::InvalidInput(
"One of the specified users do not exist.".to_owned(),
));
}
// Skip users who already have an identical notification
let body_value = serde_json::value::to_value(&body)?;
let already_notified = sqlx::query!(
"
SELECT DISTINCT user_id
FROM notifications
WHERE user_id = ANY($1::bigint[]) AND body = $2::jsonb
",
&raw_user_ids[..],
body_value,
)
.fetch_all(&mut txn)
.await?
.into_iter()
.map(|row| DBUserId(row.user_id))
.collect::<std::collections::HashSet<_>>();
let notification_user_ids = user_ids
.clone()
.into_iter()
.filter(|id| !already_notified.contains(id))
.collect::<Vec<_>>();
let notification_ids = NotificationBuilder { body: body.clone() }
.insert_many_without_delivery(notification_user_ids, &mut txn, &redis)
.await?;
let notifications = DBNotification::get_many(&notification_ids, &mut txn)
.await?
.into_iter()
.map(Notification::from)
.collect::<Vec<_>>();
txn.commit().await?;
for notification in notifications {
let Notification {
user_id: to_user,
id: notification_id,
..
} = notification;
if let Err(error) = broadcast_friends_message(
&redis,
RedisFriendsMessage::Notification {
to_user,
notification,
},
)
.await
{
tracing::warn!(
?error,
?notification_id,
?to_user,
"failed to broadcast realtime notification"
);
}
}
let mut email_txn = pool.begin().await?;
let mut failed = Vec::new();
for user_id in &user_ids {
let Some(user) =
DBUser::get_id(*user_id, &mut email_txn, &redis).await?
else {
failed.push(UserId(user_id.0 as u64));
continue;
};
let delivered = match user
.email
.and_then(|email| email.parse::<Mailbox>().ok())
{
Some(mailbox) => {
email_queue
.send_one(&mut email_txn, body.clone(), *user_id, mailbox)
.await?
== NotificationDeliveryStatus::Delivered
}
None => false,
};
if !delivered {
failed.push(UserId(user_id.0 as u64));
}
}
let status = if failed.is_empty() {
StatusCode::OK
} else {
StatusCode::MULTI_STATUS
};
Ok(web::Json(failed).customize().with_status(status))
}
#[derive(Deserialize)]
struct NotificationFilter {
pub user_ids: Vec<UserId>,
#[serde(flatten)]
pub body: serde_json::Map<String, serde_json::Value>,
}
#[delete("external_notifications", guard = "external_notification_key_guard")]
pub async fn remove(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
notification_filter: web::Json<NotificationFilter>,
) -> Result<HttpResponse, ApiError> {
let NotificationFilter { user_ids, body } =
notification_filter.into_inner();
if user_ids.is_empty() {
return Err(ApiError::Request(eyre!(
"at least one user must be provided to remove notifications from"
)));
}
if body.is_empty() {
return Err(ApiError::Request(eyre!(
"at least one `body` field must be provided to match notifications"
)));
}
let filters = serde_json::Value::Object(body);
let user_ids = user_ids
.into_iter()
.map(|x| DBUserId(x.0 as i64))
.collect::<Vec<_>>();
let mut txn = pool.begin().await?;
DBNotification::remove_many_matching_body(
&filters, &user_ids, &mut txn, &redis,
)
.await?;
txn.commit().await?;
Ok(HttpResponse::NoContent().finish())
}
#[derive(Deserialize)]
struct SendEmail {
pub users: Vec<UserId>,
@@ -2,7 +2,9 @@ use crate::auth::AuthenticationError;
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::database::PgPool;
use crate::database::models::friend_item::DBFriend;
use crate::database::models::notification_item::DBNotification;
use crate::database::redis::RedisPool;
use crate::models::notifications::{Notification, NotificationBody};
use crate::models::pats::Scopes;
use crate::models::users::User;
use crate::queue::session::AuthQueue;
@@ -42,6 +44,7 @@ struct LauncherHeartbeatInit {
code: String,
}
// TODO: Move launcher-specific tunnel traffic to a proper launcher websocket endpoint.
#[get("launcher_socket")]
pub async fn ws_init(
req: HttpRequest,
@@ -127,6 +130,26 @@ pub async fn ws_init(
)?)
.await;
let unread_server_invites = DBNotification::get_many_user_exposed_on_site(
user_id.into(),
&**pool,
&redis,
)
.await?
.into_iter()
.filter(|notification| {
!notification.read
&& matches!(
&notification.body,
NotificationBody::ServerInvite { .. }
)
})
.map(Notification::from);
for notification in unread_server_invites {
let _ = session.text(serde_json::to_string(&notification)?).await;
}
let db = db.clone();
let socket_id = db.next_socket_id.fetch_add(1, Ordering::Relaxed);
db.sockets
@@ -449,6 +472,25 @@ pub async fn send_message_to_user(
Ok(())
}
pub async fn send_notification_to_user(
db: &ActiveSockets,
user: UserId,
notification: &Notification,
) -> Result<(), crate::database::models::DatabaseError> {
let message = serde_json::to_string(notification)?;
if let Some(socket_ids) = db.sockets_by_user_id.get(&user) {
for socket_id in socket_ids.iter() {
if let Some(socket) = db.sockets.get(&socket_id) {
let mut socket = socket.socket.clone();
let _ = socket.text(message.clone()).await;
}
}
}
Ok(())
}
pub async fn close_socket(
id: SocketId,
pool: &PgPool,
+21
View File
@@ -39,6 +39,7 @@ use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("user", web::get().to(user_auth_get));
cfg.route("users", web::get().to(users_get));
cfg.route("users/search", web::get().to(users_search));
cfg.route("user_email", web::get().to(admin_user_email));
cfg.service(
@@ -327,6 +328,26 @@ pub struct UserIds {
pub ids: String,
}
#[derive(Deserialize)]
pub struct UserSearchQuery {
pub query: String,
}
pub async fn users_search(
web::Query(query): web::Query<UserSearchQuery>,
pool: web::Data<PgPool>,
) -> Result<web::Json<Vec<crate::models::users::SearchUser>>, ApiError> {
let query = query.query.trim();
let users = DBUser::search(query, &**pool)
.await
.wrap_internal_err("failed to search users")?
.into_iter()
.map(Into::into)
.collect();
Ok(web::Json(users))
}
pub async fn users_get(
req: HttpRequest,
web::Query(ids): web::Query<UserIds>,
+29 -5
View File
@@ -1,7 +1,8 @@
use crate::database::PgPool;
use crate::models::notifications::Notification;
use crate::queue::socket::ActiveSockets;
use crate::routes::internal::statuses::{
broadcast_to_local_friends, send_message_to_user,
broadcast_to_local_friends, send_message_to_user, send_notification_to_user,
};
use actix_web::web::Data;
use ariadne::ids::UserId;
@@ -14,12 +15,23 @@ use tokio_stream::StreamExt;
pub const FRIENDS_CHANNEL_NAME: &str = "friends";
#[derive(Debug, Serialize, Deserialize)]
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RedisFriendsMessage {
StatusUpdate { status: UserStatus },
UserOffline { user: UserId },
DirectStatusUpdate { to_user: UserId, status: UserStatus },
StatusUpdate {
status: UserStatus,
},
UserOffline {
user: UserId,
},
DirectStatusUpdate {
to_user: UserId,
status: UserStatus,
},
Notification {
to_user: UserId,
notification: Notification,
},
}
impl ToRedisArgs for RedisFriendsMessage {
@@ -80,6 +92,18 @@ pub async fn handle_pubsub(
.await;
}
Ok(RedisFriendsMessage::Notification {
to_user,
notification,
}) => {
let _ = send_notification_to_user(
&sockets,
to_user,
&notification,
)
.await;
}
Err(_) => {}
}
});
+103 -1
View File
@@ -1,9 +1,13 @@
use crate::common::api_common::{ApiProject, ApiTeams};
use actix_web::test;
use common::dummy_data::TestFile;
use common::{
database::{FRIEND_USER_ID, FRIEND_USER_PAT, USER_USER_ID, USER_USER_PAT},
environment::with_test_environment_all,
environment::{
TestEnvironment, with_test_environment, with_test_environment_all,
},
};
use labrinth::test::api_v3::ApiV3;
pub mod common;
@@ -16,6 +20,104 @@ pub mod common;
// patch user icon
// user follows
#[actix_rt::test]
pub async fn search_users_returns_compact_prefix_matches_with_exact_first() {
with_test_environment(
None,
|test_env: TestEnvironment<ApiV3>| async move {
sqlx::query(
"
INSERT INTO users (id, username, email, role)
VALUES
(1000, 'userland', 'userland@modrinth.com', 'developer'),
(1001, 'useful', 'useful@modrinth.com', 'developer'),
(1002, 'Useless', 'useless@modrinth.com', 'developer')
",
)
.execute(&*test_env.db.pool)
.await
.unwrap();
let req = test::TestRequest::get()
.uri("/v3/users/search?query=user")
.to_request();
let resp = test_env.call(req).await;
assert_status!(&resp, actix_http::StatusCode::OK);
let users: Vec<serde_json::Value> =
test::read_body_json(resp).await;
assert_eq!(users.len(), 2);
assert_eq!(users[0]["username"], "User");
assert_eq!(users[1]["username"], "userland");
assert!(users.iter().all(|user| {
user.as_object().is_some_and(|object| {
object.len() == 3 && object.contains_key("avatar_url")
})
}));
},
)
.await;
}
#[actix_rt::test]
pub async fn search_users_escapes_wildcards_and_limits_results() {
with_test_environment(
None,
|test_env: TestEnvironment<ApiV3>| async move {
for i in 0..30 {
sqlx::query(
"
INSERT INTO users (id, username, email, role)
VALUES ($1, $2, $3, 'developer')
",
)
.bind(2000 + i)
.bind(format!("prefix{i:02}"))
.bind(format!("prefix{i:02}@modrinth.com"))
.execute(&*test_env.db.pool)
.await
.unwrap();
}
let req = test::TestRequest::get()
.uri("/v3/users/search?query=prefix")
.to_request();
let resp = test_env.call(req).await;
assert_status!(&resp, actix_http::StatusCode::OK);
let users: Vec<serde_json::Value> =
test::read_body_json(resp).await;
assert_eq!(users.len(), 25);
assert!(users.iter().all(|user| {
user["username"]
.as_str()
.is_some_and(|username| username.starts_with("prefix"))
}));
let req = test::TestRequest::get()
.uri("/v3/users/search?query=%25")
.to_request();
let resp = test_env.call(req).await;
assert_status!(&resp, actix_http::StatusCode::OK);
let users: Vec<serde_json::Value> =
test::read_body_json(resp).await;
assert!(users.is_empty());
let req = test::TestRequest::get()
.uri("/v3/users/search?query=%20%20")
.to_request();
let resp = test_env.call(req).await;
assert_status!(&resp, actix_http::StatusCode::OK);
let users: Vec<serde_json::Value> =
test::read_body_json(resp).await;
assert!(users.is_empty());
},
)
.await;
}
#[actix_rt::test]
pub async fn get_user_projects_after_creating_project_returns_new_project() {
with_test_environment_all(None, |test_env| async move {
@@ -1,10 +1,11 @@
import type { InferredClientModules } from '../modules'
import { buildModuleStructure } from '../modules'
import type { ClientConfig } from '../types/client'
import type { BaseUrlConfig, ClientConfig } from '../types/client'
import type { RequestContext, RequestOptions } from '../types/request'
import type { UploadMetadata, UploadProgress, UploadRequestOptions } from '../types/upload'
import type { AbstractFeature } from './abstract-feature'
import type { AbstractModule } from './abstract-module'
import type { AbstractSyncClient } from './abstract-sync'
import { AbstractUploadClient } from './abstract-upload-client'
import type { AbstractWebSocketClient } from './abstract-websocket'
import { ModrinthApiError, ModrinthServerError } from './errors'
@@ -32,7 +33,10 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
private _moduleNamespaces: Map<string, Record<string, AbstractModule>> = new Map()
public readonly labrinth!: InferredClientModules['labrinth']
public readonly archon!: ArchonClientModules & { sockets: AbstractWebSocketClient }
public readonly archon!: ArchonClientModules & {
sockets: AbstractWebSocketClient
sync: AbstractSyncClient
}
public readonly kyros!: InferredClientModules['kyros']
public readonly iso3166!: InferredClientModules['iso3166']
public readonly mclogs!: InferredClientModules['mclogs']
@@ -116,9 +120,9 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
async request<T>(path: string, options: RequestOptions): Promise<T> {
let baseUrl: string
if (options.api === 'labrinth') {
baseUrl = this.config.labrinthBaseUrl!
baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!)
} else if (options.api === 'archon') {
baseUrl = this.config.archonBaseUrl!
baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!)
} else {
baseUrl = options.api
}
@@ -160,13 +164,55 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
}
}
async stream(path: string, options: RequestOptions): Promise<ReadableStream<Uint8Array>> {
let baseUrl: string
if (options.api === 'labrinth') {
baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!)
} else if (options.api === 'archon') {
baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!)
} else {
baseUrl = options.api
}
const url = this.buildUrl(path, baseUrl, options.version)
const defaultHeaders = await this.buildDefaultHeaders()
const mergedOptions: RequestOptions = {
method: 'GET',
retry: false,
circuitBreaker: false,
...options,
headers: {
...defaultHeaders,
Accept: 'text/event-stream',
...options.headers,
},
}
this.attachArchonSentryCaptureHeader(mergedOptions)
const context = this.buildContext(url, path, mergedOptions)
try {
return await this.executeFeatureChain<ReadableStream<Uint8Array>>(context, () =>
this.executeStreamRequest(context.url, context.options),
)
} catch (error) {
const apiError = this.normalizeError(error, context)
await this.config.hooks?.onError?.(apiError, context)
throw apiError
}
}
/**
* Execute the feature chain and the actual request
*
* Features are executed in order, with each feature calling next() to continue.
* The last "feature" in the chain is the actual request execution.
*/
protected async executeFeatureChain<T>(context: RequestContext): Promise<T> {
protected async executeFeatureChain<T>(
context: RequestContext,
executeTerminal: () => Promise<T> = () => this.executeRequest<T>(context.url, context.options),
): Promise<T> {
// Filter to only features that should apply
const applicableFeatures = this.features.filter((feature) => feature.shouldApply(context))
@@ -184,7 +230,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
} else {
// We've reached the end of the chain, execute the actual request
await this.config.hooks?.onRequest?.(context)
return this.executeRequest<T>(context.url, context.options)
return executeTerminal()
}
}
@@ -243,6 +289,10 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
return `${base}${versionPath}${cleanPath}`
}
protected resolveBaseUrl(baseUrl: BaseUrlConfig): string {
return typeof baseUrl === 'function' ? baseUrl() : baseUrl
}
/**
* Build the request context
*/
@@ -354,6 +404,11 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
*/
protected abstract executeRequest<T>(url: string, options: RequestOptions): Promise<T>
protected abstract executeStreamRequest(
url: string,
options: RequestOptions,
): Promise<ReadableStream<Uint8Array>>
/**
* Execute the actual XHR upload
*
@@ -0,0 +1,167 @@
import type mitt from 'mitt'
import type { Archon } from '../modules/archon/types'
import type { RequestOptions } from '../types/request'
export type SyncEventType = Archon.Sync.v1.SyncEvent['type']
export type SyncEventOfType<E extends SyncEventType> = Extract<
Archon.Sync.v1.SyncEvent,
{ type: E }
>
export type SyncEventHandler<E extends Archon.Sync.v1.SyncEvent = Archon.Sync.v1.SyncEvent> = (
event: E,
) => void
export type SyncStatusState =
| 'idle'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'disconnected'
| 'error'
export type SyncStatus = {
state: SyncStatusState
connected: boolean
reconnecting: boolean
reconnectAttempts: number
retryDelay: number
lastEventId?: string
error?: unknown
}
export type SyncStatusHandler = (status: SyncStatus) => void
export type SyncConnectOptions = {
intent?: Archon.Sync.v1.SyncIntent
force?: boolean
}
export type SyncConnection = {
serverId: string
intent: Archon.Sync.v1.SyncIntent
controller?: AbortController
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
reconnectResolve?: () => void
retryDelay: number
lastEventId?: string
stopped: boolean
status: SyncStatusState
error?: unknown
}
export type SyncEmitterEvents = Record<string, unknown>
export abstract class AbstractSyncClient {
protected connections = new Map<string, SyncConnection>()
protected abstract emitter: ReturnType<typeof mitt<SyncEmitterEvents>>
constructor(
protected client: {
stream: (path: string, options: RequestOptions) => Promise<ReadableStream<Uint8Array>>
},
) {}
abstract safeConnectServer(serverId: string, options?: SyncConnectOptions): Promise<void>
abstract disconnect(serverId: string): void
abstract disconnectAll(): void
on<E extends SyncEventType>(
serverId: string,
eventType: E,
handler: SyncEventHandler<SyncEventOfType<E>>,
): () => void {
const eventKey = this.getEventKey(serverId, eventType)
const wrapped = handler as (event: unknown) => void
this.emitter.on(eventKey, wrapped)
return () => {
this.emitter.off(eventKey, wrapped)
}
}
onAny(serverId: string, handler: SyncEventHandler): () => void {
const eventKey = this.getAnyEventKey(serverId)
const wrapped = handler as (event: unknown) => void
this.emitter.on(eventKey, wrapped)
return () => {
this.emitter.off(eventKey, wrapped)
}
}
onStatus(serverId: string, handler: SyncStatusHandler): () => void {
const eventKey = this.getStatusEventKey(serverId)
const wrapped = handler as (event: unknown) => void
this.emitter.on(eventKey, wrapped)
return () => {
this.emitter.off(eventKey, wrapped)
}
}
getStatus(serverId: string): SyncStatus | null {
const connection = this.connections.get(serverId)
if (!connection) return null
return this.connectionToStatus(connection)
}
protected emitSyncEvent(serverId: string, event: Archon.Sync.v1.SyncEvent): void {
this.emitter.emit(this.getEventKey(serverId, event.type), event)
this.emitter.emit(this.getAnyEventKey(serverId), event)
}
protected updateStatus(
connection: SyncConnection,
status: SyncStatusState,
error?: unknown,
): void {
connection.status = status
connection.error = error
this.emitter.emit(
this.getStatusEventKey(connection.serverId),
this.connectionToStatus(connection),
)
}
protected clearListeners(serverId: string): void {
this.emitter.all.forEach((_handlers, type) => {
if (type.toString().startsWith(`${serverId}:`)) {
this.emitter.all.delete(type)
}
})
}
protected connectionToStatus(connection: SyncConnection): SyncStatus {
return {
state: connection.status,
connected: connection.status === 'connected',
reconnecting: connection.status === 'reconnecting',
reconnectAttempts: connection.reconnectAttempts,
retryDelay: connection.retryDelay,
lastEventId: connection.lastEventId,
error: connection.error,
}
}
private getEventKey(serverId: string, eventType: string): string {
return `${serverId}:${eventType}`
}
private getAnyEventKey(serverId: string): string {
return `${serverId}:*`
}
private getStatusEventKey(serverId: string): string {
return `${serverId}:__status`
}
}
+19
View File
@@ -1,5 +1,16 @@
export { AbstractModrinthClient } from './core/abstract-client'
export { AbstractFeature, type FeatureConfig } from './core/abstract-feature'
export {
AbstractSyncClient,
type SyncConnection,
type SyncConnectOptions,
type SyncEventHandler,
type SyncEventOfType,
type SyncEventType,
type SyncStatus,
type SyncStatusHandler,
type SyncStatusState,
} from './core/abstract-sync'
export { AbstractUploadClient } from './core/abstract-upload-client'
export {
AbstractWebSocketClient,
@@ -25,10 +36,18 @@ export * from './modules/types'
export { GenericModrinthClient } from './platform/generic'
export type { NuxtClientConfig } from './platform/nuxt'
export { NuxtCircuitBreakerStorage, NuxtModrinthClient } from './platform/nuxt'
export { GenericSyncClient } from './platform/sync-generic'
export type { TauriClientConfig } from './platform/tauri'
export { TauriModrinthClient } from './platform/tauri'
export { XHRUploadClient } from './platform/xhr-upload-client'
export { clearNodeAuthState, nodeAuthState, setNodeAuthState } from './state/node-auth'
export * from './types'
export { withJWTRetry } from './utils/jwt-retry'
export {
type ParsedSseEvent,
type ParsedSseItem,
type ParsedSseRetry,
parseSyncEventData,
SseParser,
} from './utils/sse'
export type { Override, RawDecimal } from './utils/types'
@@ -0,0 +1,35 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonActionsV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_actions_v1'
}
/**
* Get server action log entries.
* GET /v1/servers/:server_id/action-log
*/
public async list(
serverId: string,
options: Archon.Actions.v1.ListActionLogOptions = {},
): Promise<Archon.Actions.v1.ActionLogResponse> {
const params: Record<string, string | number> = {}
if (options.filter) params.filter = JSON.stringify(options.filter)
if (options.limit !== undefined) params.limit = options.limit
if (options.offset !== undefined) params.offset = options.offset
if (options.order !== undefined) params.order = options.order
if (options.min_datetime !== undefined) params.min_datetime = options.min_datetime
if (options.max_datetime !== undefined) params.max_datetime = options.max_datetime
return this.client.request<Archon.Actions.v1.ActionLogResponse>(
`/servers/${serverId}/action-log`,
{
api: 'archon',
version: 1,
method: 'GET',
params: Object.keys(params).length > 0 ? params : undefined,
},
)
}
}
@@ -1,3 +1,4 @@
export * from './actions/v1'
export * from './backups/v1'
export * from './backups-queue/v1'
export * from './content/v1'
@@ -0,0 +1,20 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonNodesInternalModule extends AbstractModule {
public getModuleID(): string {
return 'archon_nodes_internal'
}
/**
* Get node hostnames and region summary for admin tooling.
* GET /_internal/nodes/overview
*/
public async overview(): Promise<Archon.Nodes.Internal.Overview> {
return this.client.request<Archon.Nodes.Internal.Overview>('/nodes/overview', {
api: 'archon',
version: 'internal',
method: 'GET',
})
}
}
@@ -0,0 +1,98 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonNoticesV0Module extends AbstractModule {
public getModuleID(): string {
return 'archon_notices_v0'
}
/**
* Get all server notices.
* GET /modrinth/v0/notices
*/
public async list(): Promise<Archon.Notices.v0.ListedNotice[]> {
return this.client.request<Archon.Notices.v0.ListedNotice[]>('/notices', {
api: 'archon',
version: 'modrinth/v0',
method: 'GET',
})
}
/**
* Create a server notice.
* POST /modrinth/v0/notices
*/
public async create(
request: Archon.Notices.v0.Announce,
): Promise<Archon.Notices.v0.PostNoticeResponseBody> {
return this.client.request<Archon.Notices.v0.PostNoticeResponseBody>('/notices', {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
body: request,
})
}
/**
* Update a server notice.
* PATCH /modrinth/v0/notices/:id
*/
public async update(id: number, request: Archon.Notices.v0.AnnouncePatch): Promise<void> {
await this.client.request(`/notices/${id}`, {
api: 'archon',
version: 'modrinth/v0',
method: 'PATCH',
body: request,
})
}
/**
* Delete a server notice.
* DELETE /modrinth/v0/notices/:id
*/
public async delete(id: number): Promise<void> {
await this.client.request(`/notices/${id}`, {
api: 'archon',
version: 'modrinth/v0',
method: 'DELETE',
})
}
/**
* Assign a notice to a server or node.
* PUT /modrinth/v0/notices/:id/assign?server=:serverId
* PUT /modrinth/v0/notices/:id/assign?node=:nodeId
*/
public async assign(id: number, target: Archon.Notices.v0.AssignmentTarget): Promise<void> {
await this.client.request(`/notices/${id}/assign`, {
api: 'archon',
version: 'modrinth/v0',
method: 'PUT',
params: this.assignmentTargetToParams(target),
})
}
/**
* Unassign a notice from a server or node.
* PUT /modrinth/v0/notices/:id/unassign?server=:serverId
* PUT /modrinth/v0/notices/:id/unassign?node=:nodeId
*/
public async unassign(id: number, target: Archon.Notices.v0.AssignmentTarget): Promise<void> {
await this.client.request(`/notices/${id}/unassign`, {
api: 'archon',
version: 'modrinth/v0',
method: 'PUT',
params: this.assignmentTargetToParams(target),
})
}
private assignmentTargetToParams(
target: Archon.Notices.v0.AssignmentTarget,
): Record<string, string> {
if ('server' in target) {
return { server: target.server }
}
return { node: target.node }
}
}
@@ -0,0 +1,83 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonServerUsersV1Module extends AbstractModule {
public getModuleID(): string {
return 'archon_server_users_v1'
}
/**
* Get list of users with access to a server
* GET /v1/servers/:server_id/users
*/
public async list(serverId: string): Promise<Archon.ServerUsers.v1.ServerUser[]> {
return this.client.request<Archon.ServerUsers.v1.ServerUser[]>(`/servers/${serverId}/users`, {
api: 'archon',
version: 1,
method: 'GET',
})
}
/**
* Add a user to a server
* POST /v1/servers/:server_id/users
*/
public async add(
serverId: string,
user: Archon.ServerUsers.v1.AddServerUserRequest,
): Promise<void> {
await this.client.request(`/servers/${serverId}/users`, {
api: 'archon',
version: 1,
method: 'POST',
body: user,
})
}
/**
* Re-send an invite to a pending server user.
* POST /v1/servers/:server_id/users/:user_id/reinvite
*/
public async reinvite(
serverId: string,
userId: string,
): Promise<Archon.ServerUsers.v1.ReinviteResponse> {
return this.client.request<Archon.ServerUsers.v1.ReinviteResponse>(
`/servers/${serverId}/users/${userId}/reinvite`,
{
api: 'archon',
version: 1,
method: 'POST',
},
)
}
/**
* Remove a user from a server
* DELETE /v1/servers/:server_id/users/:user_id
*/
public async delete(serverId: string, userId: string): Promise<void> {
await this.client.request(`/servers/${serverId}/users/${userId}`, {
api: 'archon',
version: 1,
method: 'DELETE',
})
}
/**
* Update a user's server role
* PATCH /v1/servers/:server_id/users/:user_id
*/
public async update(
serverId: string,
userId: string,
role: Archon.ServerUsers.v1.AssignableServerUserRole,
): Promise<void> {
await this.client.request(`/servers/${serverId}/users/${userId}`, {
api: 'archon',
version: 1,
method: 'PATCH',
body: JSON.stringify(role),
})
}
}
@@ -0,0 +1,84 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonTransfersInternalModule extends AbstractModule {
public getModuleID(): string {
return 'archon_transfers_internal'
}
/**
* Schedule transfers for specific servers.
* POST /_internal/transfers/schedule/servers
*/
public async scheduleServers(
request: Archon.Transfers.Internal.ScheduleServerTransfersRequest,
): Promise<Archon.Transfers.Internal.ScheduleTransfersResponse> {
return this.client.request<Archon.Transfers.Internal.ScheduleTransfersResponse>(
'/transfers/schedule/servers',
{
api: 'archon',
version: 'internal',
method: 'POST',
body: request,
},
)
}
/**
* Schedule transfers for all servers on specific nodes.
* POST /_internal/transfers/schedule/nodes
*/
public async scheduleNodes(
request: Archon.Transfers.Internal.ScheduleNodeTransfersRequest,
): Promise<Archon.Transfers.Internal.ScheduleTransfersResponse> {
return this.client.request<Archon.Transfers.Internal.ScheduleTransfersResponse>(
'/transfers/schedule/nodes',
{
api: 'archon',
version: 'internal',
method: 'POST',
body: request,
},
)
}
/**
* Get transfer batch history.
* GET /_internal/transfers/history
*/
public async history(
options?: Archon.Transfers.Internal.TransferHistoryQuery,
): Promise<Archon.Transfers.Internal.TransferHistoryResponse> {
const params: Record<string, number> = {}
if (options?.page !== undefined) params.page = options.page
if (options?.page_size !== undefined) params.page_size = options.page_size
return this.client.request<Archon.Transfers.Internal.TransferHistoryResponse>(
'/transfers/history',
{
api: 'archon',
version: 'internal',
method: 'GET',
params,
},
)
}
/**
* Cancel pending transfer batches.
* POST /_internal/transfers/cancel
*/
public async cancel(
request: Archon.Transfers.Internal.CancelTransfersRequest,
): Promise<Archon.Transfers.Internal.CancelTransfersResponse> {
return this.client.request<Archon.Transfers.Internal.CancelTransfersResponse>(
'/transfers/cancel',
{
api: 'archon',
version: 'internal',
method: 'POST',
body: request,
},
)
}
}

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