mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
147
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c35b6cf16 | ||
|
|
9834e86d4f | ||
|
|
3a6bcd6b30 | ||
|
|
3f7f8d4b03 | ||
|
|
d6c76fb3db | ||
|
|
8e277b2ad8 | ||
|
|
471c34da7d | ||
|
|
35cdc324b8 | ||
|
|
1e9ff0fecb | ||
|
|
248cfe6e8a | ||
|
|
6cd407b925 | ||
|
|
bab00fc7da | ||
|
|
471b67fe5a | ||
|
|
ed4d7594d7 | ||
|
|
cccaf1eee6 | ||
|
|
e2f2fd8e65 | ||
|
|
d76d2e60da | ||
|
|
ac7ae47aba | ||
|
|
2e2234e8b0 | ||
|
|
fa495201e8 | ||
|
|
888d70f629 | ||
|
|
04bdbd2d0a | ||
|
|
78b617c25c | ||
|
|
dc1c3c8662 | ||
|
|
2175be5bda | ||
|
|
edb82b5304 | ||
|
|
4653b0c803 | ||
|
|
7a420f03f4 | ||
|
|
4901830d2a | ||
|
|
5997529d78 | ||
|
|
8d1af7b68a | ||
|
|
d350e0cd7a | ||
|
|
1caff07306 | ||
|
|
a140ba4a81 | ||
|
|
ccae58925f | ||
|
|
ecd77812ce | ||
|
|
418a35fcd3 | ||
|
|
185d4e3f31 | ||
|
|
34c1a22227 | ||
|
|
8bc26f2cf1 | ||
|
|
99c77d756e | ||
|
|
af2224d44b | ||
|
|
fdecfeebe1 | ||
|
|
803aa49560 | ||
|
|
c8b64df900 | ||
|
|
8658dbf3db | ||
|
|
e8114a3614 | ||
|
|
3cd1a06243 | ||
|
|
9ed9873629 | ||
|
|
0e0dda5857 | ||
|
|
a30546461f | ||
|
|
a4d93c2c18 | ||
|
|
5380ae5732 | ||
|
|
5c6846354e | ||
|
|
a0b644fc63 | ||
|
|
d7b66254fc | ||
|
|
fb4d3f8d89 | ||
|
|
aff6d1d36c | ||
|
|
43128d473d | ||
|
|
747c0c6cbd | ||
|
|
498a719e05 | ||
|
|
ec17c40eaf | ||
|
|
39a529f556 | ||
|
|
9db8c55fa0 | ||
|
|
13eb82a369 | ||
|
|
4db623623e | ||
|
|
6612695365 | ||
|
|
1ea2a75411 | ||
|
|
645b6327b3 | ||
|
|
c7d95c30fe | ||
|
|
740331f439 | ||
|
|
96d0898141 | ||
|
|
cdced210ee | ||
|
|
a94e49899a | ||
|
|
7606329ea8 | ||
|
|
bb8e85e076 | ||
|
|
3687adeb09 | ||
|
|
c4f093b820 | ||
|
|
054a9c4b1f | ||
|
|
cf25e6e7d2 | ||
|
|
773f1423cc | ||
|
|
a1ebba5ea9 | ||
|
|
33400c7b19 | ||
|
|
224befa6c2 | ||
|
|
5689c2baed | ||
|
|
7ca96c39bc | ||
|
|
b97f338d54 | ||
|
|
145c79fa51 | ||
|
|
690f5c148b | ||
|
|
cee57ffe6e | ||
|
|
84bcf366b1 | ||
|
|
2fecbe9c95 | ||
|
|
578b8df720 | ||
|
|
611e308134 | ||
|
|
549e9d5554 | ||
|
|
25c63b542c | ||
|
|
61209ac0e1 | ||
|
|
9071f7dee7 | ||
|
|
50eaecf86d | ||
|
|
6de97cc0a8 | ||
|
|
0bfa16295d | ||
|
|
5d7a77cdfc | ||
|
|
876a4b649d | ||
|
|
e16884aec5 | ||
|
|
e11cd519cd | ||
|
|
9ae1ddc5ab | ||
|
|
cf02d151cc | ||
|
|
61ff32a58b | ||
|
|
8d82bf38b1 | ||
|
|
2d3b4ab54c | ||
|
|
3eb76da8af | ||
|
|
00993a3ff8 | ||
|
|
1f28196649 | ||
|
|
030dc2b3f7 | ||
|
|
3801039771 | ||
|
|
c75e17ec0e | ||
|
|
7315c35a41 | ||
|
|
c07b7afef6 | ||
|
|
0d488d162e | ||
|
|
867aadc2e5 | ||
|
|
b6b5271e11 | ||
|
|
2ee217bdff | ||
|
|
9819611217 | ||
|
|
1fdaf8e001 | ||
|
|
84888dc788 | ||
|
|
91d74e7705 | ||
|
|
a0471dad2b | ||
|
|
bfd592487e | ||
|
|
d53d5c4538 | ||
|
|
e30cf265a4 | ||
|
|
1b26f55f1e | ||
|
|
d983f115ed | ||
|
|
f01d149603 | ||
|
|
b93f6c682c | ||
|
|
220733fa2f | ||
|
|
e33b1dcec5 | ||
|
|
796049f6b0 | ||
|
|
15f67eae3d | ||
|
|
d846ef7f7d | ||
|
|
8d4076f8f0 | ||
|
|
bc9e586092 | ||
|
|
e1f44b861e | ||
|
|
81abcf6403 | ||
|
|
873a12bae5 | ||
|
|
2d2598b221 | ||
|
|
804f6ca031 | ||
|
|
fd28721d35 |
Vendored
+1
-1
@@ -9,7 +9,7 @@
|
||||
"files.insertFinalNewline": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "always"
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[vue]": {
|
||||
|
||||
@@ -241,6 +241,7 @@ bool_to_int_with_if = "warn"
|
||||
borrow_as_ptr = "warn"
|
||||
cfg_not_test = "warn"
|
||||
clear_with_drain = "warn"
|
||||
type_complexity = "allow"
|
||||
cloned_instead_of_copied = "warn"
|
||||
collection_is_never_read = "warn"
|
||||
dbg_macro = "warn"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
ServersManageAccessPage,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { serverId } = injectModrinthServerContext()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: ['servers', 'users', 'v1', serverId],
|
||||
queryFn: () => client.archon.server_users_v1.list(serverId),
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: ['servers', 'v1', 'detail', serverId],
|
||||
queryFn: () => client.archon.servers_v1.get(serverId),
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
])
|
||||
} catch {
|
||||
// Let mounted layouts' useQuery surface errors; do not fail route setup.
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ServersManageAccessPage />
|
||||
</template>
|
||||
@@ -1,7 +1,8 @@
|
||||
import Access from './Access.vue'
|
||||
import Backups from './Backups.vue'
|
||||
import Content from './Content.vue'
|
||||
import Files from './Files.vue'
|
||||
import Index from './Index.vue'
|
||||
import Overview from './Overview.vue'
|
||||
|
||||
export { Backups, Content, Files, Index, Overview }
|
||||
export { Access, Backups, Content, Files, Index, Overview }
|
||||
|
||||
@@ -73,6 +73,14 @@ export default new createRouter({
|
||||
breadcrumb: [{ name: '?Server' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'access',
|
||||
name: 'ServerManageAccess',
|
||||
component: Hosting.Access,
|
||||
meta: {
|
||||
breadcrumb: [{ name: '?Server' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -40,4 +40,3 @@ These composables are deprecated and should not be used in new code:
|
||||
|
||||
- **`useAsyncData`** - we use tanstack, not nuxt's built in async data utility.
|
||||
- **`useBaseFetch`** (`src/composables/fetch.js`) — legacy Labrinth fetch wrapper. Use `client.labrinth.*` modules instead.
|
||||
- **`useServersFetch`** (`src/composables/servers/servers-fetch.ts`) — legacy Archon fetch wrapper with manual retry/circuit-breaker. Use `client.archon.*` modules instead — refer to the `packages/api-client/CLAUDE.md` for more information.
|
||||
|
||||
@@ -224,6 +224,7 @@ export default defineNuxtConfig({
|
||||
globalThis.INTERCOM_APP_ID ||
|
||||
'ykeritl9',
|
||||
production: isProduction(),
|
||||
cookieSecure: isProduction(),
|
||||
buildEnv: process.env.BUILD_ENV,
|
||||
preview: process.env.PREVIEW === 'true',
|
||||
featureFlagOverrides: getFeatureFlagOverrides(),
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@types/semver": "^7.7.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"glob": "^10.2.7",
|
||||
"nuxt": "^3.20.2",
|
||||
"nuxt": "=3.20.2",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"sass": "^1.58.0",
|
||||
|
||||
@@ -1,52 +1,123 @@
|
||||
<template>
|
||||
<div
|
||||
class="notification"
|
||||
:class="{
|
||||
'has-body': hasBody,
|
||||
compact: compact,
|
||||
read: notification.read,
|
||||
}"
|
||||
:class="
|
||||
type === 'server_invite'
|
||||
? { read: notification.read }
|
||||
: {
|
||||
notification: true,
|
||||
'has-body': hasBody,
|
||||
compact: compact,
|
||||
read: notification.read,
|
||||
}
|
||||
"
|
||||
>
|
||||
<nuxt-link
|
||||
v-if="!type"
|
||||
:to="notification.link"
|
||||
class="notification__icon backed-svg"
|
||||
:class="{ raised: raised }"
|
||||
>
|
||||
<BellIcon />
|
||||
</nuxt-link>
|
||||
<DoubleIcon v-else class="notification__icon">
|
||||
<template #primary>
|
||||
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
|
||||
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
v-else-if="organization"
|
||||
:to="`/organization/${organization.slug}`"
|
||||
tabindex="-1"
|
||||
<template v-if="type === 'server_invite'">
|
||||
<div class="flex flex-col gap-4">
|
||||
<ModrinthServersIcon class="h-auto w-56 max-w-full text-[var(--color-heading)]" />
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-1.5 gap-y-2 text-lg leading-tight text-[var(--color-heading)]"
|
||||
>
|
||||
<Avatar size="xs" :src="organization.icon_url" :raised="raised" no-shadow />
|
||||
</nuxt-link>
|
||||
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
|
||||
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
|
||||
</nuxt-link>
|
||||
<Avatar v-else size="xs" :raised="raised" no-shadow />
|
||||
</template>
|
||||
<template #secondary>
|
||||
<ScaleIcon
|
||||
v-if="type === 'moderator_message' || type === 'status_change'"
|
||||
class="moderation-color"
|
||||
/>
|
||||
<UserPlusIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
|
||||
<UserPlusIcon
|
||||
v-else-if="type === 'organization_invite' && organization"
|
||||
class="creator-color"
|
||||
/>
|
||||
<VersionIcon v-else-if="type === 'project_update' && project && version" />
|
||||
<BellIcon v-else />
|
||||
</template>
|
||||
</DoubleIcon>
|
||||
<div class="notification__title">
|
||||
<nuxt-link
|
||||
v-if="invitedBy"
|
||||
:to="`/user/${invitedBy.username}`"
|
||||
class="inline-flex items-center font-bold text-[var(--color-heading)] hover:underline"
|
||||
>
|
||||
<Avatar
|
||||
:src="invitedBy.avatar_url"
|
||||
circle
|
||||
size="xxs"
|
||||
no-shadow
|
||||
:raised="raised"
|
||||
class="mr-1.5 inline-flex"
|
||||
/>
|
||||
<span>{{ invitedBy.username }}</span>
|
||||
</nuxt-link>
|
||||
<span v-if="invitedBy">has invited you to join</span>
|
||||
<span v-else>You have been invited to join</span>
|
||||
<span class="font-bold text-[var(--color-heading)]">
|
||||
{{ notification.body.server_name }}
|
||||
</span>
|
||||
<span>.</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!notification.read"
|
||||
class="flex flex-wrap items-center gap-3"
|
||||
:class="{ 'gap-2': compact }"
|
||||
>
|
||||
<ButtonStyled :circular="compact" color="brand" :type="compact ? 'transparent' : null">
|
||||
<button
|
||||
v-tooltip="compact ? 'Accept' : null"
|
||||
@click="performActionByTitle(notification, 'Accept')"
|
||||
>
|
||||
<CheckIcon />
|
||||
<span v-if="!compact">Accept</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled :circular="compact" color="red" :type="compact ? 'transparent' : null">
|
||||
<button
|
||||
v-tooltip="compact ? 'Decline' : null"
|
||||
@click="performActionByTitle(notification, 'Deny')"
|
||||
>
|
||||
<XIcon />
|
||||
<span v-if="!compact">Decline</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<span v-if="notification.read" class="inline-flex items-center font-bold text-[var(--color-text)]">
|
||||
<CheckCircleIcon /> Read
|
||||
</span>
|
||||
<span v-tooltip="formatDateTime(notification.created)" class="inline-flex items-center">
|
||||
<CalendarIcon class="mr-1" /> Received
|
||||
{{ formatRelativeTime(notification.created) }}
|
||||
</span>
|
||||
<CopyCode v-if="flags.developerMode" :text="notification.id" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<nuxt-link
|
||||
v-if="!type"
|
||||
:to="notification.link"
|
||||
class="notification__icon backed-svg"
|
||||
:class="{ raised: raised }"
|
||||
>
|
||||
<BellIcon />
|
||||
</nuxt-link>
|
||||
<DoubleIcon v-else class="notification__icon">
|
||||
<template #primary>
|
||||
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
|
||||
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
v-else-if="organization"
|
||||
:to="`/organization/${organization.slug}`"
|
||||
tabindex="-1"
|
||||
>
|
||||
<Avatar size="xs" :src="organization.icon_url" :raised="raised" no-shadow />
|
||||
</nuxt-link>
|
||||
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
|
||||
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
|
||||
</nuxt-link>
|
||||
<Avatar v-else size="xs" :raised="raised" no-shadow />
|
||||
</template>
|
||||
<template #secondary>
|
||||
<ScaleIcon
|
||||
v-if="type === 'moderator_message' || type === 'status_change'"
|
||||
class="moderation-color"
|
||||
/>
|
||||
<UserPlusIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
|
||||
<UserPlusIcon
|
||||
v-else-if="type === 'organization_invite' && organization"
|
||||
class="creator-color"
|
||||
/>
|
||||
<VersionIcon v-else-if="type === 'project_update' && project && version" />
|
||||
<BellIcon v-else />
|
||||
</template>
|
||||
</DoubleIcon>
|
||||
<div class="notification__title">
|
||||
<template v-if="type === 'project_update' && project && version">
|
||||
A project you follow,
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link>
|
||||
@@ -295,6 +366,7 @@
|
||||
<CopyCode v-if="flags.developerMode" :text="notification.id" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -328,6 +400,7 @@ import { markAsRead } from '~/helpers/platform-notifications'
|
||||
import { getProjectLink, getVersionLink } from '~/helpers/projects'
|
||||
import { acceptTeamInvite, removeSelfFromTeam } from '~/helpers/teams'
|
||||
|
||||
import ModrinthServersIcon from '../brand/ModrinthServersIcon.vue'
|
||||
import ThreadSummary from './thread/ThreadSummary.vue'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
@@ -415,9 +488,25 @@ async function performAction(notification, actionIndex) {
|
||||
await read()
|
||||
|
||||
if (actionIndex !== null) {
|
||||
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
|
||||
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
|
||||
})
|
||||
const [method, route] = notification.actions[actionIndex].action_route
|
||||
|
||||
// TODO: fix all this jank shit
|
||||
if (route.startsWith('hosting/') || route.startsWith('/hosting/')) {
|
||||
const archonRoute = route
|
||||
.replace(/^\/?hosting/, '')
|
||||
.replace('/invite/accept', '/invites/accept')
|
||||
.replace('/invite/deny', '/invites/decline')
|
||||
|
||||
await client.request(archonRoute, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: method.toUpperCase(),
|
||||
})
|
||||
} else {
|
||||
await useBaseFetch(route, {
|
||||
method: method.toUpperCase(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
@@ -429,6 +518,20 @@ async function performAction(notification, actionIndex) {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
function performActionByTitle(notification, title) {
|
||||
const actionIndex = notification.actions.findIndex((action) => action.title === title)
|
||||
if (actionIndex === -1) {
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
text: `Missing ${title.toLowerCase()} action for notification.`,
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
return performAction(notification, actionIndex)
|
||||
}
|
||||
|
||||
function getMessages() {
|
||||
const messages = []
|
||||
if (props.notification.body.message_id) {
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
ServerNotice,
|
||||
StyledInput,
|
||||
TagItem,
|
||||
} from '@modrinth/ui'
|
||||
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
type ServerNoticeType = Archon.Notices.v0.ListedNotice
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
@@ -32,28 +34,23 @@ const assignedNodes = computed(() => assigned.value.filter((n) => n.kind === 'no
|
||||
const inputField = ref('')
|
||||
|
||||
async function refresh() {
|
||||
await useServersFetch('notices').then((res) => {
|
||||
const notices = res as ServerNoticeType[]
|
||||
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? []
|
||||
})
|
||||
const notices = await client.archon.notices_v0.list()
|
||||
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? []
|
||||
}
|
||||
|
||||
async function assign(server: boolean = true) {
|
||||
const input = inputField.value.trim()
|
||||
|
||||
if (input !== '' && notice.value) {
|
||||
await useServersFetch(
|
||||
`notices/${notice.value.id}/assign?${server ? 'server' : 'node'}=${input}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
},
|
||||
).catch((err) => {
|
||||
addNotification({
|
||||
title: 'Error assigning notice',
|
||||
text: err,
|
||||
type: 'error',
|
||||
await client.archon.notices_v0
|
||||
.assign(notice.value.id, server ? { server: input } : { node: input })
|
||||
.catch((err) => {
|
||||
addNotification({
|
||||
title: 'Error assigning notice',
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
title: 'Error assigning notice',
|
||||
@@ -84,18 +81,15 @@ async function unassignDetect() {
|
||||
|
||||
async function unassign(id: string, server: boolean = true) {
|
||||
if (notice.value) {
|
||||
await useServersFetch(
|
||||
`notices/${notice.value.id}/unassign?${server ? 'server' : 'node'}=${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
},
|
||||
).catch((err) => {
|
||||
addNotification({
|
||||
title: 'Error unassigning notice',
|
||||
text: err,
|
||||
type: 'error',
|
||||
await client.archon.notices_v0
|
||||
.unassign(notice.value.id, server ? { server: id } : { node: id })
|
||||
.catch((err) => {
|
||||
addNotification({
|
||||
title: 'Error unassigning notice',
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
@@ -125,7 +119,7 @@ defineExpose({ show, hide })
|
||||
:level="notice.level"
|
||||
:message="notice.message"
|
||||
:dismissable="notice.dismissable"
|
||||
:title="notice.title"
|
||||
:title="notice.title ?? undefined"
|
||||
preview
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
@@ -133,6 +133,7 @@ import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
@@ -143,9 +144,9 @@ import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useBaseFetch } from '#imports'
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
@@ -205,12 +206,12 @@ const applyDisabled = computed(() => {
|
||||
async function ensureOverview() {
|
||||
if (regions.value.length || nodeHostnames.value.length) return
|
||||
try {
|
||||
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
|
||||
regions.value = (data.regions || []).map((r: any) => ({
|
||||
const data = await client.archon.nodes_internal.overview()
|
||||
regions.value = data.regions.map((r) => ({
|
||||
value: r.key,
|
||||
label: `${r.display_name} (${r.key})`,
|
||||
}))
|
||||
nodeHostnames.value = data.node_hostnames || []
|
||||
nodeHostnames.value = data.node_hostnames
|
||||
if (!selectedRegion.value && regions.value.length) selectedRegion.value = regions.value[0].value
|
||||
} catch (err) {
|
||||
addNotification({ title: 'Failed to load nodes overview', text: String(err), type: 'error' })
|
||||
|
||||
@@ -198,6 +198,7 @@ import {
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
Combobox,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
@@ -207,13 +208,12 @@ import {
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
@@ -341,12 +341,12 @@ const submitDisabled = computed(() => {
|
||||
async function ensureOverview() {
|
||||
if (regions.value.length || nodeHostnames.value.length) return
|
||||
try {
|
||||
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
|
||||
regions.value = (data.regions || []).map((r: any) => ({
|
||||
const data = await client.archon.nodes_internal.overview()
|
||||
regions.value = data.regions.map((r) => ({
|
||||
value: r.key,
|
||||
label: `${r.display_name} (${r.key})`,
|
||||
}))
|
||||
nodeHostnames.value = data.node_hostnames || []
|
||||
nodeHostnames.value = data.node_hostnames
|
||||
if (!selectedRegion.value && regions.value.length) {
|
||||
selectedRegion.value = regions.value[0].value
|
||||
}
|
||||
@@ -364,30 +364,22 @@ async function submit() {
|
||||
scheduleOption.value === 'now' ? undefined : dayjs(scheduledDate.value).toISOString()
|
||||
|
||||
if (mode.value === 'servers') {
|
||||
await useServersFetch('/transfers/schedule/servers', {
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: {
|
||||
server_ids: parsedServerIds.value,
|
||||
scheduled_at: scheduledAt,
|
||||
target_region: selectedRegion.value || undefined,
|
||||
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
reason: reason.value.trim(),
|
||||
},
|
||||
await client.archon.transfers_internal.scheduleServers({
|
||||
server_ids: parsedServerIds.value,
|
||||
scheduled_at: scheduledAt,
|
||||
target_region: selectedRegion.value || undefined,
|
||||
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
reason: reason.value.trim(),
|
||||
})
|
||||
} else {
|
||||
await useServersFetch('/transfers/schedule/nodes', {
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: {
|
||||
node_hostnames: selectedNodes.value.slice(),
|
||||
scheduled_at: scheduledAt,
|
||||
target_region: selectedRegion.value || undefined,
|
||||
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
reason: reason.value.trim(),
|
||||
cordon_nodes: cordonNodes.value,
|
||||
tag_nodes: tagNodes.value.trim() || undefined,
|
||||
},
|
||||
await client.archon.transfers_internal.scheduleNodes({
|
||||
node_hostnames: selectedNodes.value.slice(),
|
||||
scheduled_at: scheduledAt,
|
||||
target_region: selectedRegion.value || undefined,
|
||||
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
reason: reason.value.trim(),
|
||||
cordon_nodes: cordonNodes.value,
|
||||
tag_nodes: tagNodes.value.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,12 @@
|
||||
>{{ isIncome ? '' : '-' }}{{ formatMoney(transaction.amount) }}</span
|
||||
>
|
||||
<template v-if="transaction.type === 'withdrawal' && transaction.status === 'in-transit'">
|
||||
<Tooltip theme="dismissable-prompt" :triggers="['hover', 'focus']" no-auto-focus>
|
||||
<Tooltip
|
||||
theme="dismissable-prompt"
|
||||
class="inline-flex shrink-0"
|
||||
:triggers="['hover', 'focus']"
|
||||
no-auto-focus
|
||||
>
|
||||
<span class="my-auto align-middle"
|
||||
><ButtonStyled circular type="outlined" size="small">
|
||||
<button class="align-middle" @click="cancelPayout">
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<ButtonStyled circular>
|
||||
<button :class="!collapsed && '[&>svg]:rotate-180'" @click="$emit('toggleCollapsed')">
|
||||
<button :class="{ '[&>svg]:rotate-180': !collapsed }" @click="$emit('toggleCollapsed')">
|
||||
<DropdownIcon class="duration-250 transition-transform ease-in-out" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="visibleQuickReplies.length > 0">
|
||||
<OverflowMenu :options="visibleQuickReplies">
|
||||
Quick Reply
|
||||
Quick reply
|
||||
<ChevronDownIcon />
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export const useAffiliates = () => {
|
||||
const config = useRuntimeConfig()
|
||||
const affiliateCookie = useCookie('mrs_afl', {
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
secure: config.public.cookieSecure,
|
||||
httpOnly: false,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
@@ -30,10 +30,11 @@ export const initAuth = async (oldToken = null) => {
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const config = useRuntimeConfig()
|
||||
const authCookie = useCookie('auth-token', {
|
||||
maxAge: 60 * 60 * 24 * 365 * 10,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
secure: config.public.cookieSecure,
|
||||
httpOnly: false,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
@@ -48,12 +48,13 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
useV1ContentTabAPI: true,
|
||||
labrinthApiCanary: false,
|
||||
dismissedExternalProjectsInfo: false,
|
||||
modpackPermissionsPage: false,
|
||||
showAllBanners: false,
|
||||
alwaysIgnoreErrorBanner: false,
|
||||
showViewProdRouteBanner: false,
|
||||
showModeratorProjectMemberUi: false,
|
||||
showModeratorPrivateMessageHighlight: true,
|
||||
archonApiStaging: false,
|
||||
showHostingAccessInstanceAuditLog: false,
|
||||
} as const)
|
||||
|
||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||
@@ -64,19 +65,20 @@ export type AllFeatureFlags = {
|
||||
|
||||
export type PartialFeatureFlags = Partial<AllFeatureFlags>
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
maxAge: 60 * 60 * 24 * 365 * 10,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
path: '/',
|
||||
} satisfies CookieOptions<PartialFeatureFlags>
|
||||
const getCookieOptions = () =>
|
||||
({
|
||||
maxAge: 60 * 60 * 24 * 365 * 10,
|
||||
sameSite: 'lax',
|
||||
secure: useRuntimeConfig().public.cookieSecure,
|
||||
httpOnly: false,
|
||||
path: '/',
|
||||
}) satisfies CookieOptions<PartialFeatureFlags>
|
||||
|
||||
export const useFeatureFlags = () =>
|
||||
useState<AllFeatureFlags>('featureFlags', () => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const savedFlags = useCookie<PartialFeatureFlags>('featureFlags', COOKIE_OPTIONS)
|
||||
const savedFlags = useCookie<PartialFeatureFlags>('featureFlags', getCookieOptions())
|
||||
|
||||
if (!savedFlags.value) {
|
||||
savedFlags.value = {}
|
||||
@@ -106,6 +108,6 @@ export const useFeatureFlags = () =>
|
||||
|
||||
export const saveFeatureFlags = () => {
|
||||
const flags = useFeatureFlags()
|
||||
const cookie = useCookie<PartialFeatureFlags>('featureFlags', COOKIE_OPTIONS)
|
||||
const cookie = useCookie<PartialFeatureFlags>('featureFlags', getCookieOptions())
|
||||
cookie.value = flags.value
|
||||
}
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
/**
|
||||
* @deprecated Use `@modrinth/api-client` via `injectModrinthClient()` instead.
|
||||
* The api-client's archon modules (`client.archon.servers_v0`, etc.) handle auth,
|
||||
* retry, and circuit breaking automatically. This composable is kept for legacy
|
||||
* code that hasn't been migrated yet.
|
||||
*/
|
||||
|
||||
import { PANEL_VERSION } from '@modrinth/api-client'
|
||||
import type { V1ErrorInfo } from '@modrinth/utils'
|
||||
import { ModrinthServerError, ModrinthServersFetchError } from '@modrinth/utils'
|
||||
import { $fetch, FetchError } from 'ofetch'
|
||||
|
||||
export interface ServersFetchOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
contentType?: string
|
||||
body?: Record<string, any>
|
||||
version?: number | 'internal'
|
||||
override?: {
|
||||
url?: string
|
||||
token?: string
|
||||
}
|
||||
retry?: number | boolean
|
||||
bypassAuth?: boolean
|
||||
}
|
||||
|
||||
export async function useServersFetch<T>(
|
||||
path: string,
|
||||
options: ServersFetchOptions = {},
|
||||
module?: string,
|
||||
errorContext?: string,
|
||||
): Promise<T> {
|
||||
const config = useRuntimeConfig()
|
||||
const auth = await useAuth()
|
||||
const authToken = auth.value?.token
|
||||
|
||||
if (!authToken && !options.bypassAuth) {
|
||||
const error = new ModrinthServersFetchError(
|
||||
'[Modrinth Hosting] Cannot fetch without auth',
|
||||
10000,
|
||||
)
|
||||
throw new ModrinthServerError('Missing auth token', 401, error, module, undefined, undefined)
|
||||
}
|
||||
|
||||
const {
|
||||
method = 'GET',
|
||||
contentType = 'application/json',
|
||||
body,
|
||||
version = 0,
|
||||
override,
|
||||
retry = method === 'GET' ? 3 : 0,
|
||||
} = options
|
||||
|
||||
const circuitBreakerKey = `${module || 'default'}_${path}`
|
||||
const failureCount = useState<number>(`fetch_failures_${circuitBreakerKey}`, () => 0)
|
||||
const lastFailureTime = useState<number>(`last_failure_${circuitBreakerKey}`, () => 0)
|
||||
|
||||
const now = Date.now()
|
||||
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
|
||||
const error = new ModrinthServersFetchError(
|
||||
'[Modrinth Hosting] Circuit breaker open - too many recent failures',
|
||||
503,
|
||||
)
|
||||
throw new ModrinthServerError(
|
||||
'Service temporarily unavailable',
|
||||
503,
|
||||
error,
|
||||
module,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
|
||||
if (now - lastFailureTime.value > 30000) {
|
||||
failureCount.value = 0
|
||||
}
|
||||
|
||||
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
|
||||
/\/$/,
|
||||
'',
|
||||
)
|
||||
|
||||
if (!base) {
|
||||
const error = new ModrinthServersFetchError(
|
||||
'[Modrinth Hosting] Cannot fetch without base url. Make sure to set a PYRO_BASE_URL in environment variables',
|
||||
10001,
|
||||
)
|
||||
throw new ModrinthServerError(
|
||||
'Configuration error: Missing PYRO_BASE_URL',
|
||||
500,
|
||||
error,
|
||||
module,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
|
||||
const versionString = `v${version}`
|
||||
let newOverrideUrl = override?.url
|
||||
if (newOverrideUrl && newOverrideUrl.includes('v0') && version !== 0) {
|
||||
newOverrideUrl = newOverrideUrl.replace('v0', versionString)
|
||||
}
|
||||
|
||||
const fullUrl = newOverrideUrl
|
||||
? `https://${newOverrideUrl}/${path.replace(/^\//, '')}`
|
||||
: version === 0
|
||||
? `${base}/modrinth/v${version}/${path.replace(/^\//, '')}`
|
||||
: version === 'internal'
|
||||
? `${base}/_internal/${path.replace(/^\//, '')}`
|
||||
: `${base}/v${version}/${path.replace(/^\//, '')}`
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Modrinth/1.0 (https://modrinth.com)',
|
||||
'X-Archon-Request': 'true',
|
||||
'X-Panel-Version': String(PANEL_VERSION),
|
||||
Vary: 'Accept, Origin',
|
||||
}
|
||||
|
||||
if (!options.bypassAuth) {
|
||||
headers.Authorization = `Bearer ${override?.token ?? authToken}`
|
||||
headers['Access-Control-Allow-Headers'] = 'Authorization'
|
||||
}
|
||||
|
||||
if (contentType !== 'none') {
|
||||
headers['Content-Type'] = contentType
|
||||
}
|
||||
|
||||
if (import.meta.client && typeof window !== 'undefined') {
|
||||
headers.Origin = window.location.origin
|
||||
}
|
||||
|
||||
let attempts = 0
|
||||
const maxAttempts = (typeof retry === 'boolean' ? (retry ? 3 : 1) : retry) + 1
|
||||
let lastError: Error | null = null
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const response = await $fetch<T>(fullUrl, {
|
||||
method,
|
||||
headers,
|
||||
body:
|
||||
body && contentType === 'application/json' ? JSON.stringify(body) : (body ?? undefined),
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
failureCount.value = 0
|
||||
return response
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
attempts++
|
||||
|
||||
if (error instanceof FetchError) {
|
||||
const statusCode = error.response?.status
|
||||
const statusText = error.response?.statusText || 'Unknown error'
|
||||
|
||||
if (statusCode && statusCode >= 500) {
|
||||
failureCount.value++
|
||||
lastFailureTime.value = now
|
||||
}
|
||||
|
||||
let v1Error: V1ErrorInfo | undefined
|
||||
if (error.data?.error && error.data?.description) {
|
||||
v1Error = {
|
||||
context: errorContext,
|
||||
...error.data,
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessages: { [key: number]: string } = {
|
||||
400: 'Bad Request',
|
||||
401: 'Unauthorized',
|
||||
403: 'Forbidden',
|
||||
404: 'Not Found',
|
||||
405: 'Method Not Allowed',
|
||||
408: 'Request Timeout',
|
||||
429: "You're making requests too quickly. Please wait a moment and try again.",
|
||||
500: 'Internal Server Error',
|
||||
502: 'Bad Gateway',
|
||||
503: 'Service Unavailable',
|
||||
504: 'Gateway Timeout',
|
||||
}
|
||||
|
||||
const message =
|
||||
statusCode && statusCode in errorMessages
|
||||
? errorMessages[statusCode]
|
||||
: `HTTP Error: ${statusCode || 'unknown'} ${statusText}`
|
||||
|
||||
const isRetryable = statusCode ? [408, 429].includes(statusCode) : false
|
||||
const is5xxRetryable =
|
||||
statusCode && statusCode >= 500 && statusCode < 600 && method === 'GET' && attempts === 1
|
||||
|
||||
if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) {
|
||||
console.error('Fetch error:', error)
|
||||
|
||||
const fetchError = new ModrinthServersFetchError(
|
||||
`[Modrinth Hosting] ${error.message}`,
|
||||
statusCode,
|
||||
error,
|
||||
)
|
||||
throw new ModrinthServerError(
|
||||
`[Modrinth Hosting] ${message}`,
|
||||
statusCode,
|
||||
fetchError,
|
||||
module,
|
||||
v1Error,
|
||||
error.data,
|
||||
)
|
||||
}
|
||||
|
||||
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000
|
||||
const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000)
|
||||
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
continue
|
||||
}
|
||||
|
||||
console.error('Unexpected fetch error:', error)
|
||||
const fetchError = new ModrinthServersFetchError(
|
||||
'[Modrinth Hosting] An unexpected error occurred during the fetch operation.',
|
||||
undefined,
|
||||
error as Error,
|
||||
)
|
||||
throw new ModrinthServerError(
|
||||
'Unexpected error during fetch operation',
|
||||
undefined,
|
||||
fetchError,
|
||||
module,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.error('All retry attempts failed:', lastError)
|
||||
if (lastError instanceof FetchError) {
|
||||
const statusCode = lastError.response?.status
|
||||
const pyroError = new ModrinthServersFetchError(
|
||||
'Maximum retry attempts reached',
|
||||
statusCode,
|
||||
lastError,
|
||||
)
|
||||
throw new ModrinthServerError(
|
||||
'Maximum retry attempts reached',
|
||||
statusCode,
|
||||
pyroError,
|
||||
module,
|
||||
undefined,
|
||||
lastError.data,
|
||||
)
|
||||
}
|
||||
|
||||
const fetchError = new ModrinthServersFetchError(
|
||||
'Maximum retry attempts reached',
|
||||
undefined,
|
||||
lastError || undefined,
|
||||
)
|
||||
throw new ModrinthServerError(
|
||||
'Maximum retry attempts reached',
|
||||
undefined,
|
||||
fetchError,
|
||||
module,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,6 @@ export type FilterSelection = {
|
||||
const cookieDefaults = {
|
||||
maxAge: TEN_MINUTES,
|
||||
sameSite: 'lax' as const,
|
||||
secure: true,
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
}
|
||||
@@ -52,13 +51,19 @@ function newFilterSelection(
|
||||
}
|
||||
|
||||
export function useCdnDownloadContext() {
|
||||
const filterGameVersionCookie = useCookie<string | null>('mr_download_filter_game_version', {
|
||||
const config = useRuntimeConfig()
|
||||
const cookieOptions = {
|
||||
...cookieDefaults,
|
||||
secure: config.public.cookieSecure,
|
||||
}
|
||||
|
||||
const filterGameVersionCookie = useCookie<string | null>('mr_download_filter_game_version', {
|
||||
...cookieOptions,
|
||||
default: () => null,
|
||||
})
|
||||
|
||||
const filterLoaderCookie = useCookie<string | null>('mr_download_filter_loader', {
|
||||
...cookieDefaults,
|
||||
...cookieOptions,
|
||||
default: () => null,
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
import { withStagingArchonBaseUrl } from '~/helpers/archon.ts'
|
||||
|
||||
async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
|
||||
try {
|
||||
@@ -37,7 +38,8 @@ export function createModrinthClient(
|
||||
|
||||
const clientConfig: NuxtClientConfig = {
|
||||
labrinthBaseUrl: config.apiBaseUrl,
|
||||
archonBaseUrl: config.archonBaseUrl,
|
||||
archonBaseUrl: () =>
|
||||
withStagingArchonBaseUrl(config.archonBaseUrl, flags.value.archonApiStaging),
|
||||
archonSentryCapture: () => flags.value.archonSentryCapture,
|
||||
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
|
||||
features: [
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const STAGING_ARCHON_BASE_URL = 'https://staging-archon.modrinth.com/'
|
||||
|
||||
export function withStagingArchonBaseUrl(
|
||||
baseUrl: string,
|
||||
useStaging = useFeatureFlags().value.archonApiStaging,
|
||||
) {
|
||||
if (!useStaging) {
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
return STAGING_ARCHON_BASE_URL
|
||||
}
|
||||
@@ -3263,9 +3263,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "If you need assistance or have additional inquiries, please visit the <help-center-link>Modrinth Help Center</help-center-link> and click the blue bubble to contact support."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Show member UI"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "This is a private conversation thread with the Modrinth moderators. They may message you with issues concerning this project."
|
||||
},
|
||||
@@ -3336,43 +3333,64 @@
|
||||
"message": "URL"
|
||||
},
|
||||
"project.settings.permissions.attention-needed.description.proj-approved": {
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files and any withheld versions will be automatically published."
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files. Once completed, withheld versions will be automatically published."
|
||||
},
|
||||
"project.settings.permissions.attention-needed.description.proj-draft": {
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files before you can submit your project for review."
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files before submitting your project for review."
|
||||
},
|
||||
"project.settings.permissions.attention-needed.title": {
|
||||
"message": "Unknown embedded content"
|
||||
"message": "Unknown external content"
|
||||
},
|
||||
"project.settings.permissions.collapse-all": {
|
||||
"message": "Collapse all"
|
||||
},
|
||||
"project.settings.permissions.completed.description": {
|
||||
"message": "All external content has attributions provided."
|
||||
"message": "All external content has permission information and attributions have been provided."
|
||||
},
|
||||
"project.settings.permissions.completed.title": {
|
||||
"message": "Attributions completed!"
|
||||
"message": "Permissions completed!"
|
||||
},
|
||||
"project.settings.permissions.empty-state.description": {
|
||||
"message": "None of your versions contain external content, so you don't need to worry about obtaining permissions."
|
||||
"message": "None of your project's versions contain external content, so you don't need to worry about obtaining permissions."
|
||||
},
|
||||
"project.settings.permissions.empty-state.heading": {
|
||||
"message": "You're all set!"
|
||||
},
|
||||
"project.settings.permissions.expand-all": {
|
||||
"message": "Expand all"
|
||||
},
|
||||
"project.settings.permissions.fail.description": {
|
||||
"message": "You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content."
|
||||
"message": "You may not have permission to redistribute some of the external content in your project. In order to publish on Modrinth, please remove this content or provide proof that you do have permission to use it."
|
||||
},
|
||||
"project.settings.permissions.fail.title": {
|
||||
"message": "Some content can't be included"
|
||||
},
|
||||
"project.settings.permissions.info-banner.description": {
|
||||
"message": "If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn about how to do this properly!"
|
||||
"message": "If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn more and get started!"
|
||||
},
|
||||
"project.settings.permissions.info-banner.title": {
|
||||
"message": "Learn how attributions work"
|
||||
"message": "Learn about distribution permissions"
|
||||
},
|
||||
"project.settings.permissions.learn-more": {
|
||||
"message": "Learn more"
|
||||
},
|
||||
"project.settings.permissions.no-results": {
|
||||
"message": "No external files match your search."
|
||||
},
|
||||
"project.settings.permissions.search-placeholder": {
|
||||
"message": "Search {count} {count, plural, one {external project} other {external projects}}..."
|
||||
"message": "Search {count} {count, plural, one {project} other {projects}}..."
|
||||
},
|
||||
"project.settings.permissions.sort.most-files": {
|
||||
"message": "Most files"
|
||||
},
|
||||
"project.settings.permissions.sort.recently-edited": {
|
||||
"message": "Recently edited"
|
||||
},
|
||||
"project.settings.permissions.sort.rejected": {
|
||||
"message": "Rejected"
|
||||
},
|
||||
"project.settings.permissions.sort.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"project.settings.title": {
|
||||
"message": "Settings"
|
||||
|
||||
@@ -72,11 +72,9 @@
|
||||
<h2 id="messages" class="m-0 text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.threadSectionTitle) }}
|
||||
</h2>
|
||||
<div v-if="currentMember?.staffOnly" class="flex items-center gap-2">
|
||||
<div v-if="isStaff(currentMember?.user)" class="flex items-center gap-2">
|
||||
<Toggle id="moderator-see-user-ui-toggle" v-model="moderatorSeeUserUi" small />
|
||||
<label for="moderator-see-user-ui-toggle">
|
||||
{{ formatMessage(messages.moderatorSeeUserUiToggle) }}
|
||||
</label>
|
||||
<label for="moderator-see-user-ui-toggle"> Show member UI </label>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="userFacingUiVisible">
|
||||
@@ -151,8 +149,9 @@ import {
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { isStaff } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, type Ref, watch } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
|
||||
import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers/projects.js'
|
||||
@@ -160,7 +159,6 @@ import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers
|
||||
const { formatMessage } = useVIntl()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
type ProjectPageMember = Labrinth.Projects.v3.TeamMember & { staffOnly?: boolean }
|
||||
type ModerationAdmonitionSection =
|
||||
| {
|
||||
type: 'paragraph'
|
||||
@@ -181,10 +179,6 @@ const messages = defineMessages({
|
||||
id: 'project.moderation.thread.title',
|
||||
defaultMessage: 'Moderation messages',
|
||||
},
|
||||
moderatorSeeUserUiToggle: {
|
||||
id: 'project.moderation.thread.moderator-see-user-ui-toggle',
|
||||
defaultMessage: 'Show member UI',
|
||||
},
|
||||
threadPrivateDescription: {
|
||||
id: 'project.moderation.thread.private-description',
|
||||
defaultMessage:
|
||||
@@ -213,18 +207,10 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const {
|
||||
projectV2: project,
|
||||
currentMember: currentMemberRaw,
|
||||
invalidate,
|
||||
allMembers,
|
||||
} = injectProjectPageContext()
|
||||
const currentMember = currentMemberRaw as Ref<ProjectPageMember | null>
|
||||
const { projectV2: project, currentMember, invalidate, allMembers } = injectProjectPageContext()
|
||||
|
||||
const canAccess = computed(() => !!currentMember.value)
|
||||
const userFacingUiVisible = computed(
|
||||
() => !!currentMember.value && (!currentMember.value.staffOnly || moderatorSeeUserUi.value),
|
||||
)
|
||||
const userFacingUiVisible = computed(() => !!currentMember.value && moderatorSeeUserUi.value)
|
||||
|
||||
const approvedAdmonitionMessage = computed<MessageDescriptor | null>(() => {
|
||||
switch (project.value?.status) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
commonMessages,
|
||||
commonProjectSettingsMessages,
|
||||
injectProjectPageContext,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { isStaff } from '@modrinth/utils'
|
||||
@@ -47,10 +48,8 @@ const navItems = computed(() => {
|
||||
projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) &&
|
||||
isStaff(currentMember.value?.user)
|
||||
|
||||
const hasPermissionsPage = computed(
|
||||
() =>
|
||||
flags.value.modpackPermissionsPage &&
|
||||
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
|
||||
const hasPermissionsPage = computed(() =>
|
||||
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
|
||||
)
|
||||
|
||||
const items = [
|
||||
@@ -82,16 +81,16 @@ const navItems = computed(() => {
|
||||
label: formatMessage(commonProjectSettingsMessages.description),
|
||||
icon: AlignLeftIcon,
|
||||
},
|
||||
hasPermissionsPage.value && {
|
||||
link: `/${base}/settings/permissions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.permissions),
|
||||
icon: SignatureIcon,
|
||||
},
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/versions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.versions),
|
||||
icon: VersionIcon,
|
||||
},
|
||||
hasPermissionsPage.value && {
|
||||
link: `/${base}/settings/permissions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.permissions),
|
||||
icon: SignatureIcon,
|
||||
},
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/license`,
|
||||
label: formatMessage(commonProjectSettingsMessages.license),
|
||||
@@ -144,6 +143,16 @@ watch(route, () => {
|
||||
const scrollY = scroll.y.value
|
||||
setTimeout(() => window.scrollTo(0, scrollY), 10)
|
||||
})
|
||||
|
||||
const moderatorSeeUserUi = computed<boolean>({
|
||||
get() {
|
||||
return flags.value.showModeratorProjectMemberUi
|
||||
},
|
||||
set(value: boolean) {
|
||||
flags.value.showModeratorProjectMemberUi = value
|
||||
saveFeatureFlags()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -167,6 +176,10 @@ watch(route, () => {
|
||||
<div class="grid gap-4 lg:grid-cols-[1fr_3fr]">
|
||||
<div>
|
||||
<NavStack :items="navItems" />
|
||||
<div v-if="isStaff(currentMember?.user)" class="mt-4 flex items-center gap-2">
|
||||
<Toggle id="moderator-see-user-ui-toggle" v-model="moderatorSeeUserUi" small />
|
||||
<label for="moderator-see-user-ui-toggle"> Show member UI </label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<NuxtPage />
|
||||
|
||||
@@ -1,51 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
ArrowDown10Icon,
|
||||
ArrowDownWideNarrowIcon,
|
||||
ClockArrowDownIcon,
|
||||
FoldVerticalIcon,
|
||||
RightArrowIcon,
|
||||
SearchIcon,
|
||||
UnfoldVerticalIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
createAttributionGroupTitle,
|
||||
defineMessage,
|
||||
defineMessages,
|
||||
EmptyState,
|
||||
ExternalProjectPermissionsCard,
|
||||
injectModrinthClient,
|
||||
injectProjectPageContext,
|
||||
IntlFormatted,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import ExternalProjectPermissionsCard from '@modrinth/ui/src/components/external_files/ExternalProjectPermissionsCard.vue'
|
||||
import { ref } from 'vue'
|
||||
import { isStaff } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const auth = await useAuth()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
if (!flags.value.modpackPermissionsPage) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
})
|
||||
const isModerator = computed(() => {
|
||||
return isStaff(auth.value?.user) && !flags.value.showModeratorProjectMemberUi
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { projectV2: project } = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
type SortType = 'status' | 'most_files' | 'recently_edited' | 'rejected'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const currentSortType = ref<SortType>('status')
|
||||
const cardCollapsedById = ref<Record<string, boolean>>({})
|
||||
|
||||
const {
|
||||
data: attributionData,
|
||||
error: attributionError,
|
||||
isPending: pending,
|
||||
} = useQuery<Labrinth.Attribution.Internal.AttributionGroup[]>({
|
||||
queryKey: ['project-attribution', project.value.id],
|
||||
queryFn: () => labrinth.attribution_internal.listProjectAttribution(project.value.id),
|
||||
})
|
||||
|
||||
const sortRejectedLabel = defineMessage({
|
||||
id: 'project.settings.permissions.sort.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
})
|
||||
|
||||
const sortTypes = computed<ComboboxOption<SortType>[]>(() => {
|
||||
const options: ComboboxOption<SortType>[] = [
|
||||
{
|
||||
value: 'status',
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: 'project.settings.permissions.sort.status',
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'most_files',
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: 'project.settings.permissions.sort.most-files',
|
||||
defaultMessage: 'Most files',
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'recently_edited',
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: 'project.settings.permissions.sort.recently-edited',
|
||||
defaultMessage: 'Recently edited',
|
||||
}),
|
||||
),
|
||||
},
|
||||
]
|
||||
if (hasMixedModerationStatus.value) {
|
||||
options.push({
|
||||
value: 'rejected',
|
||||
label: formatMessage(sortRejectedLabel),
|
||||
})
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const currentSortLabel = computed(() => {
|
||||
return sortTypes.value.find((option) => option.value === currentSortType.value)?.label ?? ''
|
||||
})
|
||||
|
||||
function isAttributed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return !!group.attribution && !isNoPermission(group)
|
||||
}
|
||||
|
||||
const externalFiles = ref([{}])
|
||||
const searchQuery = ref('')
|
||||
const currentSortType = ref('Oldest')
|
||||
function isNoPermission(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return group.attribution?.kind === 'no_permission'
|
||||
}
|
||||
|
||||
function isModerationRejected(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
const kind = group.attribution?.moderation_status?.kind
|
||||
return kind === 'bad_proof' || kind === 'not_allowed'
|
||||
}
|
||||
|
||||
function rejectedSortRank(group: Labrinth.Attribution.Internal.AttributionGroup): number {
|
||||
if (isModerationRejected(group)) {
|
||||
return 0
|
||||
}
|
||||
if (group.attribution?.moderation_status?.kind === 'approved') {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
const hasMixedModerationStatus = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
let hasRejected = false
|
||||
let hasNonRejected = false
|
||||
for (const group of groups) {
|
||||
if (isModerationRejected(group)) {
|
||||
hasRejected = true
|
||||
} else {
|
||||
hasNonRejected = true
|
||||
}
|
||||
if (hasRejected && hasNonRejected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
watch(hasMixedModerationStatus, (mixed) => {
|
||||
if (!mixed && currentSortType.value === 'rejected') {
|
||||
currentSortType.value = 'status'
|
||||
}
|
||||
})
|
||||
|
||||
function statusSortRank(group: Labrinth.Attribution.Internal.AttributionGroup): number {
|
||||
if (isNoPermission(group)) {
|
||||
return 0
|
||||
} else if (!group.attribution) {
|
||||
return 1
|
||||
} else if (group.attribution?.kind === 'globally_allowed') {
|
||||
return 3
|
||||
} else {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
function alphabetSortKey(group: Labrinth.Attribution.Internal.AttributionGroup): string {
|
||||
return createAttributionGroupTitle(group, formatMessage)
|
||||
}
|
||||
|
||||
function compareAlphabetical(
|
||||
a: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
b: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
): number {
|
||||
return alphabetSortKey(a).localeCompare(alphabetSortKey(b), undefined, { sensitivity: 'base' })
|
||||
}
|
||||
|
||||
function attributedTimestamp(group: Labrinth.Attribution.Internal.AttributionGroup): number {
|
||||
if (!group.attributed_at) return Number.NEGATIVE_INFINITY
|
||||
const t = Date.parse(group.attributed_at)
|
||||
return Number.isNaN(t) ? Number.NEGATIVE_INFINITY : t
|
||||
}
|
||||
|
||||
function groupMatchesPermissionsSearch(
|
||||
group: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
queryTrimmed: string,
|
||||
): boolean {
|
||||
const query = queryTrimmed.toLowerCase()
|
||||
if (group.flame_project?.title?.toLowerCase().includes(query)) {
|
||||
return true
|
||||
}
|
||||
return (group.files ?? []).some((file) => file.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
const queryTrimmed = searchQuery.value.trim()
|
||||
const filtered = queryTrimmed
|
||||
? groups.filter((group) => groupMatchesPermissionsSearch(group, queryTrimmed))
|
||||
: [...groups]
|
||||
const sortMode = currentSortType.value
|
||||
filtered.sort(compareAlphabetical)
|
||||
filtered.sort((a, b) => {
|
||||
if (sortMode === 'status') {
|
||||
return statusSortRank(a) - statusSortRank(b)
|
||||
}
|
||||
if (sortMode === 'most_files') {
|
||||
const ac = a.files?.length ?? 0
|
||||
const bc = b.files?.length ?? 0
|
||||
return bc - ac
|
||||
}
|
||||
if (sortMode === 'rejected') {
|
||||
return rejectedSortRank(a) - rejectedSortRank(b)
|
||||
}
|
||||
const at = attributedTimestamp(a)
|
||||
const bt = attributedTimestamp(b)
|
||||
return bt - at
|
||||
})
|
||||
return filtered
|
||||
})
|
||||
|
||||
const totalGroups = computed(() => attributionData.value?.length ?? 0)
|
||||
|
||||
const stats = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
let attributed = 0
|
||||
let pending = 0
|
||||
let noPermission = 0
|
||||
for (const group of groups) {
|
||||
if (isNoPermission(group)) {
|
||||
noPermission++
|
||||
} else if (isAttributed(group)) {
|
||||
attributed++
|
||||
} else {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
return { total: groups.length, attributed, pending, noPermission }
|
||||
})
|
||||
|
||||
const projectIsApproved = computed(() => project.value.status === 'approved')
|
||||
|
||||
const sortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Oldest', label: 'Oldest' },
|
||||
{ value: 'Newest', label: 'Newest' },
|
||||
]
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'project.settings.permissions.search-placeholder',
|
||||
defaultMessage:
|
||||
'Search {count} {count, plural, one {external project} other {external projects}}...',
|
||||
defaultMessage: 'Search {count} {count, plural, one {project} other {projects}}...',
|
||||
},
|
||||
infoBannerTitle: {
|
||||
id: 'project.settings.permissions.info-banner.title',
|
||||
defaultMessage: 'Learn how attributions work',
|
||||
defaultMessage: 'Learn about distribution permissions',
|
||||
},
|
||||
infoBannerDescription: {
|
||||
id: 'project.settings.permissions.info-banner.description',
|
||||
defaultMessage: `If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn about how to do this properly!`,
|
||||
defaultMessage: `If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn more and get started!`,
|
||||
},
|
||||
learnMore: {
|
||||
id: 'project.settings.permissions.learn-more',
|
||||
@@ -57,15 +261,16 @@ const messages = defineMessages({
|
||||
},
|
||||
emptyStateDescription: {
|
||||
id: 'project.settings.permissions.empty-state.description',
|
||||
defaultMessage: `None of your versions contain external content, so you don't need to worry about obtaining permissions.`,
|
||||
defaultMessage: `None of your project's versions contain external content, so you don't need to worry about obtaining permissions.`,
|
||||
},
|
||||
completedTitle: {
|
||||
id: 'project.settings.permissions.completed.title',
|
||||
defaultMessage: `Attributions completed!`,
|
||||
defaultMessage: `Permissions completed!`,
|
||||
},
|
||||
completedDescription: {
|
||||
id: 'project.settings.permissions.completed.description',
|
||||
defaultMessage: 'All external content has attributions provided.',
|
||||
defaultMessage:
|
||||
'All external content has permission information and attributions have been provided.',
|
||||
},
|
||||
failTitle: {
|
||||
id: 'project.settings.permissions.fail.title',
|
||||
@@ -73,78 +278,144 @@ const messages = defineMessages({
|
||||
},
|
||||
failDescription: {
|
||||
id: 'project.settings.permissions.fail.description',
|
||||
defaultMessage: `You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content.`,
|
||||
defaultMessage: `You may not have permission to redistribute some of the external content in your project. In order to publish on Modrinth, please remove this content or provide proof that you do have permission to use it.`,
|
||||
},
|
||||
attentionNeededTitle: {
|
||||
id: 'project.settings.permissions.attention-needed.title',
|
||||
defaultMessage: `Unknown embedded content`,
|
||||
defaultMessage: `Unknown external content`,
|
||||
},
|
||||
attentionNeededDescriptionApproved: {
|
||||
id: 'project.settings.permissions.attention-needed.description.proj-approved',
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files and any withheld versions will be automatically published.`,
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files. Once completed, withheld versions will be automatically published.`,
|
||||
},
|
||||
attentionNeededDescriptionDraft: {
|
||||
id: 'project.settings.permissions.attention-needed.description.proj-draft',
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files before you can submit your project for review.`,
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files before submitting your project for review.`,
|
||||
},
|
||||
noResults: {
|
||||
id: 'project.settings.permissions.no-results',
|
||||
defaultMessage: 'No external files match your search.',
|
||||
},
|
||||
collapseAll: {
|
||||
id: 'project.settings.permissions.collapse-all',
|
||||
defaultMessage: 'Collapse all',
|
||||
},
|
||||
expandAll: {
|
||||
id: 'project.settings.permissions.expand-all',
|
||||
defaultMessage: 'Expand all',
|
||||
},
|
||||
})
|
||||
|
||||
function defaultCardCollapsed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return (
|
||||
(!isModerator.value && !!group.attribution) || group?.attribution?.kind === 'globally_allowed'
|
||||
)
|
||||
}
|
||||
|
||||
function getCardCollapsed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return cardCollapsedById.value[group.id] ?? defaultCardCollapsed(group)
|
||||
}
|
||||
|
||||
function setCardCollapsed(groupId: string, collapsed: boolean) {
|
||||
cardCollapsedById.value = { ...cardCollapsedById.value, [groupId]: collapsed }
|
||||
}
|
||||
|
||||
const allCardsCollapsed = computed(() => {
|
||||
const groups = filteredGroups.value
|
||||
if (groups.length === 0) {
|
||||
return true
|
||||
}
|
||||
return groups.every((group) => getCardCollapsed(group))
|
||||
})
|
||||
|
||||
const expandCollapseAllLabel = computed(() =>
|
||||
formatMessage(allCardsCollapsed.value ? messages.expandAll : messages.collapseAll),
|
||||
)
|
||||
|
||||
function toggleAllCardsCollapsed() {
|
||||
const collapsed = !allCardsCollapsed.value
|
||||
const next = { ...cardCollapsedById.value }
|
||||
for (const group of filteredGroups.value) {
|
||||
next[group.id] = collapsed
|
||||
}
|
||||
cardCollapsedById.value = next
|
||||
}
|
||||
|
||||
function dismissInfoBanner() {
|
||||
flags.value.dismissedExternalProjectsInfo = true
|
||||
saveFeatureFlags()
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<template v-if="externalFiles.length > 0">
|
||||
<Admonition
|
||||
v-if="!flags.dismissedExternalProjectsInfo"
|
||||
type="info"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.infoBannerTitle)"
|
||||
dismissible
|
||||
@dismiss="dismissInfoBanner"
|
||||
>
|
||||
<IntlFormatted :message-id="messages.infoBannerDescription">
|
||||
<template #link="{ children }">
|
||||
<a class="text-link" target="_blank"> <component :is="() => children" /> </a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
<template #actions>
|
||||
<div class="flex">
|
||||
<ButtonStyled color="blue">
|
||||
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<Admonition
|
||||
v-if="!flags.dismissedExternalProjectsInfo && !isModerator"
|
||||
type="info"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.infoBannerTitle)"
|
||||
dismissible
|
||||
@dismiss="dismissInfoBanner"
|
||||
>
|
||||
<IntlFormatted :message-id="messages.infoBannerDescription">
|
||||
<template #link="{ children }">
|
||||
<a class="text-link" target="_blank"> <component :is="() => children" /> </a>
|
||||
</template>
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-if="true"
|
||||
type="success"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.completedTitle)"
|
||||
:body="formatMessage(messages.completedDescription)"
|
||||
/>
|
||||
<Admonition
|
||||
v-if="true"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.attentionNeededTitle)"
|
||||
:body="formatMessage(messages.attentionNeededDescriptionDraft)"
|
||||
/>
|
||||
<Admonition
|
||||
v-if="true"
|
||||
type="critical"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.failTitle)"
|
||||
:body="formatMessage(messages.failDescription)"
|
||||
/>
|
||||
<div class="grid grid-cols-[1fr_auto] gap-2">
|
||||
</IntlFormatted>
|
||||
<template #actions>
|
||||
<div class="flex">
|
||||
<ButtonStyled color="blue">
|
||||
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</Admonition>
|
||||
<template v-if="pending">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="h-[56px] w-full animate-pulse rounded-2xl bg-surface-3"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="totalGroups > 0">
|
||||
<template v-if="!isModerator">
|
||||
<Admonition
|
||||
v-if="stats.pending === 0 && stats.noPermission === 0"
|
||||
type="success"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.completedTitle)"
|
||||
:body="formatMessage(messages.completedDescription)"
|
||||
/>
|
||||
<Admonition
|
||||
v-else-if="stats.noPermission > 0"
|
||||
type="critical"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.failTitle)"
|
||||
:body="formatMessage(messages.failDescription)"
|
||||
/>
|
||||
<Admonition
|
||||
v-else-if="stats.pending > 0"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.attentionNeededTitle)"
|
||||
:body="
|
||||
formatMessage(
|
||||
projectIsApproved
|
||||
? messages.attentionNeededDescriptionApproved
|
||||
: messages.attentionNeededDescriptionDraft,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<div class="grid grid-cols-[1fr_auto_auto] gap-2">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
:placeholder="
|
||||
formatMessage(messages.searchPlaceholder, {
|
||||
count: externalFiles.length,
|
||||
count: totalGroups,
|
||||
})
|
||||
"
|
||||
:icon="SearchIcon"
|
||||
@@ -153,27 +424,68 @@ function dismissInfoBanner() {
|
||||
<div>
|
||||
<Combobox
|
||||
v-model="currentSortType"
|
||||
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||
class="!w-full flex-grow sm:!w-[220px] sm:flex-grow-0"
|
||||
:options="sortTypes"
|
||||
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<SortAscIcon
|
||||
v-if="currentSortType === 'Oldest'"
|
||||
<ArrowDownWideNarrowIcon
|
||||
v-if="currentSortType === 'status'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
|
||||
<span class="truncate text-contrast">{{ currentSortType }}</span>
|
||||
<ArrowDown10Icon
|
||||
v-else-if="currentSortType === 'most_files'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<ClockArrowDownIcon
|
||||
v-else-if="currentSortType === 'recently_edited'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<ArrowDownWideNarrowIcon
|
||||
v-else-if="currentSortType === 'rejected'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<span class="truncate text-contrast">{{ currentSortLabel }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
<ButtonStyled>
|
||||
<button type="button" class="!h-[40px]" @click="toggleAllCardsCollapsed">
|
||||
<UnfoldVerticalIcon
|
||||
v-if="allCardsCollapsed"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<FoldVerticalIcon v-else class="size-5 flex-shrink-0 text-secondary" />
|
||||
{{ expandCollapseAllLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-3">
|
||||
<ExternalProjectPermissionsCard title="FTB Library" />
|
||||
<TransitionGroup name="list">
|
||||
<ExternalProjectPermissionsCard
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.id"
|
||||
:collapsed="getCardCollapsed(group)"
|
||||
:project-id="project.id"
|
||||
:group="group"
|
||||
:is-moderator="isModerator"
|
||||
@update:collapsed="setCardCollapsed(group.id, $event)"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="filteredGroups.length === 0"
|
||||
:heading="formatMessage(messages.noResults)"
|
||||
type="no-search-result"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="attributionError">
|
||||
<p v-if="attributionError" class="my-12 text-center text-red">
|
||||
{{ attributionError }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<EmptyState
|
||||
:heading="formatMessage(messages.emptyStateHeading)"
|
||||
@@ -182,3 +494,18 @@ function dismissInfoBanner() {
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<style scoped>
|
||||
.list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.list-move {
|
||||
transition: transform 200ms ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,21 +14,22 @@
|
||||
proceed-label="Delete"
|
||||
@proceed="deleteVersion()"
|
||||
/>
|
||||
|
||||
<Admonition
|
||||
v-if="flags.modpackPermissionsPage && withheldVersions.length > 0"
|
||||
v-if="withheldVersions.length > 0"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
:header="
|
||||
formatMessage(messages.withheldVersionsWarningTitle, {
|
||||
count: withheldVersions.length,
|
||||
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
|
||||
version_name:
|
||||
withheldVersions.length === 1 ? withheldVersions[0].version_number : undefined,
|
||||
})
|
||||
"
|
||||
:body="
|
||||
formatMessage(messages.withheldVersionsWarningDescription, {
|
||||
count: withheldVersions.length,
|
||||
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
|
||||
version_name:
|
||||
withheldVersions.length === 1 ? withheldVersions[0].version_number : undefined,
|
||||
})
|
||||
"
|
||||
>
|
||||
@@ -454,7 +455,9 @@ async function deleteVersion() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const withheldVersions = computed(() => ['4.0.0'])
|
||||
const withheldVersions = computed(() =>
|
||||
versions.value.filter((x) => x.files_missing_attribution?.length > 0),
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
withheldVersionsWarningTitle: {
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-else class="input-group mt-2">
|
||||
<ButtonStyled v-if="primaryFile?.url && !currentMember" color="brand">
|
||||
<ButtonStyled v-if="primaryFile?.url" color="brand">
|
||||
<a
|
||||
v-tooltip="primaryFile.filename + ' (' + formatBytes(primaryFile.size) + ')'"
|
||||
:href="decoratedPrimaryFileUrl"
|
||||
@@ -159,36 +159,44 @@
|
||||
Report
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="currentMember">
|
||||
<button @click="handleOpenEditVersionModal(version.id, project.id, 'metadata')">
|
||||
<BoxIcon aria-hidden="true" />
|
||||
Edit metadata
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="currentMember">
|
||||
<button @click="handleOpenEditVersionModal(version.id, project.id, 'add-details')">
|
||||
<InfoIcon aria-hidden="true" />
|
||||
Edit details
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="currentMember">
|
||||
<button @click="handleOpenEditVersionModal(version.id, project.id, 'add-files')">
|
||||
<FileIcon aria-hidden="true" />
|
||||
Edit files
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
v-if="
|
||||
currentMember &&
|
||||
version.loaders.some((x: string) => tags.loaderData.dataPackLoaders.includes(x))
|
||||
"
|
||||
@click="modal_package_mod?.show()"
|
||||
>
|
||||
<BoxIcon aria-hidden="true" />
|
||||
Package as mod
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<template v-if="currentMember">
|
||||
<ButtonStyled v-if="version.files_missing_attribution?.length > 0" color="orange">
|
||||
<nuxt-link :to="`/project/${project.id}/settings/permissions`">
|
||||
Resolve missing permissions
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleOpenEditVersionModal(version.id, project.id, 'metadata')">
|
||||
<BoxIcon aria-hidden="true" />
|
||||
Edit metadata
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleOpenEditVersionModal(version.id, project.id, 'add-details')">
|
||||
<InfoIcon aria-hidden="true" />
|
||||
Edit details
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleOpenEditVersionModal(version.id, project.id, 'add-files')">
|
||||
<FileIcon aria-hidden="true" />
|
||||
Edit files
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
v-if="
|
||||
version.loaders.some((x: string) => tags.loaderData.dataPackLoaders.includes(x))
|
||||
"
|
||||
@click="modal_package_mod?.show()"
|
||||
>
|
||||
<BoxIcon aria-hidden="true" />
|
||||
Package as mod
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-page__changelog universal-card">
|
||||
|
||||
@@ -23,6 +23,10 @@ function copy(id: string) {
|
||||
navigator.clipboard?.writeText(`/_internal/templates/email/${id}`).catch(() => {})
|
||||
}
|
||||
|
||||
function uncachedPreviewUrl(id: string) {
|
||||
return `/_internal/templates/email/${id}?preview=${Date.now()}`
|
||||
}
|
||||
|
||||
const previewModal = ref<{ hide: () => void; show: () => void } | null>(null)
|
||||
const previewTemplate = ref<string | null>(null)
|
||||
const previewLoading = ref(false)
|
||||
@@ -73,7 +77,7 @@ async function openPreview(id: string, event?: MouseEvent) {
|
||||
variableValues.value = {}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/_internal/templates/email/${id}`)
|
||||
const response = await fetch(uncachedPreviewUrl(id), { cache: 'no-store' })
|
||||
previewHtml.value = await response.text()
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -103,7 +107,7 @@ function openPopupPreview(id: string, offset = 0) {
|
||||
const left = window.screenX + (window.outerWidth - width) / 2 + ((offset * 28) % 320)
|
||||
const top = window.screenY + (window.outerHeight - height) / 2 + ((offset * 28) % 320)
|
||||
window.open(
|
||||
`/_internal/templates/email/${id}`,
|
||||
uncachedPreviewUrl(id),
|
||||
`email-${id}`,
|
||||
`popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,menubar=no,toolbar=no,location=no,status=no`,
|
||||
)
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
:level="notice.level"
|
||||
:message="notice.message"
|
||||
:dismissable="notice.dismissable"
|
||||
:title="notice.title"
|
||||
:title="notice.title ?? undefined"
|
||||
preview
|
||||
/>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
@@ -260,6 +260,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { EditIcon, PlusIcon, SaveIcon, SettingsIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
@@ -267,6 +268,7 @@ import {
|
||||
commonMessages,
|
||||
CopyCode,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
ServerNotice,
|
||||
@@ -278,14 +280,13 @@ import {
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { NOTICE_LEVELS } from '@modrinth/ui/src/utils/notices.ts'
|
||||
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AssignNoticeModal from '~/components/ui/admin/AssignNoticeModal.vue'
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
@@ -297,6 +298,8 @@ const formatDateTimeShortMonth = useFormatDateTime({
|
||||
dateStyle: 'medium',
|
||||
})
|
||||
|
||||
type ServerNoticeType = Archon.Notices.v0.ListedNotice
|
||||
|
||||
const notices = ref<ServerNoticeType[]>([])
|
||||
const createNoticeModal = ref<InstanceType<typeof NewModal>>()
|
||||
const assignNoticeModal = ref<InstanceType<typeof AssignNoticeModal>>()
|
||||
@@ -304,16 +307,14 @@ const assignNoticeModal = ref<InstanceType<typeof AssignNoticeModal>>()
|
||||
await refreshNotices()
|
||||
|
||||
async function refreshNotices() {
|
||||
await useServersFetch('notices').then((res) => {
|
||||
notices.value = res as ServerNoticeType[]
|
||||
notices.value.sort((a, b) => {
|
||||
const dateDiff = dayjs(b.announce_at).diff(dayjs(a.announce_at))
|
||||
if (dateDiff === 0) {
|
||||
return b.id - a.id
|
||||
}
|
||||
notices.value = await client.archon.notices_v0.list()
|
||||
notices.value.sort((a, b) => {
|
||||
const dateDiff = dayjs(b.announce_at).diff(dayjs(a.announce_at))
|
||||
if (dateDiff === 0) {
|
||||
return b.id - a.id
|
||||
}
|
||||
|
||||
return dateDiff
|
||||
})
|
||||
return dateDiff
|
||||
})
|
||||
}
|
||||
|
||||
@@ -347,7 +348,7 @@ function startEditing(notice: ServerNoticeType, assignments: boolean = false) {
|
||||
newNoticeLevel.value = levelOptions.find((x) => x.id === notice.level) ?? levelOptions[0]
|
||||
newNoticeDismissable.value = notice.dismissable
|
||||
newNoticeMessage.value = notice.message
|
||||
newNoticeTitle.value = notice.title
|
||||
newNoticeTitle.value = notice.title ?? undefined
|
||||
newNoticeScheduledDate.value = dayjs(notice.announce_at).format(DATE_TIME_FORMAT)
|
||||
newNoticeExpiresDate.value = notice.expires
|
||||
? dayjs(notice.expires).format(DATE_TIME_FORMAT)
|
||||
@@ -361,9 +362,8 @@ function startEditing(notice: ServerNoticeType, assignments: boolean = false) {
|
||||
}
|
||||
|
||||
async function deleteNotice(notice: ServerNoticeType) {
|
||||
await useServersFetch(`notices/${notice.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await client.archon.notices_v0
|
||||
.delete(notice.id)
|
||||
.then(() => {
|
||||
addNotification({
|
||||
title: `Successfully deleted notice #${notice.id}`,
|
||||
@@ -412,9 +412,10 @@ async function saveChanges() {
|
||||
return
|
||||
}
|
||||
|
||||
await useServersFetch(`notices/${editingNotice.value?.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
if (!editingNotice.value) return
|
||||
|
||||
await client.archon.notices_v0
|
||||
.update(editingNotice.value.id, {
|
||||
message: newNoticeMessage.value,
|
||||
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
|
||||
level: newNoticeLevel.value.id,
|
||||
@@ -425,14 +426,14 @@ async function saveChanges() {
|
||||
expires: newNoticeExpiresDate.value
|
||||
? dayjs(newNoticeExpiresDate.value).toISOString()
|
||||
: undefined,
|
||||
},
|
||||
}).catch((err) => {
|
||||
addNotification({
|
||||
title: 'Error saving changes to notice',
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
addNotification({
|
||||
title: 'Error saving changes to notice',
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
await refreshNotices()
|
||||
createNoticeModal.value?.hide()
|
||||
}
|
||||
@@ -442,9 +443,8 @@ async function createNotice() {
|
||||
return
|
||||
}
|
||||
|
||||
await useServersFetch('notices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
await client.archon.notices_v0
|
||||
.create({
|
||||
message: newNoticeMessage.value,
|
||||
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
|
||||
level: newNoticeLevel.value.id,
|
||||
@@ -455,14 +455,14 @@ async function createNotice() {
|
||||
expires: newNoticeExpiresDate.value
|
||||
? dayjs(newNoticeExpiresDate.value).toISOString()
|
||||
: undefined,
|
||||
},
|
||||
}).catch((err) => {
|
||||
addNotification({
|
||||
title: 'Error creating notice',
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
addNotification({
|
||||
title: 'Error creating notice',
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
await refreshNotices()
|
||||
createNoticeModal.value?.hide()
|
||||
}
|
||||
|
||||
@@ -108,11 +108,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { PlusIcon, XCircleIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
ConfirmModal,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
Pagination,
|
||||
TagItem,
|
||||
@@ -124,38 +126,16 @@ import dayjs from 'dayjs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import TransferModal from '~/components/ui/admin/TransferModal.vue'
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
timeStyle: 'short',
|
||||
dateStyle: 'long',
|
||||
})
|
||||
|
||||
// Types
|
||||
interface ProvisionOptions {
|
||||
region?: string | null
|
||||
node_tags?: string[]
|
||||
}
|
||||
|
||||
interface TransferBatch {
|
||||
id: number
|
||||
created_by: string
|
||||
created_at: string
|
||||
reason: string | null
|
||||
scheduled_at: string
|
||||
cancelled: boolean
|
||||
log_count: number
|
||||
provision_options: ProvisionOptions
|
||||
}
|
||||
|
||||
interface HistoryResponse {
|
||||
batches: TransferBatch[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
type TransferBatch = Archon.Transfers.Internal.TransferLogBatchEntry
|
||||
|
||||
const transferModal = ref<InstanceType<typeof TransferModal>>()
|
||||
const cancelModal = ref<InstanceType<typeof ConfirmModal>>()
|
||||
@@ -178,10 +158,10 @@ async function refreshHistory() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await useServersFetch<HistoryResponse>(
|
||||
`/transfers/history?page=${currentPage.value}&page_size=${pageSize}`,
|
||||
{ version: 'internal' },
|
||||
)
|
||||
const data = await client.archon.transfers_internal.history({
|
||||
page: currentPage.value,
|
||||
page_size: pageSize,
|
||||
})
|
||||
batches.value = data.batches || []
|
||||
total.value = data.total || 0
|
||||
|
||||
@@ -283,12 +263,8 @@ function showCancelModal(batchId: number) {
|
||||
async function confirmCancel() {
|
||||
if (!cancellingBatchId.value) return
|
||||
try {
|
||||
await useServersFetch('/transfers/cancel', {
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: {
|
||||
batch_ids: [cancellingBatchId.value],
|
||||
},
|
||||
await client.archon.transfers_internal.cancel({
|
||||
batch_ids: [cancellingBatchId.value],
|
||||
})
|
||||
addNotification({
|
||||
title: 'Transfer cancelled',
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
}}
|
||||
<Tooltip
|
||||
theme="dismissable-prompt"
|
||||
class="inline-flex shrink-0"
|
||||
:triggers="['hover', 'focus']"
|
||||
no-auto-focus
|
||||
:aria-id="`${baseId}-date-segment-tooltip-${i}`"
|
||||
@@ -106,6 +107,7 @@
|
||||
{{ formatMessage(messages.processing) }}
|
||||
<Tooltip
|
||||
theme="dismissable-prompt"
|
||||
class="inline-flex shrink-0"
|
||||
:triggers="['hover', 'focus']"
|
||||
no-auto-focus
|
||||
:aria-id="`${baseId}-processing-tooltip`"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
:auth-user="authUser"
|
||||
:navigate-to-billing="() => router.push('/settings/billing')"
|
||||
:navigate-to-servers="() => router.push('/hosting/manage')"
|
||||
constrain-width
|
||||
:browse-modpacks="
|
||||
({ serverId: sid, worldId: wid, from }) => {
|
||||
navigateTo({
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
ServersManageAccessPage,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { server, serverId } = injectModrinthServerContext()
|
||||
const queryClient = useQueryClient()
|
||||
const flags = useFeatureFlags()
|
||||
const ACTION_LOG_PAGE_SIZE = 200
|
||||
const ACTION_LOG_SORT_DIRECTION = 'desc'
|
||||
const actionLogDateFilter = defaultActionLogDateFilter()
|
||||
|
||||
await Promise.allSettled([
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: ['servers', 'users', 'v1', serverId],
|
||||
queryFn: () => client.archon.server_users_v1.list(serverId),
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: ['servers', 'v1', 'detail', serverId],
|
||||
queryFn: () => client.archon.servers_v1.get(serverId),
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
queryClient.prefetchInfiniteQuery({
|
||||
queryKey: [
|
||||
'servers',
|
||||
'action-log',
|
||||
'v1',
|
||||
'infinite',
|
||||
serverId,
|
||||
null,
|
||||
actionLogDateFilter.min_datetime,
|
||||
actionLogDateFilter.max_datetime,
|
||||
ACTION_LOG_SORT_DIRECTION,
|
||||
],
|
||||
queryFn: ({ pageParam = 0 }) => {
|
||||
const offset = typeof pageParam === 'number' ? pageParam : 0
|
||||
return client.archon.actions_v1.list(serverId, {
|
||||
limit: ACTION_LOG_PAGE_SIZE,
|
||||
offset,
|
||||
order: ACTION_LOG_SORT_DIRECTION,
|
||||
...actionLogDateFilter,
|
||||
})
|
||||
},
|
||||
getNextPageParam: (lastPage) =>
|
||||
typeof lastPage.next_offset === 'number' ? lastPage.next_offset : undefined,
|
||||
initialPageParam: 0,
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
])
|
||||
|
||||
useHead({
|
||||
title: computed(() => `Access - ${server.value?.name ?? 'Server'} - Modrinth`),
|
||||
})
|
||||
|
||||
function defaultActionLogDateFilter() {
|
||||
const endDate = new Date()
|
||||
const startDate = new Date(endDate)
|
||||
startDate.setDate(startDate.getDate() - 6)
|
||||
|
||||
return {
|
||||
min_datetime: startOfDay(startDate).toISOString(),
|
||||
max_datetime: endOfDay(endDate).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
}
|
||||
|
||||
function endOfDay(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ServersManageAccessPage :show-audit-log-instances="flags.showHostingAccessInstanceAuditLog" />
|
||||
</template>
|
||||
@@ -185,7 +185,7 @@ function mapExternalProject(
|
||||
exceptions: project.exceptions,
|
||||
proof: project.proof,
|
||||
flame_project_id: project.flame_project_id,
|
||||
files: project.linked_files,
|
||||
files: project.linked_files ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -284,11 +284,11 @@ import {
|
||||
VersionIcon,
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { defineMessage } from '@modrinth/ui'
|
||||
import {
|
||||
AutoLink,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessage,
|
||||
defineMessages,
|
||||
formatReportItemType,
|
||||
injectNotificationManager,
|
||||
|
||||
@@ -27,10 +27,11 @@ export interface Cosmetics {
|
||||
export default defineNuxtPlugin({
|
||||
name: 'cosmetics',
|
||||
setup() {
|
||||
const config = useRuntimeConfig()
|
||||
const cosmetics = useCookie<Cosmetics>('cosmetics', {
|
||||
maxAge: 60 * 60 * 24 * 365 * 10,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
secure: config.public.cookieSecure,
|
||||
httpOnly: false,
|
||||
path: '/',
|
||||
default: () => ({
|
||||
|
||||
@@ -8,10 +8,11 @@ interface ThemeSettings {
|
||||
export function useThemeSettings(getDefaultTheme?: () => Theme) {
|
||||
getDefaultTheme ??= () => 'dark'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const $settings = useCookie<ThemeSettings>('color-mode', {
|
||||
maxAge: 60 * 60 * 24 * 365 * 10,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
secure: config.public.cookieSecure,
|
||||
httpOnly: false,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_licenses mel\n WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%')\n AND ($2::integer IS NULL OR mel.flame_project_id = $2)\n ORDER BY mel.id\n ",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_licenses mel\n WHERE mel.flame_project_id = ANY($1)\n ORDER BY mel.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -61,8 +61,7 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4"
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -79,5 +78,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57"
|
||||
"hash": "03d85f360d4c603688d0662d24caee7b59985adba006fb531beb09f195582277"
|
||||
}
|
||||
Generated
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select\n fa.file_id as \"file_id: DBFileId\",\n f.url,\n v.mod_id as \"project_id: DBProjectId\"\n from file_scans fa\n inner join files f on f.id = fa.file_id\n inner join versions v on v.id = f.version_id\n where fa.attributions_scanned_at is null\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "file_id: DBFileId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "url",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "project_id: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0b06b60b7169a3f9ee66c7ec26f94982619e95a78ecb2d3ffa55774b098e0531"
|
||||
}
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id\n WHERE mef.sha1 = $1\n ",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_licenses mel\n WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%')\n AND (\n ($2::integer IS NULL AND $3::integer[] IS NULL)\n OR mel.flame_project_id = $2\n OR mel.flame_project_id = ANY($3)\n )\n ORDER BY mel.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -61,7 +61,9 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea"
|
||||
"Text",
|
||||
"Int4",
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -78,5 +80,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4"
|
||||
"hash": "0e9e528a008a9dd24acfabbffcfe8ee4fd48fbae7c6197bfbbf1923e6256658b"
|
||||
}
|
||||
Generated
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select distinct f.version_id as \"version_id: DBVersionId\", f.id as \"file_id: DBFileId\",\n pag.flame_project\n from files f\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join override_file_sources ofs on ofs.file_id = f.id\n inner join project_attribution_files paf on paf.sha1 = ofs.sha1\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where f.version_id = ANY($1)\n and (\n pag.attribution is null\n or pag.attribution->>'kind' = 'no_permission'\n or coalesce(\n pag.attribution->'moderation_status'->>'kind',\n 'approved'\n ) != 'approved'\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "version_id: DBVersionId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "file_id: DBFileId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "148ef260d2db9d31004be8227fba5b18600ad700fd5741553a5db810d08ae382"
|
||||
}
|
||||
+12
-6
@@ -1,30 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type\n FROM dependencies d\n WHERE dependent_id = ANY($1)\n ",
|
||||
"query": "\n SELECT DISTINCT d.id as dependency_id, dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type\n FROM dependencies d\n WHERE dependent_id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "dependency_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "version_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"ordinal": 2,
|
||||
"name": "dependency_project_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"ordinal": 3,
|
||||
"name": "dependency_version_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"ordinal": 4,
|
||||
"name": "file_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"ordinal": 5,
|
||||
"name": "dependency_type",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
@@ -35,6 +40,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
@@ -42,5 +48,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3"
|
||||
"hash": "16e13baf35118cda943abf259a0e24cfe13436844b7909e6502ec15a249fc856"
|
||||
}
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)\n values ($1, $2, $3, $4)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1d27a83fb85c4640c3fc88fc6caa8209973ced0f88d8dbecdac349bbe70930a5"
|
||||
}
|
||||
Generated
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect\n\t\t\tg.id as \"id: DBAttributionGroupId\",\n\t\t\tg.flame_project,\n\t\t\tg.attribution,\n\t\t\tg.attributed_at,\n\t\t\tg.attributed_by as \"attributed_by: i64\"\n\t\tfrom project_attribution_groups g\n\t\twhere g.project_id = $1\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id: DBAttributionGroupId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "attribution",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "attributed_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "attributed_by: i64",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1d96df0ac64801641f9af796d482d2964c1acf2d03e15b8f1397340d0c994af6"
|
||||
}
|
||||
Generated
+88
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mef.sha1 hash,\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id\n WHERE mef.sha1 = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hash",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "25d6977fa2db63f979fbe391f3475445fa92c9bbb5b034531f5e033a18c2a6a3"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1)\n select $1, unnest($2::text[]), unnest($3::bytea[])\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"TextArray",
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2e6bb84f45c0f8ba7bb9b09bf3def4322f727c7af8bd4be49dc4d7c487b925ed"
|
||||
}
|
||||
Generated
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT mel.id, mel.flame_project_id, mel.status status, mel.link\n FROM moderation_external_licenses mel\n WHERE mel.flame_project_id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "2f56a06b78a810936a77547ab5890943d6aa3e9143a8e2617d025be960880223"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM project_attribution_groups g\n WHERE NOT EXISTS (\n SELECT 1\n FROM project_attribution_files paf\n INNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1\n WHERE paf.group_id = g.id\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "301959b1ec20a4925f86188bc5e1c0cdd11f0d15004ce9f91fbeefcc3a27f8f4"
|
||||
}
|
||||
Generated
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select id as \"id: DBAttributionGroupId\", flame_project\n from project_attribution_groups\n where project_id = $1 and flame_project is not null\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id: DBAttributionGroupId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "424b975139004d2854d9365ef950984dd1a65e333bb80924b5883df5e7d3cad2"
|
||||
}
|
||||
Generated
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tselect\n\t\t\t\tpaf.group_id as \"group_id!\",\n\t\t\t\tpaf.name as \"name!\",\n\t\t\t\tconvert_from(paf.sha1, 'UTF8') as \"sha1!\",\n\t\t\t\tpaf.moderation_external_license_id,\n\t\t\t\tcoalesce(array_agg(distinct aev.id) filter (where aev.id is not null), '{}') as \"version_ids!: Vec<i64>\"\n\t\t\tfrom project_attribution_files paf\n\t\t\tleft join override_file_sources ofs on ofs.sha1 = paf.sha1\n\t\t\tleft join files f on f.id = ofs.file_id\n\t\t\tleft join versions v on v.id = f.version_id and v.mod_id = $2\n\t\t\tleft join attribution_enforced_versions aev on aev.id = v.id\n\t\t\twhere paf.group_id = ANY($1)\n\t\t\tgroup by paf.group_id, paf.name, paf.sha1, paf.moderation_external_license_id\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "group_id!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "sha1!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "moderation_external_license_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "version_ids!: Vec<i64>",
|
||||
"type_info": "Int8Array"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "48a83bc9de9bea73eac5c43e71f43fb8a1ac7eb67079d16e3de92f5ff6bf72b8"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tupdate project_attribution_files\n\t\tset group_id = $1\n\t\twhere sha1 = $2\n\t\tand group_id in (\n\t\t\tselect id from project_attribution_groups where project_id = $3\n\t\t)\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4fb69c674dca723b6b2cb7bea07d51feb43b2714c1cd6a2987a6fe1f10e2579a"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tupdate project_attribution_files\n\t\tset group_id = $1\n\t\twhere sha1 = $2 and group_id = $3\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5f7b9d628d3ff0addc12cfaab68d7cbcca08a89aedeb15ce5926ed87ed8a12c6"
|
||||
}
|
||||
Generated
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id,\n title,\n status,\n link,\n exceptions,\n proof,\n flame_project_id,\n inserted_at,\n inserted_by,\n updated_at,\n updated_by\n FROM moderation_external_licenses\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5f847946ce63d3773b9a5822971ee7acaafeed018ce0540b2826bd878f213b87"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tinsert into project_attribution_groups (id, project_id)\n\t\tvalues ($1, $2)\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6320df0491556027ee64fd031797b252c2145ecf179e9ea9a8cedbdc8b7ed622"
|
||||
}
|
||||
Generated
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO file_scans (file_id)\n VALUES ($1)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "64c6abc464f2df37372875817ceb67b0e33786fe3dd27c052311356d43b1d601"
|
||||
}
|
||||
Generated
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect exists(\n\t\t\tselect 1 from project_attribution_groups where id = $1 and project_id = $2\n\t\t) as \"exists!\"\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "73f7542b4b6738c4baf1e2a3513645c3bf07a960b7b9fac529f23099675ec0cc"
|
||||
}
|
||||
Generated
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into file_scans (file_id, attributions_scanned_at)\n values ($1, now())\n on conflict (file_id) do update set attributions_scanned_at = now()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "80751dbca09a9dcb469a30fe5b8225e520baf50750a78875f582349610439a55"
|
||||
}
|
||||
Generated
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n 'issues', (\n SELECT json_agg(\n to_jsonb(dri)\n || jsonb_build_object(\n -- TODO: replace with `json_array` in Postgres 16\n 'details', (\n SELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n )\n )\n FROM delphi_report_issues dri\n WHERE\n dri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json<FileReport>\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "data!: sqlx::types::Json<FileReport>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8820a5985291c159c98371c9650092e3eba21c81e3b3386be779978aff30451a"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM project_attribution_groups WHERE id=$1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8ffcb0c2bd82eaf64b143745adab359db6fcc448d6306292a7b345277a075883"
|
||||
}
|
||||
Generated
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\t\tselect\n\t\t\t\t\tid,\n\t\t\t\t\ttitle,\n\t\t\t\t\tstatus,\n\t\t\t\t\tlink,\n\t\t\t\t\texceptions,\n\t\t\t\t\tproof,\n\t\t\t\t\tflame_project_id,\n\t\t\t\t\tinserted_at,\n\t\t\t\t\tinserted_by,\n\t\t\t\t\tupdated_at,\n\t\t\t\t\tupdated_by\n\t\t\t\tfrom moderation_external_licenses\n\t\t\t\twhere id = ANY($1)\n\t\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "944286604bdef4ee40f79af358d1b68cc93bfd2067619d9cc1d03013f73e5424"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tupdate project_attribution_groups\n\t\tset attribution = $1, attributed_at = now(), attributed_by = $3\n\t\twhere id = $2\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "94920d66b27503c84744d41cb2b44dd213bb461596046488eaab0b62b0fe83c5"
|
||||
}
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)\n values ($1, $2, $3, $4)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "95750cd616b72347fb997c3b02a3d69f13be5993c1fd2e302761089c499fb015"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n update file_scans\n set attributions_scanned_at = now\n from unnest($1::bigint[], $2::timestamptz[]) as u(id, now)\n where file_scans.file_id = u.id\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array",
|
||||
"TimestamptzArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "968904f577c2c696c6222e19cc145bce0e845f2ab9f8629b0789a4861bddb4dc"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_groups (id, project_id)\n values ($1, $2)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b471add519874a6364b7a9cdbfa3a8e6f5a8a1ac9a7bacbce378f7995eb48664"
|
||||
}
|
||||
Generated
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect paf.group_id\n\t\tfrom project_attribution_files paf\n\t\tinner join project_attribution_groups pag on pag.id = paf.group_id\n\t\twhere paf.sha1 = $1 and pag.project_id = $2\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "group_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b782ed64e0df1e83623c8a8e55c00716f5338c85b6dfbb15c3465ff0cec339a7"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect attribution\n\t\tfrom project_attribution_groups\n\t\twhere id = $1\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "attribution",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bb7c239e2b424557f260c56c01e15ab8b0ec04d76a55a50888d28e6c5d3948bc"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1)\n values ($1, $2, $3)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c39e1f0d820101ac1b19a3849254b03259f38f3de9ddca32e6189a7e015dd853"
|
||||
}
|
||||
Generated
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect paf.group_id, paf.name from project_attribution_files paf\n\t\tinner join project_attribution_groups pag on pag.id = paf.group_id\n\t\twhere paf.sha1 = $1 and pag.project_id = $2\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "group_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa"
|
||||
}
|
||||
Generated
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tselect id, name, version_number, date_published\n\t\t\tfrom versions\n\t\t\twhere id = ANY($1)\n\t\t\torder by date_published desc\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "version_number",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "date_published",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9"
|
||||
}
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_groups (id, project_id, attribution, flame_project)\n values ($1, $2, $3, $4)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375"
|
||||
}
|
||||
Generated
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select paf.sha1 from project_attribution_files paf\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where pag.project_id = $1 and paf.sha1 = ANY($2)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "sha1",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e4b58fe6e5d19ded48ab9e457b4e96fb51c8f74a7532bb9ab2e7a945b95bbe78"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into override_file_sources (sha1, file_id)\n select unnest($1::bytea[]), $2\n on conflict do nothing\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tdelete from project_attribution_groups g\n\t\twhere not exists (\n\t\t\tselect 1 from project_attribution_files f where f.group_id = g.id\n\t\t)\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ed027c5571c34ff4fd1ac50e96f257b0055c9497d2699927b34829490e7a2529"
|
||||
}
|
||||
Generated
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select\n d.id as \"dependency_id!\",\n pag.attribution,\n pag.flame_project,\n pag.project_id as \"project_id: DBProjectId\"\n from dependencies d\n inner join files f on f.version_id = d.dependent_id\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join override_file_sources ofs on ofs.file_id = f.id\n inner join project_attribution_files paf on paf.sha1 = ofs.sha1\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where d.dependent_id = ANY($1)\n and d.dependency_file_name is not null\n and (\n pag.flame_project is not null\n or pag.attribution is not null\n )\n and split_part(paf.name, '/', -1) = d.dependency_file_name\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "dependency_id!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "attribution",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "project_id: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f453824f96385cccb703848189f25076fd9c1578e417d899d959a19dd9f940c3"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_groups (id, project_id, flame_project)\n values ($1, $2, $3)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f9131eb55490b96851ac3760e3199d2fd2dbb3d212cb2b8687dda8ba0fad2a80"
|
||||
}
|
||||
Generated
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT encode(mef.sha1, 'escape') sha1, mel.id, mel.status status, mel.link\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id\n WHERE mef.sha1 = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "sha1",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f9b96c70c83f1bf0112243602843abae6ddc8851fc623ccf0a23f87567763485"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t'issues', (\n\t\t\t\t\tSELECT coalesce(json_agg(\n\t\t\t\t\t\tto_jsonb(dri)\n\t\t\t\t\t\t|| jsonb_build_object(\n\t\t\t\t\t\t\t-- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t\t\t\t'details', (\n\t\t\t\t\t\t\t\tSELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n\t\t\t\t\t\t)\n\t\t\t\t\t), '[]'::json)\n\t\t\t\t\tFROM delphi_report_issues dri\n\t\t\t\t\tWHERE\n\t\t\t\t\t\tdri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json<FileReport>\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "data!: sqlx::types::Json<FileReport>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Labrinth
|
||||
|
||||
Labrinth is the backend API service for Modrinth, written in Rust.
|
||||
|
||||
## Code style
|
||||
|
||||
- When writing `sqlx` queries, NEVER use `query` directly. Always prefer using the `query!`, `query_as!`, `query_scalar!` macros.
|
||||
|
||||
## Pre-PR Checks
|
||||
|
||||
When the user refers to "perform[ing] pre-PR checks", do the following:
|
||||
|
||||
- Run `cargo clippy -p labrinth --all-targets` — there must be ZERO warnings, otherwise CI will fail
|
||||
- DO NOT run tests unless explicitly requested (they take a long time)
|
||||
- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare -- --tests`
|
||||
- NEVER run `cargo sqlx prepare --workspace`
|
||||
|
||||
## Testing
|
||||
|
||||
- Run `cargo test -p labrinth --all-targets` to test your changes — all tests must pass
|
||||
|
||||
## Local Services
|
||||
|
||||
- Read the root `docker-compose.yml` to see what running services are available while developing
|
||||
- Use `docker exec` to access these services
|
||||
|
||||
### Clickhouse
|
||||
|
||||
- Access: `docker exec labrinth-clickhouse clickhouse-client`
|
||||
- Database: `staging_ariadne`
|
||||
|
||||
### Postgres
|
||||
|
||||
- Access: `docker exec labrinth-postgres psql -U labrinth -d labrinth -c "<query>"`
|
||||
@@ -0,0 +1,33 @@
|
||||
create table file_scans (
|
||||
file_id bigint primary key references files(id),
|
||||
-- if a file..
|
||||
-- - does not have a row
|
||||
-- -> was created before attributions system
|
||||
-- - has a row, but `attributions_scanned_at = null`
|
||||
-- -> still needs to be scanned
|
||||
-- - has a row, and `attributions_scanned_at` is not null
|
||||
-- -> attributions have been scanned
|
||||
attributions_scanned_at timestamptz
|
||||
);
|
||||
|
||||
create table project_attribution_groups (
|
||||
id bigint primary key,
|
||||
project_id bigint not null references mods(id),
|
||||
flame_project jsonb,
|
||||
attribution jsonb,
|
||||
attributed_at timestamptz,
|
||||
attributed_by bigint references users(id)
|
||||
);
|
||||
create index on project_attribution_groups (project_id);
|
||||
|
||||
create table project_attribution_files (
|
||||
group_id bigint not null references project_attribution_groups(id),
|
||||
name text not null,
|
||||
sha1 bytea not null
|
||||
);
|
||||
|
||||
create table override_file_sources (
|
||||
sha1 bytea not null,
|
||||
file_id bigint not null references files(id),
|
||||
primary key (sha1, file_id)
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
alter table file_scans
|
||||
drop constraint file_scans_file_id_fkey,
|
||||
add constraint file_scans_file_id_fkey
|
||||
foreign key (file_id) references files(id) on delete cascade;
|
||||
|
||||
alter table project_attribution_groups
|
||||
drop constraint project_attribution_groups_project_id_fkey,
|
||||
add constraint project_attribution_groups_project_id_fkey
|
||||
foreign key (project_id) references mods(id) on delete cascade;
|
||||
|
||||
alter table project_attribution_files
|
||||
drop constraint project_attribution_files_group_id_fkey,
|
||||
add constraint project_attribution_files_group_id_fkey
|
||||
foreign key (group_id) references project_attribution_groups(id) on delete cascade;
|
||||
|
||||
alter table override_file_sources
|
||||
drop constraint override_file_sources_file_id_fkey,
|
||||
add constraint override_file_sources_file_id_fkey
|
||||
foreign key (file_id) references files(id) on delete cascade;
|
||||
@@ -0,0 +1,20 @@
|
||||
alter table project_attribution_files
|
||||
add column moderation_external_license_id bigint references moderation_external_licenses(id);
|
||||
|
||||
create table version_attribution_exemptions (
|
||||
version_id bigint primary key references versions(id) on delete cascade
|
||||
);
|
||||
|
||||
create view attribution_enforced_versions as
|
||||
select v.id
|
||||
from versions v
|
||||
left join version_attribution_exemptions vae on vae.version_id = v.id
|
||||
where vae.version_id is null;
|
||||
|
||||
-- grandfathering migration:
|
||||
-- insert into version_attribution_exemptions (version_id)
|
||||
-- select v.id
|
||||
-- from versions v
|
||||
-- inner join mods m on m.id = v.mod_id
|
||||
-- where m.status in ('approved', 'unlisted', 'archived', 'private', 'scheduled', 'withheld')
|
||||
-- on conflict do nothing;
|
||||
@@ -5,11 +5,39 @@ use crate::database::models::version_item::VersionQueryResult;
|
||||
use crate::database::models::{DBCollection, DBOrganization, DBTeamMember};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::database::{DBProject, DBVersion, models};
|
||||
use crate::models::ids::FileId;
|
||||
use crate::models::projects::{
|
||||
MissingAttributionFile, OverrideSource, Version,
|
||||
};
|
||||
use crate::models::users::User;
|
||||
use crate::queue::file_scan::{
|
||||
get_dependency_attributions, get_files_missing_attribution,
|
||||
};
|
||||
use crate::routes::ApiError;
|
||||
use futures::TryStreamExt;
|
||||
use itertools::Itertools;
|
||||
|
||||
pub async fn enrich_dependency_attributions(
|
||||
versions: &mut [VersionQueryResult],
|
||||
pool: &PgPool,
|
||||
) {
|
||||
let version_ids = versions.iter().map(|v| v.inner.id).collect::<Vec<_>>();
|
||||
let dep_attr = get_dependency_attributions(pool, &version_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
for version in versions {
|
||||
for dep in &mut version.dependencies {
|
||||
if let Some(attr) = dep_attr.get(&dep.id)
|
||||
&& (attr.attribution.flame_project.is_some()
|
||||
|| attr.attribution.resolution.is_some())
|
||||
{
|
||||
dep.attribution = Some(attr.attribution.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ValidateAuthorized {
|
||||
fn validate_authorized(
|
||||
&self,
|
||||
@@ -204,7 +232,42 @@ pub async fn filter_visible_versions(
|
||||
)
|
||||
.await?;
|
||||
versions.retain(|x| filtered_version_ids.contains(&x.inner.id));
|
||||
Ok(versions.into_iter().map(|x| x.into()).collect())
|
||||
|
||||
let version_ids: Vec<_> = versions.iter().map(|v| v.inner.id).collect();
|
||||
let missing = get_files_missing_attribution(pool, &version_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
enrich_dependency_attributions(&mut versions, pool).await;
|
||||
|
||||
Ok(versions
|
||||
.into_iter()
|
||||
.map(|v| {
|
||||
let files_missing = missing
|
||||
.get(&v.inner.id)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(id, fp)| MissingAttributionFile {
|
||||
id: FileId(id.0 as u64),
|
||||
override_source: fp
|
||||
.as_ref()
|
||||
.map(|p| OverrideSource::Flame {
|
||||
id: p.id,
|
||||
title: p.title.clone(),
|
||||
url: p.url.clone(),
|
||||
icon_url: p.icon_url.clone(),
|
||||
})
|
||||
.or(Some(OverrideSource::Unknown)),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut version = Version::from(v);
|
||||
version.files_missing_attribution = files_missing;
|
||||
version
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
impl ValidateAuthorized for models::DBOAuthClient {
|
||||
@@ -258,13 +321,20 @@ pub async fn filter_visible_version_ids(
|
||||
filter_enlisted_version_ids(versions.clone(), user_option, pool, redis)
|
||||
.await?;
|
||||
|
||||
let version_ids: Vec<_> = versions.iter().map(|v| v.id).collect();
|
||||
let withheld_versions = get_files_missing_attribution(pool, &version_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Return versions that are not hidden, we are a mod of, or we are enlisted on the team of
|
||||
for version in versions {
|
||||
let is_withheld = withheld_versions.contains_key(&version.id);
|
||||
// We can see the version if:
|
||||
// - it's not hidden and we can see the project
|
||||
// - it's not hidden and we can see the project and it's not withheld for attribution
|
||||
// - we are a mod
|
||||
// - we are enlisted on the team of the mod
|
||||
if (!version.status.is_hidden()
|
||||
&& !is_withheld
|
||||
&& visible_project_ids.contains(&version.project_id))
|
||||
|| user_option.as_ref().is_some_and(|x| x.role.is_mod())
|
||||
|| enlisted_version_ids.contains(&version.id)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use crate::database;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::queue::analytics::cache::cache_analytics;
|
||||
use crate::queue::billing::{index_billing, index_subscriptions};
|
||||
use crate::queue::email::EmailQueue;
|
||||
use crate::queue::file_scan::scan_all_files;
|
||||
use crate::queue::payouts::{
|
||||
PayoutsQueue, index_payouts_notifications,
|
||||
insert_bank_balances_and_webhook, process_affiliate_payouts,
|
||||
@@ -34,6 +36,10 @@ 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,
|
||||
/// Finds files of versions which have not been scanned for attributions
|
||||
/// yet, extracts them to find file overrides, and finds any overrides which
|
||||
/// require attribution from the creator.
|
||||
ScanFiles,
|
||||
}
|
||||
|
||||
impl BackgroundTask {
|
||||
@@ -44,6 +50,7 @@ impl BackgroundTask {
|
||||
ro_pool: PgPool,
|
||||
redis_pool: RedisPool,
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
clickhouse: clickhouse::Client,
|
||||
stripe_client: stripe::Client,
|
||||
anrok_client: anrok::Client,
|
||||
@@ -90,6 +97,7 @@ impl BackgroundTask {
|
||||
PingMinecraftJavaServers => {
|
||||
ping_minecraft_java_servers(pool, redis_pool, clickhouse).await
|
||||
}
|
||||
ScanFiles => scan_all_files(&pool, &redis_pool, &**file_host).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use super::DatabaseError;
|
||||
use crate::database::PgTransaction;
|
||||
use crate::models::ids::{
|
||||
AffiliateCodeId, AnalyticsEventId, CampaignDonationId, ChargeId,
|
||||
CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId,
|
||||
OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId,
|
||||
OrganizationId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId,
|
||||
ReportId, SessionId, SharedInstanceId, SharedInstanceVersionId, TeamId,
|
||||
TeamMemberId, ThreadId, ThreadMessageId, UserSubscriptionId, VersionId,
|
||||
AffiliateCodeId, AnalyticsEventId, AttributionGroupId, CampaignDonationId,
|
||||
ChargeId, CollectionId, FileId, ImageId, NotificationId,
|
||||
OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId,
|
||||
OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId,
|
||||
ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId,
|
||||
SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId, ThreadMessageId,
|
||||
UserSubscriptionId, VersionId,
|
||||
};
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range};
|
||||
@@ -172,6 +173,10 @@ db_id_interface!(
|
||||
CollectionId,
|
||||
generator: generate_collection_id @ "collections",
|
||||
);
|
||||
db_id_interface!(
|
||||
AttributionGroupId,
|
||||
generator: generate_attribution_group_id @ "project_attribution_groups",
|
||||
);
|
||||
db_id_interface!(
|
||||
FileId,
|
||||
generator: generate_file_id @ "files",
|
||||
|
||||
@@ -204,10 +204,11 @@ impl NotificationBuilder {
|
||||
users: Vec<DBUserId>,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
redis: &RedisPool,
|
||||
) -> Result<(), DatabaseError> {
|
||||
self.insert_many_records(&users, transaction).await?;
|
||||
) -> Result<Vec<DBNotificationId>, DatabaseError> {
|
||||
let notification_ids =
|
||||
self.insert_many_records(&users, transaction).await?;
|
||||
DBNotification::clear_user_notifications_cache(&users, redis).await?;
|
||||
Ok(())
|
||||
Ok(notification_ids)
|
||||
}
|
||||
|
||||
pub async fn insert_many_deliveries(
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{DBUser, ids::*};
|
||||
use crate::database::models::DatabaseError;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::database::{PgTransaction, models};
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::exp;
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::models::projects::{
|
||||
@@ -187,6 +188,8 @@ impl ProjectBuilder {
|
||||
pub async fn insert(
|
||||
self,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
redis: &RedisPool,
|
||||
file_host: &dyn FileHost,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<DBProjectId, DatabaseError> {
|
||||
let project_struct = DBProject {
|
||||
@@ -235,7 +238,7 @@ impl ProjectBuilder {
|
||||
|
||||
for mut version in self.initial_versions {
|
||||
version.project_id = self.project_id;
|
||||
version.insert(&mut *transaction, http).await?;
|
||||
version.insert(transaction, redis, file_host, http).await?;
|
||||
}
|
||||
|
||||
LinkUrl::insert_many_projects(
|
||||
|
||||
@@ -6,8 +6,11 @@ use crate::database::models::loader_fields::{
|
||||
QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::exp;
|
||||
|
||||
use crate::models::projects::{FileType, VersionStatus};
|
||||
use crate::queue::file_scan::scan_file;
|
||||
use crate::routes::internal::delphi::DelphiRunParameters;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::{DashMap, DashSet};
|
||||
@@ -17,10 +20,31 @@ use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::iter;
|
||||
use tracing::error;
|
||||
|
||||
pub const VERSIONS_NAMESPACE: &str = "versions";
|
||||
const VERSION_FILES_NAMESPACE: &str = "versions_files";
|
||||
|
||||
pub async fn cleanup_empty_attribution_groups(
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM project_attribution_groups g
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM project_attribution_files paf
|
||||
INNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1
|
||||
WHERE paf.group_id = g.id
|
||||
)
|
||||
",
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VersionBuilder {
|
||||
pub version_id: DBVersionId,
|
||||
@@ -134,7 +158,10 @@ impl VersionFileBuilder {
|
||||
pub async fn insert(
|
||||
self,
|
||||
version_id: DBVersionId,
|
||||
project_id: DBProjectId,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
redis: &RedisPool,
|
||||
file_host: &dyn FileHost,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<DBFileId, DatabaseError> {
|
||||
let file_id = generate_file_id(&mut *transaction).await?;
|
||||
@@ -169,6 +196,16 @@ impl VersionFileBuilder {
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO file_scans (file_id)
|
||||
VALUES ($1)
|
||||
",
|
||||
file_id as DBFileId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if let Err(err) = crate::routes::internal::delphi::run(
|
||||
&mut *transaction,
|
||||
DelphiRunParameters {
|
||||
@@ -178,7 +215,20 @@ impl VersionFileBuilder {
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error submitting new file to Delphi: {err}");
|
||||
error!("Error submitting new file to Delphi: {err:?}");
|
||||
}
|
||||
|
||||
if let Err(err) = scan_file(
|
||||
&mut *transaction,
|
||||
redis,
|
||||
file_host,
|
||||
project_id,
|
||||
file_id,
|
||||
&self.url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Error scanning new file {file_id:?}: {err:?}");
|
||||
}
|
||||
|
||||
Ok(file_id)
|
||||
@@ -195,6 +245,8 @@ impl VersionBuilder {
|
||||
pub async fn insert(
|
||||
self,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
redis: &RedisPool,
|
||||
file_host: &dyn FileHost,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<DBVersionId, DatabaseError> {
|
||||
let version = DBVersion {
|
||||
@@ -236,7 +288,15 @@ impl VersionBuilder {
|
||||
} = self;
|
||||
|
||||
for file in files {
|
||||
file.insert(version_id, transaction, http).await?;
|
||||
file.insert(
|
||||
version_id,
|
||||
self.project_id,
|
||||
transaction,
|
||||
redis,
|
||||
file_host,
|
||||
http,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
DependencyBuilder::insert_many(
|
||||
@@ -426,6 +486,8 @@ impl DBVersion {
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
cleanup_empty_attribution_groups(transaction).await?;
|
||||
|
||||
// Sync dependencies
|
||||
|
||||
let project_id = sqlx::query!(
|
||||
@@ -716,7 +778,7 @@ impl DBVersion {
|
||||
|
||||
let dependencies : DashMap<DBVersionId, Vec<DependencyQueryResult>> = sqlx::query!(
|
||||
"
|
||||
SELECT DISTINCT dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type
|
||||
SELECT DISTINCT d.id as dependency_id, dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type
|
||||
FROM dependencies d
|
||||
WHERE dependent_id = ANY($1)
|
||||
",
|
||||
@@ -724,10 +786,12 @@ impl DBVersion {
|
||||
).fetch(&mut exec)
|
||||
.try_fold(DashMap::new(), |acc : DashMap<_,Vec<DependencyQueryResult>>, m| {
|
||||
let dependency = DependencyQueryResult {
|
||||
id: m.dependency_id,
|
||||
project_id: m.dependency_project_id.map(DBProjectId),
|
||||
version_id: m.dependency_version_id.map(DBVersionId),
|
||||
file_name: m.file_name,
|
||||
dependency_type: m.dependency_type,
|
||||
attribution: None,
|
||||
};
|
||||
|
||||
acc.entry(DBVersionId(m.version_id))
|
||||
@@ -862,14 +926,14 @@ impl DBVersion {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_files_from_hash<'a, 'b, E>(
|
||||
pub async fn get_files_from_hash<'a, E>(
|
||||
algorithm: String,
|
||||
hashes: &[String],
|
||||
executor: E,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Vec<DBFile>, DatabaseError>
|
||||
where
|
||||
E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let val = redis.get_cached_keys(
|
||||
VERSION_FILES_NAMESPACE,
|
||||
@@ -977,10 +1041,12 @@ pub struct VersionQueryResult {
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct DependencyQueryResult {
|
||||
pub id: i32,
|
||||
pub project_id: Option<DBProjectId>,
|
||||
pub version_id: Option<DBVersionId>,
|
||||
pub file_name: Option<String>,
|
||||
pub dependency_type: String,
|
||||
pub attribution: Option<crate::models::projects::DependencyAttribution>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
|
||||
@@ -29,9 +29,7 @@ impl FileHost for MockHost {
|
||||
file_publicity: FileHostPublicity,
|
||||
file_bytes: Bytes,
|
||||
) -> Result<UploadFileData, FileHostingError> {
|
||||
let file_name = urlencoding::decode(file_name)
|
||||
.map_err(|_| FileHostingError::InvalidFilename)?;
|
||||
let path = get_file_path(&file_name, file_publicity);
|
||||
let path = get_file_path(file_name, file_publicity);
|
||||
std::fs::create_dir_all(
|
||||
path.parent().ok_or(FileHostingError::InvalidFilename)?,
|
||||
)?;
|
||||
@@ -72,6 +70,16 @@ impl FileHost for MockHost {
|
||||
file_name: file_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_file(
|
||||
&self,
|
||||
file_name: &str,
|
||||
file_publicity: FileHostPublicity,
|
||||
) -> Result<Bytes, FileHostingError> {
|
||||
let path = get_file_path(file_name, file_publicity);
|
||||
let data = std::fs::read(&path)?;
|
||||
Ok(Bytes::from(data))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_file_path(
|
||||
|
||||
@@ -45,7 +45,11 @@ pub enum FileHostPublicity {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait FileHost {
|
||||
pub trait FileHost: Send + Sync {
|
||||
/// Uploads a file at the exact storage key provided.
|
||||
///
|
||||
/// Callers must URL-decode keys derived from public URLs before passing
|
||||
/// them here, and URL-encode this key before exposing it in a public URL.
|
||||
async fn upload_file(
|
||||
&self,
|
||||
content_type: &str,
|
||||
@@ -54,17 +58,35 @@ pub trait FileHost {
|
||||
file_bytes: Bytes,
|
||||
) -> Result<UploadFileData, FileHostingError>;
|
||||
|
||||
/// Returns a private URL for the exact storage key provided.
|
||||
///
|
||||
/// Callers must URL-decode keys derived from public URLs before passing
|
||||
/// them here.
|
||||
async fn get_url_for_private_file(
|
||||
&self,
|
||||
file_name: &str,
|
||||
expiry_secs: u32,
|
||||
) -> Result<String, FileHostingError>;
|
||||
|
||||
/// Deletes the file at the exact storage key provided.
|
||||
///
|
||||
/// Callers must URL-decode keys derived from public URLs before passing
|
||||
/// them here.
|
||||
async fn delete_file(
|
||||
&self,
|
||||
file_name: &str,
|
||||
file_publicity: FileHostPublicity,
|
||||
) -> Result<DeleteFileData, FileHostingError>;
|
||||
|
||||
/// Reads the file at the exact storage key provided.
|
||||
///
|
||||
/// Callers must URL-decode keys derived from public URLs before passing
|
||||
/// them here.
|
||||
async fn read_file(
|
||||
&self,
|
||||
file_name: &str,
|
||||
file_publicity: FileHostPublicity,
|
||||
) -> Result<Bytes, FileHostingError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
|
||||
@@ -169,4 +169,28 @@ impl FileHost for S3Host {
|
||||
file_name: file_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_file(
|
||||
&self,
|
||||
file_name: &str,
|
||||
file_publicity: FileHostPublicity,
|
||||
) -> Result<Bytes, FileHostingError> {
|
||||
let bucket = self.get_bucket(file_publicity);
|
||||
|
||||
let response = bucket
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(bucket.name.as_str())
|
||||
.key(file_name)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| s3_error("reading file", e))?;
|
||||
|
||||
Ok(response
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| s3_error("reading file body", e))?
|
||||
.into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ pub struct LabrinthConfig {
|
||||
pub ro_pool: ReadOnlyPgPool,
|
||||
pub redis_pool: RedisPool,
|
||||
pub clickhouse: Client,
|
||||
pub file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
|
||||
pub file_host: web::Data<dyn file_hosting::FileHost>,
|
||||
pub scheduler: Arc<scheduler::Scheduler>,
|
||||
pub ip_salt: Pepper,
|
||||
pub search_backend: web::Data<dyn search::SearchBackend>,
|
||||
@@ -84,7 +84,7 @@ pub fn app_setup(
|
||||
redis_pool: RedisPool,
|
||||
search_backend: actix_web::web::Data<dyn search::SearchBackend>,
|
||||
clickhouse: &mut Client,
|
||||
file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
|
||||
file_host: web::Data<dyn file_hosting::FileHost>,
|
||||
stripe_client: stripe::Client,
|
||||
anrok_client: anrok::Client,
|
||||
email_queue: EmailQueue,
|
||||
@@ -344,7 +344,7 @@ pub fn app_config(
|
||||
.app_data(web::Data::new(labrinth_config.redis_pool.clone()))
|
||||
.app_data(web::Data::new(labrinth_config.pool.clone()))
|
||||
.app_data(web::Data::new(labrinth_config.ro_pool.clone()))
|
||||
.app_data(web::Data::new(labrinth_config.file_host.clone()))
|
||||
.app_data(labrinth_config.file_host.clone())
|
||||
.app_data(labrinth_config.search_backend.clone())
|
||||
.app_data(web::Data::new(labrinth_config.gotenberg_client.clone()))
|
||||
.app_data(labrinth_config.http_client.clone())
|
||||
|
||||
+33
-38
@@ -2,14 +2,14 @@
|
||||
|
||||
use actix_web::dev::Service;
|
||||
use actix_web::middleware::from_fn;
|
||||
use actix_web::{App, HttpServer};
|
||||
use actix_web::{App, HttpServer, web};
|
||||
use actix_web_prom::PrometheusMetricsBuilder;
|
||||
use clap::Parser;
|
||||
|
||||
use labrinth::background_task::BackgroundTask;
|
||||
use labrinth::database::redis::RedisPool;
|
||||
use labrinth::env::ENV;
|
||||
use labrinth::file_hosting::{FileHostKind, S3BucketConfig, S3Host};
|
||||
use labrinth::file_hosting::{FileHost, FileHostKind, S3BucketConfig, S3Host};
|
||||
use labrinth::queue::email::EmailQueue;
|
||||
use labrinth::search;
|
||||
use labrinth::util::anrok;
|
||||
@@ -111,44 +111,38 @@ async fn app() -> std::io::Result<()> {
|
||||
let redis_pool = RedisPool::new("");
|
||||
|
||||
let storage_backend = ENV.STORAGE_BACKEND;
|
||||
let file_host: Arc<dyn file_hosting::FileHost + Send + Sync> =
|
||||
match storage_backend {
|
||||
FileHostKind::S3 => {
|
||||
let not_empty = |v: &str| -> String {
|
||||
assert!(!v.is_empty(), "S3 env var is empty");
|
||||
v.to_string()
|
||||
};
|
||||
let file_host: Arc<dyn FileHost> = match storage_backend {
|
||||
FileHostKind::S3 => {
|
||||
let not_empty = |v: &str| -> String {
|
||||
assert!(!v.is_empty(), "S3 env var is empty");
|
||||
v.to_string()
|
||||
};
|
||||
|
||||
Arc::new(
|
||||
S3Host::new(
|
||||
S3BucketConfig {
|
||||
name: not_empty(&ENV.S3_PUBLIC_BUCKET_NAME),
|
||||
uses_path_style: ENV
|
||||
.S3_PUBLIC_USES_PATH_STYLE_BUCKET,
|
||||
region: not_empty(&ENV.S3_PUBLIC_REGION),
|
||||
url: not_empty(&ENV.S3_PUBLIC_URL),
|
||||
access_token: not_empty(
|
||||
&ENV.S3_PUBLIC_ACCESS_TOKEN,
|
||||
),
|
||||
secret: not_empty(&ENV.S3_PUBLIC_SECRET),
|
||||
},
|
||||
S3BucketConfig {
|
||||
name: not_empty(&ENV.S3_PRIVATE_BUCKET_NAME),
|
||||
uses_path_style: ENV
|
||||
.S3_PRIVATE_USES_PATH_STYLE_BUCKET,
|
||||
region: not_empty(&ENV.S3_PRIVATE_REGION),
|
||||
url: not_empty(&ENV.S3_PRIVATE_URL),
|
||||
access_token: not_empty(
|
||||
&ENV.S3_PRIVATE_ACCESS_TOKEN,
|
||||
),
|
||||
secret: not_empty(&ENV.S3_PRIVATE_SECRET),
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
Arc::new(
|
||||
S3Host::new(
|
||||
S3BucketConfig {
|
||||
name: not_empty(&ENV.S3_PUBLIC_BUCKET_NAME),
|
||||
uses_path_style: ENV.S3_PUBLIC_USES_PATH_STYLE_BUCKET,
|
||||
region: not_empty(&ENV.S3_PUBLIC_REGION),
|
||||
url: not_empty(&ENV.S3_PUBLIC_URL),
|
||||
access_token: not_empty(&ENV.S3_PUBLIC_ACCESS_TOKEN),
|
||||
secret: not_empty(&ENV.S3_PUBLIC_SECRET),
|
||||
},
|
||||
S3BucketConfig {
|
||||
name: not_empty(&ENV.S3_PRIVATE_BUCKET_NAME),
|
||||
uses_path_style: ENV.S3_PRIVATE_USES_PATH_STYLE_BUCKET,
|
||||
region: not_empty(&ENV.S3_PRIVATE_REGION),
|
||||
url: not_empty(&ENV.S3_PRIVATE_URL),
|
||||
access_token: not_empty(&ENV.S3_PRIVATE_ACCESS_TOKEN),
|
||||
secret: not_empty(&ENV.S3_PRIVATE_SECRET),
|
||||
},
|
||||
)
|
||||
}
|
||||
FileHostKind::Local => Arc::new(file_hosting::MockHost::new()),
|
||||
};
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
FileHostKind::Local => Arc::new(file_hosting::MockHost::new()),
|
||||
};
|
||||
let file_host = web::Data::<dyn FileHost>::from(file_host);
|
||||
|
||||
info!("Initializing clickhouse connection");
|
||||
let mut clickhouse = clickhouse::init_client().await.unwrap();
|
||||
@@ -174,6 +168,7 @@ async fn app() -> std::io::Result<()> {
|
||||
ro_pool.into_inner(),
|
||||
redis_pool,
|
||||
search_backend,
|
||||
file_host,
|
||||
clickhouse,
|
||||
stripe_client,
|
||||
anrok_client.clone(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use ariadne::ids::base62_id;
|
||||
|
||||
base62_id!(AttributionGroupId);
|
||||
base62_id!(ChargeId);
|
||||
base62_id!(CampaignDonationId);
|
||||
base62_id!(CollectionId);
|
||||
|
||||
@@ -12,6 +12,7 @@ use ariadne::ids::UserId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use validator::Validate;
|
||||
|
||||
/// A project returned from the API
|
||||
@@ -645,6 +646,98 @@ impl SideTypesMigrationReviewStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
pub struct MissingAttributionFile {
|
||||
pub id: FileId,
|
||||
pub override_source: Option<OverrideSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum OverrideSource {
|
||||
Flame {
|
||||
id: u32,
|
||||
title: String,
|
||||
url: String,
|
||||
icon_url: String,
|
||||
},
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Serialize, Deserialize, Clone, PartialEq, Eq, utoipa::ToSchema,
|
||||
)]
|
||||
pub struct FlameProject {
|
||||
pub id: u32,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub icon_url: String,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Serialize, Deserialize, Clone, PartialEq, Eq, utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(untagged)]
|
||||
pub enum AttributionLicense {
|
||||
Spdx(String),
|
||||
Custom { name: String },
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Serialize, Deserialize, Clone, PartialEq, Eq, utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AttributionResolutionKind {
|
||||
License {
|
||||
license: AttributionLicense,
|
||||
link_to_work: Url,
|
||||
},
|
||||
GloballyAllowed {
|
||||
link_to_work: Url,
|
||||
},
|
||||
MyProject {
|
||||
license: AttributionLicense,
|
||||
},
|
||||
SpecialPermissions {
|
||||
link_to_work: Url,
|
||||
},
|
||||
NoPermission {
|
||||
link_to_work: Option<Url>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AttributionModerationStatusKind {
|
||||
NotAllowed,
|
||||
Approved,
|
||||
BadProof,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
pub struct AttributionModerationStatus {
|
||||
#[serde(flatten)]
|
||||
pub kind: AttributionModerationStatusKind,
|
||||
#[serde(default)]
|
||||
pub reason: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub moderated_at: Option<DateTime<Utc>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub moderated_by: Option<UserId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
pub struct AttributionResolution {
|
||||
#[serde(flatten)]
|
||||
pub kind: AttributionResolutionKind,
|
||||
#[serde(default)]
|
||||
pub moderation_status: Option<AttributionModerationStatus>,
|
||||
#[serde(default)]
|
||||
pub updated_by_moderator: bool,
|
||||
pub notes: String,
|
||||
pub image_urls: Vec<Url>,
|
||||
}
|
||||
|
||||
/// A specific version of a project
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
pub struct Version {
|
||||
@@ -681,6 +774,9 @@ pub struct Version {
|
||||
|
||||
/// A list of files available for download for this version.
|
||||
pub files: Vec<VersionFile>,
|
||||
/// Files in this version that contain override files not yet attributed.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub files_missing_attribution: Vec<MissingAttributionFile>,
|
||||
/// A list of projects that this version depends on.
|
||||
pub dependencies: Vec<Dependency>,
|
||||
|
||||
@@ -757,6 +853,7 @@ impl From<VersionQueryResult> for Version {
|
||||
dependency_type: DependencyType::from_string(
|
||||
d.dependency_type.as_str(),
|
||||
),
|
||||
attribution: d.attribution,
|
||||
})
|
||||
.collect(),
|
||||
loaders: data.loaders.into_iter().map(Loader).collect(),
|
||||
@@ -768,6 +865,7 @@ impl From<VersionQueryResult> for Version {
|
||||
.map(|vf| (vf.field_name, vf.value.serialize_internal()))
|
||||
.collect(),
|
||||
components: data.components,
|
||||
files_missing_attribution: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -899,6 +997,18 @@ pub struct Dependency {
|
||||
pub file_name: Option<String>,
|
||||
/// The type of the dependency
|
||||
pub dependency_type: DependencyType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub attribution: Option<DependencyAttribution>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Serialize, Deserialize, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
|
||||
)]
|
||||
pub struct DependencyAttribution {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub flame_project: Option<FlameProject>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<AttributionResolutionKind>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
pub mod analytics;
|
||||
pub mod billing;
|
||||
pub mod email;
|
||||
pub mod file_scan;
|
||||
pub mod moderation;
|
||||
pub mod payouts;
|
||||
pub mod server_ping;
|
||||
|
||||
@@ -570,7 +570,7 @@ impl AutomatedModerationQueue {
|
||||
Vec::new()
|
||||
} else {
|
||||
let res = client
|
||||
.post(format!("{}v1/mods", ENV.FLAME_ANVIL_URL))
|
||||
.post(format!("{}/v1/mods", ENV.FLAME_ANVIL_URL))
|
||||
.json(&serde_json::json!({
|
||||
"modIds": flame_files.iter().map(|x| x.1).collect::<Vec<_>>()
|
||||
}))
|
||||
@@ -579,7 +579,7 @@ impl AutomatedModerationQueue {
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
serde_json::from_str::<FlameResponse<Vec<FlameProject>>>(&res)?.data
|
||||
serde_json::from_str::<FlameResponse<Vec<FlameProjectResponse>>>(&res)?.data
|
||||
};
|
||||
|
||||
let mut missing_metadata = MissingMetadata {
|
||||
@@ -823,7 +823,7 @@ pub enum ApprovalType {
|
||||
}
|
||||
|
||||
impl ApprovalType {
|
||||
fn approved(&self) -> bool {
|
||||
pub fn approved(&self) -> bool {
|
||||
match self {
|
||||
ApprovalType::Yes => true,
|
||||
ApprovalType::WithAttributionAndSource => true,
|
||||
@@ -896,11 +896,18 @@ pub struct FlameFileHash {
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FlameProject {
|
||||
pub struct FlameProjectResponse {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub links: FlameLinks,
|
||||
pub logo: FlameLogo,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FlameLogo {
|
||||
pub thumbnail_url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
use actix_web::{HttpRequest, get, patch, post, web};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::models::ids::{
|
||||
DBAttributionGroupId, DBProjectId, generate_attribution_group_id,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::{ProjectId, VersionId};
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::projects::{
|
||||
AttributionModerationStatusKind, AttributionResolution,
|
||||
AttributionResolutionKind, FlameProject,
|
||||
};
|
||||
use crate::models::users::User;
|
||||
use crate::queue::moderation::ApprovalType;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(list)
|
||||
.service(update_group)
|
||||
.service(assign)
|
||||
.service(split);
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AttributionGroupResponse {
|
||||
id: crate::models::ids::AttributionGroupId,
|
||||
flame_project: Option<FlameProject>,
|
||||
attribution: Option<crate::models::projects::AttributionResolution>,
|
||||
attributed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
attributed_by: Option<ariadne::ids::UserId>,
|
||||
files: Vec<AttributionFileResponse>,
|
||||
versions: Vec<VersionInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct VersionInfo {
|
||||
id: VersionId,
|
||||
name: String,
|
||||
version_number: String,
|
||||
date_created: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AttributionFileResponse {
|
||||
name: String,
|
||||
sha1: String,
|
||||
versions: Vec<VersionId>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
moderation_external_license_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
moderation_external_license: Option<ModerationExternalLicenseResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct ModerationExternalLicenseResponse {
|
||||
id: i64,
|
||||
title: Option<String>,
|
||||
status: ApprovalType,
|
||||
link: Option<String>,
|
||||
exceptions: Option<String>,
|
||||
proof: Option<String>,
|
||||
flame_project_id: Option<i32>,
|
||||
inserted_at: Option<DateTime<Utc>>,
|
||||
inserted_by: Option<i64>,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
updated_by: Option<i64>,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[get("/{project_id}")]
|
||||
async fn list(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
path: web::Path<ProjectId>,
|
||||
) -> Result<web::Json<Vec<AttributionGroupResponse>>, ApiError> {
|
||||
let project_id: DBProjectId = path.into_inner().into();
|
||||
let requester_is_mod = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_READ,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.is_some_and(|(_, user)| user.role.is_mod());
|
||||
|
||||
let groups = sqlx::query!(
|
||||
r#"
|
||||
select
|
||||
g.id as "id: DBAttributionGroupId",
|
||||
g.flame_project,
|
||||
g.attribution,
|
||||
g.attributed_at,
|
||||
g.attributed_by as "attributed_by: i64"
|
||||
from project_attribution_groups g
|
||||
where g.project_id = $1
|
||||
"#,
|
||||
project_id as DBProjectId,
|
||||
)
|
||||
.fetch_all(pool.as_ref())
|
||||
.await?;
|
||||
|
||||
let group_ids: Vec<i64> = groups.iter().map(|g| g.id.0).collect();
|
||||
|
||||
let files = if group_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
select
|
||||
paf.group_id as "group_id!",
|
||||
paf.name as "name!",
|
||||
convert_from(paf.sha1, 'UTF8') as "sha1!",
|
||||
paf.moderation_external_license_id,
|
||||
coalesce(array_agg(distinct aev.id) filter (where aev.id is not null), '{}') as "version_ids!: Vec<i64>"
|
||||
from project_attribution_files paf
|
||||
left join override_file_sources ofs on ofs.sha1 = paf.sha1
|
||||
left join files f on f.id = ofs.file_id
|
||||
left join versions v on v.id = f.version_id and v.mod_id = $2
|
||||
left join attribution_enforced_versions aev on aev.id = v.id
|
||||
where paf.group_id = ANY($1)
|
||||
group by paf.group_id, paf.name, paf.sha1, paf.moderation_external_license_id
|
||||
"#,
|
||||
&group_ids,
|
||||
project_id as DBProjectId,
|
||||
)
|
||||
.fetch_all(pool.as_ref())
|
||||
.await?
|
||||
};
|
||||
|
||||
let moderation_external_licenses = if requester_is_mod {
|
||||
let mut ids: Vec<i64> = files
|
||||
.iter()
|
||||
.filter_map(|f| f.moderation_external_license_id)
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
|
||||
if ids.is_empty() {
|
||||
std::collections::HashMap::new()
|
||||
} else {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
select
|
||||
id,
|
||||
title,
|
||||
status,
|
||||
link,
|
||||
exceptions,
|
||||
proof,
|
||||
flame_project_id,
|
||||
inserted_at,
|
||||
inserted_by,
|
||||
updated_at,
|
||||
updated_by
|
||||
from moderation_external_licenses
|
||||
where id = ANY($1)
|
||||
"#,
|
||||
&ids,
|
||||
)
|
||||
.fetch_all(pool.as_ref())
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
(
|
||||
row.id,
|
||||
ModerationExternalLicenseResponse {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
status: ApprovalType::from_string(&row.status)
|
||||
.unwrap_or(ApprovalType::Unidentified),
|
||||
link: row.link,
|
||||
exceptions: row.exceptions,
|
||||
proof: row.proof,
|
||||
flame_project_id: row.flame_project_id,
|
||||
inserted_at: row.inserted_at,
|
||||
inserted_by: row.inserted_by,
|
||||
updated_at: row.updated_at,
|
||||
updated_by: row.updated_by,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
let mut all_version_ids: Vec<i64> = files
|
||||
.iter()
|
||||
.flat_map(|f| f.version_ids.iter().copied())
|
||||
.collect();
|
||||
all_version_ids.sort_unstable();
|
||||
all_version_ids.dedup();
|
||||
|
||||
let version_infos = if all_version_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let rows = sqlx::query!(
|
||||
"
|
||||
select id, name, version_number, date_published
|
||||
from versions
|
||||
where id = ANY($1)
|
||||
order by date_published desc
|
||||
",
|
||||
&all_version_ids,
|
||||
)
|
||||
.fetch_all(pool.as_ref())
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|v| VersionInfo {
|
||||
id: VersionId(v.id as u64),
|
||||
name: v.name,
|
||||
version_number: v.version_number,
|
||||
date_created: v.date_published,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let version_order = version_infos
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, version)| (version.id, index))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
let mut result = Vec::new();
|
||||
for group in groups {
|
||||
let group_files: Vec<AttributionFileResponse> = files
|
||||
.iter()
|
||||
.filter(|f| f.group_id == group.id.0)
|
||||
.map(|f| AttributionFileResponse {
|
||||
name: f.name.clone(),
|
||||
sha1: f.sha1.clone(),
|
||||
moderation_external_license_id: if requester_is_mod {
|
||||
f.moderation_external_license_id
|
||||
} else {
|
||||
None
|
||||
},
|
||||
moderation_external_license: if requester_is_mod {
|
||||
f.moderation_external_license_id.and_then(|id| {
|
||||
moderation_external_licenses.get(&id).cloned()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
versions: {
|
||||
let mut versions: Vec<_> = f
|
||||
.version_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|id| VersionId(id as u64))
|
||||
.collect();
|
||||
versions.sort_by_key(|id| {
|
||||
version_order.get(id).copied().unwrap_or(usize::MAX)
|
||||
});
|
||||
versions
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let group_version_ids = group_files
|
||||
.iter()
|
||||
.flat_map(|file| file.versions.iter().copied())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let group_versions = version_infos
|
||||
.iter()
|
||||
.filter(|version| group_version_ids.contains(&version.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let mut attribution = group.attribution.and_then(|v| {
|
||||
serde_json::from_value::<AttributionResolution>(v).ok()
|
||||
});
|
||||
if let Some(moderation_status) = attribution
|
||||
.as_mut()
|
||||
.and_then(|a| a.moderation_status.as_mut())
|
||||
&& !requester_is_mod
|
||||
{
|
||||
moderation_status.moderated_at = None;
|
||||
moderation_status.moderated_by = None;
|
||||
}
|
||||
let attributed_by = if attribution
|
||||
.as_ref()
|
||||
.is_some_and(|attribution| attribution.updated_by_moderator)
|
||||
&& !requester_is_mod
|
||||
{
|
||||
None
|
||||
} else {
|
||||
group
|
||||
.attributed_by
|
||||
.map(|id| ariadne::ids::UserId(id as u64))
|
||||
};
|
||||
|
||||
result.push(AttributionGroupResponse {
|
||||
id: group.id.into(),
|
||||
flame_project: group
|
||||
.flame_project
|
||||
.and_then(|v| serde_json::from_value(v).ok()),
|
||||
attribution,
|
||||
attributed_at: group.attributed_at,
|
||||
attributed_by,
|
||||
files: group_files,
|
||||
versions: group_versions,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(web::Json(result))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
struct UpdateGroupBody {
|
||||
attribution: AttributionResolution,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[patch("/group/{group_id}")]
|
||||
async fn update_group(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
path: web::Path<i64>,
|
||||
web::Json(body): web::Json<UpdateGroupBody>,
|
||||
) -> Result<(), ApiError> {
|
||||
let group_id = path.into_inner();
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::VERSION_WRITE,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !can_edit_attribution_group(pool.as_ref(), group_id, &user).await? {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"This attribution group cannot be edited".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if matches!(
|
||||
body.attribution.kind,
|
||||
AttributionResolutionKind::GloballyAllowed { .. }
|
||||
) && !user.role.is_mod()
|
||||
{
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"Only moderators can set globally allowed attributions".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if body.attribution.moderation_status.is_some() && !user.role.is_mod() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"Only moderators can set attribution moderation status".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut attribution = body.attribution;
|
||||
attribution.updated_by_moderator = user.role.is_mod();
|
||||
if let Some(moderation_status) = &mut attribution.moderation_status {
|
||||
moderation_status.moderated_at = Some(Utc::now());
|
||||
moderation_status.moderated_by = Some(user.id);
|
||||
}
|
||||
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
update project_attribution_groups
|
||||
set attribution = $1, attributed_at = now(), attributed_by = $3
|
||||
where id = $2
|
||||
",
|
||||
&serde_json::to_value(&attribution).unwrap_or_default(),
|
||||
group_id,
|
||||
user.id.0 as i64,
|
||||
)
|
||||
.execute(pool.as_ref())
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
struct AssignBody {
|
||||
sha1: String,
|
||||
target_group_id: i64,
|
||||
project_id: ProjectId,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("/assign")]
|
||||
async fn assign(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
web::Json(body): web::Json<AssignBody>,
|
||||
) -> Result<(), ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::VERSION_WRITE,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let sha1 = body.sha1.trim().to_lowercase();
|
||||
if hex_to_bytes(&sha1).is_none() {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"invalid sha1 hex string".to_string(),
|
||||
));
|
||||
}
|
||||
let sha1_bytes = sha1.as_bytes().to_vec();
|
||||
let project_id: DBProjectId = body.project_id.into();
|
||||
|
||||
let source_group_id = sqlx::query_scalar!(
|
||||
"
|
||||
select paf.group_id
|
||||
from project_attribution_files paf
|
||||
inner join project_attribution_groups pag on pag.id = paf.group_id
|
||||
where paf.sha1 = $1 and pag.project_id = $2
|
||||
",
|
||||
&sha1_bytes,
|
||||
project_id as DBProjectId,
|
||||
)
|
||||
.fetch_optional(pool.as_ref())
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let target_group_exists = sqlx::query_scalar!(
|
||||
"
|
||||
select exists(
|
||||
select 1 from project_attribution_groups where id = $1 and project_id = $2
|
||||
) as \"exists!\"
|
||||
",
|
||||
body.target_group_id,
|
||||
project_id as DBProjectId,
|
||||
)
|
||||
.fetch_one(pool.as_ref())
|
||||
.await?;
|
||||
|
||||
if !target_group_exists {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
|
||||
if !can_edit_attribution_group(pool.as_ref(), source_group_id, &user)
|
||||
.await?
|
||||
|| !can_edit_attribution_group(
|
||||
pool.as_ref(),
|
||||
body.target_group_id,
|
||||
&user,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"This attribution group cannot be edited".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
update project_attribution_files
|
||||
set group_id = $1
|
||||
where sha1 = $2
|
||||
and group_id in (
|
||||
select id from project_attribution_groups where project_id = $3
|
||||
)
|
||||
",
|
||||
body.target_group_id,
|
||||
&sha1_bytes,
|
||||
project_id as DBProjectId,
|
||||
)
|
||||
.execute(pool.as_ref())
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
|
||||
cleanup_empty_groups(pool.as_ref()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
struct SplitBody {
|
||||
sha1: String,
|
||||
project_id: ProjectId,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("/split")]
|
||||
async fn split(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
web::Json(body): web::Json<SplitBody>,
|
||||
) -> Result<(), ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::VERSION_WRITE,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let sha1 = body.sha1.trim().to_lowercase();
|
||||
if hex_to_bytes(&sha1).is_none() {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"invalid sha1 hex string".to_string(),
|
||||
));
|
||||
}
|
||||
let sha1_bytes = sha1.as_bytes().to_vec();
|
||||
let project_id: DBProjectId = body.project_id.into();
|
||||
|
||||
let existing = sqlx::query!(
|
||||
"
|
||||
select paf.group_id, paf.name from project_attribution_files paf
|
||||
inner join project_attribution_groups pag on pag.id = paf.group_id
|
||||
where paf.sha1 = $1 and pag.project_id = $2
|
||||
",
|
||||
&sha1_bytes,
|
||||
project_id as DBProjectId,
|
||||
)
|
||||
.fetch_optional(pool.as_ref())
|
||||
.await?;
|
||||
|
||||
let Some(existing) = existing else {
|
||||
return Err(ApiError::NotFound);
|
||||
};
|
||||
|
||||
if !can_edit_attribution_group(pool.as_ref(), existing.group_id, &user)
|
||||
.await?
|
||||
{
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"This attribution group cannot be edited".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut txn = pool.begin().await?;
|
||||
|
||||
let new_group_id = generate_attribution_group_id(&mut txn).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
insert into project_attribution_groups (id, project_id)
|
||||
values ($1, $2)
|
||||
",
|
||||
new_group_id as DBAttributionGroupId,
|
||||
project_id as DBProjectId,
|
||||
)
|
||||
.execute(&mut txn)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
update project_attribution_files
|
||||
set group_id = $1
|
||||
where sha1 = $2 and group_id = $3
|
||||
",
|
||||
new_group_id as DBAttributionGroupId,
|
||||
&sha1_bytes,
|
||||
existing.group_id,
|
||||
)
|
||||
.execute(&mut txn)
|
||||
.await?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
cleanup_empty_groups(pool.as_ref()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn can_edit_attribution_group(
|
||||
pool: &PgPool,
|
||||
group_id: i64,
|
||||
user: &User,
|
||||
) -> Result<bool, ApiError> {
|
||||
if user.role.is_mod() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let attribution = sqlx::query_scalar!(
|
||||
"
|
||||
select attribution
|
||||
from project_attribution_groups
|
||||
where id = $1
|
||||
",
|
||||
group_id,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let attribution: Option<AttributionResolution> =
|
||||
attribution.and_then(|value| serde_json::from_value(value).ok());
|
||||
|
||||
Ok(!matches!(
|
||||
attribution
|
||||
.and_then(|attribution| attribution.moderation_status)
|
||||
.map(|status| status.kind),
|
||||
Some(AttributionModerationStatusKind::NotAllowed)
|
||||
))
|
||||
}
|
||||
|
||||
async fn cleanup_empty_groups(pool: &PgPool) -> Result<(), ApiError> {
|
||||
sqlx::query!(
|
||||
"
|
||||
delete from project_attribution_groups g
|
||||
where not exists (
|
||||
select 1 from project_attribution_files f where f.group_id = g.id
|
||||
)
|
||||
",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hex_to_bytes(hex: &str) -> Option<Vec<u8>> {
|
||||
if !hex.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
(0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
|
||||
.collect()
|
||||
}
|
||||
@@ -242,6 +242,152 @@ pub async fn remove(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
/// Inserts notifications for all users and tries to send emails immediately.
|
||||
///
|
||||
/// Responds with the user IDs that could not be emailed:
|
||||
/// - `200` if every recipient was emailed (empty list)
|
||||
/// - `207` if some recipients could not be emailed (list of failed IDs)
|
||||
#[post(
|
||||
"external_notifications/email-sync",
|
||||
guard = "external_notification_key_guard"
|
||||
)]
|
||||
pub async fn create_email_sync(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
email_queue: web::Data<EmailQueue>,
|
||||
create_notification: web::Json<CreateNotification>,
|
||||
) -> Result<CustomizeResponder<web::Json<Vec<UserId>>>, ApiError> {
|
||||
let CreateNotification { body, user_ids } =
|
||||
create_notification.into_inner();
|
||||
let raw_user_ids = user_ids.iter().map(|x| x.0 as i64).collect::<Vec<_>>();
|
||||
|
||||
let user_ids = raw_user_ids
|
||||
.iter()
|
||||
.map(|x| DBUserId(*x))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut txn = pool.begin().await?;
|
||||
|
||||
if !DBUser::exists_many(&user_ids, &mut txn).await? {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"One of the specified users do not exist.".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
// Skip users who already have an identical notification
|
||||
let body_value = serde_json::value::to_value(&body)?;
|
||||
let already_notified = sqlx::query!(
|
||||
"
|
||||
SELECT DISTINCT user_id
|
||||
FROM notifications
|
||||
WHERE user_id = ANY($1::bigint[]) AND body = $2::jsonb
|
||||
",
|
||||
&raw_user_ids[..],
|
||||
body_value,
|
||||
)
|
||||
.fetch_all(&mut txn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| DBUserId(row.user_id))
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
|
||||
let notification_user_ids = user_ids
|
||||
.clone()
|
||||
.into_iter()
|
||||
.filter(|id| !already_notified.contains(id))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
NotificationBuilder { body: body.clone() }
|
||||
.insert_many_without_delivery(notification_user_ids, &mut txn, &redis)
|
||||
.await?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
let mut email_txn = pool.begin().await?;
|
||||
|
||||
let mut failed = Vec::new();
|
||||
for user_id in &user_ids {
|
||||
let Some(user) =
|
||||
DBUser::get_id(*user_id, &mut email_txn, &redis).await?
|
||||
else {
|
||||
failed.push(UserId(user_id.0 as u64));
|
||||
continue;
|
||||
};
|
||||
|
||||
let delivered = match user
|
||||
.email
|
||||
.and_then(|email| email.parse::<Mailbox>().ok())
|
||||
{
|
||||
Some(mailbox) => {
|
||||
email_queue
|
||||
.send_one(&mut email_txn, body.clone(), *user_id, mailbox)
|
||||
.await?
|
||||
== NotificationDeliveryStatus::Delivered
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
|
||||
if !delivered {
|
||||
failed.push(UserId(user_id.0 as u64));
|
||||
}
|
||||
}
|
||||
|
||||
let status = if failed.is_empty() {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::MULTI_STATUS
|
||||
};
|
||||
|
||||
Ok(web::Json(failed).customize().with_status(status))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NotificationFilter {
|
||||
pub user_ids: Vec<UserId>,
|
||||
#[serde(flatten)]
|
||||
pub body: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[delete("external_notifications", guard = "external_notification_key_guard")]
|
||||
pub async fn remove(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
notification_filter: web::Json<NotificationFilter>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let NotificationFilter { user_ids, body } =
|
||||
notification_filter.into_inner();
|
||||
|
||||
if user_ids.is_empty() {
|
||||
return Err(ApiError::Request(eyre!(
|
||||
"at least one user must be provided to remove notifications from"
|
||||
)));
|
||||
}
|
||||
|
||||
if body.is_empty() {
|
||||
return Err(ApiError::Request(eyre!(
|
||||
"at least one `body` field must be provided to match notifications"
|
||||
)));
|
||||
}
|
||||
|
||||
let filters = serde_json::Value::Object(body);
|
||||
|
||||
let user_ids = user_ids
|
||||
.into_iter()
|
||||
.map(|x| DBUserId(x.0 as i64))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut txn = pool.begin().await?;
|
||||
|
||||
DBNotification::remove_many_matching_body(
|
||||
&filters, &user_ids, &mut txn, &redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendEmail {
|
||||
pub users: Vec<UserId>,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user