mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
Merge branch 'main' into cal/drag-and-drop-to-move-skins
This commit is contained in:
+2
-2
@@ -14,7 +14,7 @@ import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
import { type MinecraftAuthError, minecraftAuthErrors } from './minecraft-auth-errors'
|
||||
import { findMinecraftAuthError, type MinecraftAuthError } from './minecraft-auth-errors'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const rawError = ref<string>('')
|
||||
@@ -26,7 +26,7 @@ const loadingSignIn = ref(false)
|
||||
function show(errorVal: { message?: string }) {
|
||||
rawError.value = errorVal?.message ?? String(errorVal)
|
||||
|
||||
matchedError.value = minecraftAuthErrors.find((e) => rawError.value.includes(e.errorCode)) ?? null
|
||||
matchedError.value = findMinecraftAuthError(rawError.value)
|
||||
|
||||
debugCollapsed.value = true
|
||||
hide_ads_window()
|
||||
|
||||
+111
-1
@@ -1,10 +1,93 @@
|
||||
export interface MinecraftAuthError {
|
||||
errorCode: string
|
||||
errorCode?: string
|
||||
errorMatchers?: string[]
|
||||
matches?: (message: string) => boolean
|
||||
whatHappened: string
|
||||
stepsToFix: string[]
|
||||
}
|
||||
|
||||
export const minecraftAuthErrors: MinecraftAuthError[] = [
|
||||
{
|
||||
errorMatchers: ['Failed to deserialize response to JSON during step RefreshOAuthToken:'],
|
||||
whatHappened:
|
||||
'Your saved Microsoft sign-in token has expired or was revoked, so Modrinth App cannot refresh your Minecraft session.',
|
||||
stepsToFix: [
|
||||
'Sign out of the affected Minecraft account in Modrinth App',
|
||||
'Sign in to the account again',
|
||||
'Once the new sign-in finishes, try launching Minecraft again',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorMatchers: ['Failed to deserialize response to JSON during step SisuAuthenticate:'],
|
||||
whatHappened:
|
||||
'Xbox services rejected the first sign-in response. This is most often caused by your system clock or time zone being out of sync.',
|
||||
stepsToFix: [
|
||||
'Open your system date and time settings',
|
||||
'Turn on automatic time zone and automatic time, if available',
|
||||
'Use the sync option in your system settings to synchronize the clock',
|
||||
'Restart Modrinth App',
|
||||
'Try signing in again',
|
||||
],
|
||||
},
|
||||
{
|
||||
matches: (message) =>
|
||||
message.includes('Failed to deserialize response to JSON during step MinecraftToken:') &&
|
||||
message.includes('429 Too Many Requests'),
|
||||
whatHappened:
|
||||
'Microsoft or Minecraft temporarily blocked the sign-in request because there were too many recent attempts.',
|
||||
stepsToFix: [
|
||||
'Wait about an hour before trying again',
|
||||
'Restart Modrinth App after waiting',
|
||||
'Try signing in once more',
|
||||
'If the same message appears, wait longer before retrying so the temporary limit can clear',
|
||||
],
|
||||
},
|
||||
{
|
||||
matches: (message) =>
|
||||
message.includes('Failed to deserialize response to JSON during step MinecraftToken:') &&
|
||||
/Status Code: 5\d\d/.test(message),
|
||||
whatHappened:
|
||||
"Minecraft's authentication service is returning a server error, so Modrinth App cannot finish signing you in right now.",
|
||||
stepsToFix: [
|
||||
'Wait a few minutes and try signing in again',
|
||||
'Check <a href="https://support.xbox.com/xbox-live-status">Xbox Status</a> for current service issues',
|
||||
'Try signing in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a> to confirm whether Minecraft sign-in is also affected there',
|
||||
'If the service is healthy and this keeps happening, contact support with the debug information below',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorMatchers: ['Failed to fetch player profile'],
|
||||
whatHappened:
|
||||
'Minecraft services could not return a Java Edition profile for this account. This most often happens when the game was purchased recently, the Java profile has not finished being created, or the wrong Microsoft account is being used.',
|
||||
stepsToFix: [
|
||||
'Sign in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>',
|
||||
'Launch Minecraft: Java Edition once from the official launcher',
|
||||
'Wait up to an hour if the purchase or profile setup was recent',
|
||||
'Make sure you are using the Microsoft account that owns Minecraft. See <a href="https://support.modrinth.com/en/articles/9409136-finding-the-right-xbox-account">Finding the right Xbox account</a> for help',
|
||||
'Try signing in to Modrinth App again',
|
||||
],
|
||||
},
|
||||
{
|
||||
matches: (message) =>
|
||||
message.includes('error sending request for url (') &&
|
||||
[
|
||||
'minecraft.net',
|
||||
'minecraftservices.com',
|
||||
'mojang.com',
|
||||
'xbox.com',
|
||||
'xboxlive.com',
|
||||
'live.com',
|
||||
].some((domain) => message.includes(domain)),
|
||||
whatHappened:
|
||||
'Modrinth App could not connect to a Microsoft, Xbox, or Minecraft service needed for sign-in. This is usually caused by a local network, DNS, proxy, firewall, hosts file, VPN, or antivirus issue.',
|
||||
stepsToFix: [
|
||||
'Restart Modrinth App and try signing in again',
|
||||
'Check that your internet connection is working',
|
||||
'Allow Modrinth App through your firewall, antivirus, proxy, VPN, and hosts file rules',
|
||||
'Try a different network or temporarily disable VPN/proxy software if you use one',
|
||||
'If routing or DNS is the issue, a service like Cloudflare WARP can sometimes help',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorCode: '2148916222',
|
||||
whatHappened:
|
||||
@@ -87,4 +170,31 @@ export const minecraftAuthErrors: MinecraftAuthError[] = [
|
||||
'Once finished, try signing in again',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorMatchers: ['Failed to deserialize response to JSON during step XstsAuthorize:'],
|
||||
whatHappened:
|
||||
'Xbox services rejected the request to authorize this account for Minecraft services, but did not return a specific account restriction that Modrinth App recognizes.',
|
||||
stepsToFix: [
|
||||
'Sign in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>',
|
||||
'Complete any prompts shown by Microsoft, Xbox, or Minecraft',
|
||||
'Try signing in to Modrinth App again',
|
||||
'If the official launcher also fails, follow the error shown there or contact Xbox Support',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function findMinecraftAuthError(message: string): MinecraftAuthError | null {
|
||||
return (
|
||||
minecraftAuthErrors.find((error) => {
|
||||
if (error.errorCode && message.includes(error.errorCode)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (error.errorMatchers?.some((matcher) => message.includes(matcher))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return error.matches?.(message) ?? false
|
||||
}) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownIcon,
|
||||
EditIcon,
|
||||
GripVerticalIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
UnknownIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
@@ -20,7 +13,6 @@ import {
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import type { Skin } from '@/helpers/skins.ts'
|
||||
@@ -81,18 +73,6 @@ const messages = defineMessages({
|
||||
id: 'app.skins.delete-button',
|
||||
defaultMessage: 'Delete skin',
|
||||
},
|
||||
reorderButton: {
|
||||
id: 'app.skins.section.saved-skins.reorder-button',
|
||||
defaultMessage: 'Reorder',
|
||||
},
|
||||
finishReorderButton: {
|
||||
id: 'app.skins.section.saved-skins.finish-reorder-button',
|
||||
defaultMessage: 'Finish',
|
||||
},
|
||||
reorderSkinButton: {
|
||||
id: 'app.skins.reorder-skin-button',
|
||||
defaultMessage: 'Reorder skin',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -102,7 +82,7 @@ const props = defineProps<{
|
||||
isSkinSelected: (skin: Skin) => boolean
|
||||
isSkinActive: (skin: Skin) => boolean
|
||||
isAddSkinButtonDragActive: boolean
|
||||
isSavedSkinReorderMode: boolean
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -114,8 +94,6 @@ const emit = defineEmits<{
|
||||
'add-skin-dragover': [event: DragEvent]
|
||||
'add-skin-dragleave': [event: DragEvent]
|
||||
'add-skin-drop': [event: DragEvent]
|
||||
'reorder-saved-skins': [textureKeys: string[]]
|
||||
'toggle-saved-skin-reorder': []
|
||||
}>()
|
||||
|
||||
const addSkinButton = useTemplateRef<AddSkinButtonRef>('addSkinButton')
|
||||
@@ -318,13 +296,6 @@ function getAddSkinButtonElement() {
|
||||
return button?.getRootElement()
|
||||
}
|
||||
|
||||
function emitSavedSkinOrder(nextSkins: Skin[]) {
|
||||
emit(
|
||||
'reorder-saved-skins',
|
||||
nextSkins.map((skin) => skin.texture_key),
|
||||
)
|
||||
}
|
||||
|
||||
defineExpose({ getAddSkinButtonElement })
|
||||
</script>
|
||||
|
||||
@@ -346,24 +317,8 @@ defineExpose({ getAddSkinButtonElement })
|
||||
]"
|
||||
:style="{ transform: `translateY(${top}px)` }"
|
||||
>
|
||||
<div
|
||||
v-if="section.kind === 'saved'"
|
||||
class="absolute right-0 z-20"
|
||||
:class="index === 0 ? 'top-1' : 'top-6'"
|
||||
>
|
||||
<ButtonStyled size="small" :color="isSavedSkinReorderMode ? 'brand' : 'standard'">
|
||||
<button @click.stop="emit('toggle-saved-skin-reorder')">
|
||||
{{
|
||||
formatMessage(
|
||||
isSavedSkinReorderMode ? messages.finishReorderButton : messages.reorderButton,
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<Accordion
|
||||
button-class="group flex w-full items-center gap-[6px] bg-transparent m-0 p-0 pr-24 border-none cursor-pointer text-left"
|
||||
button-class="group flex w-full items-center gap-[6px] bg-transparent m-0 p-0 border-none cursor-pointer text-left"
|
||||
content-class="pt-2"
|
||||
:open-by-default="isSectionOpen(section.key)"
|
||||
@on-open="setSectionOpen(section.key, true)"
|
||||
@@ -400,91 +355,63 @@ defineExpose({ getAddSkinButtonElement })
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<Draggable
|
||||
<div
|
||||
v-if="section.kind === 'saved'"
|
||||
:model-value="section.skins"
|
||||
item-key="texture_key"
|
||||
tag="div"
|
||||
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
|
||||
handle=".saved-skin-reorder-handle"
|
||||
ghost-class="saved-skin-sortable-ghost"
|
||||
chosen-class="saved-skin-sortable-chosen"
|
||||
drag-class="saved-skin-sortable-drag"
|
||||
fallback-class="saved-skin-sortable-fallback"
|
||||
:animation="150"
|
||||
:disabled="!isSavedSkinReorderMode"
|
||||
:fallback-on-body="true"
|
||||
:fallback-tolerance="4"
|
||||
:force-fallback="true"
|
||||
@update:model-value="emitSavedSkinOrder"
|
||||
>
|
||||
<template #header>
|
||||
<SkinLikeTextButton
|
||||
ref="addSkinButton"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
dropzone
|
||||
:drag-active="isAddSkinButtonDragActive"
|
||||
@click="emit('add-skin')"
|
||||
@dragenter="emit('add-skin-dragenter', $event)"
|
||||
@dragover="emit('add-skin-dragover', $event)"
|
||||
@dragleave="emit('add-skin-dragleave', $event)"
|
||||
@drop="emit('add-skin-drop', $event)"
|
||||
>
|
||||
<template #icon>
|
||||
<PlusIcon class="size-8" />
|
||||
</template>
|
||||
{{ formatMessage(messages.addSkinButton) }}
|
||||
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
|
||||
</SkinLikeTextButton>
|
||||
</template>
|
||||
<SkinLikeTextButton
|
||||
ref="addSkinButton"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
dropzone
|
||||
:disabled="readOnly"
|
||||
:drag-active="!readOnly && isAddSkinButtonDragActive"
|
||||
@click="emit('add-skin')"
|
||||
@dragenter="emit('add-skin-dragenter', $event)"
|
||||
@dragover="emit('add-skin-dragover', $event)"
|
||||
@dragleave="emit('add-skin-dragleave', $event)"
|
||||
@drop="emit('add-skin-drop', $event)"
|
||||
>
|
||||
<template #icon>
|
||||
<PlusIcon class="size-8" />
|
||||
</template>
|
||||
{{ formatMessage(messages.addSkinButton) }}
|
||||
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
|
||||
</SkinLikeTextButton>
|
||||
|
||||
<template #item="{ element: skin }">
|
||||
<SkinButton
|
||||
:key="skinKey(skin, 'saved-skin')"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:selectable="!isSavedSkinReorderMode"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template v-if="isSavedSkinReorderMode" #top-buttons>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.reorderSkinButton)"
|
||||
:aria-label="formatMessage(messages.reorderSkinButton)"
|
||||
class="saved-skin-reorder-handle pointer-events-auto cursor-grab active:cursor-grabbing"
|
||||
@click.stop
|
||||
>
|
||||
<GripVerticalIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-if="!isSavedSkinReorderMode" #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
|
||||
>
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.deleteSkinButton)"
|
||||
:aria-label="formatMessage(messages.deleteSkinButton)"
|
||||
class="!rounded-[100%] pointer-events-auto"
|
||||
@click.stop="emit('delete', skin)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</template>
|
||||
</Draggable>
|
||||
<SkinButton
|
||||
v-for="skin in section.skins"
|
||||
:key="skinKey(skin, 'saved-skin')"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:disabled="readOnly"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template v-if="!readOnly" #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
|
||||
>
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.deleteSkinButton)"
|
||||
:aria-label="formatMessage(messages.deleteSkinButton)"
|
||||
class="!rounded-[100%] pointer-events-auto"
|
||||
@click.stop="emit('delete', skin)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
@@ -499,28 +426,23 @@ defineExpose({ getAddSkinButtonElement })
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:tooltip="skin.name"
|
||||
:disabled="readOnly"
|
||||
@select="emit('select', skin)"
|
||||
/>
|
||||
>
|
||||
<template #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
|
||||
>
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.saved-skin-sortable-chosen {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.saved-skin-sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.saved-skin-sortable-drag,
|
||||
.saved-skin-sortable-fallback {
|
||||
box-shadow:
|
||||
0 8px 18px rgba(0, 0, 0, 0.25),
|
||||
0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -197,11 +197,13 @@ export async function add_project_from_version(
|
||||
path: string,
|
||||
versionId: string,
|
||||
reason: DownloadReason,
|
||||
dependentOnVersionId?: string,
|
||||
): Promise<string> {
|
||||
return await invoke('plugin:profile|profile_add_project_from_version', {
|
||||
path,
|
||||
versionId,
|
||||
reason,
|
||||
dependentOnVersionId,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -404,7 +404,7 @@ async function generateSkinPreviewsForGeneration(
|
||||
const headKey = headKeys[i]
|
||||
|
||||
const rawCached = cachedSkinPreviews[skinKey]
|
||||
if (rawCached) {
|
||||
if (rawCached && !skinBlobUrlMap.has(skinKey)) {
|
||||
const cached: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawCached.forwards),
|
||||
backwards: URL.createObjectURL(rawCached.backwards),
|
||||
@@ -413,7 +413,7 @@ async function generateSkinPreviewsForGeneration(
|
||||
}
|
||||
|
||||
const cachedHead = cachedHeadPreviews[headKey]
|
||||
if (cachedHead) {
|
||||
if (cachedHead && !headBlobUrlMap.has(headKey)) {
|
||||
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
||||
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
|
||||
import { check_reachable, get_default_user, login as login_flow, users } from '@/helpers/auth'
|
||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
get_normalized_skin_texture,
|
||||
normalize_skin_texture,
|
||||
remove_custom_skin,
|
||||
set_custom_skin_order,
|
||||
} from '@/helpers/skins.ts'
|
||||
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
@@ -182,6 +181,7 @@ const client = injectModrinthClient()
|
||||
const themeStore = useTheming()
|
||||
const skins = ref<Skin[]>([])
|
||||
const capes = ref<Cape[]>([])
|
||||
const offline = ref(!navigator.onLine)
|
||||
|
||||
const accountsCard = inject('accountsCard') as Ref<typeof AccountsCard>
|
||||
const currentUser = ref(undefined)
|
||||
@@ -201,6 +201,16 @@ const savedSkins = computed(() => {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const authServerQuery = useQuery({
|
||||
queryKey: ['authServerReachability'],
|
||||
queryFn: async () => {
|
||||
await check_reachable()
|
||||
return true
|
||||
},
|
||||
refetchInterval: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
const { data: modrinthUser } = useQuery({
|
||||
queryKey: computed(() => ['authenticated-user', 'campaigns', auth.user.value?.id]),
|
||||
queryFn: () => client.labrinth.users_v3.getAuthenticated(),
|
||||
@@ -250,8 +260,18 @@ const currentCape = computed(() => {
|
||||
})
|
||||
|
||||
const skinTexture = computedAsync(async () => {
|
||||
if (selectedSkin.value?.texture) {
|
||||
return await get_normalized_skin_texture(selectedSkin.value)
|
||||
const skin = selectedSkin.value
|
||||
if (skin?.texture) {
|
||||
try {
|
||||
return await get_normalized_skin_texture(skin)
|
||||
} catch (error) {
|
||||
if (skin.texture.startsWith('data:image/')) {
|
||||
return skin.texture
|
||||
}
|
||||
|
||||
handleError(error as Error)
|
||||
return ''
|
||||
}
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
@@ -259,6 +279,9 @@ const skinTexture = computedAsync(async () => {
|
||||
const capeTexture = computed(() => currentCape.value?.texture)
|
||||
const skinVariant = computed(() => selectedSkin.value?.variant)
|
||||
const skinNametag = computed(() => (themeStore.hideNametagSkinsPage ? undefined : username.value))
|
||||
const isSkinManagementReadOnly = computed(
|
||||
() => offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value),
|
||||
)
|
||||
const hasPendingSkinChange = computed(
|
||||
() => !skinsMatch(selectedSkin.value, originalSelectedSkin.value),
|
||||
)
|
||||
@@ -270,17 +293,20 @@ let isUnmounted = false
|
||||
|
||||
const isDraggingSkinFile = ref(false)
|
||||
const isAddSkinButtonDragActive = ref(false)
|
||||
const isSavedSkinReorderMode = ref(false)
|
||||
|
||||
const deleteSkinModal = ref()
|
||||
const skinToDelete = ref<Skin | null>(null)
|
||||
|
||||
function confirmDeleteSkin(skin: Skin) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
skinToDelete.value = skin
|
||||
deleteSkinModal.value?.show()
|
||||
}
|
||||
|
||||
async function deleteSkin() {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
const deletedSkin = skinToDelete.value
|
||||
if (!deletedSkin) return
|
||||
|
||||
@@ -306,7 +332,23 @@ async function loadCapes() {
|
||||
|
||||
async function loadSkins() {
|
||||
try {
|
||||
skins.value = (await get_available_skins()) ?? []
|
||||
const loadedSkins = (await get_available_skins()) ?? []
|
||||
const loadedEquippedSkin = loadedSkins.find((s) => s.is_equipped)
|
||||
const locallyKnownEquippedSkin =
|
||||
originalSelectedSkin.value &&
|
||||
(loadedSkins.find((skin) => skinsMatch(skin, originalSelectedSkin.value)) ??
|
||||
(originalSelectedSkin.value.texture.startsWith('data:image/')
|
||||
? originalSelectedSkin.value
|
||||
: undefined))
|
||||
const shouldPreserveKnownEquippedSkin =
|
||||
isSkinManagementReadOnly.value &&
|
||||
locallyKnownEquippedSkin &&
|
||||
!skinsMatch(loadedEquippedSkin, locallyKnownEquippedSkin)
|
||||
|
||||
skins.value =
|
||||
shouldPreserveKnownEquippedSkin && locallyKnownEquippedSkin
|
||||
? mergeEquippedSkin(loadedSkins, locallyKnownEquippedSkin)
|
||||
: loadedSkins
|
||||
generateSkinPreviews(skins.value, capes.value)
|
||||
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
|
||||
originalSelectedSkin.value = selectedSkin.value
|
||||
@@ -317,6 +359,28 @@ async function loadSkins() {
|
||||
}
|
||||
}
|
||||
|
||||
function mergeEquippedSkin(list: Skin[], equippedSkin: Skin) {
|
||||
let foundEquippedSkin = false
|
||||
const mergedSkins = list.map((skin) => {
|
||||
const isEquipped = skinsMatch(skin, equippedSkin)
|
||||
foundEquippedSkin ||= isEquipped
|
||||
|
||||
return {
|
||||
...skin,
|
||||
is_equipped: isEquipped,
|
||||
}
|
||||
})
|
||||
|
||||
if (!foundEquippedSkin) {
|
||||
mergedSkins.unshift({
|
||||
...equippedSkin,
|
||||
is_equipped: true,
|
||||
})
|
||||
}
|
||||
|
||||
return mergedSkins
|
||||
}
|
||||
|
||||
function skinsMatch(a?: Skin | null, b?: Skin | null) {
|
||||
return (
|
||||
a?.source === b?.source &&
|
||||
@@ -387,6 +451,8 @@ function getDefaultSkinSectionSortIndex(section: string) {
|
||||
}
|
||||
|
||||
function changeSkin(newSkin: Skin) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
selectedSkin.value = newSkin
|
||||
}
|
||||
|
||||
@@ -482,44 +548,6 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
|
||||
generateSkinPreviews(skins.value, capes.value)
|
||||
}
|
||||
|
||||
function reorderLocalSavedSkins(textureKeys: string[]) {
|
||||
const requestedKeys = new Set(textureKeys)
|
||||
const savedSkinList = skins.value.filter((skin) => skin.source !== 'default')
|
||||
const savedSkinByKey = new Map(savedSkinList.map((skin) => [skin.texture_key, skin]))
|
||||
const orderedSavedSkins = [
|
||||
...textureKeys.map((key) => savedSkinByKey.get(key)).filter((skin): skin is Skin => !!skin),
|
||||
...savedSkinList.filter((skin) => !requestedKeys.has(skin.texture_key)),
|
||||
]
|
||||
let savedSkinIndex = 0
|
||||
|
||||
skins.value = skins.value.flatMap((skin) => {
|
||||
if (skin.source === 'default') {
|
||||
return [skin]
|
||||
}
|
||||
|
||||
const nextSkin = orderedSavedSkins[savedSkinIndex++]
|
||||
return nextSkin ? [nextSkin] : []
|
||||
})
|
||||
}
|
||||
|
||||
async function onSavedSkinsReordered(textureKeys: string[]) {
|
||||
const previousSkins = skins.value
|
||||
|
||||
reorderLocalSavedSkins(textureKeys)
|
||||
|
||||
try {
|
||||
await set_custom_skin_order(textureKeys)
|
||||
} catch (error) {
|
||||
skins.value = previousSkins
|
||||
handleError(error as Error)
|
||||
await loadSkins()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSavedSkinReorderMode() {
|
||||
isSavedSkinReorderMode.value = !isSavedSkinReorderMode.value
|
||||
}
|
||||
|
||||
function schedulePendingSkinRefresh() {
|
||||
if (pendingSkinRefreshTimeout !== null) {
|
||||
window.clearTimeout(pendingSkinRefreshTimeout)
|
||||
@@ -557,7 +585,13 @@ function schedulePendingSkinRefresh() {
|
||||
|
||||
async function applySelectedSkin() {
|
||||
const skinToApply = selectedSkin.value
|
||||
if (!skinToApply || !hasPendingSkinChange.value || isApplyingSkin.value) return
|
||||
if (
|
||||
!skinToApply ||
|
||||
!hasPendingSkinChange.value ||
|
||||
isApplyingSkin.value ||
|
||||
isSkinManagementReadOnly.value
|
||||
)
|
||||
return
|
||||
|
||||
isApplyingSkin.value = true
|
||||
try {
|
||||
@@ -626,10 +660,14 @@ async function login() {
|
||||
}
|
||||
|
||||
function openAddSkinFileBrowser() {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
addSkinFileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onAddSkinFileInputChange(e: Event) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
const file = files?.[0]
|
||||
|
||||
@@ -672,6 +710,8 @@ function isPositionOverAddSkinButton(position: { x: number; y: number }) {
|
||||
}
|
||||
|
||||
async function handleAddSkinNativeDragDrop(event: { payload: DragDropEvent }) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
const payload = event.payload
|
||||
|
||||
if (payload.type === 'leave') {
|
||||
@@ -720,6 +760,8 @@ async function handleAddSkinNativeDragDrop(event: { payload: DragDropEvent }) {
|
||||
}
|
||||
|
||||
function onAddSkinDragOver(event: DragEvent) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
if (!isSkinFileDrag(event)) {
|
||||
return
|
||||
}
|
||||
@@ -728,10 +770,14 @@ function onAddSkinDragOver(event: DragEvent) {
|
||||
}
|
||||
|
||||
function onAddSkinDragLeave() {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
isAddSkinButtonDragActive.value = false
|
||||
}
|
||||
|
||||
async function onAddSkinDrop(event: DragEvent) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
isAddSkinButtonDragActive.value = false
|
||||
|
||||
const file = Array.from(event.dataTransfer?.files ?? []).find(
|
||||
@@ -761,6 +807,8 @@ async function setupAddSkinDragDropListener() {
|
||||
}
|
||||
|
||||
async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer)
|
||||
try {
|
||||
@@ -780,13 +828,24 @@ watch(
|
||||
() => {},
|
||||
)
|
||||
|
||||
watch(isSkinManagementReadOnly, (readOnly) => {
|
||||
if (readOnly) {
|
||||
isDraggingSkinFile.value = false
|
||||
isAddSkinButtonDragActive.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('offline', onOffline)
|
||||
window.addEventListener('online', onOnline)
|
||||
userCheckInterval = window.setInterval(checkUserChanges, 250)
|
||||
void setupAddSkinDragDropListener()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
isUnmounted = true
|
||||
window.removeEventListener('offline', onOffline)
|
||||
window.removeEventListener('online', onOnline)
|
||||
|
||||
if (userCheckInterval !== null) {
|
||||
window.clearInterval(userCheckInterval)
|
||||
@@ -803,6 +862,15 @@ onUnmounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
function onOffline() {
|
||||
offline.value = true
|
||||
}
|
||||
|
||||
function onOnline() {
|
||||
offline.value = false
|
||||
void authServerQuery.refetch()
|
||||
}
|
||||
|
||||
async function checkUserChanges() {
|
||||
try {
|
||||
const defaultId = await get_default_user()
|
||||
@@ -874,7 +942,7 @@ await loadSkins()
|
||||
>
|
||||
<button
|
||||
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 text-contrast shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
||||
:disabled="isApplyingSkin"
|
||||
:disabled="isApplyingSkin || isSkinManagementReadOnly"
|
||||
@click="resetSelectedSkin"
|
||||
>
|
||||
<RotateCounterClockwiseIcon />
|
||||
@@ -882,7 +950,7 @@ await loadSkins()
|
||||
</button>
|
||||
<button
|
||||
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
||||
:disabled="isApplyingSkin"
|
||||
:disabled="isApplyingSkin || isSkinManagementReadOnly"
|
||||
@click="applySelectedSkin"
|
||||
>
|
||||
<SpinnerIcon v-if="isApplyingSkin" class="animate-spin" />
|
||||
@@ -893,7 +961,7 @@ await loadSkins()
|
||||
<button
|
||||
v-else
|
||||
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
||||
:disabled="!selectedSkin || isSavedSkinReorderMode"
|
||||
:disabled="!selectedSkin || isSkinManagementReadOnly"
|
||||
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
|
||||
>
|
||||
<EditIcon />
|
||||
@@ -913,12 +981,10 @@ await loadSkins()
|
||||
:is-skin-selected="isSkinSelected"
|
||||
:is-skin-active="isSkinActive"
|
||||
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
|
||||
:is-saved-skin-reorder-mode="isSavedSkinReorderMode"
|
||||
:read-only="isSkinManagementReadOnly"
|
||||
@select="changeSkin"
|
||||
@edit="(skin, event) => editSkinModal?.show(event, skin)"
|
||||
@delete="confirmDeleteSkin"
|
||||
@reorder-saved-skins="onSavedSkinsReordered"
|
||||
@toggle-saved-skin-reorder="toggleSavedSkinReorderMode"
|
||||
@add-skin="openAddSkinFileBrowser"
|
||||
@add-skin-dragenter="onAddSkinDragOver"
|
||||
@add-skin-dragover="onAddSkinDragOver"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { findMinecraftAuthError } from '@/components/ui/minecraft-auth-error-modal/minecraft-auth-errors'
|
||||
|
||||
export const useError = defineStore('errorsStore', {
|
||||
state: () => ({
|
||||
errorModal: null,
|
||||
@@ -15,7 +17,8 @@ export const useError = defineStore('errorsStore', {
|
||||
showError(error, context, closable = true, source = null) {
|
||||
if (
|
||||
error.message &&
|
||||
error.message.includes('Minecraft authentication error:') &&
|
||||
(error.message.includes('Minecraft authentication error:') ||
|
||||
findMinecraftAuthError(error.message)) &&
|
||||
this.minecraftAuthErrorModal
|
||||
) {
|
||||
this.minecraftAuthErrorModal.show(error)
|
||||
|
||||
@@ -71,7 +71,7 @@ export const installVersionDependencies = async (profile, version, reason, onDep
|
||||
return installed
|
||||
}
|
||||
|
||||
const queueInstall = async (projectId, resolvedVersion) => {
|
||||
const queueInstall = async (projectId, resolvedVersion, dependentOn) => {
|
||||
if (!resolvedVersion?.id) return false
|
||||
|
||||
const versionId = resolvedVersion.id
|
||||
@@ -91,7 +91,11 @@ export const installVersionDependencies = async (profile, version, reason, onDep
|
||||
if (resolvedProjectId) {
|
||||
queuedProjectVersions.set(resolvedProjectId, versionId)
|
||||
}
|
||||
queuedInstalls.push({ versionId, projectId: resolvedProjectId })
|
||||
queuedInstalls.push({
|
||||
versionId,
|
||||
projectId: resolvedProjectId,
|
||||
dependentOnVersionId: dependentOn?.id,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -159,7 +163,7 @@ export const installVersionDependencies = async (profile, version, reason, onDep
|
||||
if (!resolved) continue
|
||||
|
||||
const { depVersion, depProjectId } = resolved
|
||||
const queued = await queueInstall(depProjectId, depVersion)
|
||||
const queued = await queueInstall(depProjectId, depVersion, inputVersion)
|
||||
if (queued && depProjectId) {
|
||||
await announceDependency(depProjectId, depVersion)
|
||||
}
|
||||
@@ -176,8 +180,8 @@ export const installVersionDependencies = async (profile, version, reason, onDep
|
||||
for (let i = 0; i < queuedInstalls.length; i += batchSize) {
|
||||
const batch = queuedInstalls.slice(i, i + batchSize)
|
||||
await Promise.all(
|
||||
batch.map(async ({ versionId }) => {
|
||||
await add_project_from_version(profile.path, versionId, reason)
|
||||
batch.map(async ({ versionId, dependentOnVersionId }) => {
|
||||
await add_project_from_version(profile.path, versionId, reason, dependentOnVersionId)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -251,8 +251,15 @@ pub async fn profile_add_project_from_version(
|
||||
path: &str,
|
||||
version_id: &str,
|
||||
reason: DownloadReason,
|
||||
dependent_on_version_id: Option<String>,
|
||||
) -> Result<String> {
|
||||
Ok(profile::add_project_from_version(path, version_id, reason).await?)
|
||||
Ok(profile::add_project_from_version(
|
||||
path,
|
||||
version_id,
|
||||
reason,
|
||||
dependent_on_version_id,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
// Adds a project to a profile from a path
|
||||
|
||||
@@ -383,6 +383,12 @@
|
||||
action: (event) => $refs.modal_batch_credit.show(event),
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'analytics-events',
|
||||
color: 'primary',
|
||||
link: '/admin/analytics/events',
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<ModrinthIcon aria-hidden="true" />
|
||||
@@ -417,6 +423,9 @@
|
||||
<template #servers-nodes>
|
||||
<ServerIcon aria-hidden="true" /> Credit server nodes
|
||||
</template>
|
||||
<template #analytics-events>
|
||||
<ChartIcon aria-hidden="true" /> {{ formatMessage(messages.analyticsEvents) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
@@ -954,6 +963,10 @@ const messages = defineMessages({
|
||||
id: 'layout.action.manage-affiliates',
|
||||
defaultMessage: 'Manage affiliate links',
|
||||
},
|
||||
analyticsEvents: {
|
||||
id: 'layout.action.analytics-events',
|
||||
defaultMessage: 'Analytics events',
|
||||
},
|
||||
newProject: {
|
||||
id: 'layout.action.new-project',
|
||||
defaultMessage: 'New project',
|
||||
|
||||
@@ -1466,6 +1466,24 @@
|
||||
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
|
||||
"message": "You've used up your <b>{withdrawLimit}</b> withdrawal limit. You must complete a tax form to withdraw more."
|
||||
},
|
||||
"dashboard.discord-roles.banner.body": {
|
||||
"message": "You're eligible for {roles}. Link your Discord account through Modrinth and we'll sync them automatically."
|
||||
},
|
||||
"dashboard.discord-roles.banner.cta": {
|
||||
"message": "Link Discord"
|
||||
},
|
||||
"dashboard.discord-roles.banner.title": {
|
||||
"message": "Claim your Discord roles"
|
||||
},
|
||||
"dashboard.discord-roles.role.big-creator": {
|
||||
"message": "1M+ Downloads"
|
||||
},
|
||||
"dashboard.discord-roles.role.creator": {
|
||||
"message": "Creator"
|
||||
},
|
||||
"dashboard.discord-roles.role.pride": {
|
||||
"message": "Pride 2026"
|
||||
},
|
||||
"dashboard.head-title": {
|
||||
"message": "Dashboard"
|
||||
},
|
||||
@@ -2297,6 +2315,9 @@
|
||||
"landing.subheading": {
|
||||
"message": "Discover, play, and share Minecraft content through our open-source platform built for the community."
|
||||
},
|
||||
"layout.action.analytics-events": {
|
||||
"message": "Analytics events"
|
||||
},
|
||||
"layout.action.change-theme": {
|
||||
"message": "Change theme"
|
||||
},
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
placeholder="Select start..."
|
||||
input-class="w-full"
|
||||
wrapper-class="w-full"
|
||||
clearable
|
||||
show-today
|
||||
/>
|
||||
</div>
|
||||
@@ -89,6 +90,7 @@
|
||||
placeholder="Select end..."
|
||||
input-class="w-full"
|
||||
wrapper-class="w-full"
|
||||
clearable
|
||||
show-today
|
||||
/>
|
||||
</div>
|
||||
@@ -214,7 +216,11 @@
|
||||
|
||||
<template #empty-state>
|
||||
<div class="flex h-64 items-center justify-center text-secondary">
|
||||
{{ isLoadingEvents ? 'Loading analytics events...' : 'No results.' }}
|
||||
<div v-if="isFetchingEvents" class="flex items-center gap-2">
|
||||
<SpinnerIcon class="size-5 animate-spin" aria-hidden="true" />
|
||||
Loading
|
||||
</div>
|
||||
<template v-else>No results.</template>
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
@@ -224,7 +230,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { EditIcon, ExternalIcon, PlusIcon, SaveIcon, SearchIcon, TrashIcon } from '@modrinth/assets'
|
||||
import {
|
||||
EditIcon,
|
||||
ExternalIcon,
|
||||
PlusIcon,
|
||||
SaveIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
ConfirmModal,
|
||||
@@ -322,7 +336,7 @@ let resetFormTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const {
|
||||
data: analyticsEvents,
|
||||
error: eventsError,
|
||||
isLoading: isLoadingEvents,
|
||||
isFetching: isFetchingEvents,
|
||||
} = useQuery({
|
||||
queryKey: analyticsEventsQueryKey,
|
||||
queryFn: () => client.labrinth.analytics_v3.getEvents(),
|
||||
@@ -439,7 +453,7 @@ function openEditModal(event: Labrinth.Analytics.v3.AnalyticsEvent) {
|
||||
title: event.title,
|
||||
announcementUrl: event.announcement_url ?? '',
|
||||
startsAt: getDateTimeInputValue(event.starts),
|
||||
endsAt: getDateTimeInputValue(event.ends),
|
||||
endsAt: isEventDateRange(event) ? getDateTimeInputValue(event.ends) : '',
|
||||
metricKinds: event.for_metric_kind?.length ? [...event.for_metric_kind] : [...allMetricKinds],
|
||||
}
|
||||
committedAnnouncementUrl.value = event.announcement_url ?? ''
|
||||
|
||||
@@ -48,28 +48,69 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="normal-page__content mt-4 lg:!mt-0">
|
||||
<Admonition
|
||||
v-if="showDiscordRoleBanner"
|
||||
class="mb-3"
|
||||
type="info"
|
||||
:header="formatMessage(messages.discordRoleBannerTitle)"
|
||||
show-actions-underneath
|
||||
dismissible
|
||||
@dismiss="dismissDiscordRoleBanner"
|
||||
>
|
||||
<div class="text-primary">
|
||||
{{
|
||||
formatMessage(messages.discordRoleBannerBody, {
|
||||
roles: eligibleDiscordRolesLabel,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<template #actions>
|
||||
<ButtonStyled color="blue">
|
||||
<NuxtLink to="/discord/link" class="w-fit !px-4">
|
||||
<ExternalIcon />
|
||||
{{ formatMessage(messages.discordRoleBannerCta) }}
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
<NuxtPage :route="route" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
AffiliateIcon,
|
||||
BellIcon as NotificationsIcon,
|
||||
ChartIcon,
|
||||
CurrencyIcon,
|
||||
DashboardIcon,
|
||||
ExternalIcon,
|
||||
LibraryIcon,
|
||||
ListIcon,
|
||||
OrganizationIcon,
|
||||
ReportIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { type User, UserBadge } from '@modrinth/utils'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { UserBadge } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
|
||||
const auth = (await useAuth()) as Ref<{ user: User | null }>
|
||||
const auth = (await useAuth()) as Ref<{ user: Labrinth.Users.v3.User | null }>
|
||||
const client = injectModrinthClient()
|
||||
const dismissedDiscordRoleBannerUsers = useLocalStorage<string[]>(
|
||||
'dashboard-discord-role-banner-dismissed-users',
|
||||
[],
|
||||
)
|
||||
|
||||
const isAffiliate = computed(() => {
|
||||
return auth.value.user && auth.value.user.badges & UserBadge.AFFILIATE
|
||||
@@ -114,6 +155,31 @@ const messages = defineMessages({
|
||||
id: 'dashboard.sidebar.label.revenue',
|
||||
defaultMessage: 'Revenue',
|
||||
},
|
||||
discordRoleBannerTitle: {
|
||||
id: 'dashboard.discord-roles.banner.title',
|
||||
defaultMessage: 'Claim your Discord roles',
|
||||
},
|
||||
discordRoleBannerBody: {
|
||||
id: 'dashboard.discord-roles.banner.body',
|
||||
defaultMessage:
|
||||
"You're eligible for {roles}. Link your Discord account through Modrinth and we'll sync them automatically.",
|
||||
},
|
||||
discordRoleBannerCta: {
|
||||
id: 'dashboard.discord-roles.banner.cta',
|
||||
defaultMessage: 'Link Discord',
|
||||
},
|
||||
discordRolePride: {
|
||||
id: 'dashboard.discord-roles.role.pride',
|
||||
defaultMessage: 'Pride 2026',
|
||||
},
|
||||
discordRoleCreator: {
|
||||
id: 'dashboard.discord-roles.role.creator',
|
||||
defaultMessage: 'Creator',
|
||||
},
|
||||
discordRoleBigCreator: {
|
||||
id: 'dashboard.discord-roles.role.big-creator',
|
||||
defaultMessage: '1M+ Downloads',
|
||||
},
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
@@ -125,4 +191,60 @@ useSeoMeta({
|
||||
})
|
||||
|
||||
const route = useNativeRoute()
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: computed(() => ['dashboard-discord-role-eligibility', auth.value.user?.id, 'projects']),
|
||||
queryFn: () => {
|
||||
const userId = auth.value.user?.id
|
||||
if (!userId) return []
|
||||
|
||||
return client.labrinth.users_v2.getProjects(userId)
|
||||
},
|
||||
enabled: computed(() => !!auth.value.user?.id),
|
||||
})
|
||||
|
||||
const totalProjectDownloads = computed(() =>
|
||||
(projects.value ?? []).reduce((total, project) => total + (project.downloads ?? 0), 0),
|
||||
)
|
||||
|
||||
const eligibleDiscordRoles = computed(() => {
|
||||
const roles = []
|
||||
|
||||
if (auth.value.user?.campaigns?.pride_26?.has_badge === true) {
|
||||
roles.push(formatMessage(messages.discordRolePride))
|
||||
}
|
||||
|
||||
if (totalProjectDownloads.value >= 20_000) {
|
||||
roles.push(formatMessage(messages.discordRoleCreator))
|
||||
}
|
||||
|
||||
if (totalProjectDownloads.value >= 1_000_000) {
|
||||
roles.push(formatMessage(messages.discordRoleBigCreator))
|
||||
}
|
||||
|
||||
return roles
|
||||
})
|
||||
|
||||
const roleListFormatter = new Intl.ListFormat(undefined, {
|
||||
style: 'long',
|
||||
type: 'conjunction',
|
||||
})
|
||||
|
||||
const eligibleDiscordRolesLabel = computed(() =>
|
||||
roleListFormatter.format(eligibleDiscordRoles.value),
|
||||
)
|
||||
|
||||
const hasDismissedDiscordRoleBanner = computed(() =>
|
||||
dismissedDiscordRoleBannerUsers.value.includes(auth.value.user?.id ?? ''),
|
||||
)
|
||||
const showDiscordRoleBanner = computed(
|
||||
() => eligibleDiscordRoles.value.length > 0 && !hasDismissedDiscordRoleBanner.value,
|
||||
)
|
||||
|
||||
function dismissDiscordRoleBanner() {
|
||||
const userId = auth.value.user?.id
|
||||
if (!userId || dismissedDiscordRoleBannerUsers.value.includes(userId)) return
|
||||
|
||||
dismissedDiscordRoleBannerUsers.value = [...dismissedDiscordRoleBannerUsers.value, userId]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { injectModrinthClient } from '@modrinth/ui'
|
||||
|
||||
import { getAuthUrl } from '~/composables/auth.js'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'empty',
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const auth = await useAuth()
|
||||
const client = injectModrinthClient()
|
||||
const error = ref<unknown>(null)
|
||||
const isLinkedCallback = computed(() => route.query.callback === 'linked')
|
||||
|
||||
onMounted(async () => {
|
||||
if (isLinkedCallback.value) return
|
||||
|
||||
try {
|
||||
if (!auth.value.user?.auth_providers?.includes('discord')) {
|
||||
window.location.href = `${getAuthUrl('discord', '/discord/link')}&token=${auth.value.token}`
|
||||
return
|
||||
}
|
||||
|
||||
const res = await client.labrinth.auth_internal.createDiscordCommunityLink()
|
||||
window.location.href = res.url
|
||||
} catch (err) {
|
||||
error.value = err
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="discord-link-container universal-card">
|
||||
<h1>{{ isLinkedCallback ? 'Modrinth account linked' : 'Linking Discord' }}</h1>
|
||||
<p v-if="isLinkedCallback">Your Modrinth account has been linked to the Discord server.</p>
|
||||
<p v-else-if="!error">Connecting your Modrinth account to the Discord server...</p>
|
||||
<p v-else>Discord linking failed. Please try again later.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.discord-link-container {
|
||||
width: 26rem;
|
||||
max-width: calc(100% - 2rem);
|
||||
margin: 1rem auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.discord-link-container h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin: 0 0 -1rem 0;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.discord-link-container p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Heading, Section, Text } from '@vue-email/components'
|
||||
|
||||
import StyledEmail from '../shared/StyledEmail.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StyledEmail
|
||||
title="You're invited to the Creator Club"
|
||||
:manual-links="[{ link: '{discord.link_url}', label: 'Link Discord' }]"
|
||||
>
|
||||
<Heading as="h1" class="mb-2 text-2xl font-bold">You're invited to the Creator Club</Heading>
|
||||
|
||||
<Text class="text-base">Hey <span class="no-auto-link">{user.name}</span>!</Text>
|
||||
|
||||
<Text class="text-base"> Your projects just passed 20,000 total downloads, nice! </Text>
|
||||
|
||||
<Text class="text-base">
|
||||
We want to invite you to Modrinth's Creator Club, a space in our discord where you can chat
|
||||
with other creators, share feedback with us, and stay plugged in.
|
||||
</Text>
|
||||
|
||||
<Text class="text-base">
|
||||
To join just link your Discord account through Modrinth and we'll grant access automatically!
|
||||
</Text>
|
||||
|
||||
<Section class="mb-4 mt-4">
|
||||
<Button
|
||||
href="{discord.link_url}"
|
||||
target="_blank"
|
||||
class="text-accentContrast inline-block rounded-[12px] bg-brand pb-3 pl-4 pr-4 pt-3 text-[14px] font-bold"
|
||||
>
|
||||
Join the Creator Club
|
||||
</Button>
|
||||
</Section>
|
||||
</StyledEmail>
|
||||
</template>
|
||||
@@ -36,6 +36,9 @@ export default {
|
||||
'server-invited': () => import('./server/ServerInvited.vue'),
|
||||
'server-invited-no-account': () => import('./server/ServerInvitedNoAccount.vue'),
|
||||
|
||||
// Discord
|
||||
'discord-role-creator-club': () => import('./discord/DiscordRoleCreatorClub.vue'),
|
||||
|
||||
// Organizations
|
||||
'organization-invited': () => import('./organization/OrganizationInvited.vue'),
|
||||
} as Record<string, () => Promise<{ default: Component }>>
|
||||
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id FROM versions\n WHERE mod_id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3eacabccb1da975ceba03932880681c39ef3190c365e292c49dfe4acd7671395"
|
||||
}
|
||||
Generated
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT pg_try_advisory_xact_lock(hashtextextended('discord_role_email_campaign', 0)) AS \"lock_acquired!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "lock_acquired!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "67d6f3431c2c78227c10c1ff2658e89ffec91b671e65915d7f6923dc2c95f82b"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT n.id AS \"id!\"\n FROM notifications n\n INNER JOIN notifications_types nt ON nt.name = n.body ->> 'type'\n WHERE n.id = ANY($1::BIGINT[])\n AND nt.expose_in_site_notifications = TRUE\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e89c5b87467dc019925f58ec789e15599d0f6121c41a4224746cfe2fde41ab60"
|
||||
}
|
||||
Generated
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH\n user_project_downloads AS (\n SELECT\n tm.user_id,\n SUM(m.downloads)::BIGINT total_downloads\n FROM team_members tm\n INNER JOIN mods m ON m.team_id = tm.team_id\n WHERE tm.accepted = TRUE\n GROUP BY tm.user_id\n )\n SELECT u.id AS \"id!\"\n FROM users u\n INNER JOIN user_project_downloads upd ON upd.user_id = u.id\n WHERE u.email IS NOT NULL\n AND u.email_verified = TRUE\n AND upd.total_downloads > 20000\n AND NOT EXISTS (\n SELECT 1\n FROM notifications n\n WHERE n.user_id = u.id\n AND n.body ->> 'type' = 'discord_role_creator_club'\n )\n ORDER BY upd.total_downloads DESC, u.id\n LIMIT 1000\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ffb5c12a0af95670946839a2f17247fc27601850a7b74b92863b740efbf794c9"
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Fixture for user HGwXDEgw.
|
||||
-- User 60829878552966 = HGwXDEgw
|
||||
-- Team 930000000000001 = 4G5AdLiy1
|
||||
-- Team member 930000000000002 = 4G5AdLiy2
|
||||
-- Project 930000000000003 = 4G5AdLiy3
|
||||
-- Thread 930000000000004 = 4G5AdLiy4
|
||||
-- Pride donation 930000000000005 = 4G5AdLiy5
|
||||
|
||||
INSERT INTO users (
|
||||
id, username, email, role, badges, balance, email_verified
|
||||
)
|
||||
VALUES (
|
||||
60829878552966, 'fixture_hgwxdegw', 'admin@modrinth.invalid',
|
||||
'developer', 15, 0, TRUE
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
badges = users.badges | EXCLUDED.badges,
|
||||
email = COALESCE(users.email, EXCLUDED.email),
|
||||
email_verified = TRUE;
|
||||
|
||||
INSERT INTO teams (id)
|
||||
VALUES (930000000000001)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO team_members (
|
||||
id, team_id, user_id, role, permissions, accepted, payouts_split, ordering,
|
||||
organization_permissions, is_owner
|
||||
)
|
||||
VALUES (
|
||||
930000000000002, 930000000000001, 60829878552966, 'Owner',
|
||||
1023, TRUE, 100, 0, NULL, TRUE
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
team_id = EXCLUDED.team_id,
|
||||
user_id = EXCLUDED.user_id,
|
||||
permissions = EXCLUDED.permissions,
|
||||
accepted = EXCLUDED.accepted,
|
||||
is_owner = EXCLUDED.is_owner;
|
||||
|
||||
INSERT INTO mods (
|
||||
id, team_id, name, summary, downloads, slug, description, follows,
|
||||
license, status, requested_status, monetization_status,
|
||||
side_types_migration_review_status, components
|
||||
)
|
||||
VALUES (
|
||||
930000000000003, 930000000000001, 'HGwXDEgw Million Download Fixture',
|
||||
'Project used to exercise badges and high download counts.', 1000000,
|
||||
'hgwxdegw-million-download-fixture', '', 0,
|
||||
'LicenseRef-All-Rights-Reserved', 'approved', 'approved',
|
||||
'monetized', 'reviewed', '{}'::jsonb
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
team_id = EXCLUDED.team_id,
|
||||
name = EXCLUDED.name,
|
||||
summary = EXCLUDED.summary,
|
||||
downloads = EXCLUDED.downloads,
|
||||
slug = EXCLUDED.slug,
|
||||
status = EXCLUDED.status,
|
||||
requested_status = EXCLUDED.requested_status,
|
||||
monetization_status = EXCLUDED.monetization_status,
|
||||
side_types_migration_review_status = EXCLUDED.side_types_migration_review_status,
|
||||
components = EXCLUDED.components;
|
||||
|
||||
INSERT INTO threads (id, thread_type, mod_id)
|
||||
VALUES (930000000000004, 'project', 930000000000003)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
thread_type = EXCLUDED.thread_type,
|
||||
mod_id = EXCLUDED.mod_id;
|
||||
|
||||
INSERT INTO campaign_donations (
|
||||
id, tiltify_event_id, raw_data, donated_at, amount_usd, user_id
|
||||
)
|
||||
VALUES (
|
||||
930000000000005, '00000000-0000-4000-8000-000000000005',
|
||||
'{"fixture": "hgwxdegw-badges-project"}'::jsonb,
|
||||
'2026-06-01T00:00:00Z', 5, 60829878552966
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
amount_usd = EXCLUDED.amount_usd,
|
||||
user_id = EXCLUDED.user_id;
|
||||
@@ -0,0 +1,38 @@
|
||||
INSERT INTO notifications_types
|
||||
(name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications)
|
||||
VALUES
|
||||
('discord_role_creator_club', 3, FALSE, FALSE);
|
||||
|
||||
INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
|
||||
VALUES
|
||||
(NULL, 'email', 'discord_role_creator_club', TRUE);
|
||||
|
||||
INSERT INTO notifications_templates
|
||||
(channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
|
||||
VALUES
|
||||
(
|
||||
'email',
|
||||
'discord_role_creator_club',
|
||||
'You''re invited to the Creator Club',
|
||||
'https://modrinth.com/_internal/templates/email/discord-role-creator-club',
|
||||
CONCAT(
|
||||
'Hi {user.name},',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'Thanks for building on Modrinth. Your projects have passed 20,000 total downloads, which is wild to think about.',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'That means thousands of players have found something useful, fun, or worth coming back to because of what you made.',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'We''re opening up a Creator Club role in the Modrinth Discord for creators like you. Link your Discord account through Modrinth and we''ll sync it automatically.',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'Join the Creator Club: {discord.link_url}',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'Thanks for making Modrinth what it is,',
|
||||
CHR(10),
|
||||
'The Modrinth Team'
|
||||
)
|
||||
);
|
||||
@@ -1,6 +1,9 @@
|
||||
use crate::database;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::models::ids::DBUserId;
|
||||
use crate::database::models::notification_item::NotificationBuilder;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::notifications::NotificationBody;
|
||||
use crate::queue::analytics::cache::cache_analytics;
|
||||
use crate::queue::billing::{index_billing, index_subscriptions};
|
||||
use crate::queue::email::EmailQueue;
|
||||
@@ -34,6 +37,8 @@ pub enum BackgroundTask {
|
||||
/// Attempts to ping Minecraft Java servers as if we were a client, to
|
||||
/// collect info on if they're online, game version, description, etc.
|
||||
PingMinecraftJavaServers,
|
||||
/// Queues Discord Creator Club role claim emails for newly eligible users.
|
||||
DiscordRoleEmailCampaign,
|
||||
}
|
||||
|
||||
impl BackgroundTask {
|
||||
@@ -90,6 +95,9 @@ impl BackgroundTask {
|
||||
PingMinecraftJavaServers => {
|
||||
ping_minecraft_java_servers(pool, redis_pool, clickhouse).await
|
||||
}
|
||||
DiscordRoleEmailCampaign => {
|
||||
discord_role_email_campaign(pool, redis_pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +216,83 @@ pub async fn payouts(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn discord_role_email_campaign(
|
||||
pool: PgPool,
|
||||
redis_pool: RedisPool,
|
||||
) -> eyre::Result<()> {
|
||||
info!("Started indexing Discord role email campaign");
|
||||
|
||||
let mut txn = pool
|
||||
.begin()
|
||||
.await
|
||||
.wrap_err("failed to begin Discord role email campaign transaction")?;
|
||||
|
||||
let lock_acquired = sqlx::query_scalar!(
|
||||
r#"SELECT pg_try_advisory_xact_lock(hashtextextended('discord_role_email_campaign', 0)) AS "lock_acquired!""#,
|
||||
)
|
||||
.fetch_one(&mut txn)
|
||||
.await
|
||||
.wrap_err("failed to acquire Discord role email campaign lock")?;
|
||||
|
||||
if !lock_acquired {
|
||||
info!("Discord role email campaign is already running");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let user_ids = sqlx::query_scalar!(
|
||||
r#"
|
||||
WITH
|
||||
user_project_downloads AS (
|
||||
SELECT
|
||||
tm.user_id,
|
||||
SUM(m.downloads)::BIGINT total_downloads
|
||||
FROM team_members tm
|
||||
INNER JOIN mods m ON m.team_id = tm.team_id
|
||||
WHERE tm.accepted = TRUE
|
||||
GROUP BY tm.user_id
|
||||
)
|
||||
SELECT u.id AS "id!"
|
||||
FROM users u
|
||||
INNER JOIN user_project_downloads upd ON upd.user_id = u.id
|
||||
WHERE u.email IS NOT NULL
|
||||
AND u.email_verified = TRUE
|
||||
AND upd.total_downloads > 20000
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM notifications n
|
||||
WHERE n.user_id = u.id
|
||||
AND n.body ->> 'type' = 'discord_role_creator_club'
|
||||
)
|
||||
ORDER BY upd.total_downloads DESC, u.id
|
||||
LIMIT 1000
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&mut txn)
|
||||
.await
|
||||
.wrap_err("failed to fetch Discord role email campaign recipients")?
|
||||
.into_iter()
|
||||
.map(DBUserId)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let count = user_ids.len();
|
||||
|
||||
if !user_ids.is_empty() {
|
||||
NotificationBuilder {
|
||||
body: NotificationBody::DiscordRoleCreatorClub,
|
||||
}
|
||||
.insert_many(user_ids, &mut txn, &redis_pool)
|
||||
.await
|
||||
.wrap_err("failed to queue Discord role email notifications")?;
|
||||
}
|
||||
|
||||
txn.commit()
|
||||
.await
|
||||
.wrap_err("failed to commit Discord role email campaign transaction")?;
|
||||
|
||||
info!(count, "Finished indexing Discord role email campaign");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_payout_statuses(
|
||||
pool: PgPool,
|
||||
mural: muralpay::Client,
|
||||
|
||||
@@ -247,7 +247,8 @@ pub async fn init_client_with_database(
|
||||
ALTER TABLE {database}.{DOWNLOADS} {cluster_line}
|
||||
ADD COLUMN IF NOT EXISTS reason String,
|
||||
ADD COLUMN IF NOT EXISTS game_version String,
|
||||
ADD COLUMN IF NOT EXISTS loader String
|
||||
ADD COLUMN IF NOT EXISTS loader String,
|
||||
ADD COLUMN IF NOT EXISTS dependent_on_version_id UInt64
|
||||
"
|
||||
))
|
||||
.execute()
|
||||
|
||||
@@ -189,6 +189,8 @@ vars! {
|
||||
GITLAB_CLIENT_SECRET: String = "none";
|
||||
DISCORD_CLIENT_ID: String = "none";
|
||||
DISCORD_CLIENT_SECRET: String = "none";
|
||||
DISCORD_COMMUNITY_BOT_HANDOFF_URL: String = "http://localhost:3000/modrinth/handoff";
|
||||
DISCORD_COMMUNITY_LINK_SECRET: String = "";
|
||||
MICROSOFT_CLIENT_ID: String = "none";
|
||||
MICROSOFT_CLIENT_SECRET: String = "none";
|
||||
GOOGLE_CLIENT_ID: String = "none";
|
||||
|
||||
@@ -153,6 +153,7 @@ pub enum LegacyNotificationBody {
|
||||
amount: u64,
|
||||
date_available: DateTime<Utc>,
|
||||
},
|
||||
DiscordRoleCreatorClub,
|
||||
Custom {
|
||||
key: String,
|
||||
title: String,
|
||||
@@ -242,6 +243,9 @@ impl LegacyNotification {
|
||||
NotificationBody::PayoutAvailable { .. } => {
|
||||
Some("payout_available".to_string())
|
||||
}
|
||||
NotificationBody::DiscordRoleCreatorClub => {
|
||||
Some("discord_role_creator_club".to_string())
|
||||
}
|
||||
NotificationBody::Custom { .. } => Some("custom".to_string()),
|
||||
NotificationBody::LegacyMarkdown {
|
||||
notification_type, ..
|
||||
@@ -350,6 +354,9 @@ impl LegacyNotification {
|
||||
amount,
|
||||
date_available,
|
||||
},
|
||||
NotificationBody::DiscordRoleCreatorClub => {
|
||||
LegacyNotificationBody::DiscordRoleCreatorClub
|
||||
}
|
||||
NotificationBody::LegacyMarkdown {
|
||||
notification_type,
|
||||
name,
|
||||
|
||||
@@ -29,6 +29,7 @@ pub struct Download {
|
||||
pub reason: String,
|
||||
pub game_version: String,
|
||||
pub loader: String,
|
||||
pub dependent_on_version_id: u64,
|
||||
}
|
||||
|
||||
/// Why a project was downloaded.
|
||||
|
||||
@@ -59,6 +59,7 @@ pub enum NotificationType {
|
||||
ProjectStatusNeutral,
|
||||
ProjectTransferred,
|
||||
PayoutAvailable,
|
||||
DiscordRoleCreatorClub,
|
||||
Custom,
|
||||
Unknown,
|
||||
}
|
||||
@@ -98,6 +99,9 @@ impl NotificationType {
|
||||
NotificationType::Custom => "custom",
|
||||
NotificationType::ProjectStatusNeutral => "project_status_neutral",
|
||||
NotificationType::ProjectTransferred => "project_transferred",
|
||||
NotificationType::DiscordRoleCreatorClub => {
|
||||
"discord_role_creator_club"
|
||||
}
|
||||
NotificationType::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
@@ -134,6 +138,9 @@ impl NotificationType {
|
||||
}
|
||||
"project_status_neutral" => NotificationType::ProjectStatusNeutral,
|
||||
"project_transferred" => NotificationType::ProjectTransferred,
|
||||
"discord_role_creator_club" => {
|
||||
NotificationType::DiscordRoleCreatorClub
|
||||
}
|
||||
"custom" => NotificationType::Custom,
|
||||
"unknown" => NotificationType::Unknown,
|
||||
_ => NotificationType::Unknown,
|
||||
@@ -259,6 +266,7 @@ pub enum NotificationBody {
|
||||
date_available: DateTime<Utc>,
|
||||
amount: u64,
|
||||
},
|
||||
DiscordRoleCreatorClub,
|
||||
Custom {
|
||||
key: String,
|
||||
title: String,
|
||||
@@ -347,6 +355,9 @@ impl NotificationBody {
|
||||
NotificationBody::PayoutAvailable { .. } => {
|
||||
NotificationType::PayoutAvailable
|
||||
}
|
||||
NotificationBody::DiscordRoleCreatorClub => {
|
||||
NotificationType::DiscordRoleCreatorClub
|
||||
}
|
||||
NotificationBody::Custom { .. } => NotificationType::Custom,
|
||||
NotificationBody::Unknown => NotificationType::Unknown,
|
||||
}
|
||||
@@ -619,6 +630,12 @@ impl From<DBNotification> for Notification {
|
||||
"A payout is available!".to_string(),
|
||||
"#".to_string(),
|
||||
vec![],
|
||||
),
|
||||
NotificationBody::DiscordRoleCreatorClub => (
|
||||
"Join the Creator Club".to_string(),
|
||||
"Link your Discord account to claim your creator community role.".to_string(),
|
||||
"/discord/link".to_string(),
|
||||
vec![],
|
||||
),
|
||||
NotificationBody::ModerationMessageReceived { .. } => (
|
||||
"New message in moderation thread".to_string(),
|
||||
|
||||
@@ -90,6 +90,8 @@ const NEWOWNER_NAME: &str = "new_owner.name";
|
||||
const PAYOUTAVAILABLE_AMOUNT: &str = "payout.amount";
|
||||
const PAYOUTAVAILABLE_PERIOD: &str = "payout.period";
|
||||
|
||||
const DISCORD_LINK_URL: &str = "discord.link_url";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MailingIdentity {
|
||||
from_name: String,
|
||||
@@ -602,6 +604,15 @@ async fn collect_template_variables(
|
||||
| NotificationBody::PasswordChanged
|
||||
| NotificationBody::PasswordRemoved => Ok(EmailTemplate::Static(map)),
|
||||
|
||||
NotificationBody::DiscordRoleCreatorClub => {
|
||||
map.insert(
|
||||
DISCORD_LINK_URL,
|
||||
format!("{}/discord/link", ENV.SITE_URL.trim_end_matches('/')),
|
||||
);
|
||||
|
||||
Ok(EmailTemplate::Static(map))
|
||||
}
|
||||
|
||||
NotificationBody::EmailChanged {
|
||||
new_email,
|
||||
to_email: _,
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::auth::validate::get_user_record_from_bearer_token;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::analytics::{Download, DownloadReason};
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::models::ids::{ProjectId, VersionId};
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::queue::analytics::AnalyticsQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
@@ -13,10 +13,12 @@ use crate::util::error::Context;
|
||||
use crate::util::guards::admin_key_guard;
|
||||
use crate::util::tags::valid_download_tags;
|
||||
use actix_web::{HttpRequest, HttpResponse, patch, post, web};
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use eyre::eyre;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tracing::trace;
|
||||
|
||||
@@ -45,10 +47,86 @@ pub struct DownloadMeta {
|
||||
pub reason: Option<DownloadReason>,
|
||||
pub game_version: Option<String>,
|
||||
pub loader: Option<String>,
|
||||
pub dependent_on: Option<VersionId>,
|
||||
}
|
||||
|
||||
pub const DOWNLOAD_META_HEADER: &str = "modrinth-download-meta";
|
||||
|
||||
fn parse_download_meta_version(
|
||||
version_id: &str,
|
||||
field: &str,
|
||||
) -> Result<VersionId, ApiError> {
|
||||
parse_base62(version_id)
|
||||
.map(VersionId)
|
||||
.wrap_request_err_with(|| {
|
||||
eyre!("invalid `{field}` version id '{version_id}'")
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_download_meta_from_query(
|
||||
url: &url::Url,
|
||||
) -> Result<Option<DownloadMeta>, ApiError> {
|
||||
let mut meta = DownloadMeta {
|
||||
reason: None,
|
||||
game_version: None,
|
||||
loader: None,
|
||||
dependent_on: None,
|
||||
};
|
||||
|
||||
for (key, value) in url.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"mr_download_reason" => {
|
||||
meta.reason =
|
||||
Some(DownloadReason::from_str(&value).map_err(|_| {
|
||||
ApiError::Request(eyre!(
|
||||
"invalid download reason specified"
|
||||
))
|
||||
})?);
|
||||
}
|
||||
"mr_game_version" => {
|
||||
meta.game_version = Some(value.into_owned());
|
||||
}
|
||||
"mr_loader" => {
|
||||
meta.loader = Some(value.into_owned());
|
||||
}
|
||||
"mr_dependent_on" => {
|
||||
meta.dependent_on =
|
||||
Some(parse_download_meta_version(&value, "dependent_on")?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((meta.reason.is_some()
|
||||
|| meta.game_version.is_some()
|
||||
|| meta.loader.is_some()
|
||||
|| meta.dependent_on.is_some())
|
||||
.then_some(meta))
|
||||
}
|
||||
|
||||
async fn resolve_download_attribution_version(
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
version_id: Option<VersionId>,
|
||||
field: &str,
|
||||
) -> Result<u64, ApiError> {
|
||||
let Some(version_id) = version_id else {
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
let version_id =
|
||||
crate::database::models::ids::DBVersionId::from(version_id);
|
||||
|
||||
crate::database::models::DBVersion::get(version_id, pool, redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch download attribution version")?
|
||||
.ok_or_else(|| {
|
||||
ApiError::Request(eyre!("invalid `{field}` version specified"))
|
||||
})?;
|
||||
|
||||
Ok(version_id.0 as u64)
|
||||
}
|
||||
|
||||
// This is an internal route, cannot be used without key
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
@@ -89,10 +167,9 @@ pub async fn count_download(
|
||||
let project_id: crate::database::models::ids::DBProjectId =
|
||||
download_body.project_id.into();
|
||||
|
||||
let id_option =
|
||||
ariadne::ids::base62_impl::parse_base62(&download_body.version_name)
|
||||
.ok()
|
||||
.map(|x| x as i64);
|
||||
let id_option = parse_base62(&download_body.version_name)
|
||||
.ok()
|
||||
.map(|x| x as i64);
|
||||
|
||||
let (version_id, project_id) = if let Some(version) = sqlx::query!(
|
||||
"
|
||||
@@ -138,7 +215,7 @@ pub async fn count_download(
|
||||
.map(Some)
|
||||
.wrap_request_err("invalid download meta")?
|
||||
} else {
|
||||
None
|
||||
parse_download_meta_from_query(&url)?
|
||||
};
|
||||
|
||||
if let Some(meta) = &meta {
|
||||
@@ -162,6 +239,14 @@ pub async fn count_download(
|
||||
}
|
||||
}
|
||||
|
||||
let dependent_on_version_id = resolve_download_attribution_version(
|
||||
&pool,
|
||||
&redis,
|
||||
meta.as_ref().and_then(|m| m.dependent_on),
|
||||
"dependent_on",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let download = Download {
|
||||
recorded: get_current_tenths_of_ms(),
|
||||
domain: url.host_str().unwrap_or_default().to_string(),
|
||||
@@ -212,6 +297,7 @@ pub async fn count_download(
|
||||
.and_then(|m| m.loader.as_ref())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default(),
|
||||
dependent_on_version_id,
|
||||
};
|
||||
trace!("added download {download:#?}");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::models::ids::DBUserId;
|
||||
use crate::database::models::ids::{DBNotificationId, DBUserId};
|
||||
use crate::database::models::notification_item::DBNotification;
|
||||
use crate::database::models::notification_item::NotificationBuilder;
|
||||
use crate::database::models::user_item::DBUser;
|
||||
@@ -64,34 +64,12 @@ pub async fn create(
|
||||
.insert_many(user_ids, &mut txn, &redis)
|
||||
.await?;
|
||||
|
||||
let notifications = DBNotification::get_many(¬ification_ids, &mut txn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Notification::from)
|
||||
.collect::<Vec<_>>();
|
||||
let notifications =
|
||||
get_site_exposed_notifications(¬ification_ids, &mut txn).await?;
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
broadcast_notifications(&redis, notifications).await;
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
||||
@@ -155,37 +133,12 @@ pub async fn create_email_sync(
|
||||
.insert_many_without_delivery(notification_user_ids, &mut txn, &redis)
|
||||
.await?;
|
||||
|
||||
let notifications = DBNotification::get_many(¬ification_ids, &mut txn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Notification::from)
|
||||
.collect::<Vec<_>>();
|
||||
let notifications =
|
||||
get_site_exposed_notifications(¬ification_ids, &mut txn).await?;
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
broadcast_notifications(&redis, notifications).await;
|
||||
|
||||
let mut email_txn = pool.begin().await?;
|
||||
|
||||
@@ -332,3 +285,57 @@ pub async fn send_custom_email(
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
||||
|
||||
async fn get_site_exposed_notifications(
|
||||
notification_ids: &[DBNotificationId],
|
||||
txn: &mut crate::database::PgTransaction<'_>,
|
||||
) -> Result<Vec<Notification>, ApiError> {
|
||||
let raw_ids = notification_ids.iter().map(|x| x.0).collect::<Vec<_>>();
|
||||
let exposed_ids = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT n.id AS "id!"
|
||||
FROM notifications n
|
||||
INNER JOIN notifications_types nt ON nt.name = n.body ->> 'type'
|
||||
WHERE n.id = ANY($1::BIGINT[])
|
||||
AND nt.expose_in_site_notifications = TRUE
|
||||
"#,
|
||||
&raw_ids[..],
|
||||
)
|
||||
.fetch_all(&mut *txn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(DBNotificationId)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(DBNotification::get_many(&exposed_ids, txn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Notification::from)
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn broadcast_notifications(
|
||||
redis: &RedisPool,
|
||||
notifications: Vec<Notification>,
|
||||
) {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,13 +29,18 @@ use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use ariadne::ids::base62_impl::{parse_base62, to_base62};
|
||||
use ariadne::ids::random_base62_rng;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::{Duration, Utc};
|
||||
use eyre::eyre;
|
||||
use hmac::{Hmac, Mac};
|
||||
use lettre::message::Mailbox;
|
||||
use rand::Rng;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
use rand_chacha::rand_core::SeedableRng;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -61,7 +66,8 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
.service(set_email)
|
||||
.service(verify_email)
|
||||
.service(subscribe_newsletter)
|
||||
.service(get_newsletter_subscription_status),
|
||||
.service(get_newsletter_subscription_status)
|
||||
.service(discord_community_link),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1369,6 +1375,97 @@ pub struct DeleteAuthProvider {
|
||||
pub provider: AuthProvider,
|
||||
}
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct DiscordCommunityLinkResponse {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DiscordCommunityHandoffPayload {
|
||||
v: u8,
|
||||
modrinth_user_id: String,
|
||||
discord_user_id: String,
|
||||
iat: i64,
|
||||
exp: i64,
|
||||
nonce: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
operation_id = "discordCommunityLink",
|
||||
responses(
|
||||
(status = 200, description = "Discord community bot handoff URL", body = DiscordCommunityLinkResponse),
|
||||
(status = 400, description = "Discord provider not linked"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
security(("bearer_auth" = ["SESSION_ACCESS"]))
|
||||
)]
|
||||
#[post("/discord-community-link")]
|
||||
pub async fn discord_community_link(
|
||||
req: HttpRequest,
|
||||
client: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<web::Json<DiscordCommunityLinkResponse>, ApiError> {
|
||||
if ENV.DISCORD_COMMUNITY_LINK_SECRET.is_empty()
|
||||
|| ENV.DISCORD_COMMUNITY_BOT_HANDOFF_URL.is_empty()
|
||||
{
|
||||
return Err(ApiError::Internal(eyre!(
|
||||
"discord community linking is not configured"
|
||||
)));
|
||||
}
|
||||
|
||||
let db_user = get_full_user_from_headers(
|
||||
&req,
|
||||
&**client,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::SESSION_ACCESS,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let Some(discord_id) = db_user.discord_id else {
|
||||
return Err(ApiError::Request(eyre!("discord account is not linked")));
|
||||
};
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
let nonce = ChaCha20Rng::from_entropy()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(32)
|
||||
.map(char::from)
|
||||
.collect::<String>();
|
||||
|
||||
let payload = DiscordCommunityHandoffPayload {
|
||||
v: 1,
|
||||
modrinth_user_id: ariadne::ids::UserId::from(db_user.id).to_string(),
|
||||
discord_user_id: discord_id.to_string(),
|
||||
iat: now,
|
||||
exp: now + 600,
|
||||
nonce,
|
||||
};
|
||||
|
||||
let payload_json = serde_json::to_vec(&payload).wrap_internal_err(
|
||||
"failed to serialize discord community handoff payload",
|
||||
)?;
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json);
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(
|
||||
ENV.DISCORD_COMMUNITY_LINK_SECRET.as_bytes(),
|
||||
)
|
||||
.wrap_internal_err("failed to initialize discord community link hmac")?;
|
||||
mac.update(payload_b64.as_bytes());
|
||||
let sig = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
||||
|
||||
let url = format!(
|
||||
"{}?payload={}&sig={}",
|
||||
ENV.DISCORD_COMMUNITY_BOT_HANDOFF_URL, payload_b64, sig,
|
||||
);
|
||||
|
||||
Ok(web::Json(DiscordCommunityLinkResponse { url }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
operation_id = "deleteAuthProvider",
|
||||
|
||||
@@ -54,7 +54,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
.service(flows::set_email)
|
||||
.service(flows::verify_email)
|
||||
.service(flows::subscribe_newsletter)
|
||||
.service(flows::get_newsletter_subscription_status),
|
||||
.service(flows::get_newsletter_subscription_status)
|
||||
.service(flows::discord_community_link),
|
||||
);
|
||||
cfg.service(pats::get_pats);
|
||||
cfg.service(pats::create_pat);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
sync::{
|
||||
LazyLock,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
@@ -12,9 +12,16 @@ use regex::Regex;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
|
||||
use crate::{
|
||||
database::models::{DBProjectId, DBVersionId},
|
||||
models::{ids::VersionId, v3::analytics::DownloadReason},
|
||||
database::{
|
||||
PgPool,
|
||||
models::{DBProjectId, DBVersion, DBVersionId},
|
||||
},
|
||||
models::{
|
||||
ids::{ProjectId, VersionId},
|
||||
v3::analytics::DownloadReason,
|
||||
},
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
@@ -39,6 +46,8 @@ pub enum ProjectDownloadsField {
|
||||
ProjectId,
|
||||
/// Version ID of this project.
|
||||
VersionId,
|
||||
/// Project ID that caused this project to be downloaded.
|
||||
DependentProjectId,
|
||||
/// Referrer domain which linked to this project.
|
||||
Domain,
|
||||
/// Normalized user agent used to download this project.
|
||||
@@ -63,6 +72,9 @@ pub struct ProjectDownloadsFilters {
|
||||
/// Version IDs to include.
|
||||
#[serde(default)]
|
||||
pub version_id: Vec<VersionId>,
|
||||
/// Dependent project IDs to include.
|
||||
#[serde(default)]
|
||||
pub dependent_project_id: Vec<ProjectId>,
|
||||
/// Referrer domains to include.
|
||||
#[serde(default)]
|
||||
pub domain: Vec<String>,
|
||||
@@ -98,6 +110,9 @@ pub struct ProjectDownloads {
|
||||
/// [`ProjectDownloadsField::VersionId`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) version_id: Option<VersionId>,
|
||||
/// [`ProjectDownloadsField::DependentProjectId`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) dependent_project_id: Option<ProjectId>,
|
||||
/// [`ProjectDownloadsField::Monetized`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) monetized: Option<bool>,
|
||||
@@ -175,6 +190,7 @@ struct DownloadRow {
|
||||
domain: String,
|
||||
user_agent: String,
|
||||
version_id: DBVersionId,
|
||||
dependent_on_version_id: DBVersionId,
|
||||
monetized: i8,
|
||||
country: String,
|
||||
reason: String,
|
||||
@@ -188,6 +204,7 @@ const DOWNLOADS: &str = {
|
||||
const USE_DOMAIN: &str = "{use_domain: Bool}";
|
||||
const USE_USER_AGENT: &str = "{use_user_agent: Bool}";
|
||||
const USE_VERSION_ID: &str = "{use_version_id: Bool}";
|
||||
const USE_DEPENDENT_PROJECT_ID: &str = "{use_dependent_project_id: Bool}";
|
||||
const USE_MONETIZED: &str = "{use_monetized: Bool}";
|
||||
const USE_COUNTRY: &str = "{use_country: Bool}";
|
||||
const USE_REASON: &str = "{use_reason: Bool}";
|
||||
@@ -195,6 +212,8 @@ const DOWNLOADS: &str = {
|
||||
const USE_LOADER: &str = "{use_loader: Bool}";
|
||||
const FILTER_DOMAIN: &str = "filter_domain";
|
||||
const FILTER_VERSION_ID: &str = "filter_version_id";
|
||||
const FILTER_DEPENDENT_ON_VERSION_ID: &str =
|
||||
"filter_dependent_on_version_id";
|
||||
const FILTER_MONETIZED: &str = "{filter_monetized: UInt8}";
|
||||
const FILTER_COUNTRY: &str = "filter_country";
|
||||
const FILTER_REASON: &str = "filter_reason";
|
||||
@@ -206,6 +225,7 @@ const DOWNLOADS: &str = {
|
||||
? AS {PROJECT_IDS},
|
||||
? AS {FILTER_DOMAIN},
|
||||
? AS {FILTER_VERSION_ID},
|
||||
? AS {FILTER_DEPENDENT_ON_VERSION_ID},
|
||||
? AS {FILTER_COUNTRY},
|
||||
? AS {FILTER_REASON},
|
||||
? AS {FILTER_GAME_VERSION},
|
||||
@@ -217,6 +237,7 @@ const DOWNLOADS: &str = {
|
||||
if({USE_DOMAIN}, domain, '') AS domain,
|
||||
if({USE_USER_AGENT}, user_agent, '') AS user_agent,
|
||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||
if({USE_DEPENDENT_PROJECT_ID}, dependent_on_version_id, 0) AS dependent_on_version_id,
|
||||
if({USE_MONETIZED}, CAST(user_id != 0 AS Int8), -1) AS monetized,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
if({USE_REASON}, reason, '') AS reason,
|
||||
@@ -233,12 +254,13 @@ const DOWNLOADS: &str = {
|
||||
AND downloads.project_id IN {PROJECT_IDS}
|
||||
AND (empty({FILTER_DOMAIN}) OR downloads.domain IN {FILTER_DOMAIN})
|
||||
AND (empty({FILTER_VERSION_ID}) OR downloads.version_id IN {FILTER_VERSION_ID})
|
||||
AND (empty({FILTER_DEPENDENT_ON_VERSION_ID}) OR downloads.dependent_on_version_id IN {FILTER_DEPENDENT_ON_VERSION_ID})
|
||||
AND ({FILTER_MONETIZED} = 2 OR CAST(downloads.user_id != 0 AS UInt8) = {FILTER_MONETIZED})
|
||||
AND (empty({FILTER_COUNTRY}) OR downloads.country IN {FILTER_COUNTRY})
|
||||
AND (empty({FILTER_REASON}) OR downloads.reason IN {FILTER_REASON})
|
||||
AND (empty({FILTER_GAME_VERSION}) OR downloads.game_version IN {FILTER_GAME_VERSION})
|
||||
AND (empty({FILTER_LOADER}) OR downloads.loader IN {FILTER_LOADER})
|
||||
GROUP BY bucket, source_project_id, project_id, domain, user_agent, version_id, monetized, country, reason, game_version, loader"
|
||||
GROUP BY bucket, source_project_id, project_id, domain, user_agent, version_id, dependent_on_version_id, monetized, country, reason, game_version, loader"
|
||||
)
|
||||
};
|
||||
|
||||
@@ -249,6 +271,7 @@ struct DownloadBucket {
|
||||
domain: Option<String>,
|
||||
user_agent: Option<DownloadSource>,
|
||||
version_id: Option<DBVersionId>,
|
||||
dependent_project_id: Option<DBProjectId>,
|
||||
monetized: Option<bool>,
|
||||
country: Option<String>,
|
||||
reason: Option<DownloadReason>,
|
||||
@@ -256,12 +279,79 @@ struct DownloadBucket {
|
||||
loader: Option<String>,
|
||||
}
|
||||
|
||||
async fn fetch_dependent_on_version_filter(
|
||||
metrics: &Metrics<ProjectDownloadsField, ProjectDownloadsFilters>,
|
||||
pool: &PgPool,
|
||||
) -> Result<Vec<VersionId>, ApiError> {
|
||||
if metrics.filter_by.dependent_project_id.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let project_ids = metrics
|
||||
.filter_by
|
||||
.dependent_project_id
|
||||
.iter()
|
||||
.map(|id| DBProjectId::from(*id).0)
|
||||
.collect::<Vec<_>>();
|
||||
let versions = sqlx::query!(
|
||||
"
|
||||
SELECT id FROM versions
|
||||
WHERE mod_id = ANY($1)
|
||||
",
|
||||
&project_ids
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch dependent project versions")?;
|
||||
|
||||
Ok(versions
|
||||
.into_iter()
|
||||
.map(|version| DBVersionId(version.id).into())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn fetch_dependent_version_projects(
|
||||
rows: &[DownloadRow],
|
||||
cx: &QueryClickhouseContext<'_>,
|
||||
) -> Result<HashMap<DBVersionId, DBProjectId>, ApiError> {
|
||||
let dependent_on_version_ids = rows
|
||||
.iter()
|
||||
.filter_map(|row| {
|
||||
(row.dependent_on_version_id.0 != 0)
|
||||
.then_some(row.dependent_on_version_id)
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
if dependent_on_version_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let dependent_on_version_ids =
|
||||
dependent_on_version_ids.into_iter().collect::<Vec<_>>();
|
||||
let versions =
|
||||
DBVersion::get_many(&dependent_on_version_ids, cx.pool, cx.redis)
|
||||
.await?;
|
||||
|
||||
Ok(versions
|
||||
.into_iter()
|
||||
.map(|version| (version.inner.id, version.inner.project_id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch(
|
||||
cx: &mut QueryClickhouseContext<'_>,
|
||||
metrics: &Metrics<ProjectDownloadsField, ProjectDownloadsFilters>,
|
||||
) -> Result<(), ApiError> {
|
||||
use ProjectDownloadsField as F;
|
||||
let uses = |field| metrics.bucket_by.contains(&field);
|
||||
let dependent_on_version_filter =
|
||||
fetch_dependent_on_version_filter(metrics, cx.pool).await?;
|
||||
if !metrics.filter_by.dependent_project_id.is_empty()
|
||||
&& dependent_on_version_filter.is_empty()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let use_columns = &[
|
||||
("use_project_id", uses(F::ProjectId)),
|
||||
("use_domain", uses(F::Domain)),
|
||||
@@ -270,6 +360,7 @@ pub(crate) async fn fetch(
|
||||
uses(F::UserAgent) || !metrics.filter_by.user_agent.is_empty(),
|
||||
),
|
||||
("use_version_id", uses(F::VersionId)),
|
||||
("use_dependent_project_id", uses(F::DependentProjectId)),
|
||||
("use_monetized", uses(F::Monetized)),
|
||||
("use_country", uses(F::Country)),
|
||||
("use_reason", uses(F::Reason)),
|
||||
@@ -290,6 +381,7 @@ pub(crate) async fn fetch(
|
||||
for filter_param in [
|
||||
ClickhouseFilterParam::String(&metrics.filter_by.domain),
|
||||
ClickhouseFilterParam::VersionId(&metrics.filter_by.version_id),
|
||||
ClickhouseFilterParam::VersionId(&dependent_on_version_filter),
|
||||
ClickhouseFilterParam::Bool(
|
||||
"filter_monetized",
|
||||
&metrics.filter_by.monetized,
|
||||
@@ -308,9 +400,17 @@ pub(crate) async fn fetch(
|
||||
.any(|(column_name, used)| *column_name == name && *used)
|
||||
};
|
||||
let mut cursor = query.fetch::<DownloadRow>()?;
|
||||
let mut buckets = HashMap::<DownloadBucket, u64>::new();
|
||||
let mut rows = Vec::new();
|
||||
|
||||
while let Some(row) = cursor.next().await? {
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
let dependent_version_projects =
|
||||
fetch_dependent_version_projects(&rows, cx).await?;
|
||||
let mut buckets = HashMap::<DownloadBucket, u64>::new();
|
||||
|
||||
for row in rows {
|
||||
let normalized_source = normalize_download_source(&row.user_agent);
|
||||
if !metrics.filter_by.user_agent.is_empty()
|
||||
&& !normalized_source.as_ref().is_some_and(|source| {
|
||||
@@ -328,6 +428,15 @@ pub(crate) async fn fetch(
|
||||
.then_some(normalized_source)
|
||||
.flatten(),
|
||||
version_id: uses_column("use_version_id").then_some(row.version_id),
|
||||
dependent_project_id: if uses(F::DependentProjectId)
|
||||
&& row.dependent_on_version_id.0 != 0
|
||||
{
|
||||
dependent_version_projects
|
||||
.get(&row.dependent_on_version_id)
|
||||
.copied()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
monetized: if uses_column("use_monetized") {
|
||||
match row.monetized {
|
||||
0 => Some(false),
|
||||
@@ -382,6 +491,9 @@ pub(crate) async fn fetch(
|
||||
version_id: key
|
||||
.version_id
|
||||
.and_then(none_if_zero_version_id),
|
||||
dependent_project_id: key
|
||||
.dependent_project_id
|
||||
.map(Into::into),
|
||||
monetized: key.monetized,
|
||||
country: key.country,
|
||||
reason: key.reason,
|
||||
@@ -468,6 +580,7 @@ static DOWNLOAD_SOURCE_PATTERNS: LazyLock<Vec<(Regex, DownloadSourcePattern)>> =
|
||||
(r"^unsup", P::Named("unsup")),
|
||||
(r"nothub/mrpack-install", P::Named("mrpack-install")),
|
||||
(r"^(packwiz-installer|packwiz/)", P::Named("Packwiz")),
|
||||
(r"^mrpack4server", P::Named("mrpack4server")),
|
||||
(
|
||||
r"^(Mozilla/|Chrome/|Chromium/|Firefox/|Safari/|AppleWebKit/|Edg/|OPR/)",
|
||||
P::Website,
|
||||
|
||||
@@ -24,7 +24,8 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
AuthenticationError, checks::filter_visible_version_ids,
|
||||
AuthenticationError,
|
||||
checks::{filter_visible_project_ids, filter_visible_version_ids},
|
||||
get_user_from_headers,
|
||||
},
|
||||
database::{
|
||||
@@ -42,7 +43,7 @@ use crate::{
|
||||
projects::ProjectStatus,
|
||||
teams::ProjectPermissions,
|
||||
threads::MessageBody,
|
||||
v3::analytics::DownloadReason,
|
||||
v3::{analytics::DownloadReason, projects::Project},
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
routes::ApiError,
|
||||
@@ -127,6 +128,9 @@ pub struct GetResponse {
|
||||
/// time interval of metrics collection. The number of slices is determined
|
||||
/// by [`GetRequest::time_range`].
|
||||
pub metrics: Vec<TimeSlice>,
|
||||
/// Project metadata for projects referenced in the response metrics.
|
||||
#[serde(default)]
|
||||
pub projects: HashMap<ProjectId, Project>,
|
||||
/// List of events associated with projects that were requested.
|
||||
pub project_events: Vec<ProjectAnalyticsEvent>,
|
||||
}
|
||||
@@ -318,6 +322,8 @@ pub async fn fetch_analytics(
|
||||
|
||||
let mut query_clickhouse_cx = QueryClickhouseContext {
|
||||
clickhouse: &clickhouse,
|
||||
pool: &pool,
|
||||
redis: &redis,
|
||||
req: &req,
|
||||
time_slices: &mut time_slices,
|
||||
project_ids: &project_ids,
|
||||
@@ -392,8 +398,12 @@ pub async fn fetch_analytics(
|
||||
.await?;
|
||||
}
|
||||
|
||||
let projects =
|
||||
fetch_response_projects(&mut time_slices, &user, &pool, &redis).await?;
|
||||
|
||||
Ok(web::Json(GetResponse {
|
||||
metrics: time_slices,
|
||||
projects,
|
||||
project_events,
|
||||
}))
|
||||
}
|
||||
@@ -462,6 +472,88 @@ pub(crate) fn normalize_loader_for_project(
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_response_projects(
|
||||
time_slices: &mut [TimeSlice],
|
||||
user: &crate::models::users::User,
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
) -> Result<HashMap<ProjectId, Project>, ApiError> {
|
||||
let mut project_ids = HashSet::<DBProjectId>::new();
|
||||
|
||||
for time_slice in &*time_slices {
|
||||
for data in &time_slice.0 {
|
||||
let AnalyticsData::Project(project) = data else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let source_project_id = DBProjectId::from(project.source_project);
|
||||
if source_project_id.0 != 0 {
|
||||
project_ids.insert(source_project_id);
|
||||
}
|
||||
if let ProjectMetrics::Downloads(downloads) = &project.metrics
|
||||
&& let Some(dependent_project_id) =
|
||||
downloads.dependent_project_id
|
||||
{
|
||||
project_ids.insert(dependent_project_id.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let project_ids = project_ids.into_iter().collect::<Vec<_>>();
|
||||
let projects = DBProject::get_many_ids(&project_ids, pool, redis).await?;
|
||||
let visible_project_ids = filter_visible_project_ids(
|
||||
projects.iter().map(|project| &project.inner).collect(),
|
||||
&Some(user.clone()),
|
||||
pool,
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
filter_response_project_ids(time_slices, &visible_project_ids);
|
||||
|
||||
Ok(projects
|
||||
.into_iter()
|
||||
.filter(|project| visible_project_ids.contains(&project.inner.id))
|
||||
.map(|project| {
|
||||
let project_id = project.inner.id.into();
|
||||
(project_id, Project::from(project))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn filter_response_project_ids(
|
||||
time_slices: &mut [TimeSlice],
|
||||
visible_project_ids: &HashSet<DBProjectId>,
|
||||
) {
|
||||
for time_slice in time_slices {
|
||||
time_slice.0.retain_mut(|data| {
|
||||
let AnalyticsData::Project(project) = data else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let source_project_id = DBProjectId::from(project.source_project);
|
||||
if source_project_id.0 != 0
|
||||
&& !visible_project_ids.contains(&source_project_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let ProjectMetrics::Downloads(downloads) = &mut project.metrics
|
||||
&& let Some(dependent_project_id) =
|
||||
downloads.dependent_project_id
|
||||
&& !visible_project_ids
|
||||
.contains(&DBProjectId::from(dependent_project_id))
|
||||
{
|
||||
downloads.dependent_project_id = None;
|
||||
}
|
||||
|
||||
true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_project_status_change_events(
|
||||
project_ids: &[DBProjectId],
|
||||
time_range: &TimeRange,
|
||||
@@ -516,6 +608,8 @@ async fn fetch_project_status_change_events(
|
||||
|
||||
pub(crate) struct QueryClickhouseContext<'a> {
|
||||
pub(crate) clickhouse: &'a clickhouse::Client,
|
||||
pub(crate) pool: &'a PgPool,
|
||||
pub(crate) redis: &'a RedisPool,
|
||||
pub(crate) req: &'a GetRequest,
|
||||
pub(crate) time_slices: &'a mut [TimeSlice],
|
||||
pub(crate) project_ids: &'a [DBProjectId],
|
||||
@@ -875,6 +969,7 @@ mod tests {
|
||||
}),
|
||||
})]),
|
||||
],
|
||||
projects: HashMap::new(),
|
||||
project_events: vec![],
|
||||
};
|
||||
let target = json!({
|
||||
@@ -901,6 +996,7 @@ mod tests {
|
||||
}
|
||||
]
|
||||
],
|
||||
"projects": {},
|
||||
"project_events": []
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user