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:
Prospector
2026-05-04 09:31:37 -07:00
committed by GitHub
parent 565ac2cb53
commit e13a89dd72
40 changed files with 2099 additions and 95 deletions

View File

@@ -47,6 +47,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showDiscoverProjectButtons: false,
useV1ContentTabAPI: true,
labrinthApiCanary: false,
dismissedExternalProjectsInfo: false,
modpackPermissionsPage: false,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS

View File

@@ -323,6 +323,11 @@
color: 'orange',
link: '/moderation/reports',
},
{
id: 'external-projects',
color: 'orange',
link: '/moderation/external-projects',
},
{
divider: true,
},
@@ -377,6 +382,9 @@
<template #review-reports>
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.reports) }}
</template>
<template #external-projects>
<GlobeIcon aria-hidden="true" /> {{ formatMessage(messages.externalProjects) }}
</template>
<template #user-lookup>
<UserSearchIcon aria-hidden="true" /> {{ formatMessage(messages.lookupByEmail) }}
</template>
@@ -705,6 +713,7 @@ import {
DropdownIcon,
FileIcon,
GlassesIcon,
GlobeIcon,
HamburgerIcon,
HomeIcon,
IssuesIcon,
@@ -880,6 +889,10 @@ const messages = defineMessages({
id: 'layout.action.reports',
defaultMessage: 'Review reports',
},
externalProjects: {
id: 'layout.action.external-projects',
defaultMessage: 'External projects',
},
lookupByEmail: {
id: 'layout.action.lookup-by-email',
defaultMessage: 'Lookup by email',

View File

@@ -1676,6 +1676,9 @@
"layout.action.create-new": {
"message": "Create new..."
},
"layout.action.external-projects": {
"message": "External projects"
},
"layout.action.file-lookup": {
"message": "File lookup"
},
@@ -1928,6 +1931,9 @@
"moderation.moderate": {
"message": "Moderate"
},
"moderation.page.external-projects": {
"message": "External projects"
},
"moderation.page.projects": {
"message": "Projects"
},
@@ -1935,7 +1941,7 @@
"message": "Reports"
},
"moderation.page.technicalReview": {
"message": "Technical Review"
"message": "Tech review"
},
"muralpay.account-type.checking": {
"message": "Checking"
@@ -2621,6 +2627,45 @@
"project.settings.general.url.title": {
"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 isnt hosted on Modrinth, you need to let us know where its 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": {
"message": "Settings"
},
@@ -2639,6 +2684,15 @@
"project.versions.title": {
"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": {
"message": "You've already reported {title}"
},

View File

@@ -8,6 +8,7 @@ import {
InfoIcon,
LinkIcon,
ServerIcon,
SignatureIcon,
TagsIcon,
UsersIcon,
VersionIcon,
@@ -46,6 +47,12 @@ const navItems = computed(() => {
projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) &&
isStaff(currentMember.value?.user)
const hasPermissionsPage = computed(
() =>
flags.value.modpackPermissionsPage &&
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
)
const items = [
{
link: `/${base}/settings`,
@@ -75,6 +82,11 @@ const navItems = computed(() => {
label: formatMessage(commonProjectSettingsMessages.description),
icon: AlignLeftIcon,
},
hasPermissionsPage.value && {
link: `/${base}/settings/permissions`,
label: formatMessage(commonProjectSettingsMessages.permissions),
icon: SignatureIcon,
},
!isServerProject.value && {
link: `/${base}/settings/versions`,
label: formatMessage(commonProjectSettingsMessages.versions),

View 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 isnt hosted on Modrinth, you need to let us know where its 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>

View File

@@ -15,6 +15,37 @@
@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
v-if="versions?.length"
:project="project"
@@ -293,17 +324,21 @@ import {
MoreVerticalIcon,
PlusIcon,
ReportIcon,
RightArrowIcon,
ShareIcon,
TrashIcon,
} from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
ConfirmModal,
defineMessages,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
OverflowMenu,
ProjectPageVersions,
useVIntl,
} from '@modrinth/ui'
import { useTemplateRef } from 'vue'
@@ -315,6 +350,7 @@ const route = useRoute()
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const {
projectV2: project,
currentMember,
@@ -396,4 +432,23 @@ async function deleteVersion() {
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>

View File

@@ -15,7 +15,7 @@
</template>
<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'
definePageMeta({
@@ -37,12 +37,16 @@ const messages = defineMessages({
},
technicalReviewTitle: {
id: 'moderation.page.technicalReview',
defaultMessage: 'Technical Review',
defaultMessage: 'Tech review',
},
reportsTitle: {
id: 'moderation.page.reports',
defaultMessage: 'Reports',
},
externalFilesTitle: {
id: 'moderation.page.external-projects',
defaultMessage: 'External projects',
},
})
const moderationLinks = [
@@ -53,12 +57,18 @@ const moderationLinks = [
icon: ShieldCheckIcon,
},
{ label: formatMessage(messages.reportsTitle), href: '/moderation/reports', icon: ReportIcon },
{
label: formatMessage(messages.externalFilesTitle),
href: '/moderation/external-projects',
icon: GlobeIcon,
},
]
const mobileNavOptions = [
formatMessage(messages.projectsTitle),
formatMessage(messages.technicalReviewTitle),
formatMessage(messages.reportsTitle),
formatMessage(messages.externalFilesTitle),
]
const selectedChip = computed({

View 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>

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View File

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

View File

@@ -1,30 +1 @@
# Labrinth
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>"`
Read @AGENTS.md

View 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)

View 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)

View File

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

View File

@@ -11,6 +11,7 @@ pub mod ids;
pub mod image_item;
pub mod legacy_loader_fields;
pub mod loader_fields;
pub mod moderation_external_item;
pub mod moderation_lock_item;
pub mod notification_item;
pub mod notifications_deliveries_item;

View File

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

View File

@@ -1,6 +1,7 @@
use crate::auth::checks::filter_visible_versions;
use crate::database;
use crate::database::PgPool;
use crate::database::models::DBUserId;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::redis::RedisPool;
@@ -507,6 +508,7 @@ impl AutomatedModerationQueue {
.fetch_all(&pool).await?;
let mut insert_hashes = Vec::new();
let mut insert_filenames = Vec::new();
let mut insert_ids = Vec::new();
for row in rows {
@@ -518,6 +520,7 @@ impl AutomatedModerationQueue {
});
insert_hashes.push(hash.clone().as_bytes().to_vec());
insert_filenames.push(Some(file_name.clone()));
insert_ids.push(row.id);
hashes.remove(index);
@@ -526,16 +529,13 @@ impl AutomatedModerationQueue {
}
if !insert_ids.is_empty() && !insert_hashes.is_empty() {
sqlx::query!(
"
INSERT INTO moderation_external_files (sha1, external_license_id)
SELECT * FROM UNNEST ($1::bytea[], $2::bigint[])
ON CONFLICT (sha1) DO NOTHING
",
&insert_hashes[..],
&insert_ids[..]
crate::database::models::moderation_external_item::ExternalLicense::insert_files(
&pool,
&insert_hashes,
&insert_filenames,
&insert_ids,
DBUserId(0),
)
.execute(&pool)
.await?;
}

View 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),
))
}

View File

@@ -3,6 +3,7 @@ use crate::auth::get_user_from_headers;
use crate::database;
use crate::database::PgPool;
use crate::database::models::DBModerationLock;
use crate::database::models::moderation_external_item;
use crate::database::redis::RedisPool;
use crate::models::ids::OrganizationId;
use crate::models::projects::{Project, ProjectStatus};
@@ -20,6 +21,7 @@ use ownership::get_projects_ownership;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
mod external_license;
mod ownership;
mod tech_review;
@@ -36,6 +38,10 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
.service(
utoipa_actix_web::scope("/tech-review")
.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>,
judgements: web::Json<HashMap<String, Judgement>>,
) -> Result<(), ApiError> {
check_is_moderator_from_headers(
let user = check_is_moderator_from_headers(
&req,
&**pool,
&redis,
@@ -423,14 +429,10 @@ async fn set_project_meta(
let mut transaction = pool.begin().await?;
let mut ids = 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 licenses = 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 {
let id = random_base62(8);
@@ -456,41 +458,38 @@ async fn set_project_meta(
} => (title, status, link, proof, None),
};
ids.push(id as i64);
titles.push(title);
statuses.push(status.as_str());
links.push(link);
proofs.push(proof);
flame_ids.push(flame_id);
licenses.push(moderation_external_item::ExternalLicense {
id: id as i64,
title,
status: status.as_str().to_string(),
link,
proof,
flame_project_id: flame_id,
});
file_hashes.push(hash);
file_filenames.push(None);
file_license_ids.push(id as i64);
}
sqlx::query(
"
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?;
let user_id = database::models::ids::DBUserId::from(user.id);
sqlx::query(
"
INSERT INTO moderation_external_files (sha1, external_license_id)
SELECT * FROM UNNEST ($1::bytea[], $2::bigint[])
ON CONFLICT (sha1)
DO NOTHING
",
moderation_external_item::ExternalLicense::insert_many(
&mut transaction,
&licenses,
user_id,
)
.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?;
transaction.commit().await?;

View File

@@ -17,6 +17,7 @@ import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
import { LabrinthAuthV2Module } from './labrinth/auth/v2'
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal'
import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal'
import { LabrinthLimitsV3Module } from './labrinth/limits/v3'
import { LabrinthModerationInternalModule } from './labrinth/moderation/internal'
@@ -75,6 +76,7 @@ export const MODULE_REGISTRY = {
labrinth_auth_v2: LabrinthAuthV2Module,
labrinth_billing_internal: LabrinthBillingInternalModule,
labrinth_collections: LabrinthCollectionsModule,
labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule,
labrinth_globals_internal: LabrinthGlobalsInternalModule,
labrinth_moderation_internal: LabrinthModerationInternalModule,
labrinth_notifications_v2: LabrinthNotificationsV2Module,

View File

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

View File

@@ -2,6 +2,7 @@ export * from './auth/internal'
export * from './auth/v2'
export * from './billing/internal'
export * from './collections'
export * from './external-projects/internal'
export * from './globals/internal'
export * from './limits/v3'
export * from './moderation/internal'

View File

@@ -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 Internal {
export type SearchProjectsRequest = {

View File

@@ -26,6 +26,7 @@ import _BadgeDollarSignIcon from './icons/badge-dollar-sign.svg?component'
import _BanIcon from './icons/ban.svg?component'
import _BellIcon from './icons/bell.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 _BlocksIcon from './icons/blocks.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 _ShieldCheckIcon from './icons/shield-check.svg?component'
import _SignalIcon from './icons/signal.svg?component'
import _SignatureIcon from './icons/signature.svg?component'
import _SkullIcon from './icons/skull.svg?component'
import _SlashIcon from './icons/slash.svg?component'
import _SortAscIcon from './icons/sort-asc.svg?component'
@@ -416,6 +418,7 @@ export const BadgeDollarSignIcon = _BadgeDollarSignIcon
export const BanIcon = _BanIcon
export const BellIcon = _BellIcon
export const BellRingIcon = _BellRingIcon
export const BinaryIcon = _BinaryIcon
export const BlendIcon = _BlendIcon
export const BlocksIcon = _BlocksIcon
export const BoldIcon = _BoldIcon
@@ -601,6 +604,7 @@ export const ShieldIcon = _ShieldIcon
export const ShieldAlertIcon = _ShieldAlertIcon
export const ShieldCheckIcon = _ShieldCheckIcon
export const SignalIcon = _SignalIcon
export const SignatureIcon = _SignatureIcon
export const SkullIcon = _SkullIcon
export const SlashIcon = _SlashIcon
export const SortAscIcon = _SortAscIcon

View 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

View 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

View File

@@ -71,6 +71,9 @@
variant === 'outlined'
? 'bg-transparent border border-solid border-button-bg rounded-l-xl border-r-0'
: 'bg-surface-4 border-none rounded-xl',
{
'placeholder:text-sm': type === 'search',
},
]"
@input="onInput"
@focus="isFocused = true"

View File

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

View File

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

View File

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

View 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'

View File

@@ -0,0 +1,7 @@
export type ExternalLicenseStatus =
| 'yes'
| 'with-attribution-and-source'
| 'with-attribution'
| 'no'
| 'permanent-no'
| 'unidentified'

View File

@@ -5,6 +5,7 @@ export * from './brand'
export * from './changelog'
export * from './chart'
export * from './content'
export * from './external_files'
export * from './modal'
export * from './nav'
export * from './page'

View File

@@ -758,6 +758,24 @@
"creation-flow.title.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": {
"defaultMessage": "Extract summary"
},
@@ -2789,6 +2807,9 @@
"project.settings.upload.title": {
"defaultMessage": "Upload"
},
"project.settings.versions.permissions": {
"defaultMessage": "Permissions"
},
"project.settings.versions.title": {
"defaultMessage": "Versions"
},

View File

@@ -952,6 +952,10 @@ export const commonProjectSettingsMessages = defineMessages({
id: 'project.settings.versions.title',
defaultMessage: 'Versions',
},
permissions: {
id: 'project.settings.versions.permissions',
defaultMessage: 'Permissions',
},
view: {
id: 'project.settings.view.title',
defaultMessage: 'View',
@@ -1090,3 +1094,30 @@ export const paymentMethodMessages = defineMessages({
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',
},
})