External projects moderator database (#5692)
* Begin external projects moderator database frontend * add copy link button * begin project page permissions settings * MEL database backend routes * include filename in external files * Hook up frontend external license page to backend * more work on user-facing external projects stuff * put user-facing stuff behind feature flag * prepr * clippy --------- Co-authored-by: aecsocket <aecsocket@tutanota.com>
This commit is contained in:
@@ -47,6 +47,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
|||||||
showDiscoverProjectButtons: false,
|
showDiscoverProjectButtons: false,
|
||||||
useV1ContentTabAPI: true,
|
useV1ContentTabAPI: true,
|
||||||
labrinthApiCanary: false,
|
labrinthApiCanary: false,
|
||||||
|
dismissedExternalProjectsInfo: false,
|
||||||
|
modpackPermissionsPage: false,
|
||||||
} as const)
|
} as const)
|
||||||
|
|
||||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||||
|
|||||||
@@ -323,6 +323,11 @@
|
|||||||
color: 'orange',
|
color: 'orange',
|
||||||
link: '/moderation/reports',
|
link: '/moderation/reports',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'external-projects',
|
||||||
|
color: 'orange',
|
||||||
|
link: '/moderation/external-projects',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
divider: true,
|
divider: true,
|
||||||
},
|
},
|
||||||
@@ -377,6 +382,9 @@
|
|||||||
<template #review-reports>
|
<template #review-reports>
|
||||||
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.reports) }}
|
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.reports) }}
|
||||||
</template>
|
</template>
|
||||||
|
<template #external-projects>
|
||||||
|
<GlobeIcon aria-hidden="true" /> {{ formatMessage(messages.externalProjects) }}
|
||||||
|
</template>
|
||||||
<template #user-lookup>
|
<template #user-lookup>
|
||||||
<UserSearchIcon aria-hidden="true" /> {{ formatMessage(messages.lookupByEmail) }}
|
<UserSearchIcon aria-hidden="true" /> {{ formatMessage(messages.lookupByEmail) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -705,6 +713,7 @@ import {
|
|||||||
DropdownIcon,
|
DropdownIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
GlassesIcon,
|
GlassesIcon,
|
||||||
|
GlobeIcon,
|
||||||
HamburgerIcon,
|
HamburgerIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
IssuesIcon,
|
IssuesIcon,
|
||||||
@@ -880,6 +889,10 @@ const messages = defineMessages({
|
|||||||
id: 'layout.action.reports',
|
id: 'layout.action.reports',
|
||||||
defaultMessage: 'Review reports',
|
defaultMessage: 'Review reports',
|
||||||
},
|
},
|
||||||
|
externalProjects: {
|
||||||
|
id: 'layout.action.external-projects',
|
||||||
|
defaultMessage: 'External projects',
|
||||||
|
},
|
||||||
lookupByEmail: {
|
lookupByEmail: {
|
||||||
id: 'layout.action.lookup-by-email',
|
id: 'layout.action.lookup-by-email',
|
||||||
defaultMessage: 'Lookup by email',
|
defaultMessage: 'Lookup by email',
|
||||||
|
|||||||
@@ -1676,6 +1676,9 @@
|
|||||||
"layout.action.create-new": {
|
"layout.action.create-new": {
|
||||||
"message": "Create new..."
|
"message": "Create new..."
|
||||||
},
|
},
|
||||||
|
"layout.action.external-projects": {
|
||||||
|
"message": "External projects"
|
||||||
|
},
|
||||||
"layout.action.file-lookup": {
|
"layout.action.file-lookup": {
|
||||||
"message": "File lookup"
|
"message": "File lookup"
|
||||||
},
|
},
|
||||||
@@ -1928,6 +1931,9 @@
|
|||||||
"moderation.moderate": {
|
"moderation.moderate": {
|
||||||
"message": "Moderate"
|
"message": "Moderate"
|
||||||
},
|
},
|
||||||
|
"moderation.page.external-projects": {
|
||||||
|
"message": "External projects"
|
||||||
|
},
|
||||||
"moderation.page.projects": {
|
"moderation.page.projects": {
|
||||||
"message": "Projects"
|
"message": "Projects"
|
||||||
},
|
},
|
||||||
@@ -1935,7 +1941,7 @@
|
|||||||
"message": "Reports"
|
"message": "Reports"
|
||||||
},
|
},
|
||||||
"moderation.page.technicalReview": {
|
"moderation.page.technicalReview": {
|
||||||
"message": "Technical Review"
|
"message": "Tech review"
|
||||||
},
|
},
|
||||||
"muralpay.account-type.checking": {
|
"muralpay.account-type.checking": {
|
||||||
"message": "Checking"
|
"message": "Checking"
|
||||||
@@ -2621,6 +2627,45 @@
|
|||||||
"project.settings.general.url.title": {
|
"project.settings.general.url.title": {
|
||||||
"message": "URL"
|
"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."
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"project.settings.permissions.attention-needed.title": {
|
||||||
|
"message": "Unknown embedded content"
|
||||||
|
},
|
||||||
|
"project.settings.permissions.completed.description": {
|
||||||
|
"message": "All external content has attributions provided."
|
||||||
|
},
|
||||||
|
"project.settings.permissions.completed.title": {
|
||||||
|
"message": "Attributions 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."
|
||||||
|
},
|
||||||
|
"project.settings.permissions.empty-state.heading": {
|
||||||
|
"message": "You're all set!"
|
||||||
|
},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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!"
|
||||||
|
},
|
||||||
|
"project.settings.permissions.info-banner.title": {
|
||||||
|
"message": "Learn how attributions work"
|
||||||
|
},
|
||||||
|
"project.settings.permissions.learn-more": {
|
||||||
|
"message": "Learn more"
|
||||||
|
},
|
||||||
|
"project.settings.permissions.search-placeholder": {
|
||||||
|
"message": "Search {count} {count, plural, one {external project} other {external projects}}..."
|
||||||
|
},
|
||||||
"project.settings.title": {
|
"project.settings.title": {
|
||||||
"message": "Settings"
|
"message": "Settings"
|
||||||
},
|
},
|
||||||
@@ -2639,6 +2684,15 @@
|
|||||||
"project.versions.title": {
|
"project.versions.title": {
|
||||||
"message": "Versions"
|
"message": "Versions"
|
||||||
},
|
},
|
||||||
|
"project.versions.withheld-versions-warning.description": {
|
||||||
|
"message": "{count, plural, one {This version is} other {These versions are}} currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included in the modpack {count, plural, one {version} other {versions}}."
|
||||||
|
},
|
||||||
|
"project.versions.withheld-versions-warning.resolve-button": {
|
||||||
|
"message": "Resolve"
|
||||||
|
},
|
||||||
|
"project.versions.withheld-versions-warning.title": {
|
||||||
|
"message": "{count, plural, one {Version {version_name}} other {Versions}} withheld due to unknown embedded content"
|
||||||
|
},
|
||||||
"report.already-reported": {
|
"report.already-reported": {
|
||||||
"message": "You've already reported {title}"
|
"message": "You've already reported {title}"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
InfoIcon,
|
InfoIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
|
SignatureIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
VersionIcon,
|
VersionIcon,
|
||||||
@@ -46,6 +47,12 @@ const navItems = computed(() => {
|
|||||||
projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) &&
|
projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) &&
|
||||||
isStaff(currentMember.value?.user)
|
isStaff(currentMember.value?.user)
|
||||||
|
|
||||||
|
const hasPermissionsPage = computed(
|
||||||
|
() =>
|
||||||
|
flags.value.modpackPermissionsPage &&
|
||||||
|
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
|
||||||
|
)
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
link: `/${base}/settings`,
|
link: `/${base}/settings`,
|
||||||
@@ -75,6 +82,11 @@ const navItems = computed(() => {
|
|||||||
label: formatMessage(commonProjectSettingsMessages.description),
|
label: formatMessage(commonProjectSettingsMessages.description),
|
||||||
icon: AlignLeftIcon,
|
icon: AlignLeftIcon,
|
||||||
},
|
},
|
||||||
|
hasPermissionsPage.value && {
|
||||||
|
link: `/${base}/settings/permissions`,
|
||||||
|
label: formatMessage(commonProjectSettingsMessages.permissions),
|
||||||
|
icon: SignatureIcon,
|
||||||
|
},
|
||||||
!isServerProject.value && {
|
!isServerProject.value && {
|
||||||
link: `/${base}/settings/versions`,
|
link: `/${base}/settings/versions`,
|
||||||
label: formatMessage(commonProjectSettingsMessages.versions),
|
label: formatMessage(commonProjectSettingsMessages.versions),
|
||||||
|
|||||||
184
apps/frontend/src/pages/[type]/[id]/settings/permissions.vue
Normal file
184
apps/frontend/src/pages/[type]/[id]/settings/permissions.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RightArrowIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
Admonition,
|
||||||
|
ButtonStyled,
|
||||||
|
Combobox,
|
||||||
|
type ComboboxOption,
|
||||||
|
commonMessages,
|
||||||
|
defineMessages,
|
||||||
|
EmptyState,
|
||||||
|
IntlFormatted,
|
||||||
|
StyledInput,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import ExternalProjectPermissionsCard from '@modrinth/ui/src/components/external_files/ExternalProjectPermissionsCard.vue'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const flags = useFeatureFlags()
|
||||||
|
|
||||||
|
if (!flags.value.modpackPermissionsPage) {
|
||||||
|
throw createError({
|
||||||
|
fatal: true,
|
||||||
|
statusCode: 404,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalFiles = ref([{}])
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const currentSortType = ref('Oldest')
|
||||||
|
|
||||||
|
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}}...',
|
||||||
|
},
|
||||||
|
infoBannerTitle: {
|
||||||
|
id: 'project.settings.permissions.info-banner.title',
|
||||||
|
defaultMessage: 'Learn how attributions work',
|
||||||
|
},
|
||||||
|
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!`,
|
||||||
|
},
|
||||||
|
learnMore: {
|
||||||
|
id: 'project.settings.permissions.learn-more',
|
||||||
|
defaultMessage: 'Learn more',
|
||||||
|
},
|
||||||
|
emptyStateHeading: {
|
||||||
|
id: 'project.settings.permissions.empty-state.heading',
|
||||||
|
defaultMessage: `You're all set!`,
|
||||||
|
},
|
||||||
|
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.`,
|
||||||
|
},
|
||||||
|
completedTitle: {
|
||||||
|
id: 'project.settings.permissions.completed.title',
|
||||||
|
defaultMessage: `Attributions completed!`,
|
||||||
|
},
|
||||||
|
completedDescription: {
|
||||||
|
id: 'project.settings.permissions.completed.description',
|
||||||
|
defaultMessage: 'All external content has attributions provided.',
|
||||||
|
},
|
||||||
|
failTitle: {
|
||||||
|
id: 'project.settings.permissions.fail.title',
|
||||||
|
defaultMessage: `Some content can't be included`,
|
||||||
|
},
|
||||||
|
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.`,
|
||||||
|
},
|
||||||
|
attentionNeededTitle: {
|
||||||
|
id: 'project.settings.permissions.attention-needed.title',
|
||||||
|
defaultMessage: `Unknown embedded 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.`,
|
||||||
|
},
|
||||||
|
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.`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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">
|
||||||
|
<StyledInput
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="search"
|
||||||
|
:placeholder="
|
||||||
|
formatMessage(messages.searchPlaceholder, {
|
||||||
|
count: externalFiles.length,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:icon="SearchIcon"
|
||||||
|
input-class="h-[40px]"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Combobox
|
||||||
|
v-model="currentSortType"
|
||||||
|
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||||
|
:options="sortTypes"
|
||||||
|
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
||||||
|
>
|
||||||
|
<template #selected>
|
||||||
|
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||||
|
<SortAscIcon
|
||||||
|
v-if="currentSortType === 'Oldest'"
|
||||||
|
class="size-5 flex-shrink-0 text-secondary"
|
||||||
|
/>
|
||||||
|
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
|
||||||
|
<span class="truncate text-contrast">{{ currentSortType }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex flex-col gap-3">
|
||||||
|
<ExternalProjectPermissionsCard title="FTB Library" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<EmptyState
|
||||||
|
:heading="formatMessage(messages.emptyStateHeading)"
|
||||||
|
:description="formatMessage(messages.emptyStateDescription)"
|
||||||
|
type="done"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
@@ -15,6 +15,37 @@
|
|||||||
@proceed="deleteVersion()"
|
@proceed="deleteVersion()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Admonition
|
||||||
|
v-if="flags.modpackPermissionsPage && withheldVersions.length > 0"
|
||||||
|
type="warning"
|
||||||
|
class="mb-4"
|
||||||
|
:header="
|
||||||
|
formatMessage(messages.withheldVersionsWarningTitle, {
|
||||||
|
count: withheldVersions.length,
|
||||||
|
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:body="
|
||||||
|
formatMessage(messages.withheldVersionsWarningDescription, {
|
||||||
|
count: withheldVersions.length,
|
||||||
|
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex">
|
||||||
|
<ButtonStyled color="orange">
|
||||||
|
<nuxt-link
|
||||||
|
:to="`/${project.project_type}/${
|
||||||
|
project.slug ? project.slug : project.id
|
||||||
|
}/settings/permissions`"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.withheldVersionsWarningResolve) }} <RightArrowIcon />
|
||||||
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Admonition>
|
||||||
<ProjectPageVersions
|
<ProjectPageVersions
|
||||||
v-if="versions?.length"
|
v-if="versions?.length"
|
||||||
:project="project"
|
:project="project"
|
||||||
@@ -293,17 +324,21 @@ import {
|
|||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
ReportIcon,
|
ReportIcon,
|
||||||
|
RightArrowIcon,
|
||||||
ShareIcon,
|
ShareIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
|
Admonition,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
ConfirmModal,
|
ConfirmModal,
|
||||||
|
defineMessages,
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
injectProjectPageContext,
|
injectProjectPageContext,
|
||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
ProjectPageVersions,
|
ProjectPageVersions,
|
||||||
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { useTemplateRef } from 'vue'
|
import { useTemplateRef } from 'vue'
|
||||||
|
|
||||||
@@ -315,6 +350,7 @@ const route = useRoute()
|
|||||||
|
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
const {
|
const {
|
||||||
projectV2: project,
|
projectV2: project,
|
||||||
currentMember,
|
currentMember,
|
||||||
@@ -396,4 +432,23 @@ async function deleteVersion() {
|
|||||||
|
|
||||||
stopLoading()
|
stopLoading()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const withheldVersions = computed(() => ['4.0.0'])
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
withheldVersionsWarningTitle: {
|
||||||
|
id: 'project.versions.withheld-versions-warning.title',
|
||||||
|
defaultMessage:
|
||||||
|
'{count, plural, one {Version {version_name}} other {Versions}} withheld due to unknown embedded content',
|
||||||
|
},
|
||||||
|
withheldVersionsWarningDescription: {
|
||||||
|
id: 'project.versions.withheld-versions-warning.description',
|
||||||
|
defaultMessage:
|
||||||
|
'{count, plural, one {This version is} other {These versions are}} currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included in the modpack {count, plural, one {version} other {versions}}.',
|
||||||
|
},
|
||||||
|
withheldVersionsWarningResolve: {
|
||||||
|
id: 'project.versions.withheld-versions-warning.resolve-button',
|
||||||
|
defaultMessage: 'Resolve',
|
||||||
|
},
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { FolderIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
|
import { FolderIcon, GlobeIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
|
||||||
import { Chips, defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
|
import { Chips, defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
@@ -37,12 +37,16 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
technicalReviewTitle: {
|
technicalReviewTitle: {
|
||||||
id: 'moderation.page.technicalReview',
|
id: 'moderation.page.technicalReview',
|
||||||
defaultMessage: 'Technical Review',
|
defaultMessage: 'Tech review',
|
||||||
},
|
},
|
||||||
reportsTitle: {
|
reportsTitle: {
|
||||||
id: 'moderation.page.reports',
|
id: 'moderation.page.reports',
|
||||||
defaultMessage: 'Reports',
|
defaultMessage: 'Reports',
|
||||||
},
|
},
|
||||||
|
externalFilesTitle: {
|
||||||
|
id: 'moderation.page.external-projects',
|
||||||
|
defaultMessage: 'External projects',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const moderationLinks = [
|
const moderationLinks = [
|
||||||
@@ -53,12 +57,18 @@ const moderationLinks = [
|
|||||||
icon: ShieldCheckIcon,
|
icon: ShieldCheckIcon,
|
||||||
},
|
},
|
||||||
{ label: formatMessage(messages.reportsTitle), href: '/moderation/reports', icon: ReportIcon },
|
{ label: formatMessage(messages.reportsTitle), href: '/moderation/reports', icon: ReportIcon },
|
||||||
|
{
|
||||||
|
label: formatMessage(messages.externalFilesTitle),
|
||||||
|
href: '/moderation/external-projects',
|
||||||
|
icon: GlobeIcon,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const mobileNavOptions = [
|
const mobileNavOptions = [
|
||||||
formatMessage(messages.projectsTitle),
|
formatMessage(messages.projectsTitle),
|
||||||
formatMessage(messages.technicalReviewTitle),
|
formatMessage(messages.technicalReviewTitle),
|
||||||
formatMessage(messages.reportsTitle),
|
formatMessage(messages.reportsTitle),
|
||||||
|
formatMessage(messages.externalFilesTitle),
|
||||||
]
|
]
|
||||||
|
|
||||||
const selectedChip = computed({
|
const selectedChip = computed({
|
||||||
|
|||||||
393
apps/frontend/src/pages/moderation/external-projects.vue
Normal file
393
apps/frontend/src/pages/moderation/external-projects.vue
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal ref="editModal" header="Edit external project">
|
||||||
|
<form class="flex flex-col gap-2" @submit.prevent="saveExternalProjectEdit">
|
||||||
|
<label class="font-semibold text-contrast" for="edit-form-title">Title</label>
|
||||||
|
<StyledInput id="edit-form-title" v-model="editForm.title" type="text" />
|
||||||
|
<label class="mt-2 font-semibold text-contrast" for="edit-form-link">Link</label>
|
||||||
|
<StyledInput id="edit-form-link" v-model="editForm.link" type="text" />
|
||||||
|
<label class="mt-2 font-semibold text-contrast" for="edit-form-cf-id">
|
||||||
|
CurseForge project ID
|
||||||
|
</label>
|
||||||
|
<StyledInput id="edit-form-cf-id" v-model="editForm.flameProjectId" type="text" />
|
||||||
|
<label class="mt-2 font-semibold text-contrast" for="edit-form-status">Allowed?</label>
|
||||||
|
<Combobox
|
||||||
|
id="edit-form-status"
|
||||||
|
v-model="editForm.status"
|
||||||
|
:options="statusOptions"
|
||||||
|
class="!w-full"
|
||||||
|
/>
|
||||||
|
<label class="mt-2 font-semibold text-contrast" for="edit-form-proof">Proof</label>
|
||||||
|
<StyledInput
|
||||||
|
id="edit-form-proof"
|
||||||
|
v-model="editForm.proof"
|
||||||
|
type="text"
|
||||||
|
multiline
|
||||||
|
resize="both"
|
||||||
|
class="w-[30rem]"
|
||||||
|
/>
|
||||||
|
<label class="mt-2 font-semibold text-contrast" for="edit-form-exceptions">
|
||||||
|
Exceptions / notes
|
||||||
|
</label>
|
||||||
|
<StyledInput
|
||||||
|
id="edit-form-exceptions"
|
||||||
|
v-model="editForm.exceptions"
|
||||||
|
type="text"
|
||||||
|
multiline
|
||||||
|
resize="both"
|
||||||
|
class="w-[30rem]"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="closeEditModal">Cancel</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button type="submit" :disabled="isSavingEdit">
|
||||||
|
{{ isSavingEdit ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</NewModal>
|
||||||
|
<div>
|
||||||
|
<form class="flex gap-2" @submit.prevent="executeSearch">
|
||||||
|
<StyledInput
|
||||||
|
v-model="query"
|
||||||
|
:icon="SearchIcon"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Search external projects..."
|
||||||
|
clearable
|
||||||
|
wrapper-class="flex-1 w-full"
|
||||||
|
/>
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button type="submit">
|
||||||
|
<SearchIcon aria-hidden="true" />
|
||||||
|
Search by title
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button type="button" @click="executeFlameIdLookup">
|
||||||
|
<BinaryIcon aria-hidden="true" />
|
||||||
|
Lookup CurseForge ID
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button type="button" @click="executeSha1Lookup">
|
||||||
|
<HashIcon aria-hidden="true" />
|
||||||
|
Lookup SHA-1
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</form>
|
||||||
|
<div>
|
||||||
|
<template v-if="lastSearchKind !== 'none'">
|
||||||
|
<template v-if="lookupNeedsInput">
|
||||||
|
<EmptyState
|
||||||
|
type="no-search-result"
|
||||||
|
:heading="lookupEmptyHeading"
|
||||||
|
:description="lookupEmptyDescription"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<EmptyState
|
||||||
|
v-if="isLoading"
|
||||||
|
type="no-search-result"
|
||||||
|
heading="Loading external projects..."
|
||||||
|
/>
|
||||||
|
<div v-else-if="displayProjects.length > 0" class="mt-4 flex flex-col gap-3">
|
||||||
|
<ExternalProjectLookupCard
|
||||||
|
v-for="project in displayProjects"
|
||||||
|
:key="project.id"
|
||||||
|
:title="project.title"
|
||||||
|
:state="project.status"
|
||||||
|
:link="project.link"
|
||||||
|
:notes="project.exceptions"
|
||||||
|
:proof="project.proof"
|
||||||
|
:files="project.files"
|
||||||
|
:cf_id="project.flame_project_id"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="openEditModal(project)">
|
||||||
|
<EditIcon />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
</ExternalProjectLookupCard>
|
||||||
|
</div>
|
||||||
|
<EmptyState v-else type="no-search-result" :heading="noResultsHeading" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<EmptyState
|
||||||
|
v-else
|
||||||
|
type="no-search-result"
|
||||||
|
heading="Enter a search term to get started"
|
||||||
|
description="Type at least 3 characters of a project's title to begin browsing."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
|
import { BinaryIcon, EditIcon, HashIcon, SearchIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
ButtonStyled,
|
||||||
|
Combobox,
|
||||||
|
type ComboboxOption,
|
||||||
|
EmptyState,
|
||||||
|
type ExternalLicenseStatus,
|
||||||
|
externalProjectLicenseStatusMessages,
|
||||||
|
ExternalProjectLookupCard,
|
||||||
|
injectModrinthClient,
|
||||||
|
NewModal,
|
||||||
|
StyledInput,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isSavingEdit = ref(false)
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const editModal = useTemplateRef<InstanceType<typeof NewModal>>('editModal')
|
||||||
|
|
||||||
|
useHead({ title: 'External projects - Modrinth' })
|
||||||
|
|
||||||
|
type ExternalProject = {
|
||||||
|
id: number
|
||||||
|
title: string | null
|
||||||
|
status: ExternalLicenseStatus
|
||||||
|
link: string | null
|
||||||
|
exceptions: string | null
|
||||||
|
proof: string | null
|
||||||
|
flame_project_id: number | null
|
||||||
|
files: {
|
||||||
|
sha1: string
|
||||||
|
name: string | null
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchKind = 'none' | 'title' | 'flame_id' | 'sha1'
|
||||||
|
|
||||||
|
const lastSearchKind = ref<SearchKind>('none')
|
||||||
|
const activeQuery = ref('')
|
||||||
|
const externalProjects = ref<ExternalProject[]>([])
|
||||||
|
|
||||||
|
function mapExternalProject(
|
||||||
|
project: Labrinth.ExternalProjects.Internal.ExternalProject,
|
||||||
|
): ExternalProject {
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
title: project.title,
|
||||||
|
status: project.status,
|
||||||
|
link: project.link,
|
||||||
|
exceptions: project.exceptions,
|
||||||
|
proof: project.proof,
|
||||||
|
flame_project_id: project.flame_project_id,
|
||||||
|
files: project.linked_files,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayProjects = computed(() => {
|
||||||
|
return externalProjects.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const lookupNeedsInput = computed(() => {
|
||||||
|
const q = activeQuery.value
|
||||||
|
const kind = lastSearchKind.value
|
||||||
|
|
||||||
|
if (kind === 'title') {
|
||||||
|
return q.length < 3
|
||||||
|
}
|
||||||
|
if (kind === 'flame_id' || kind === 'sha1') {
|
||||||
|
return q.trim().length === 0
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const lookupEmptyHeading = computed(() => {
|
||||||
|
const kind = lastSearchKind.value
|
||||||
|
if (kind === 'title') {
|
||||||
|
return 'Enter a search term to get started'
|
||||||
|
}
|
||||||
|
if (kind === 'flame_id') {
|
||||||
|
return 'Enter a CurseForge project ID'
|
||||||
|
}
|
||||||
|
if (kind === 'sha1') {
|
||||||
|
return 'Enter a SHA-1 hash'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const lookupEmptyDescription = computed(() => {
|
||||||
|
const kind = lastSearchKind.value
|
||||||
|
if (kind === 'title') {
|
||||||
|
return "Type at least 3 characters of a project's title to begin browsing."
|
||||||
|
}
|
||||||
|
if (kind === 'flame_id') {
|
||||||
|
return 'Type the numeric project ID, then use lookup for an exact match.'
|
||||||
|
}
|
||||||
|
if (kind === 'sha1') {
|
||||||
|
return 'Paste the full 40-character hex hash, then use lookup for an exact match.'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const noResultsHeading = computed(() => {
|
||||||
|
const kind = lastSearchKind.value
|
||||||
|
if (kind === 'title') {
|
||||||
|
return 'No projects matched that title search'
|
||||||
|
}
|
||||||
|
if (kind === 'flame_id') {
|
||||||
|
return 'No external project has that CurseForge ID'
|
||||||
|
}
|
||||||
|
if (kind === 'sha1') {
|
||||||
|
return 'No external file has that SHA-1 hash'
|
||||||
|
}
|
||||||
|
return 'No projects matched that title search'
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusOptions = computed<ComboboxOption<ExternalLicenseStatus>[]>(() => [
|
||||||
|
{ value: 'yes' as const, label: formatMessage(externalProjectLicenseStatusMessages.yes) },
|
||||||
|
{
|
||||||
|
value: 'with-attribution-and-source' as const,
|
||||||
|
label: formatMessage(externalProjectLicenseStatusMessages['with-attribution-and-source']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'with-attribution' as const,
|
||||||
|
label: formatMessage(externalProjectLicenseStatusMessages['with-attribution']),
|
||||||
|
},
|
||||||
|
{ value: 'no' as const, label: formatMessage(externalProjectLicenseStatusMessages.no) },
|
||||||
|
{
|
||||||
|
value: 'permanent-no' as const,
|
||||||
|
label: formatMessage(externalProjectLicenseStatusMessages['permanent-no']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'unidentified' as const,
|
||||||
|
label: formatMessage(externalProjectLicenseStatusMessages.unidentified),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const editingProjectId = ref<number | null>(null)
|
||||||
|
const editForm = ref({
|
||||||
|
title: '',
|
||||||
|
status: 'unidentified' as ExternalLicenseStatus,
|
||||||
|
link: '',
|
||||||
|
proof: '',
|
||||||
|
exceptions: '',
|
||||||
|
flameProjectId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function openEditModal(project: ExternalProject) {
|
||||||
|
editingProjectId.value = project.id
|
||||||
|
editForm.value = {
|
||||||
|
title: project.title ?? '',
|
||||||
|
status: project.status,
|
||||||
|
link: project.link ?? '',
|
||||||
|
proof: project.proof ?? '',
|
||||||
|
exceptions: project.exceptions ?? '',
|
||||||
|
flameProjectId: project.flame_project_id?.toString() ?? '',
|
||||||
|
}
|
||||||
|
editModal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
editModal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveExternalProjectEdit() {
|
||||||
|
if (!editingProjectId.value) return
|
||||||
|
isSavingEdit.value = true
|
||||||
|
|
||||||
|
const parsedFlameProjectId = Number.parseInt(editForm.value.flameProjectId.trim(), 10)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await client.labrinth.external_projects_internal.update(
|
||||||
|
editingProjectId.value,
|
||||||
|
{
|
||||||
|
status: editForm.value.status,
|
||||||
|
title: editForm.value.title.trim() || undefined,
|
||||||
|
link: editForm.value.link.trim() || undefined,
|
||||||
|
proof: editForm.value.proof.trim() || undefined,
|
||||||
|
exceptions: editForm.value.exceptions.trim() || undefined,
|
||||||
|
flame_project_id: Number.isFinite(parsedFlameProjectId) ? parsedFlameProjectId : undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const mapped = mapExternalProject(updated)
|
||||||
|
const index = externalProjects.value.findIndex((project) => project.id === mapped.id)
|
||||||
|
if (index >= 0) {
|
||||||
|
externalProjects.value[index] = mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEditModal()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update external project', error)
|
||||||
|
} finally {
|
||||||
|
isSavingEdit.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeLookup(kind: SearchKind) {
|
||||||
|
if (kind === 'none') return
|
||||||
|
|
||||||
|
lastSearchKind.value = kind
|
||||||
|
activeQuery.value = query.value
|
||||||
|
externalProjects.value = []
|
||||||
|
|
||||||
|
if (lookupNeedsInput.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (kind === 'title') {
|
||||||
|
const response = await client.labrinth.external_projects_internal.search({
|
||||||
|
title: activeQuery.value.trim(),
|
||||||
|
})
|
||||||
|
externalProjects.value = response.map(mapExternalProject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'flame_id') {
|
||||||
|
const parsedFlameId = Number.parseInt(activeQuery.value.trim(), 10)
|
||||||
|
if (!Number.isFinite(parsedFlameId)) {
|
||||||
|
externalProjects.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await client.labrinth.external_projects_internal.search({
|
||||||
|
flame_id: parsedFlameId,
|
||||||
|
})
|
||||||
|
externalProjects.value = response.map(mapExternalProject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'sha1') {
|
||||||
|
const response = await client.labrinth.external_projects_internal.getBySha1(
|
||||||
|
activeQuery.value.trim(),
|
||||||
|
)
|
||||||
|
externalProjects.value = [mapExternalProject(response)]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to query external projects', error)
|
||||||
|
externalProjects.value = []
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeSearch() {
|
||||||
|
await executeLookup('title')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeFlameIdLookup() {
|
||||||
|
await executeLookup('flame_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeSha1Lookup() {
|
||||||
|
await executeLookup('sha1')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
21
apps/labrinth/.sqlx/query-56428d0eec87c0cdaca1183c642f0478b9974de6b6d95f7ca48de605b3bf1103.json
generated
Normal file
21
apps/labrinth/.sqlx/query-56428d0eec87c0cdaca1183c642f0478b9974de6b6d95f7ca48de605b3bf1103.json
generated
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO moderation_external_licenses (id, title, status, link, proof, flame_project_id, inserted_at, inserted_by, updated_at, updated_by)\n SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::varchar[], $4::varchar[], $5::varchar[], $6::integer[], $7::timestamptz[], $8::bigint[], $7::timestamptz[], $8::bigint[])\n ON CONFLICT (id) DO UPDATE SET\n title = EXCLUDED.title,\n status = EXCLUDED.status,\n link = EXCLUDED.link,\n proof = EXCLUDED.proof,\n flame_project_id = EXCLUDED.flame_project_id,\n updated_at = EXCLUDED.updated_at,\n updated_by = EXCLUDED.updated_by\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8Array",
|
||||||
|
"VarcharArray",
|
||||||
|
"VarcharArray",
|
||||||
|
"VarcharArray",
|
||||||
|
"VarcharArray",
|
||||||
|
"Int4Array",
|
||||||
|
"TimestamptzArray",
|
||||||
|
"Int8Array"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "56428d0eec87c0cdaca1183c642f0478b9974de6b6d95f7ca48de605b3bf1103"
|
||||||
|
}
|
||||||
83
apps/labrinth/.sqlx/query-6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57.json
generated
Normal file
83
apps/labrinth/.sqlx/query-6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57.json
generated
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"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 ",
|
||||||
|
"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": [
|
||||||
|
"Text",
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57"
|
||||||
|
}
|
||||||
34
apps/labrinth/.sqlx/query-7720108c0a9e93119f5252e2102eeea0ee67b228924e288d1d6c3e169e941688.json
generated
Normal file
34
apps/labrinth/.sqlx/query-7720108c0a9e93119f5252e2102eeea0ee67b228924e288d1d6c3e169e941688.json
generated
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT\n mef.external_license_id,\n mef.sha1,\n mef.filename\n FROM moderation_external_files mef\n WHERE mef.external_license_id = ANY($1)\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "external_license_id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "sha1",
|
||||||
|
"type_info": "Bytea"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "filename",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8Array"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "7720108c0a9e93119f5252e2102eeea0ee67b228924e288d1d6c3e169e941688"
|
||||||
|
}
|
||||||
82
apps/labrinth/.sqlx/query-99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4.json
generated
Normal file
82
apps/labrinth/.sqlx/query-99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4.json
generated
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"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 ",
|
||||||
|
"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": [
|
||||||
|
"Bytea"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4"
|
||||||
|
}
|
||||||
90
apps/labrinth/.sqlx/query-b539eccb9fbb13270748f5c102dc0eb3325a39daa45f8964704713fb704a3e26.json
generated
Normal file
90
apps/labrinth/.sqlx/query-b539eccb9fbb13270748f5c102dc0eb3325a39daa45f8964704713fb704a3e26.json
generated
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE moderation_external_licenses\n SET title = COALESCE($2, title),\n status = $3,\n link = COALESCE($4, link),\n exceptions = COALESCE($5, exceptions),\n proof = COALESCE($6, proof),\n flame_project_id = COALESCE($7, flame_project_id),\n updated_at = $8,\n updated_by = $9\n WHERE id = $1\n RETURNING id, title, status, link, exceptions, proof, flame_project_id,\n inserted_at, inserted_by, updated_at, updated_by\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",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Int4",
|
||||||
|
"Timestamptz",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "b539eccb9fbb13270748f5c102dc0eb3325a39daa45f8964704713fb704a3e26"
|
||||||
|
}
|
||||||
18
apps/labrinth/.sqlx/query-f26e41a619a51d5b5a39af6117b7d2f6106ec67a209a285b0a523a677dba4a5b.json
generated
Normal file
18
apps/labrinth/.sqlx/query-f26e41a619a51d5b5a39af6117b7d2f6106ec67a209a285b0a523a677dba4a5b.json
generated
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO moderation_external_files (sha1, filename, external_license_id, inserted_at, inserted_by, updated_at, updated_by)\n SELECT * FROM UNNEST ($1::bytea[], $2::varchar[], $3::bigint[], $4::timestamptz[], $5::bigint[], $4::timestamptz[], $5::bigint[])\n ON CONFLICT (sha1) DO UPDATE SET\n filename = COALESCE(EXCLUDED.filename, moderation_external_files.filename),\n external_license_id = EXCLUDED.external_license_id,\n updated_at = EXCLUDED.updated_at,\n updated_by = EXCLUDED.updated_by\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"ByteaArray",
|
||||||
|
"VarcharArray",
|
||||||
|
"Int8Array",
|
||||||
|
"TimestamptzArray",
|
||||||
|
"Int8Array"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "f26e41a619a51d5b5a39af6117b7d2f6106ec67a209a285b0a523a677dba4a5b"
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n INSERT INTO moderation_external_files (sha1, external_license_id)\n SELECT * FROM UNNEST ($1::bytea[], $2::bigint[])\n ON CONFLICT (sha1) DO NOTHING\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"ByteaArray",
|
|
||||||
"Int8Array"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "f297b517bc3bbd8628c0c222c0e3daf8f4efbe628ee2e8ddbbb4b9734cc9c915"
|
|
||||||
}
|
|
||||||
@@ -1,30 +1 @@
|
|||||||
# Labrinth
|
Read @AGENTS.md
|
||||||
|
|
||||||
Labrinth is the backend API service for Modrinth, written in Rust.
|
|
||||||
|
|
||||||
## 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>"`
|
|
||||||
|
|||||||
18
apps/labrinth/fixtures/license.sql
Normal file
18
apps/labrinth/fixtures/license.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Dummy moderation_external_licenses (explicit IDs required)
|
||||||
|
INSERT INTO moderation_external_licenses (id, title, status, link, exceptions, proof, flame_project_id, inserted_at, inserted_by, updated_at, updated_by)
|
||||||
|
VALUES
|
||||||
|
(9001, 'Example Mod', 'yes', 'https://example.com/license', NULL, 'Verified by team', 101, now(), 0, now(), 0),
|
||||||
|
(9002, 'Cool Resource Pack', 'no', 'https://coolpack.com/terms', 'Non-commercial only', 'DMCA takedown filed', 202, now(), 0, now(), 0),
|
||||||
|
(9003, 'Mystery Project', 'unidentified', NULL, NULL, NULL, NULL, now(), 0, now(), 0),
|
||||||
|
(9004, 'Widget Lib', 'with-attribution', 'https://widgets.dev/MIT', NULL, 'License header in JAR', 303, now(), 0, now(), 0),
|
||||||
|
(9005, 'Shadow Mod', 'permanent-no', 'https://shadow.net/eula', 'Redistribution restricted','Under review', NULL, now(), 0, now(), 0);
|
||||||
|
|
||||||
|
-- Dummy moderation_external_files (sha1 stored as ASCII bytes of hex string, matching Rust's .as_bytes())
|
||||||
|
INSERT INTO moderation_external_files (sha1, filename, external_license_id)
|
||||||
|
VALUES
|
||||||
|
('aabbccdd11223344aabbccdd11223344aabbccdd', 'example-mod-1.0.jar', 9001),
|
||||||
|
('11223344aabbccdd11223344aabbccdd11223344', 'example-mod-1.1.jar', 9001),
|
||||||
|
('deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', 'coolpack-v2.zip', 9002),
|
||||||
|
('cafebabecafebabecafebabecafebabecafebabe', 'mystery.dat', 9003),
|
||||||
|
('0102030405060708090a0b0c0d0e0f1011121314', 'widget-lib.jar', 9004);
|
||||||
|
-- License 9005 intentionally has no files (tests empty linked_files case)
|
||||||
18
apps/labrinth/fixtures/moderation-data.sql
Normal file
18
apps/labrinth/fixtures/moderation-data.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Dummy moderation_external_licenses (explicit IDs required)
|
||||||
|
INSERT INTO moderation_external_licenses (id, title, status, link, exceptions, proof, flame_project_id, inserted_at, inserted_by, updated_at, updated_by)
|
||||||
|
VALUES
|
||||||
|
(9001, 'Example Mod', 'yes', 'https://example.com/license', NULL, 'Verified by team', 101, now(), 0, now(), 0),
|
||||||
|
(9002, 'Cool Resource Pack', 'no', 'https://coolpack.com/terms', 'Non-commercial only', 'DMCA takedown filed', 202, now(), 0, now(), 0),
|
||||||
|
(9003, 'Mystery Project', 'unidentified', NULL, NULL, NULL, NULL, now(), 0, now(), 0),
|
||||||
|
(9004, 'Widget Lib', 'with-attribution', 'https://widgets.dev/MIT', NULL, 'License header in JAR', 303, now(), 0, now(), 0),
|
||||||
|
(9005, 'Shadow Mod', 'permanent-no', 'https://shadow.net/eula', 'Redistribution restricted','Under review', NULL, now(), 0, now(), 0);
|
||||||
|
|
||||||
|
-- Dummy moderation_external_files (sha1 stored as ASCII bytes of hex string, matching Rust's .as_bytes())
|
||||||
|
INSERT INTO moderation_external_files (sha1, filename, external_license_id)
|
||||||
|
VALUES
|
||||||
|
('aabbccdd11223344aabbccdd11223344aabbccdd', 'example-mod-1.0.jar', 9001),
|
||||||
|
('11223344aabbccdd11223344aabbccdd11223344', 'example-mod-1.1.jar', 9001),
|
||||||
|
('deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', 'coolpack-v2.zip', 9002),
|
||||||
|
('cafebabecafebabecafebabecafebabecafebabe', 'mystery.dat', 9003),
|
||||||
|
('0102030405060708090a0b0c0d0e0f1011121314', 'widget-lib.jar', 9004);
|
||||||
|
-- License 9005 intentionally has no files (tests empty linked_files case)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
ALTER TABLE moderation_external_licenses
|
||||||
|
ADD COLUMN inserted_at timestamptz,
|
||||||
|
ADD COLUMN inserted_by bigint REFERENCES users(id),
|
||||||
|
ADD COLUMN updated_at timestamptz,
|
||||||
|
ADD COLUMN updated_by bigint REFERENCES users(id);
|
||||||
|
|
||||||
|
ALTER TABLE moderation_external_files
|
||||||
|
ADD COLUMN filename text,
|
||||||
|
ADD COLUMN inserted_at timestamptz,
|
||||||
|
ADD COLUMN inserted_by bigint REFERENCES users(id),
|
||||||
|
ADD COLUMN updated_at timestamptz,
|
||||||
|
ADD COLUMN updated_by bigint REFERENCES users(id);
|
||||||
@@ -11,6 +11,7 @@ pub mod ids;
|
|||||||
pub mod image_item;
|
pub mod image_item;
|
||||||
pub mod legacy_loader_fields;
|
pub mod legacy_loader_fields;
|
||||||
pub mod loader_fields;
|
pub mod loader_fields;
|
||||||
|
pub mod moderation_external_item;
|
||||||
pub mod moderation_lock_item;
|
pub mod moderation_lock_item;
|
||||||
pub mod notification_item;
|
pub mod notification_item;
|
||||||
pub mod notifications_deliveries_item;
|
pub mod notifications_deliveries_item;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
use crate::database::models::DBUserId;
|
||||||
|
|
||||||
|
pub struct ExternalLicense {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub link: Option<String>,
|
||||||
|
pub proof: Option<String>,
|
||||||
|
pub flame_project_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExternalLicense {
|
||||||
|
pub async fn insert_many(
|
||||||
|
exec: impl sqlx::PgExecutor<'_>,
|
||||||
|
licenses: &[ExternalLicense],
|
||||||
|
user_id: DBUserId,
|
||||||
|
) -> sqlx::Result<()> {
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
let ids: Vec<i64> = licenses.iter().map(|x| x.id).collect();
|
||||||
|
let titles: Vec<Option<String>> =
|
||||||
|
licenses.iter().map(|x| x.title.clone()).collect();
|
||||||
|
let statuses: Vec<String> =
|
||||||
|
licenses.iter().map(|x| x.status.clone()).collect();
|
||||||
|
let links: Vec<Option<String>> =
|
||||||
|
licenses.iter().map(|x| x.link.clone()).collect();
|
||||||
|
let proofs: Vec<Option<String>> =
|
||||||
|
licenses.iter().map(|x| x.proof.clone()).collect();
|
||||||
|
let flame_ids: Vec<Option<i32>> =
|
||||||
|
licenses.iter().map(|x| x.flame_project_id).collect();
|
||||||
|
let nows: Vec<DateTime<Utc>> = vec![now; licenses.len()];
|
||||||
|
let user_ids: Vec<i64> = vec![user_id.0; licenses.len()];
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO moderation_external_licenses (id, title, status, link, proof, flame_project_id, inserted_at, inserted_by, updated_at, updated_by)
|
||||||
|
SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::varchar[], $4::varchar[], $5::varchar[], $6::integer[], $7::timestamptz[], $8::bigint[], $7::timestamptz[], $8::bigint[])
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
link = EXCLUDED.link,
|
||||||
|
proof = EXCLUDED.proof,
|
||||||
|
flame_project_id = EXCLUDED.flame_project_id,
|
||||||
|
updated_at = EXCLUDED.updated_at,
|
||||||
|
updated_by = EXCLUDED.updated_by
|
||||||
|
"#,
|
||||||
|
&ids,
|
||||||
|
&titles as _,
|
||||||
|
&statuses,
|
||||||
|
&links as _,
|
||||||
|
&proofs as _,
|
||||||
|
&flame_ids as _,
|
||||||
|
&nows,
|
||||||
|
&user_ids,
|
||||||
|
)
|
||||||
|
.execute(exec)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert_files(
|
||||||
|
exec: impl sqlx::PgExecutor<'_>,
|
||||||
|
hashes: &[Vec<u8>],
|
||||||
|
filenames: &[Option<String>],
|
||||||
|
license_ids: &[i64],
|
||||||
|
user_id: DBUserId,
|
||||||
|
) -> sqlx::Result<()> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let nows: Vec<DateTime<Utc>> = vec![now; license_ids.len()];
|
||||||
|
let user_ids: Vec<i64> = vec![user_id.0; license_ids.len()];
|
||||||
|
|
||||||
|
let filenames: Vec<Option<String>> = filenames.to_vec();
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO moderation_external_files (sha1, filename, external_license_id, inserted_at, inserted_by, updated_at, updated_by)
|
||||||
|
SELECT * FROM UNNEST ($1::bytea[], $2::varchar[], $3::bigint[], $4::timestamptz[], $5::bigint[], $4::timestamptz[], $5::bigint[])
|
||||||
|
ON CONFLICT (sha1) DO UPDATE SET
|
||||||
|
filename = COALESCE(EXCLUDED.filename, moderation_external_files.filename),
|
||||||
|
external_license_id = EXCLUDED.external_license_id,
|
||||||
|
updated_at = EXCLUDED.updated_at,
|
||||||
|
updated_by = EXCLUDED.updated_by
|
||||||
|
"#,
|
||||||
|
hashes,
|
||||||
|
&filenames as _,
|
||||||
|
license_ids,
|
||||||
|
&nows,
|
||||||
|
&user_ids,
|
||||||
|
)
|
||||||
|
.execute(exec)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::auth::checks::filter_visible_versions;
|
use crate::auth::checks::filter_visible_versions;
|
||||||
use crate::database;
|
use crate::database;
|
||||||
use crate::database::PgPool;
|
use crate::database::PgPool;
|
||||||
|
use crate::database::models::DBUserId;
|
||||||
use crate::database::models::notification_item::NotificationBuilder;
|
use crate::database::models::notification_item::NotificationBuilder;
|
||||||
use crate::database::models::thread_item::ThreadMessageBuilder;
|
use crate::database::models::thread_item::ThreadMessageBuilder;
|
||||||
use crate::database::redis::RedisPool;
|
use crate::database::redis::RedisPool;
|
||||||
@@ -507,6 +508,7 @@ impl AutomatedModerationQueue {
|
|||||||
.fetch_all(&pool).await?;
|
.fetch_all(&pool).await?;
|
||||||
|
|
||||||
let mut insert_hashes = Vec::new();
|
let mut insert_hashes = Vec::new();
|
||||||
|
let mut insert_filenames = Vec::new();
|
||||||
let mut insert_ids = Vec::new();
|
let mut insert_ids = Vec::new();
|
||||||
|
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@@ -518,6 +520,7 @@ impl AutomatedModerationQueue {
|
|||||||
});
|
});
|
||||||
|
|
||||||
insert_hashes.push(hash.clone().as_bytes().to_vec());
|
insert_hashes.push(hash.clone().as_bytes().to_vec());
|
||||||
|
insert_filenames.push(Some(file_name.clone()));
|
||||||
insert_ids.push(row.id);
|
insert_ids.push(row.id);
|
||||||
|
|
||||||
hashes.remove(index);
|
hashes.remove(index);
|
||||||
@@ -526,16 +529,13 @@ impl AutomatedModerationQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !insert_ids.is_empty() && !insert_hashes.is_empty() {
|
if !insert_ids.is_empty() && !insert_hashes.is_empty() {
|
||||||
sqlx::query!(
|
crate::database::models::moderation_external_item::ExternalLicense::insert_files(
|
||||||
"
|
&pool,
|
||||||
INSERT INTO moderation_external_files (sha1, external_license_id)
|
&insert_hashes,
|
||||||
SELECT * FROM UNNEST ($1::bytea[], $2::bigint[])
|
&insert_filenames,
|
||||||
ON CONFLICT (sha1) DO NOTHING
|
&insert_ids,
|
||||||
",
|
DBUserId(0),
|
||||||
&insert_hashes[..],
|
|
||||||
&insert_ids[..]
|
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
334
apps/labrinth/src/routes/internal/moderation/external_license.rs
Normal file
334
apps/labrinth/src/routes/internal/moderation/external_license.rs
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use actix_web::{HttpRequest, get, patch, post, web};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::database::PgPool;
|
||||||
|
use crate::database::redis::RedisPool;
|
||||||
|
use crate::models::pats::Scopes;
|
||||||
|
use crate::queue::moderation::ApprovalType;
|
||||||
|
use crate::routes::ApiError;
|
||||||
|
use crate::{auth::check_is_moderator_from_headers, queue::session::AuthQueue};
|
||||||
|
|
||||||
|
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||||
|
cfg.service(search)
|
||||||
|
.service(get_by_sha1)
|
||||||
|
.service(update_license);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct ExternalProject {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub status: ApprovalType,
|
||||||
|
pub link: Option<String>,
|
||||||
|
pub exceptions: Option<String>,
|
||||||
|
pub proof: Option<String>,
|
||||||
|
pub flame_project_id: Option<i32>,
|
||||||
|
pub inserted_at: Option<DateTime<Utc>>,
|
||||||
|
pub inserted_by: Option<i64>,
|
||||||
|
pub updated_at: Option<DateTime<Utc>>,
|
||||||
|
pub updated_by: Option<i64>,
|
||||||
|
pub linked_files: Vec<LinkedFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||||
|
pub struct LinkedFile {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub sha1: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct SearchRequest {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub flame_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct UpdateLicenseRequest {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub status: ApprovalType,
|
||||||
|
pub link: Option<String>,
|
||||||
|
pub exceptions: Option<String>,
|
||||||
|
pub proof: Option<String>,
|
||||||
|
pub flame_project_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LicenseRow {
|
||||||
|
id: i64,
|
||||||
|
title: Option<String>,
|
||||||
|
status: String,
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LicenseRow {
|
||||||
|
fn into_external_project(
|
||||||
|
self,
|
||||||
|
linked_files: Vec<LinkedFile>,
|
||||||
|
) -> ExternalProject {
|
||||||
|
ExternalProject {
|
||||||
|
id: self.id,
|
||||||
|
title: self.title,
|
||||||
|
status: ApprovalType::from_string(&self.status)
|
||||||
|
.unwrap_or(ApprovalType::Unidentified),
|
||||||
|
link: self.link,
|
||||||
|
exceptions: self.exceptions,
|
||||||
|
proof: self.proof,
|
||||||
|
flame_project_id: self.flame_project_id,
|
||||||
|
inserted_at: self.inserted_at,
|
||||||
|
inserted_by: self.inserted_by,
|
||||||
|
updated_at: self.updated_at,
|
||||||
|
updated_by: self.updated_by,
|
||||||
|
linked_files,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_linked_files(
|
||||||
|
pool: &PgPool,
|
||||||
|
license_ids: &[i64],
|
||||||
|
) -> Result<HashMap<i64, Vec<LinkedFile>>, ApiError> {
|
||||||
|
if license_ids.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_rows = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
mef.external_license_id,
|
||||||
|
mef.sha1,
|
||||||
|
mef.filename
|
||||||
|
FROM moderation_external_files mef
|
||||||
|
WHERE mef.external_license_id = ANY($1)
|
||||||
|
"#,
|
||||||
|
license_ids,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut map: HashMap<i64, Vec<LinkedFile>> = HashMap::new();
|
||||||
|
for row in file_rows {
|
||||||
|
map.entry(row.external_license_id)
|
||||||
|
.or_default()
|
||||||
|
.push(LinkedFile {
|
||||||
|
name: row.filename,
|
||||||
|
sha1: hex::encode(&row.sha1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path]
|
||||||
|
#[post("/search")]
|
||||||
|
async fn search(
|
||||||
|
req: HttpRequest,
|
||||||
|
pool: web::Data<PgPool>,
|
||||||
|
redis: web::Data<RedisPool>,
|
||||||
|
session_queue: web::Data<AuthQueue>,
|
||||||
|
body: web::Json<SearchRequest>,
|
||||||
|
) -> Result<web::Json<Vec<ExternalProject>>, ApiError> {
|
||||||
|
check_is_moderator_from_headers(
|
||||||
|
&req,
|
||||||
|
&**pool,
|
||||||
|
&redis,
|
||||||
|
&session_queue,
|
||||||
|
Scopes::PROJECT_READ,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
mel.id,
|
||||||
|
mel.title,
|
||||||
|
mel.status,
|
||||||
|
mel.link,
|
||||||
|
mel.exceptions,
|
||||||
|
mel.proof,
|
||||||
|
mel.flame_project_id,
|
||||||
|
mel.inserted_at,
|
||||||
|
mel.inserted_by,
|
||||||
|
mel.updated_at,
|
||||||
|
mel.updated_by
|
||||||
|
FROM moderation_external_licenses mel
|
||||||
|
WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%')
|
||||||
|
AND ($2::integer IS NULL OR mel.flame_project_id = $2)
|
||||||
|
ORDER BY mel.id
|
||||||
|
"#,
|
||||||
|
body.title,
|
||||||
|
body.flame_id,
|
||||||
|
)
|
||||||
|
.fetch_all(&**pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let license_ids: Vec<i64> = rows.iter().map(|r| r.id).collect();
|
||||||
|
let files_map = fetch_linked_files(&pool, &license_ids).await?;
|
||||||
|
|
||||||
|
let results = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let linked_files =
|
||||||
|
files_map.get(&row.id).cloned().unwrap_or_default();
|
||||||
|
LicenseRow {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
status: row.status,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
.into_external_project(linked_files)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(web::Json(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path]
|
||||||
|
#[get("/by-sha1/{sha1}")]
|
||||||
|
async fn get_by_sha1(
|
||||||
|
req: HttpRequest,
|
||||||
|
pool: web::Data<PgPool>,
|
||||||
|
redis: web::Data<RedisPool>,
|
||||||
|
session_queue: web::Data<AuthQueue>,
|
||||||
|
path: web::Path<(String,)>,
|
||||||
|
) -> Result<web::Json<ExternalProject>, ApiError> {
|
||||||
|
check_is_moderator_from_headers(
|
||||||
|
&req,
|
||||||
|
&**pool,
|
||||||
|
&redis,
|
||||||
|
&session_queue,
|
||||||
|
Scopes::PROJECT_READ,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let sha1 = path.into_inner().0;
|
||||||
|
|
||||||
|
let row = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
mel.id,
|
||||||
|
mel.title,
|
||||||
|
mel.status,
|
||||||
|
mel.link,
|
||||||
|
mel.exceptions,
|
||||||
|
mel.proof,
|
||||||
|
mel.flame_project_id,
|
||||||
|
mel.inserted_at,
|
||||||
|
mel.inserted_by,
|
||||||
|
mel.updated_at,
|
||||||
|
mel.updated_by
|
||||||
|
FROM moderation_external_files mef
|
||||||
|
INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id
|
||||||
|
WHERE mef.sha1 = $1
|
||||||
|
"#,
|
||||||
|
sha1.as_bytes().to_vec(),
|
||||||
|
)
|
||||||
|
.fetch_optional(&**pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
|
||||||
|
let files_map = fetch_linked_files(&pool, &[row.id]).await?;
|
||||||
|
let linked_files = files_map.get(&row.id).cloned().unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(web::Json(
|
||||||
|
LicenseRow {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
status: row.status,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
.into_external_project(linked_files),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path]
|
||||||
|
#[patch("/{id}")]
|
||||||
|
async fn update_license(
|
||||||
|
req: HttpRequest,
|
||||||
|
pool: web::Data<PgPool>,
|
||||||
|
redis: web::Data<RedisPool>,
|
||||||
|
session_queue: web::Data<AuthQueue>,
|
||||||
|
path: web::Path<(i64,)>,
|
||||||
|
body: web::Json<UpdateLicenseRequest>,
|
||||||
|
) -> Result<web::Json<ExternalProject>, ApiError> {
|
||||||
|
let user = check_is_moderator_from_headers(
|
||||||
|
&req,
|
||||||
|
&**pool,
|
||||||
|
&redis,
|
||||||
|
&session_queue,
|
||||||
|
Scopes::PROJECT_READ,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let id = path.into_inner().0;
|
||||||
|
|
||||||
|
let result = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
UPDATE moderation_external_licenses
|
||||||
|
SET title = COALESCE($2, title),
|
||||||
|
status = $3,
|
||||||
|
link = COALESCE($4, link),
|
||||||
|
exceptions = COALESCE($5, exceptions),
|
||||||
|
proof = COALESCE($6, proof),
|
||||||
|
flame_project_id = COALESCE($7, flame_project_id),
|
||||||
|
updated_at = $8,
|
||||||
|
updated_by = $9
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, title, status, link, exceptions, proof, flame_project_id,
|
||||||
|
inserted_at, inserted_by, updated_at, updated_by
|
||||||
|
"#,
|
||||||
|
id,
|
||||||
|
body.title,
|
||||||
|
body.status.as_str(),
|
||||||
|
body.link,
|
||||||
|
body.exceptions,
|
||||||
|
body.proof,
|
||||||
|
body.flame_project_id,
|
||||||
|
Utc::now(),
|
||||||
|
user.id.0 as i64,
|
||||||
|
)
|
||||||
|
.fetch_optional(&**pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
|
||||||
|
let files_map = fetch_linked_files(&pool, &[id]).await?;
|
||||||
|
let linked_files = files_map.get(&id).cloned().unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(web::Json(
|
||||||
|
LicenseRow {
|
||||||
|
id: result.id,
|
||||||
|
title: result.title,
|
||||||
|
status: result.status,
|
||||||
|
link: result.link,
|
||||||
|
exceptions: result.exceptions,
|
||||||
|
proof: result.proof,
|
||||||
|
flame_project_id: result.flame_project_id,
|
||||||
|
inserted_at: result.inserted_at,
|
||||||
|
inserted_by: result.inserted_by,
|
||||||
|
updated_at: result.updated_at,
|
||||||
|
updated_by: result.updated_by,
|
||||||
|
}
|
||||||
|
.into_external_project(linked_files),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ use crate::auth::get_user_from_headers;
|
|||||||
use crate::database;
|
use crate::database;
|
||||||
use crate::database::PgPool;
|
use crate::database::PgPool;
|
||||||
use crate::database::models::DBModerationLock;
|
use crate::database::models::DBModerationLock;
|
||||||
|
use crate::database::models::moderation_external_item;
|
||||||
use crate::database::redis::RedisPool;
|
use crate::database::redis::RedisPool;
|
||||||
use crate::models::ids::OrganizationId;
|
use crate::models::ids::OrganizationId;
|
||||||
use crate::models::projects::{Project, ProjectStatus};
|
use crate::models::projects::{Project, ProjectStatus};
|
||||||
@@ -20,6 +21,7 @@ use ownership::get_projects_ownership;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
mod external_license;
|
||||||
mod ownership;
|
mod ownership;
|
||||||
mod tech_review;
|
mod tech_review;
|
||||||
|
|
||||||
@@ -36,6 +38,10 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
|||||||
.service(
|
.service(
|
||||||
utoipa_actix_web::scope("/tech-review")
|
utoipa_actix_web::scope("/tech-review")
|
||||||
.configure(tech_review::config),
|
.configure(tech_review::config),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
utoipa_actix_web::scope("/external-license")
|
||||||
|
.configure(external_license::config),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +418,7 @@ async fn set_project_meta(
|
|||||||
session_queue: web::Data<AuthQueue>,
|
session_queue: web::Data<AuthQueue>,
|
||||||
judgements: web::Json<HashMap<String, Judgement>>,
|
judgements: web::Json<HashMap<String, Judgement>>,
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
check_is_moderator_from_headers(
|
let user = check_is_moderator_from_headers(
|
||||||
&req,
|
&req,
|
||||||
&**pool,
|
&**pool,
|
||||||
&redis,
|
&redis,
|
||||||
@@ -423,14 +429,10 @@ async fn set_project_meta(
|
|||||||
|
|
||||||
let mut transaction = pool.begin().await?;
|
let mut transaction = pool.begin().await?;
|
||||||
|
|
||||||
let mut ids = Vec::new();
|
let mut licenses = Vec::new();
|
||||||
let mut titles = Vec::new();
|
|
||||||
let mut statuses = Vec::new();
|
|
||||||
let mut links = Vec::new();
|
|
||||||
let mut proofs = Vec::new();
|
|
||||||
let mut flame_ids = Vec::new();
|
|
||||||
|
|
||||||
let mut file_hashes = Vec::new();
|
let mut file_hashes = Vec::new();
|
||||||
|
let mut file_filenames = Vec::new();
|
||||||
|
let mut file_license_ids = Vec::new();
|
||||||
|
|
||||||
for (hash, judgement) in judgements.0 {
|
for (hash, judgement) in judgements.0 {
|
||||||
let id = random_base62(8);
|
let id = random_base62(8);
|
||||||
@@ -456,41 +458,38 @@ async fn set_project_meta(
|
|||||||
} => (title, status, link, proof, None),
|
} => (title, status, link, proof, None),
|
||||||
};
|
};
|
||||||
|
|
||||||
ids.push(id as i64);
|
licenses.push(moderation_external_item::ExternalLicense {
|
||||||
titles.push(title);
|
id: id as i64,
|
||||||
statuses.push(status.as_str());
|
title,
|
||||||
links.push(link);
|
status: status.as_str().to_string(),
|
||||||
proofs.push(proof);
|
link,
|
||||||
flame_ids.push(flame_id);
|
proof,
|
||||||
|
flame_project_id: flame_id,
|
||||||
|
});
|
||||||
file_hashes.push(hash);
|
file_hashes.push(hash);
|
||||||
|
file_filenames.push(None);
|
||||||
|
file_license_ids.push(id as i64);
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query(
|
let user_id = database::models::ids::DBUserId::from(user.id);
|
||||||
"
|
|
||||||
INSERT INTO moderation_external_licenses (id, title, status, link, proof, flame_project_id)
|
|
||||||
SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::varchar[], $4::varchar[], $5::varchar[], $6::integer[])
|
|
||||||
"
|
|
||||||
)
|
|
||||||
.bind(&ids[..])
|
|
||||||
.bind(&titles[..])
|
|
||||||
.bind(&statuses[..])
|
|
||||||
.bind(&links[..])
|
|
||||||
.bind(&proofs[..])
|
|
||||||
.bind(&flame_ids[..])
|
|
||||||
.execute(&mut transaction)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
moderation_external_item::ExternalLicense::insert_many(
|
||||||
"
|
&mut transaction,
|
||||||
INSERT INTO moderation_external_files (sha1, external_license_id)
|
&licenses,
|
||||||
SELECT * FROM UNNEST ($1::bytea[], $2::bigint[])
|
user_id,
|
||||||
ON CONFLICT (sha1)
|
)
|
||||||
DO NOTHING
|
.await?;
|
||||||
",
|
|
||||||
|
moderation_external_item::ExternalLicense::insert_files(
|
||||||
|
&mut transaction,
|
||||||
|
&file_hashes
|
||||||
|
.iter()
|
||||||
|
.map(|x| x.as_bytes().to_vec())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
&file_filenames,
|
||||||
|
&file_license_ids,
|
||||||
|
user_id,
|
||||||
)
|
)
|
||||||
.bind(&file_hashes[..])
|
|
||||||
.bind(&ids[..])
|
|
||||||
.execute(&mut transaction)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
transaction.commit().await?;
|
transaction.commit().await?;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
|
|||||||
import { LabrinthAuthV2Module } from './labrinth/auth/v2'
|
import { LabrinthAuthV2Module } from './labrinth/auth/v2'
|
||||||
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
|
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
|
||||||
import { LabrinthCollectionsModule } from './labrinth/collections'
|
import { LabrinthCollectionsModule } from './labrinth/collections'
|
||||||
|
import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal'
|
||||||
import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal'
|
import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal'
|
||||||
import { LabrinthLimitsV3Module } from './labrinth/limits/v3'
|
import { LabrinthLimitsV3Module } from './labrinth/limits/v3'
|
||||||
import { LabrinthModerationInternalModule } from './labrinth/moderation/internal'
|
import { LabrinthModerationInternalModule } from './labrinth/moderation/internal'
|
||||||
@@ -75,6 +76,7 @@ export const MODULE_REGISTRY = {
|
|||||||
labrinth_auth_v2: LabrinthAuthV2Module,
|
labrinth_auth_v2: LabrinthAuthV2Module,
|
||||||
labrinth_billing_internal: LabrinthBillingInternalModule,
|
labrinth_billing_internal: LabrinthBillingInternalModule,
|
||||||
labrinth_collections: LabrinthCollectionsModule,
|
labrinth_collections: LabrinthCollectionsModule,
|
||||||
|
labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule,
|
||||||
labrinth_globals_internal: LabrinthGlobalsInternalModule,
|
labrinth_globals_internal: LabrinthGlobalsInternalModule,
|
||||||
labrinth_moderation_internal: LabrinthModerationInternalModule,
|
labrinth_moderation_internal: LabrinthModerationInternalModule,
|
||||||
labrinth_notifications_v2: LabrinthNotificationsV2Module,
|
labrinth_notifications_v2: LabrinthNotificationsV2Module,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { AbstractModule } from '../../../core/abstract-module'
|
||||||
|
import type { Labrinth } from '../types'
|
||||||
|
|
||||||
|
export class LabrinthExternalProjectsInternalModule extends AbstractModule {
|
||||||
|
public getModuleID(): string {
|
||||||
|
return 'labrinth_external_projects_internal'
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(
|
||||||
|
data: Labrinth.ExternalProjects.Internal.SearchRequest,
|
||||||
|
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject[]> {
|
||||||
|
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject[]>(
|
||||||
|
'/moderation/external-license/search',
|
||||||
|
{
|
||||||
|
api: 'labrinth',
|
||||||
|
version: 'internal',
|
||||||
|
method: 'POST',
|
||||||
|
body: data,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBySha1(
|
||||||
|
sha1: string,
|
||||||
|
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject> {
|
||||||
|
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject>(
|
||||||
|
`/moderation/external-license/by-sha1/${sha1}`,
|
||||||
|
{
|
||||||
|
api: 'labrinth',
|
||||||
|
version: 'internal',
|
||||||
|
method: 'GET',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(
|
||||||
|
id: number,
|
||||||
|
data: Labrinth.ExternalProjects.Internal.UpdateLicenseRequest,
|
||||||
|
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject> {
|
||||||
|
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject>(
|
||||||
|
`/moderation/external-license/${id}`,
|
||||||
|
{
|
||||||
|
api: 'labrinth',
|
||||||
|
version: 'internal',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: data,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export * from './auth/internal'
|
|||||||
export * from './auth/v2'
|
export * from './auth/v2'
|
||||||
export * from './billing/internal'
|
export * from './billing/internal'
|
||||||
export * from './collections'
|
export * from './collections'
|
||||||
|
export * from './external-projects/internal'
|
||||||
export * from './globals/internal'
|
export * from './globals/internal'
|
||||||
export * from './limits/v3'
|
export * from './limits/v3'
|
||||||
export * from './moderation/internal'
|
export * from './moderation/internal'
|
||||||
|
|||||||
@@ -1462,6 +1462,52 @@ export namespace Labrinth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace ExternalProjects {
|
||||||
|
export namespace Internal {
|
||||||
|
export type ExternalLicenseStatus =
|
||||||
|
| 'yes'
|
||||||
|
| 'with-attribution-and-source'
|
||||||
|
| 'with-attribution'
|
||||||
|
| 'no'
|
||||||
|
| 'permanent-no'
|
||||||
|
| 'unidentified'
|
||||||
|
|
||||||
|
export type LinkedFile = {
|
||||||
|
name: string | null
|
||||||
|
sha1: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExternalProject = {
|
||||||
|
id: number
|
||||||
|
title: string | null
|
||||||
|
status: ExternalLicenseStatus
|
||||||
|
link: string | null
|
||||||
|
exceptions: string | null
|
||||||
|
proof: string | null
|
||||||
|
flame_project_id: number | null
|
||||||
|
inserted_at: string | null
|
||||||
|
inserted_by: number | null
|
||||||
|
updated_at: string | null
|
||||||
|
updated_by: number | null
|
||||||
|
linked_files: LinkedFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SearchRequest = {
|
||||||
|
title?: string
|
||||||
|
flame_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateLicenseRequest = {
|
||||||
|
title?: string
|
||||||
|
status: ExternalLicenseStatus
|
||||||
|
link?: string
|
||||||
|
exceptions?: string
|
||||||
|
proof?: string
|
||||||
|
flame_project_id?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export namespace TechReview {
|
export namespace TechReview {
|
||||||
export namespace Internal {
|
export namespace Internal {
|
||||||
export type SearchProjectsRequest = {
|
export type SearchProjectsRequest = {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import _BadgeDollarSignIcon from './icons/badge-dollar-sign.svg?component'
|
|||||||
import _BanIcon from './icons/ban.svg?component'
|
import _BanIcon from './icons/ban.svg?component'
|
||||||
import _BellIcon from './icons/bell.svg?component'
|
import _BellIcon from './icons/bell.svg?component'
|
||||||
import _BellRingIcon from './icons/bell-ring.svg?component'
|
import _BellRingIcon from './icons/bell-ring.svg?component'
|
||||||
|
import _BinaryIcon from './icons/binary.svg?component'
|
||||||
import _BlendIcon from './icons/blend.svg?component'
|
import _BlendIcon from './icons/blend.svg?component'
|
||||||
import _BlocksIcon from './icons/blocks.svg?component'
|
import _BlocksIcon from './icons/blocks.svg?component'
|
||||||
import _BoldIcon from './icons/bold.svg?component'
|
import _BoldIcon from './icons/bold.svg?component'
|
||||||
@@ -211,6 +212,7 @@ import _ShieldIcon from './icons/shield.svg?component'
|
|||||||
import _ShieldAlertIcon from './icons/shield-alert.svg?component'
|
import _ShieldAlertIcon from './icons/shield-alert.svg?component'
|
||||||
import _ShieldCheckIcon from './icons/shield-check.svg?component'
|
import _ShieldCheckIcon from './icons/shield-check.svg?component'
|
||||||
import _SignalIcon from './icons/signal.svg?component'
|
import _SignalIcon from './icons/signal.svg?component'
|
||||||
|
import _SignatureIcon from './icons/signature.svg?component'
|
||||||
import _SkullIcon from './icons/skull.svg?component'
|
import _SkullIcon from './icons/skull.svg?component'
|
||||||
import _SlashIcon from './icons/slash.svg?component'
|
import _SlashIcon from './icons/slash.svg?component'
|
||||||
import _SortAscIcon from './icons/sort-asc.svg?component'
|
import _SortAscIcon from './icons/sort-asc.svg?component'
|
||||||
@@ -416,6 +418,7 @@ export const BadgeDollarSignIcon = _BadgeDollarSignIcon
|
|||||||
export const BanIcon = _BanIcon
|
export const BanIcon = _BanIcon
|
||||||
export const BellIcon = _BellIcon
|
export const BellIcon = _BellIcon
|
||||||
export const BellRingIcon = _BellRingIcon
|
export const BellRingIcon = _BellRingIcon
|
||||||
|
export const BinaryIcon = _BinaryIcon
|
||||||
export const BlendIcon = _BlendIcon
|
export const BlendIcon = _BlendIcon
|
||||||
export const BlocksIcon = _BlocksIcon
|
export const BlocksIcon = _BlocksIcon
|
||||||
export const BoldIcon = _BoldIcon
|
export const BoldIcon = _BoldIcon
|
||||||
@@ -601,6 +604,7 @@ export const ShieldIcon = _ShieldIcon
|
|||||||
export const ShieldAlertIcon = _ShieldAlertIcon
|
export const ShieldAlertIcon = _ShieldAlertIcon
|
||||||
export const ShieldCheckIcon = _ShieldCheckIcon
|
export const ShieldCheckIcon = _ShieldCheckIcon
|
||||||
export const SignalIcon = _SignalIcon
|
export const SignalIcon = _SignalIcon
|
||||||
|
export const SignatureIcon = _SignatureIcon
|
||||||
export const SkullIcon = _SkullIcon
|
export const SkullIcon = _SkullIcon
|
||||||
export const SlashIcon = _SlashIcon
|
export const SlashIcon = _SlashIcon
|
||||||
export const SortAscIcon = _SortAscIcon
|
export const SortAscIcon = _SortAscIcon
|
||||||
|
|||||||
20
packages/assets/icons/binary.svg
Normal file
20
packages/assets/icons/binary.svg
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-binary"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="14" y="14" width="4" height="6" rx="2" />
|
||||||
|
<rect x="6" y="4" width="4" height="6" rx="2" />
|
||||||
|
<path d="M6 20h4" />
|
||||||
|
<path d="M14 10h4" />
|
||||||
|
<path d="M6 14h2v6" />
|
||||||
|
<path d="M14 4h2v6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 487 B |
16
packages/assets/icons/signature.svg
Normal file
16
packages/assets/icons/signature.svg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-signature"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m21 17-2.156-1.868A.5.5 0 0 0 18 15.5v.5a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1c0-2.545-3.991-3.97-8.5-4a1 1 0 0 0 0 5c4.153 0 4.745-11.295 5.708-13.5a2.5 2.5 0 1 1 3.31 3.284" />
|
||||||
|
<path d="M3 21h18" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
@@ -71,6 +71,9 @@
|
|||||||
variant === 'outlined'
|
variant === 'outlined'
|
||||||
? 'bg-transparent border border-solid border-button-bg rounded-l-xl border-r-0'
|
? 'bg-transparent border border-solid border-button-bg rounded-l-xl border-r-0'
|
||||||
: 'bg-surface-4 border-none rounded-xl',
|
: 'bg-surface-4 border-none rounded-xl',
|
||||||
|
{
|
||||||
|
'placeholder:text-sm': type === 'search',
|
||||||
|
},
|
||||||
]"
|
]"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@focus="isFocused = true"
|
@focus="isFocused = true"
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { TagItem } from '#ui/components'
|
||||||
|
import { externalProjectLicenseStatusMessages } from '#ui/utils'
|
||||||
|
|
||||||
|
import { useVIntl } from '../../composables/i18n'
|
||||||
|
import type { ExternalLicenseStatus } from './types.ts'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
state: ExternalLicenseStatus
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TagItem>
|
||||||
|
{{ formatMessage(externalProjectLicenseStatusMessages[props.state]) }}
|
||||||
|
</TagItem>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ClipboardCopyIcon,
|
||||||
|
CurseForgeIcon,
|
||||||
|
FileIcon,
|
||||||
|
LinkIcon,
|
||||||
|
UnknownIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import { Menu } from 'floating-vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { ButtonStyled, CopyCode } from '#ui/components'
|
||||||
|
|
||||||
|
import ExternalProjectLicenseStateTag from './ExternalProjectLicenseStateTag.vue'
|
||||||
|
import type { ExternalLicenseStatus } from './types.ts'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string | null
|
||||||
|
link: string | null
|
||||||
|
state: ExternalLicenseStatus
|
||||||
|
proof: string | null
|
||||||
|
notes: string | null
|
||||||
|
last_updated?: string
|
||||||
|
last_updated_by?: string
|
||||||
|
cf_id?: number | null
|
||||||
|
files: {
|
||||||
|
sha1: string
|
||||||
|
name: string | null
|
||||||
|
}[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const lastEditedText = computed(() =>
|
||||||
|
props.last_updated
|
||||||
|
? `Last edited on ${props.last_updated} by ${props.last_updated_by ?? 'unknown'}`
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
|
||||||
|
async function copyProjectLink() {
|
||||||
|
if (!props.link) return
|
||||||
|
await navigator.clipboard.writeText(props.link)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-surface-3 p-4 rounded-2xl flex flex-col gap-3">
|
||||||
|
<div class="flex gap-4 justify-between">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-contrast font-semibold">{{ title }}</span>
|
||||||
|
<div v-if="!!link" class="flex gap-2 items-center">
|
||||||
|
<a
|
||||||
|
class="flex items-center gap-2 font-medium hover:underline focus:underline w-fit text-blue"
|
||||||
|
:href="link"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<template v-if="link?.includes('curseforge.com')">
|
||||||
|
<CurseForgeIcon class="size-5 shrink-0" />
|
||||||
|
CurseForge project
|
||||||
|
</template>
|
||||||
|
<template v-else> <LinkIcon class="size-5 shrink-0" /> Project link </template>
|
||||||
|
</a>
|
||||||
|
<ButtonStyled circular type="transparent" size="small">
|
||||||
|
<button v-tooltip="'Copy link'" @click="copyProjectLink">
|
||||||
|
<ClipboardCopyIcon />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2">
|
||||||
|
<div class="font-medium">Allowed:</div>
|
||||||
|
<div>
|
||||||
|
<ExternalProjectLicenseStateTag :state="state" />
|
||||||
|
</div>
|
||||||
|
<div class="font-medium">Proof:</div>
|
||||||
|
<div>{{ proof ?? 'N/A' }}</div>
|
||||||
|
<template v-if="cf_id">
|
||||||
|
<div class="font-medium">CurseForge ID:</div>
|
||||||
|
<CopyCode :text="`${cf_id}`" />
|
||||||
|
</template>
|
||||||
|
<div class="font-medium">Notes:</div>
|
||||||
|
<div>{{ notes ?? 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface-2 p-4 rounded-2xl flex flex-col gap-3">
|
||||||
|
<span class="text-contrast font-semibold">Files</span>
|
||||||
|
<span v-if="!(files?.length > 0)" class="text-secondary">
|
||||||
|
No files available for external project.
|
||||||
|
</span>
|
||||||
|
<div v-else class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
|
<Menu v-for="file in files" :key="file.sha1" :delay="{ hide: 50, show: 0 }">
|
||||||
|
<div
|
||||||
|
class="line-clamp-1 truncate px-2 py-1 flex gap-2 rounded-xl items-center border-solid border-2 border-surface-5 text-sm font-medium text-secondary"
|
||||||
|
>
|
||||||
|
<FileIcon class="shrink-0 size-4" /> {{ file.name ?? file.sha1 }}
|
||||||
|
</div>
|
||||||
|
<template #popper>
|
||||||
|
<div class="text-primary p-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2">
|
||||||
|
<div>First identified name:</div>
|
||||||
|
<div v-if="file.name" class="text-sm">
|
||||||
|
{{ file.name }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-secondary flex items-center gap-2 text-sm">
|
||||||
|
<UnknownIcon /> Unknown
|
||||||
|
</div>
|
||||||
|
<div>SHA-1:</div>
|
||||||
|
<div class="text-sm"><CopyCode :text="file.sha1" /></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="last_updated" class="pt-4 border-t-[1px] border-solid border-surface-5">
|
||||||
|
{{ lastEditedText }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronDownIcon, ListBulletedIcon, SaveIcon, VersionIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { Admonition, ButtonStyled, Chips, Collapsible, Combobox, StyledInput } from '#ui/components'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const collapsed = ref(true)
|
||||||
|
|
||||||
|
const selectedPermissionsType = ref('My project')
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="bg-surface-2 p-0 rounded-2xl flex flex-col border-[1px] border-solid border-surface-5 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="flex items-center bg-surface-3">
|
||||||
|
<button
|
||||||
|
class="flex grow m-0 appearance-none p-4 bg-transparent group transition-all"
|
||||||
|
@click="collapsed = !collapsed"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-3 group-active:scale-[0.98]">
|
||||||
|
<ChevronDownIcon
|
||||||
|
class="size-6 text-primary transition-transform duration-300"
|
||||||
|
:class="{ 'rotate-180': !collapsed }"
|
||||||
|
/>
|
||||||
|
<span class="text-contrast font-semibold">{{ title }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-2 m-4 ml-0">
|
||||||
|
<ButtonStyled type="outlined">
|
||||||
|
<button>
|
||||||
|
<ListBulletedIcon />
|
||||||
|
Versions
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Collapsible
|
||||||
|
:collapsed="collapsed"
|
||||||
|
class="border-0 border-solid border-t border-surface-5 rounded-b-2xl"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2 p-4">
|
||||||
|
<span class="text-contrast font-semibold">Included in versions:</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<template v-for="version in ['4.0.0', '3.5.15', '3.5.14']" :key="version">
|
||||||
|
<div
|
||||||
|
class="px-3 py-2 rounded-xl flex items-center gap-2 border-[1px] border-solid border-surface-5"
|
||||||
|
>
|
||||||
|
<VersionIcon />
|
||||||
|
{{ version }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-2xl p-4 mt-2 border-[1px] border-solid border-surface-5 flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<span class="text-contrast font-semibold">Type</span>
|
||||||
|
<Chips
|
||||||
|
v-model="selectedPermissionsType"
|
||||||
|
:items="['License', 'My project', 'Special permission', 'No permission']"
|
||||||
|
/>
|
||||||
|
<template v-if="selectedPermissionsType === 'License'">
|
||||||
|
<span>The license of this work permits you to redistribute it in your modpack.</span>
|
||||||
|
<span class="text-contrast font-semibold mt-1">License</span>
|
||||||
|
<Combobox
|
||||||
|
class="max-w-80"
|
||||||
|
:options="[{ label: 'MIT', value: 'MIT' }]"
|
||||||
|
:model-value="'MIT'"
|
||||||
|
/>
|
||||||
|
<span class="text-contrast font-semibold mt-1"> Link to work </span>
|
||||||
|
<StyledInput
|
||||||
|
type="text"
|
||||||
|
class="max-w-[30rem]"
|
||||||
|
placeholder="https://example.com/link-to-work"
|
||||||
|
/>
|
||||||
|
<span class="text-contrast font-semibold mt-1">
|
||||||
|
Notes
|
||||||
|
<span class="font-normal text-primary">(optional)</span>
|
||||||
|
</span>
|
||||||
|
<StyledInput
|
||||||
|
type="text"
|
||||||
|
resize="both"
|
||||||
|
multiline
|
||||||
|
class="max-w-[40rem]"
|
||||||
|
placeholder="Write something here..."
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="selectedPermissionsType === 'My project'">
|
||||||
|
<span>Original work created by you.</span>
|
||||||
|
<span class="text-contrast font-semibold mt-1">License</span>
|
||||||
|
<Combobox
|
||||||
|
class="max-w-80"
|
||||||
|
:options="[{ label: 'MIT', value: 'MIT' }]"
|
||||||
|
:model-value="'MIT'"
|
||||||
|
/>
|
||||||
|
<span class="text-contrast font-semibold mt-1">
|
||||||
|
Notes
|
||||||
|
<span class="font-normal text-primary">(optional)</span>
|
||||||
|
</span>
|
||||||
|
<StyledInput
|
||||||
|
type="text"
|
||||||
|
resize="both"
|
||||||
|
multiline
|
||||||
|
class="max-w-[40rem]"
|
||||||
|
placeholder="Write something here..."
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="selectedPermissionsType === 'Special permission'">
|
||||||
|
<span>
|
||||||
|
You have obtained special permission to redistribute this work in your modpack.
|
||||||
|
</span>
|
||||||
|
<span class="text-contrast font-semibold mt-1"> Link to work </span>
|
||||||
|
<StyledInput
|
||||||
|
type="text"
|
||||||
|
class="max-w-[30rem]"
|
||||||
|
placeholder="https://example.com/link-to-work"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col gap-1 mt-1">
|
||||||
|
<span class="text-contrast font-semibold"> Proof and explanation </span>
|
||||||
|
<span>
|
||||||
|
Include screenshots of messages, emails, or replies from the copyright owner showing
|
||||||
|
that they granted you permission to redistribute their work in your modpack.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<StyledInput
|
||||||
|
type="text"
|
||||||
|
resize="both"
|
||||||
|
multiline
|
||||||
|
class="max-w-[40rem]"
|
||||||
|
placeholder="Write something here..."
|
||||||
|
/>
|
||||||
|
<Admonition
|
||||||
|
type="warning"
|
||||||
|
header="Modrinth staff may attempt to verify submitted proof"
|
||||||
|
>
|
||||||
|
If you are found to have lied or manipulated the images uploaded, your project and
|
||||||
|
account may be terminated.
|
||||||
|
</Admonition>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="selectedPermissionsType === 'No permission'">
|
||||||
|
<span>You don't have permission to use this work.</span>
|
||||||
|
<span class="text-contrast font-semibold mt-1">
|
||||||
|
Notes
|
||||||
|
<span class="font-normal text-primary">(optional)</span>
|
||||||
|
</span>
|
||||||
|
<StyledInput
|
||||||
|
type="text"
|
||||||
|
resize="both"
|
||||||
|
multiline
|
||||||
|
class="max-w-[40rem]"
|
||||||
|
placeholder="Write something here..."
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<hr class="mt-1 bg-surface-5 border-none h-[1px] w-full" />
|
||||||
|
<div class="flex items-center gap-2 justify-end">
|
||||||
|
<ButtonStyled type="outlined">
|
||||||
|
<button>
|
||||||
|
<XIcon />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button>
|
||||||
|
<SaveIcon />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
3
packages/ui/src/components/external_files/index.ts
Normal file
3
packages/ui/src/components/external_files/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as ExternalProjectLicenseStateTag } from './ExternalProjectLicenseStateTag.vue'
|
||||||
|
export { default as ExternalProjectLookupCard } from './ExternalProjectLookupCard.vue'
|
||||||
|
export type { ExternalLicenseStatus } from './types.ts'
|
||||||
7
packages/ui/src/components/external_files/types.ts
Normal file
7
packages/ui/src/components/external_files/types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type ExternalLicenseStatus =
|
||||||
|
| 'yes'
|
||||||
|
| 'with-attribution-and-source'
|
||||||
|
| 'with-attribution'
|
||||||
|
| 'no'
|
||||||
|
| 'permanent-no'
|
||||||
|
| 'unidentified'
|
||||||
@@ -5,6 +5,7 @@ export * from './brand'
|
|||||||
export * from './changelog'
|
export * from './changelog'
|
||||||
export * from './chart'
|
export * from './chart'
|
||||||
export * from './content'
|
export * from './content'
|
||||||
|
export * from './external_files'
|
||||||
export * from './modal'
|
export * from './modal'
|
||||||
export * from './nav'
|
export * from './nav'
|
||||||
export * from './page'
|
export * from './page'
|
||||||
|
|||||||
@@ -758,6 +758,24 @@
|
|||||||
"creation-flow.title.set-up-server": {
|
"creation-flow.title.set-up-server": {
|
||||||
"defaultMessage": "Set up server"
|
"defaultMessage": "Set up server"
|
||||||
},
|
},
|
||||||
|
"external-project-license-status.no": {
|
||||||
|
"defaultMessage": "No"
|
||||||
|
},
|
||||||
|
"external-project-license-status.permanent-no": {
|
||||||
|
"defaultMessage": "Permanent no"
|
||||||
|
},
|
||||||
|
"external-project-license-status.unidentified": {
|
||||||
|
"defaultMessage": "Unidentified"
|
||||||
|
},
|
||||||
|
"external-project-license-status.with-attribution": {
|
||||||
|
"defaultMessage": "With attribution"
|
||||||
|
},
|
||||||
|
"external-project-license-status.with-attribution-and-source": {
|
||||||
|
"defaultMessage": "With attribution and source"
|
||||||
|
},
|
||||||
|
"external-project-license-status.yes": {
|
||||||
|
"defaultMessage": "Yes"
|
||||||
|
},
|
||||||
"files.conflict-modal.header": {
|
"files.conflict-modal.header": {
|
||||||
"defaultMessage": "Extract summary"
|
"defaultMessage": "Extract summary"
|
||||||
},
|
},
|
||||||
@@ -2789,6 +2807,9 @@
|
|||||||
"project.settings.upload.title": {
|
"project.settings.upload.title": {
|
||||||
"defaultMessage": "Upload"
|
"defaultMessage": "Upload"
|
||||||
},
|
},
|
||||||
|
"project.settings.versions.permissions": {
|
||||||
|
"defaultMessage": "Permissions"
|
||||||
|
},
|
||||||
"project.settings.versions.title": {
|
"project.settings.versions.title": {
|
||||||
"defaultMessage": "Versions"
|
"defaultMessage": "Versions"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -952,6 +952,10 @@ export const commonProjectSettingsMessages = defineMessages({
|
|||||||
id: 'project.settings.versions.title',
|
id: 'project.settings.versions.title',
|
||||||
defaultMessage: 'Versions',
|
defaultMessage: 'Versions',
|
||||||
},
|
},
|
||||||
|
permissions: {
|
||||||
|
id: 'project.settings.versions.permissions',
|
||||||
|
defaultMessage: 'Permissions',
|
||||||
|
},
|
||||||
view: {
|
view: {
|
||||||
id: 'project.settings.view.title',
|
id: 'project.settings.view.title',
|
||||||
defaultMessage: 'View',
|
defaultMessage: 'View',
|
||||||
@@ -1090,3 +1094,30 @@ export const paymentMethodMessages = defineMessages({
|
|||||||
defaultMessage: 'Charities',
|
defaultMessage: 'Charities',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const externalProjectLicenseStatusMessages = defineMessages({
|
||||||
|
yes: {
|
||||||
|
id: 'external-project-license-status.yes',
|
||||||
|
defaultMessage: 'Yes',
|
||||||
|
},
|
||||||
|
'with-attribution-and-source': {
|
||||||
|
id: 'external-project-license-status.with-attribution-and-source',
|
||||||
|
defaultMessage: 'With attribution and source',
|
||||||
|
},
|
||||||
|
'with-attribution': {
|
||||||
|
id: 'external-project-license-status.with-attribution',
|
||||||
|
defaultMessage: 'With attribution',
|
||||||
|
},
|
||||||
|
no: {
|
||||||
|
id: 'external-project-license-status.no',
|
||||||
|
defaultMessage: 'No',
|
||||||
|
},
|
||||||
|
'permanent-no': {
|
||||||
|
id: 'external-project-license-status.permanent-no',
|
||||||
|
defaultMessage: 'Permanent no',
|
||||||
|
},
|
||||||
|
unidentified: {
|
||||||
|
id: 'external-project-license-status.unidentified',
|
||||||
|
defaultMessage: 'Unidentified',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user