mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49b235460c | ||
|
|
4abbd3b5af | ||
|
|
6a8ba22a89 | ||
|
|
f1002ebfa0 | ||
|
|
7e6dae1506 | ||
|
|
ecd77812ce | ||
|
|
8bc26f2cf1 | ||
|
|
99c77d756e | ||
|
|
af2224d44b | ||
|
|
c8b64df900 | ||
|
|
8658dbf3db | ||
|
|
e8114a3614 | ||
|
|
3cd1a06243 | ||
|
|
145c79fa51 | ||
|
|
690f5c148b | ||
|
|
cee57ffe6e | ||
|
|
84bcf366b1 | ||
|
|
2fecbe9c95 | ||
|
|
578b8df720 | ||
|
|
611e308134 | ||
|
|
549e9d5554 | ||
|
|
25c63b542c | ||
|
|
61209ac0e1 | ||
|
|
9071f7dee7 | ||
|
|
50eaecf86d | ||
|
|
6de97cc0a8 | ||
|
|
0bfa16295d | ||
|
|
5d7a77cdfc | ||
|
|
876a4b649d | ||
|
|
e16884aec5 | ||
|
|
e11cd519cd | ||
|
|
9ae1ddc5ab | ||
|
|
cf02d151cc | ||
|
|
61ff32a58b | ||
|
|
8d82bf38b1 | ||
|
|
2d3b4ab54c | ||
|
|
3eb76da8af | ||
|
|
00993a3ff8 | ||
|
|
1f28196649 | ||
|
|
030dc2b3f7 | ||
|
|
3801039771 | ||
|
|
c75e17ec0e | ||
|
|
7315c35a41 | ||
|
|
c07b7afef6 | ||
|
|
0d488d162e | ||
|
|
867aadc2e5 | ||
|
|
b6b5271e11 | ||
|
|
2ee217bdff | ||
|
|
9819611217 | ||
|
|
1fdaf8e001 | ||
|
|
84888dc788 | ||
|
|
91d74e7705 | ||
|
|
a0471dad2b | ||
|
|
bfd592487e | ||
|
|
d53d5c4538 | ||
|
|
e30cf265a4 | ||
|
|
1b26f55f1e | ||
|
|
d983f115ed | ||
|
|
f01d149603 | ||
|
|
b93f6c682c | ||
|
|
220733fa2f | ||
|
|
e33b1dcec5 | ||
|
|
796049f6b0 | ||
|
|
15f67eae3d | ||
|
|
d846ef7f7d | ||
|
|
8d4076f8f0 | ||
|
|
bc9e586092 | ||
|
|
e1f44b861e | ||
|
|
81abcf6403 | ||
|
|
873a12bae5 | ||
|
|
2d2598b221 | ||
|
|
804f6ca031 | ||
|
|
fd28721d35 |
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<ButtonStyled circular>
|
||||
<button :class="!collapsed && '[&>svg]:rotate-180'" @click="$emit('toggleCollapsed')">
|
||||
<button :class="{ '[&>svg]:rotate-180': !collapsed }" @click="$emit('toggleCollapsed')">
|
||||
<DropdownIcon class="duration-250 transition-transform ease-in-out" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -48,7 +48,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
useV1ContentTabAPI: true,
|
||||
labrinthApiCanary: false,
|
||||
dismissedExternalProjectsInfo: false,
|
||||
modpackPermissionsPage: false,
|
||||
showAllBanners: false,
|
||||
alwaysIgnoreErrorBanner: false,
|
||||
showViewProdRouteBanner: false,
|
||||
|
||||
@@ -2853,43 +2853,55 @@
|
||||
"message": "URL"
|
||||
},
|
||||
"project.settings.permissions.attention-needed.description.proj-approved": {
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files and any withheld versions will be automatically published."
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files. Once completed, withheld versions will be automatically published."
|
||||
},
|
||||
"project.settings.permissions.attention-needed.description.proj-draft": {
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files before you can submit your project for review."
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files before submitting your project for review."
|
||||
},
|
||||
"project.settings.permissions.attention-needed.title": {
|
||||
"message": "Unknown embedded content"
|
||||
"message": "Unknown external content"
|
||||
},
|
||||
"project.settings.permissions.completed.description": {
|
||||
"message": "All external content has attributions provided."
|
||||
"message": "All external content has permission information and attributions have been provided."
|
||||
},
|
||||
"project.settings.permissions.completed.title": {
|
||||
"message": "Attributions completed!"
|
||||
"message": "Permissions completed!"
|
||||
},
|
||||
"project.settings.permissions.empty-state.description": {
|
||||
"message": "None of your versions contain external content, so you don't need to worry about obtaining permissions."
|
||||
"message": "None of your project's versions contain external content, so you don't need to worry about obtaining permissions."
|
||||
},
|
||||
"project.settings.permissions.empty-state.heading": {
|
||||
"message": "You're all set!"
|
||||
},
|
||||
"project.settings.permissions.fail.description": {
|
||||
"message": "You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content."
|
||||
"message": "You may not have permission to redistribute some of the external content in your project. In order to publish on Modrinth, please remove this content or provide proof that you do have permission to use it."
|
||||
},
|
||||
"project.settings.permissions.fail.title": {
|
||||
"message": "Some content can't be included"
|
||||
},
|
||||
"project.settings.permissions.info-banner.description": {
|
||||
"message": "If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn about how to do this properly!"
|
||||
"message": "If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn more and get started!"
|
||||
},
|
||||
"project.settings.permissions.info-banner.title": {
|
||||
"message": "Learn how attributions work"
|
||||
"message": "Learn about distribution permissions"
|
||||
},
|
||||
"project.settings.permissions.learn-more": {
|
||||
"message": "Learn more"
|
||||
},
|
||||
"project.settings.permissions.no-results": {
|
||||
"message": "No external files match your search."
|
||||
},
|
||||
"project.settings.permissions.search-placeholder": {
|
||||
"message": "Search {count} {count, plural, one {external project} other {external projects}}..."
|
||||
"message": "Search {count} {count, plural, one {project} other {projects}}..."
|
||||
},
|
||||
"project.settings.permissions.sort.most-files": {
|
||||
"message": "Most files"
|
||||
},
|
||||
"project.settings.permissions.sort.recently-edited": {
|
||||
"message": "Recently edited"
|
||||
},
|
||||
"project.settings.permissions.sort.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"project.settings.title": {
|
||||
"message": "Settings"
|
||||
|
||||
@@ -47,10 +47,8 @@ const navItems = computed(() => {
|
||||
projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) &&
|
||||
isStaff(currentMember.value?.user)
|
||||
|
||||
const hasPermissionsPage = computed(
|
||||
() =>
|
||||
flags.value.modpackPermissionsPage &&
|
||||
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
|
||||
const hasPermissionsPage = computed(() =>
|
||||
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
|
||||
)
|
||||
|
||||
const items = [
|
||||
@@ -82,16 +80,16 @@ const navItems = computed(() => {
|
||||
label: formatMessage(commonProjectSettingsMessages.description),
|
||||
icon: AlignLeftIcon,
|
||||
},
|
||||
hasPermissionsPage.value && {
|
||||
link: `/${base}/settings/permissions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.permissions),
|
||||
icon: SignatureIcon,
|
||||
},
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/versions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.versions),
|
||||
icon: VersionIcon,
|
||||
},
|
||||
hasPermissionsPage.value && {
|
||||
link: `/${base}/settings/permissions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.permissions),
|
||||
icon: SignatureIcon,
|
||||
},
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/license`,
|
||||
label: formatMessage(commonProjectSettingsMessages.license),
|
||||
|
||||
@@ -1,51 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
ArrowDown10Icon,
|
||||
ArrowDownWideNarrowIcon,
|
||||
ClockArrowDownIcon,
|
||||
RightArrowIcon,
|
||||
SearchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
createAttributionGroupTitle,
|
||||
defineMessage,
|
||||
defineMessages,
|
||||
EmptyState,
|
||||
ExternalProjectPermissionsCard,
|
||||
injectModrinthClient,
|
||||
injectProjectPageContext,
|
||||
IntlFormatted,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import ExternalProjectPermissionsCard from '@modrinth/ui/src/components/external_files/ExternalProjectPermissionsCard.vue'
|
||||
import { ref } from 'vue'
|
||||
import { isStaff } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const isModerator = computed(() => {
|
||||
return isStaff(auth.value?.user)
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const flags = useFeatureFlags()
|
||||
const { projectV2: project } = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
if (!flags.value.modpackPermissionsPage) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
})
|
||||
type SortType = 'status' | 'most_files' | 'recently_edited'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const currentSortType = ref<SortType>('status')
|
||||
|
||||
const {
|
||||
data: attributionData,
|
||||
error: attributionError,
|
||||
isPending: pending,
|
||||
} = useQuery<Labrinth.Attribution.Internal.AttributionGroup[]>({
|
||||
queryKey: ['project-attribution', project.value.id],
|
||||
queryFn: () => labrinth.attribution_internal.listProjectAttribution(project.value.id),
|
||||
})
|
||||
|
||||
const sortTypes = computed<ComboboxOption<SortType>[]>(() => [
|
||||
{
|
||||
value: 'status',
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: 'project.settings.permissions.sort.status',
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'most_files',
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: 'project.settings.permissions.sort.most-files',
|
||||
defaultMessage: 'Most files',
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'recently_edited',
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: 'project.settings.permissions.sort.recently-edited',
|
||||
defaultMessage: 'Recently edited',
|
||||
}),
|
||||
),
|
||||
},
|
||||
])
|
||||
|
||||
const currentSortLabel = computed(() => {
|
||||
return sortTypes.value.find((option) => option.value === currentSortType.value)?.label ?? ''
|
||||
})
|
||||
|
||||
function isAttributed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return !!group.attribution && !isNoPermission(group)
|
||||
}
|
||||
|
||||
const externalFiles = ref([{}])
|
||||
const searchQuery = ref('')
|
||||
const currentSortType = ref('Oldest')
|
||||
function isNoPermission(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return group.attribution?.kind === 'no_permission'
|
||||
}
|
||||
|
||||
function statusSortRank(group: Labrinth.Attribution.Internal.AttributionGroup): number {
|
||||
if (isNoPermission(group)) {
|
||||
return 0
|
||||
} else if (!group.attribution) {
|
||||
return 1
|
||||
} else if (group.attribution?.kind === 'globally_allowed') {
|
||||
return 3
|
||||
} else {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
function alphabetSortKey(group: Labrinth.Attribution.Internal.AttributionGroup): string {
|
||||
return createAttributionGroupTitle(group, formatMessage)
|
||||
}
|
||||
|
||||
function compareAlphabetical(
|
||||
a: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
b: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
): number {
|
||||
return alphabetSortKey(a).localeCompare(alphabetSortKey(b), undefined, { sensitivity: 'base' })
|
||||
}
|
||||
|
||||
function attributedTimestamp(group: Labrinth.Attribution.Internal.AttributionGroup): number {
|
||||
if (!group.attributed_at) return Number.NEGATIVE_INFINITY
|
||||
const t = Date.parse(group.attributed_at)
|
||||
return Number.isNaN(t) ? Number.NEGATIVE_INFINITY : t
|
||||
}
|
||||
|
||||
function groupMatchesPermissionsSearch(
|
||||
group: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
queryTrimmed: string,
|
||||
): boolean {
|
||||
const query = queryTrimmed.toLowerCase()
|
||||
if (group.flame_project?.title?.toLowerCase().includes(query)) {
|
||||
return true
|
||||
}
|
||||
return (group.files ?? []).some((file) => file.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
const queryTrimmed = searchQuery.value.trim()
|
||||
const filtered = queryTrimmed
|
||||
? groups.filter((group) => groupMatchesPermissionsSearch(group, queryTrimmed))
|
||||
: [...groups]
|
||||
const sortMode = currentSortType.value
|
||||
filtered.sort(compareAlphabetical)
|
||||
filtered.sort((a, b) => {
|
||||
if (sortMode === 'status') {
|
||||
return statusSortRank(a) - statusSortRank(b)
|
||||
}
|
||||
if (sortMode === 'most_files') {
|
||||
const ac = a.files?.length ?? 0
|
||||
const bc = b.files?.length ?? 0
|
||||
return bc - ac
|
||||
}
|
||||
const at = attributedTimestamp(a)
|
||||
const bt = attributedTimestamp(b)
|
||||
return bt - at
|
||||
})
|
||||
return filtered
|
||||
})
|
||||
|
||||
const totalGroups = computed(() => attributionData.value?.length ?? 0)
|
||||
|
||||
const stats = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
let attributed = 0
|
||||
let pending = 0
|
||||
let noPermission = 0
|
||||
for (const group of groups) {
|
||||
if (isNoPermission(group)) {
|
||||
noPermission++
|
||||
} else if (isAttributed(group)) {
|
||||
attributed++
|
||||
} else {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
return { total: groups.length, attributed, pending, noPermission }
|
||||
})
|
||||
|
||||
const projectIsApproved = computed(() => project.value.status === 'approved')
|
||||
|
||||
const sortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Oldest', label: 'Oldest' },
|
||||
{ value: 'Newest', label: 'Newest' },
|
||||
]
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'project.settings.permissions.search-placeholder',
|
||||
defaultMessage:
|
||||
'Search {count} {count, plural, one {external project} other {external projects}}...',
|
||||
defaultMessage: 'Search {count} {count, plural, one {project} other {projects}}...',
|
||||
},
|
||||
infoBannerTitle: {
|
||||
id: 'project.settings.permissions.info-banner.title',
|
||||
defaultMessage: 'Learn how attributions work',
|
||||
defaultMessage: 'Learn about distribution permissions',
|
||||
},
|
||||
infoBannerDescription: {
|
||||
id: 'project.settings.permissions.info-banner.description',
|
||||
defaultMessage: `If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn about how to do this properly!`,
|
||||
defaultMessage: `If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn more and get started!`,
|
||||
},
|
||||
learnMore: {
|
||||
id: 'project.settings.permissions.learn-more',
|
||||
@@ -57,15 +203,16 @@ const messages = defineMessages({
|
||||
},
|
||||
emptyStateDescription: {
|
||||
id: 'project.settings.permissions.empty-state.description',
|
||||
defaultMessage: `None of your versions contain external content, so you don't need to worry about obtaining permissions.`,
|
||||
defaultMessage: `None of your project's versions contain external content, so you don't need to worry about obtaining permissions.`,
|
||||
},
|
||||
completedTitle: {
|
||||
id: 'project.settings.permissions.completed.title',
|
||||
defaultMessage: `Attributions completed!`,
|
||||
defaultMessage: `Permissions completed!`,
|
||||
},
|
||||
completedDescription: {
|
||||
id: 'project.settings.permissions.completed.description',
|
||||
defaultMessage: 'All external content has attributions provided.',
|
||||
defaultMessage:
|
||||
'All external content has permission information and attributions have been provided.',
|
||||
},
|
||||
failTitle: {
|
||||
id: 'project.settings.permissions.fail.title',
|
||||
@@ -73,19 +220,23 @@ const messages = defineMessages({
|
||||
},
|
||||
failDescription: {
|
||||
id: 'project.settings.permissions.fail.description',
|
||||
defaultMessage: `You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content.`,
|
||||
defaultMessage: `You may not have permission to redistribute some of the external content in your project. In order to publish on Modrinth, please remove this content or provide proof that you do have permission to use it.`,
|
||||
},
|
||||
attentionNeededTitle: {
|
||||
id: 'project.settings.permissions.attention-needed.title',
|
||||
defaultMessage: `Unknown embedded content`,
|
||||
defaultMessage: `Unknown external content`,
|
||||
},
|
||||
attentionNeededDescriptionApproved: {
|
||||
id: 'project.settings.permissions.attention-needed.description.proj-approved',
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files and any withheld versions will be automatically published.`,
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files. Once completed, withheld versions will be automatically published.`,
|
||||
},
|
||||
attentionNeededDescriptionDraft: {
|
||||
id: 'project.settings.permissions.attention-needed.description.proj-draft',
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files before you can submit your project for review.`,
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files before submitting your project for review.`,
|
||||
},
|
||||
noResults: {
|
||||
id: 'project.settings.permissions.no-results',
|
||||
defaultMessage: 'No external files match your search.',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -95,56 +246,73 @@ function dismissInfoBanner() {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<template v-if="externalFiles.length > 0">
|
||||
<Admonition
|
||||
v-if="!flags.dismissedExternalProjectsInfo"
|
||||
type="info"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.infoBannerTitle)"
|
||||
dismissible
|
||||
@dismiss="dismissInfoBanner"
|
||||
>
|
||||
<IntlFormatted :message-id="messages.infoBannerDescription">
|
||||
<template #link="{ children }">
|
||||
<a class="text-link" target="_blank"> <component :is="() => children" /> </a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
<template #actions>
|
||||
<div class="flex">
|
||||
<ButtonStyled color="blue">
|
||||
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<Admonition
|
||||
v-if="!flags.dismissedExternalProjectsInfo"
|
||||
type="info"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.infoBannerTitle)"
|
||||
dismissible
|
||||
@dismiss="dismissInfoBanner"
|
||||
>
|
||||
<IntlFormatted :message-id="messages.infoBannerDescription">
|
||||
<template #link="{ children }">
|
||||
<a class="text-link" target="_blank"> <component :is="() => children" /> </a>
|
||||
</template>
|
||||
</Admonition>
|
||||
</IntlFormatted>
|
||||
<template #actions>
|
||||
<div class="flex">
|
||||
<ButtonStyled color="blue">
|
||||
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</Admonition>
|
||||
<template v-if="pending">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="h-[56px] w-full animate-pulse rounded-2xl bg-surface-3"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="totalGroups > 0">
|
||||
<Admonition
|
||||
v-if="true"
|
||||
v-if="stats.pending === 0 && stats.noPermission === 0"
|
||||
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"
|
||||
v-else-if="stats.noPermission > 0"
|
||||
type="critical"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.failTitle)"
|
||||
:body="formatMessage(messages.failDescription)"
|
||||
/>
|
||||
<Admonition
|
||||
v-else-if="stats.pending > 0"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.attentionNeededTitle)"
|
||||
:body="
|
||||
formatMessage(
|
||||
projectIsApproved
|
||||
? messages.attentionNeededDescriptionApproved
|
||||
: messages.attentionNeededDescriptionDraft,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div class="grid grid-cols-[1fr_auto] gap-2">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
:placeholder="
|
||||
formatMessage(messages.searchPlaceholder, {
|
||||
count: externalFiles.length,
|
||||
count: totalGroups,
|
||||
})
|
||||
"
|
||||
:icon="SearchIcon"
|
||||
@@ -153,27 +321,52 @@ function dismissInfoBanner() {
|
||||
<div>
|
||||
<Combobox
|
||||
v-model="currentSortType"
|
||||
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||
class="!w-full flex-grow sm:!w-[220px] sm:flex-grow-0"
|
||||
:options="sortTypes"
|
||||
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<SortAscIcon
|
||||
v-if="currentSortType === 'Oldest'"
|
||||
<ArrowDownWideNarrowIcon
|
||||
v-if="currentSortType === 'status'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
|
||||
<span class="truncate text-contrast">{{ currentSortType }}</span>
|
||||
<ArrowDown10Icon
|
||||
v-else-if="currentSortType === 'most_files'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<ClockArrowDownIcon
|
||||
v-else-if="currentSortType === 'recently_edited'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<span class="truncate text-contrast">{{ currentSortLabel }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-3">
|
||||
<ExternalProjectPermissionsCard title="FTB Library" />
|
||||
<TransitionGroup name="list">
|
||||
<ExternalProjectPermissionsCard
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.id"
|
||||
:project-id="project.id"
|
||||
:group="group"
|
||||
:is-moderator="isModerator"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="filteredGroups.length === 0"
|
||||
:heading="formatMessage(messages.noResults)"
|
||||
type="no-search-result"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="attributionError">
|
||||
<p v-if="attributionError" class="my-12 text-center text-red">
|
||||
{{ attributionError }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<EmptyState
|
||||
:heading="formatMessage(messages.emptyStateHeading)"
|
||||
@@ -182,3 +375,18 @@ function dismissInfoBanner() {
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<style scoped>
|
||||
.list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.list-move {
|
||||
transition: transform 200ms ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,21 +14,22 @@
|
||||
proceed-label="Delete"
|
||||
@proceed="deleteVersion()"
|
||||
/>
|
||||
|
||||
<Admonition
|
||||
v-if="flags.modpackPermissionsPage && withheldVersions.length > 0"
|
||||
v-if="withheldVersions.length > 0"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
:header="
|
||||
formatMessage(messages.withheldVersionsWarningTitle, {
|
||||
count: withheldVersions.length,
|
||||
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
|
||||
version_name:
|
||||
withheldVersions.length === 1 ? withheldVersions[0].version_number : undefined,
|
||||
})
|
||||
"
|
||||
:body="
|
||||
formatMessage(messages.withheldVersionsWarningDescription, {
|
||||
count: withheldVersions.length,
|
||||
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
|
||||
version_name:
|
||||
withheldVersions.length === 1 ? withheldVersions[0].version_number : undefined,
|
||||
})
|
||||
"
|
||||
>
|
||||
@@ -454,7 +455,9 @@ async function deleteVersion() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const withheldVersions = computed(() => ['4.0.0'])
|
||||
const withheldVersions = computed(() =>
|
||||
versions.value.filter((x) => x.files_missing_attribution?.length > 0),
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
withheldVersionsWarningTitle: {
|
||||
|
||||
@@ -185,7 +185,7 @@ function mapExternalProject(
|
||||
exceptions: project.exceptions,
|
||||
proof: project.proof,
|
||||
flame_project_id: project.flame_project_id,
|
||||
files: project.linked_files,
|
||||
files: project.linked_files ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -280,11 +280,11 @@ import {
|
||||
VersionIcon,
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { defineMessage } from '@modrinth/ui'
|
||||
import {
|
||||
AutoLink,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessage,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_licenses mel\n WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%')\n AND ($2::integer IS NULL OR mel.flame_project_id = $2)\n ORDER BY mel.id\n ",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_licenses mel\n WHERE mel.flame_project_id = ANY($1)\n ORDER BY mel.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -61,8 +61,7 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4"
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -79,5 +78,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57"
|
||||
"hash": "03d85f360d4c603688d0662d24caee7b59985adba006fb531beb09f195582277"
|
||||
}
|
||||
Generated
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select\n fa.file_id as \"file_id: DBFileId\",\n f.url,\n v.mod_id as \"project_id: DBProjectId\"\n from file_scans fa\n inner join files f on f.id = fa.file_id\n inner join versions v on v.id = f.version_id\n where fa.attributions_scanned_at is null\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "file_id: DBFileId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "url",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "project_id: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0b06b60b7169a3f9ee66c7ec26f94982619e95a78ecb2d3ffa55774b098e0531"
|
||||
}
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id\n WHERE mef.sha1 = $1\n ",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_licenses mel\n WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%')\n AND (\n ($2::integer IS NULL AND $3::integer[] IS NULL)\n OR mel.flame_project_id = $2\n OR mel.flame_project_id = ANY($3)\n )\n ORDER BY mel.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -61,7 +61,9 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea"
|
||||
"Text",
|
||||
"Int4",
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -78,5 +80,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4"
|
||||
"hash": "0e9e528a008a9dd24acfabbffcfe8ee4fd48fbae7c6197bfbbf1923e6256658b"
|
||||
}
|
||||
Generated
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect\n\t\t\tg.id as \"id: DBAttributionGroupId\",\n\t\t\tg.flame_project,\n\t\t\tg.attribution,\n\t\t\tg.attributed_at,\n\t\t\tg.attributed_by as \"attributed_by: i64\"\n\t\tfrom project_attribution_groups g\n\t\twhere g.project_id = $1\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id: DBAttributionGroupId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "attribution",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "attributed_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "attributed_by: i64",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1d96df0ac64801641f9af796d482d2964c1acf2d03e15b8f1397340d0c994af6"
|
||||
}
|
||||
Generated
+88
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mef.sha1 hash,\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id\n WHERE mef.sha1 = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hash",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "25d6977fa2db63f979fbe391f3475445fa92c9bbb5b034531f5e033a18c2a6a3"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1)\n select $1, unnest($2::text[]), unnest($3::bytea[])\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"TextArray",
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2e6bb84f45c0f8ba7bb9b09bf3def4322f727c7af8bd4be49dc4d7c487b925ed"
|
||||
}
|
||||
Generated
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT mel.id, mel.flame_project_id, mel.status status, mel.link\n FROM moderation_external_licenses mel\n WHERE mel.flame_project_id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "2f56a06b78a810936a77547ab5890943d6aa3e9143a8e2617d025be960880223"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM project_attribution_groups g\n WHERE NOT EXISTS (\n SELECT 1\n FROM project_attribution_files paf\n INNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1\n WHERE paf.group_id = g.id\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "301959b1ec20a4925f86188bc5e1c0cdd11f0d15004ce9f91fbeefcc3a27f8f4"
|
||||
}
|
||||
Generated
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select id as \"id: DBAttributionGroupId\", flame_project\n from project_attribution_groups\n where project_id = $1 and flame_project is not null\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id: DBAttributionGroupId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "424b975139004d2854d9365ef950984dd1a65e333bb80924b5883df5e7d3cad2"
|
||||
}
|
||||
Generated
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select\n d.dependent_id as \"version_id: DBVersionId\",\n d.dependency_file_name as \"file_name!\",\n pag.attribution,\n pag.flame_project,\n pag.project_id as \"project_id: DBProjectId\"\n from dependencies d\n inner join files f on f.version_id = d.dependent_id\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join override_file_sources ofs on ofs.file_id = f.id\n inner join project_attribution_files paf on paf.sha1 = ofs.sha1\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where d.dependent_id = ANY($1)\n and d.dependency_file_name is not null\n and pag.attribution is not null\n and pag.attribution->>'kind' not in ('no_permission')\n and (\n pag.attribution->'moderation_status' is null\n or pag.attribution->'moderation_status'->>'kind' = 'approved'\n )\n and split_part(paf.name, '/', -1) = d.dependency_file_name\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "version_id: DBVersionId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "file_name!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "attribution",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "project_id: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "46e1f1512ff148b532fb382b51b6a5a4a81d3e8bc17886c75bc574cd5662c49f"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tupdate project_attribution_files\n\t\tset group_id = $1\n\t\twhere sha1 = $2\n\t\tand group_id in (\n\t\t\tselect id from project_attribution_groups where project_id = $3\n\t\t)\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4fb69c674dca723b6b2cb7bea07d51feb43b2714c1cd6a2987a6fe1f10e2579a"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tupdate project_attribution_files\n\t\tset group_id = $1\n\t\twhere sha1 = $2 and group_id = $3\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5f7b9d628d3ff0addc12cfaab68d7cbcca08a89aedeb15ce5926ed87ed8a12c6"
|
||||
}
|
||||
Generated
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id,\n title,\n status,\n link,\n exceptions,\n proof,\n flame_project_id,\n inserted_at,\n inserted_by,\n updated_at,\n updated_by\n FROM moderation_external_licenses\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5f847946ce63d3773b9a5822971ee7acaafeed018ce0540b2826bd878f213b87"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tinsert into project_attribution_groups (id, project_id)\n\t\tvalues ($1, $2)\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6320df0491556027ee64fd031797b252c2145ecf179e9ea9a8cedbdc8b7ed622"
|
||||
}
|
||||
Generated
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO file_scans (file_id)\n VALUES ($1)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "64c6abc464f2df37372875817ceb67b0e33786fe3dd27c052311356d43b1d601"
|
||||
}
|
||||
Generated
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect exists(\n\t\t\tselect 1 from project_attribution_groups where id = $1 and project_id = $2\n\t\t) as \"exists!\"\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "73f7542b4b6738c4baf1e2a3513645c3bf07a960b7b9fac529f23099675ec0cc"
|
||||
}
|
||||
Generated
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into file_scans (file_id, attributions_scanned_at)\n values ($1, now())\n on conflict (file_id) do update set attributions_scanned_at = now()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "80751dbca09a9dcb469a30fe5b8225e520baf50750a78875f582349610439a55"
|
||||
}
|
||||
Generated
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n 'issues', (\n SELECT json_agg(\n to_jsonb(dri)\n || jsonb_build_object(\n -- TODO: replace with `json_array` in Postgres 16\n 'details', (\n SELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n )\n )\n FROM delphi_report_issues dri\n WHERE\n dri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json<FileReport>\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "data!: sqlx::types::Json<FileReport>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8820a5985291c159c98371c9650092e3eba21c81e3b3386be779978aff30451a"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM project_attribution_groups WHERE id=$1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8ffcb0c2bd82eaf64b143745adab359db6fcc448d6306292a7b345277a075883"
|
||||
}
|
||||
Generated
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\t\tselect\n\t\t\t\t\tid,\n\t\t\t\t\ttitle,\n\t\t\t\t\tstatus,\n\t\t\t\t\tlink,\n\t\t\t\t\texceptions,\n\t\t\t\t\tproof,\n\t\t\t\t\tflame_project_id,\n\t\t\t\t\tinserted_at,\n\t\t\t\t\tinserted_by,\n\t\t\t\t\tupdated_at,\n\t\t\t\t\tupdated_by\n\t\t\t\tfrom moderation_external_licenses\n\t\t\t\twhere id = ANY($1)\n\t\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "944286604bdef4ee40f79af358d1b68cc93bfd2067619d9cc1d03013f73e5424"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tupdate project_attribution_groups\n\t\tset attribution = $1, attributed_at = now(), attributed_by = $3\n\t\twhere id = $2\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "94920d66b27503c84744d41cb2b44dd213bb461596046488eaab0b62b0fe83c5"
|
||||
}
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)\n values ($1, $2, $3, $4)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "95750cd616b72347fb997c3b02a3d69f13be5993c1fd2e302761089c499fb015"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n update file_scans\n set attributions_scanned_at = now\n from unnest($1::bigint[], $2::timestamptz[]) as u(id, now)\n where file_scans.file_id = u.id\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array",
|
||||
"TimestamptzArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "968904f577c2c696c6222e19cc145bce0e845f2ab9f8629b0789a4861bddb4dc"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_groups (id, project_id)\n values ($1, $2)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b471add519874a6364b7a9cdbfa3a8e6f5a8a1ac9a7bacbce378f7995eb48664"
|
||||
}
|
||||
Generated
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect paf.group_id\n\t\tfrom project_attribution_files paf\n\t\tinner join project_attribution_groups pag on pag.id = paf.group_id\n\t\twhere paf.sha1 = $1 and pag.project_id = $2\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "group_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b782ed64e0df1e83623c8a8e55c00716f5338c85b6dfbb15c3465ff0cec339a7"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect attribution\n\t\tfrom project_attribution_groups\n\t\twhere id = $1\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "attribution",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bb7c239e2b424557f260c56c01e15ab8b0ec04d76a55a50888d28e6c5d3948bc"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1)\n values ($1, $2, $3)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c39e1f0d820101ac1b19a3849254b03259f38f3de9ddca32e6189a7e015dd853"
|
||||
}
|
||||
Generated
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect paf.group_id, paf.name from project_attribution_files paf\n\t\tinner join project_attribution_groups pag on pag.id = paf.group_id\n\t\twhere paf.sha1 = $1 and pag.project_id = $2\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "group_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa"
|
||||
}
|
||||
Generated
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select distinct f.version_id as \"version_id: DBVersionId\", f.id as \"file_id: DBFileId\",\n pag.flame_project\n from files f\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join override_file_sources ofs on ofs.file_id = f.id\n inner join project_attribution_files paf on paf.sha1 = ofs.sha1\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where f.version_id = ANY($1)\n and (\n pag.attribution is null\n or pag.attribution->>'kind' = 'no_permission'\n or (\n pag.attribution->'moderation_status' is not null\n and pag.attribution->'moderation_status'->>'kind' != 'approved'\n )\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "version_id: DBVersionId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "file_id: DBFileId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d9cd479a00fa1bdef23ad3ed781e0ab992b0305f07d5194a9166e5babb1dd44a"
|
||||
}
|
||||
Generated
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tselect id, name, version_number, date_published\n\t\t\tfrom versions\n\t\t\twhere id = ANY($1)\n\t\t\torder by date_published desc\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "version_number",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "date_published",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9"
|
||||
}
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_groups (id, project_id, attribution, flame_project)\n values ($1, $2, $3, $4)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375"
|
||||
}
|
||||
Generated
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select paf.sha1 from project_attribution_files paf\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where pag.project_id = $1 and paf.sha1 = ANY($2)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "sha1",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e4b58fe6e5d19ded48ab9e457b4e96fb51c8f74a7532bb9ab2e7a945b95bbe78"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into override_file_sources (sha1, file_id)\n select unnest($1::bytea[]), $2\n on conflict do nothing\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tdelete from project_attribution_groups g\n\t\twhere not exists (\n\t\t\tselect 1 from project_attribution_files f where f.group_id = g.id\n\t\t)\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ed027c5571c34ff4fd1ac50e96f257b0055c9497d2699927b34829490e7a2529"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_groups (id, project_id, flame_project)\n values ($1, $2, $3)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f9131eb55490b96851ac3760e3199d2fd2dbb3d212cb2b8687dda8ba0fad2a80"
|
||||
}
|
||||
Generated
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT encode(mef.sha1, 'escape') sha1, mel.id, mel.status status, mel.link\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id\n WHERE mef.sha1 = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "sha1",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f9b96c70c83f1bf0112243602843abae6ddc8851fc623ccf0a23f87567763485"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t'issues', (\n\t\t\t\t\tSELECT coalesce(json_agg(\n\t\t\t\t\t\tto_jsonb(dri)\n\t\t\t\t\t\t|| jsonb_build_object(\n\t\t\t\t\t\t\t-- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t\t\t\t'details', (\n\t\t\t\t\t\t\t\tSELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n\t\t\t\t\t\t)\n\t\t\t\t\t), '[]'::json)\n\t\t\t\t\tFROM delphi_report_issues dri\n\t\t\t\t\tWHERE\n\t\t\t\t\t\tdri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json<FileReport>\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "data!: sqlx::types::Json<FileReport>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Labrinth
|
||||
|
||||
Labrinth is the backend API service for Modrinth, written in Rust.
|
||||
|
||||
## Code style
|
||||
|
||||
- When writing `sqlx` queries, NEVER use `query` directly. Always prefer using the `query!`, `query_as!`, `query_scalar!` macros.
|
||||
|
||||
## Pre-PR Checks
|
||||
|
||||
When the user refers to "perform[ing] pre-PR checks", do the following:
|
||||
|
||||
- Run `cargo clippy -p labrinth --all-targets` — there must be ZERO warnings, otherwise CI will fail
|
||||
- DO NOT run tests unless explicitly requested (they take a long time)
|
||||
- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare -- --tests`
|
||||
- NEVER run `cargo sqlx prepare --workspace`
|
||||
|
||||
## Testing
|
||||
|
||||
- Run `cargo test -p labrinth --all-targets` to test your changes — all tests must pass
|
||||
|
||||
## Local Services
|
||||
|
||||
- Read the root `docker-compose.yml` to see what running services are available while developing
|
||||
- Use `docker exec` to access these services
|
||||
|
||||
### Clickhouse
|
||||
|
||||
- Access: `docker exec labrinth-clickhouse clickhouse-client`
|
||||
- Database: `staging_ariadne`
|
||||
|
||||
### Postgres
|
||||
|
||||
- Access: `docker exec labrinth-postgres psql -U labrinth -d labrinth -c "<query>"`
|
||||
@@ -0,0 +1,33 @@
|
||||
create table file_scans (
|
||||
file_id bigint primary key references files(id),
|
||||
-- if a file..
|
||||
-- - does not have a row
|
||||
-- -> was created before attributions system
|
||||
-- - has a row, but `attributions_scanned_at = null`
|
||||
-- -> still needs to be scanned
|
||||
-- - has a row, and `attributions_scanned_at` is not null
|
||||
-- -> attributions have been scanned
|
||||
attributions_scanned_at timestamptz
|
||||
);
|
||||
|
||||
create table project_attribution_groups (
|
||||
id bigint primary key,
|
||||
project_id bigint not null references mods(id),
|
||||
flame_project jsonb,
|
||||
attribution jsonb,
|
||||
attributed_at timestamptz,
|
||||
attributed_by bigint references users(id)
|
||||
);
|
||||
create index on project_attribution_groups (project_id);
|
||||
|
||||
create table project_attribution_files (
|
||||
group_id bigint not null references project_attribution_groups(id),
|
||||
name text not null,
|
||||
sha1 bytea not null
|
||||
);
|
||||
|
||||
create table override_file_sources (
|
||||
sha1 bytea not null,
|
||||
file_id bigint not null references files(id),
|
||||
primary key (sha1, file_id)
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
alter table file_scans
|
||||
drop constraint file_scans_file_id_fkey,
|
||||
add constraint file_scans_file_id_fkey
|
||||
foreign key (file_id) references files(id) on delete cascade;
|
||||
|
||||
alter table project_attribution_groups
|
||||
drop constraint project_attribution_groups_project_id_fkey,
|
||||
add constraint project_attribution_groups_project_id_fkey
|
||||
foreign key (project_id) references mods(id) on delete cascade;
|
||||
|
||||
alter table project_attribution_files
|
||||
drop constraint project_attribution_files_group_id_fkey,
|
||||
add constraint project_attribution_files_group_id_fkey
|
||||
foreign key (group_id) references project_attribution_groups(id) on delete cascade;
|
||||
|
||||
alter table override_file_sources
|
||||
drop constraint override_file_sources_file_id_fkey,
|
||||
add constraint override_file_sources_file_id_fkey
|
||||
foreign key (file_id) references files(id) on delete cascade;
|
||||
@@ -0,0 +1,20 @@
|
||||
alter table project_attribution_files
|
||||
add column moderation_external_license_id bigint references moderation_external_licenses(id);
|
||||
|
||||
create table version_attribution_exemptions (
|
||||
version_id bigint primary key references versions(id) on delete cascade
|
||||
);
|
||||
|
||||
create view attribution_enforced_versions as
|
||||
select v.id
|
||||
from versions v
|
||||
left join version_attribution_exemptions vae on vae.version_id = v.id
|
||||
where vae.version_id is null;
|
||||
|
||||
-- grandfathering migration:
|
||||
-- insert into version_attribution_exemptions (version_id)
|
||||
-- select v.id
|
||||
-- from versions v
|
||||
-- inner join mods m on m.id = v.mod_id
|
||||
-- where m.status in ('approved', 'unlisted', 'archived', 'private', 'scheduled', 'withheld')
|
||||
-- on conflict do nothing;
|
||||
@@ -5,11 +5,49 @@ use crate::database::models::version_item::VersionQueryResult;
|
||||
use crate::database::models::{DBCollection, DBOrganization, DBTeamMember};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::database::{DBProject, DBVersion, models};
|
||||
use crate::models::ids::FileId;
|
||||
use crate::models::projects::{
|
||||
DependencyAttribution, MissingAttributionFile, OverrideSource, Version,
|
||||
};
|
||||
use crate::models::users::User;
|
||||
use crate::queue::file_scan::{
|
||||
DependencyAttributionData, get_dependency_attributions,
|
||||
get_files_missing_attribution,
|
||||
};
|
||||
use crate::routes::ApiError;
|
||||
use futures::TryStreamExt;
|
||||
use itertools::Itertools;
|
||||
|
||||
pub fn enrich_dependency_attributions(
|
||||
version: &mut Version,
|
||||
dep_attr: &std::collections::HashMap<
|
||||
(database::models::ids::DBVersionId, String),
|
||||
DependencyAttributionData,
|
||||
>,
|
||||
) {
|
||||
let version_id = database::models::ids::DBVersionId(version.id.0 as i64);
|
||||
for dep in &mut version.dependencies {
|
||||
if let Some(file_name) = &dep.file_name
|
||||
&& let Some(attr) = dep_attr.get(&(version_id, file_name.clone()))
|
||||
{
|
||||
let attribution = DependencyAttribution {
|
||||
link: attr.link.clone().and_then(|u| u.parse().ok()),
|
||||
icon_url: attr.icon_url.clone().and_then(|u| u.parse().ok()),
|
||||
license: attr
|
||||
.license
|
||||
.clone()
|
||||
.and_then(|v| serde_json::from_value(v).ok()),
|
||||
};
|
||||
if attribution.link.is_some()
|
||||
|| attribution.icon_url.is_some()
|
||||
|| attribution.license.is_some()
|
||||
{
|
||||
dep.attribution = Some(attribution);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ValidateAuthorized {
|
||||
fn validate_authorized(
|
||||
&self,
|
||||
@@ -204,7 +242,45 @@ pub async fn filter_visible_versions(
|
||||
)
|
||||
.await?;
|
||||
versions.retain(|x| filtered_version_ids.contains(&x.inner.id));
|
||||
Ok(versions.into_iter().map(|x| x.into()).collect())
|
||||
|
||||
let version_ids: Vec<_> = versions.iter().map(|v| v.inner.id).collect();
|
||||
let missing = get_files_missing_attribution(pool, &version_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let dep_attr = get_dependency_attributions(pool, &version_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(versions
|
||||
.into_iter()
|
||||
.map(|v| {
|
||||
let files_missing = missing
|
||||
.get(&v.inner.id)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(id, fp)| MissingAttributionFile {
|
||||
id: FileId(id.0 as u64),
|
||||
override_source: fp
|
||||
.as_ref()
|
||||
.map(|p| OverrideSource::Flame {
|
||||
id: p.id,
|
||||
title: p.title.clone(),
|
||||
url: p.url.clone(),
|
||||
icon_url: p.icon_url.clone(),
|
||||
})
|
||||
.or(Some(OverrideSource::Unknown)),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut version = Version::from(v);
|
||||
version.files_missing_attribution = files_missing;
|
||||
enrich_dependency_attributions(&mut version, &dep_attr);
|
||||
version
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
impl ValidateAuthorized for models::DBOAuthClient {
|
||||
@@ -258,13 +334,20 @@ pub async fn filter_visible_version_ids(
|
||||
filter_enlisted_version_ids(versions.clone(), user_option, pool, redis)
|
||||
.await?;
|
||||
|
||||
let version_ids: Vec<_> = versions.iter().map(|v| v.id).collect();
|
||||
let withheld_versions = get_files_missing_attribution(pool, &version_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Return versions that are not hidden, we are a mod of, or we are enlisted on the team of
|
||||
for version in versions {
|
||||
let is_withheld = withheld_versions.contains_key(&version.id);
|
||||
// We can see the version if:
|
||||
// - it's not hidden and we can see the project
|
||||
// - it's not hidden and we can see the project and it's not withheld for attribution
|
||||
// - we are a mod
|
||||
// - we are enlisted on the team of the mod
|
||||
if (!version.status.is_hidden()
|
||||
&& !is_withheld
|
||||
&& visible_project_ids.contains(&version.project_id))
|
||||
|| user_option.as_ref().is_some_and(|x| x.role.is_mod())
|
||||
|| enlisted_version_ids.contains(&version.id)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use crate::database;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::queue::analytics::cache::cache_analytics;
|
||||
use crate::queue::billing::{index_billing, index_subscriptions};
|
||||
use crate::queue::email::EmailQueue;
|
||||
use crate::queue::file_scan::scan_all_files;
|
||||
use crate::queue::payouts::{
|
||||
PayoutsQueue, index_payouts_notifications,
|
||||
insert_bank_balances_and_webhook, process_affiliate_payouts,
|
||||
@@ -34,6 +36,10 @@ pub enum BackgroundTask {
|
||||
/// Attempts to ping Minecraft Java servers as if we were a client, to
|
||||
/// collect info on if they're online, game version, description, etc.
|
||||
PingMinecraftJavaServers,
|
||||
/// Finds files of versions which have not been scanned for attributions
|
||||
/// yet, extracts them to find file overrides, and finds any overrides which
|
||||
/// require attribution from the creator.
|
||||
ScanFiles,
|
||||
}
|
||||
|
||||
impl BackgroundTask {
|
||||
@@ -44,6 +50,7 @@ impl BackgroundTask {
|
||||
ro_pool: PgPool,
|
||||
redis_pool: RedisPool,
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
clickhouse: clickhouse::Client,
|
||||
stripe_client: stripe::Client,
|
||||
anrok_client: anrok::Client,
|
||||
@@ -90,6 +97,7 @@ impl BackgroundTask {
|
||||
PingMinecraftJavaServers => {
|
||||
ping_minecraft_java_servers(pool, redis_pool, clickhouse).await
|
||||
}
|
||||
ScanFiles => scan_all_files(&pool, &redis_pool, &**file_host).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use super::DatabaseError;
|
||||
use crate::database::PgTransaction;
|
||||
use crate::models::ids::{
|
||||
AffiliateCodeId, AnalyticsEventId, ChargeId, CollectionId, FileId, ImageId,
|
||||
NotificationId, OAuthAccessTokenId, OAuthClientAuthorizationId,
|
||||
OAuthClientId, OAuthRedirectUriId, OrganizationId, PatId, PayoutId,
|
||||
ProductId, ProductPriceId, ProjectId, ReportId, SessionId,
|
||||
SharedInstanceId, SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId,
|
||||
ThreadMessageId, UserSubscriptionId, VersionId,
|
||||
AffiliateCodeId, AnalyticsEventId, AttributionGroupId, ChargeId,
|
||||
CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId,
|
||||
OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId,
|
||||
OrganizationId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId,
|
||||
ReportId, SessionId, SharedInstanceId, SharedInstanceVersionId, TeamId,
|
||||
TeamMemberId, ThreadId, ThreadMessageId, UserSubscriptionId, VersionId,
|
||||
};
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range};
|
||||
@@ -168,6 +168,10 @@ db_id_interface!(
|
||||
CollectionId,
|
||||
generator: generate_collection_id @ "collections",
|
||||
);
|
||||
db_id_interface!(
|
||||
AttributionGroupId,
|
||||
generator: generate_attribution_group_id @ "project_attribution_groups",
|
||||
);
|
||||
db_id_interface!(
|
||||
FileId,
|
||||
generator: generate_file_id @ "files",
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{DBUser, ids::*};
|
||||
use crate::database::models::DatabaseError;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::database::{PgTransaction, models};
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::exp;
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::models::projects::{
|
||||
@@ -187,6 +188,8 @@ impl ProjectBuilder {
|
||||
pub async fn insert(
|
||||
self,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
redis: &RedisPool,
|
||||
file_host: &dyn FileHost,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<DBProjectId, DatabaseError> {
|
||||
let project_struct = DBProject {
|
||||
@@ -235,7 +238,7 @@ impl ProjectBuilder {
|
||||
|
||||
for mut version in self.initial_versions {
|
||||
version.project_id = self.project_id;
|
||||
version.insert(&mut *transaction, http).await?;
|
||||
version.insert(transaction, redis, file_host, http).await?;
|
||||
}
|
||||
|
||||
LinkUrl::insert_many_projects(
|
||||
|
||||
@@ -6,8 +6,11 @@ use crate::database::models::loader_fields::{
|
||||
QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::exp;
|
||||
|
||||
use crate::models::projects::{FileType, VersionStatus};
|
||||
use crate::queue::file_scan::scan_file;
|
||||
use crate::routes::internal::delphi::DelphiRunParameters;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::{DashMap, DashSet};
|
||||
@@ -17,10 +20,31 @@ use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::iter;
|
||||
use tracing::error;
|
||||
|
||||
pub const VERSIONS_NAMESPACE: &str = "versions";
|
||||
const VERSION_FILES_NAMESPACE: &str = "versions_files";
|
||||
|
||||
pub async fn cleanup_empty_attribution_groups(
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM project_attribution_groups g
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM project_attribution_files paf
|
||||
INNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1
|
||||
WHERE paf.group_id = g.id
|
||||
)
|
||||
",
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VersionBuilder {
|
||||
pub version_id: DBVersionId,
|
||||
@@ -134,7 +158,10 @@ impl VersionFileBuilder {
|
||||
pub async fn insert(
|
||||
self,
|
||||
version_id: DBVersionId,
|
||||
project_id: DBProjectId,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
redis: &RedisPool,
|
||||
file_host: &dyn FileHost,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<DBFileId, DatabaseError> {
|
||||
let file_id = generate_file_id(&mut *transaction).await?;
|
||||
@@ -169,6 +196,16 @@ impl VersionFileBuilder {
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO file_scans (file_id)
|
||||
VALUES ($1)
|
||||
",
|
||||
file_id as DBFileId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if let Err(err) = crate::routes::internal::delphi::run(
|
||||
&mut *transaction,
|
||||
DelphiRunParameters {
|
||||
@@ -178,7 +215,20 @@ impl VersionFileBuilder {
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error submitting new file to Delphi: {err}");
|
||||
error!("Error submitting new file to Delphi: {err:?}");
|
||||
}
|
||||
|
||||
if let Err(err) = scan_file(
|
||||
&mut *transaction,
|
||||
redis,
|
||||
file_host,
|
||||
project_id,
|
||||
file_id,
|
||||
&self.url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Error scanning new file {file_id:?}: {err:?}");
|
||||
}
|
||||
|
||||
Ok(file_id)
|
||||
@@ -195,6 +245,8 @@ impl VersionBuilder {
|
||||
pub async fn insert(
|
||||
self,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
redis: &RedisPool,
|
||||
file_host: &dyn FileHost,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<DBVersionId, DatabaseError> {
|
||||
let version = DBVersion {
|
||||
@@ -236,7 +288,15 @@ impl VersionBuilder {
|
||||
} = self;
|
||||
|
||||
for file in files {
|
||||
file.insert(version_id, transaction, http).await?;
|
||||
file.insert(
|
||||
version_id,
|
||||
self.project_id,
|
||||
transaction,
|
||||
redis,
|
||||
file_host,
|
||||
http,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
DependencyBuilder::insert_many(
|
||||
@@ -426,6 +486,8 @@ impl DBVersion {
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
cleanup_empty_attribution_groups(transaction).await?;
|
||||
|
||||
// Sync dependencies
|
||||
|
||||
let project_id = sqlx::query!(
|
||||
@@ -862,14 +924,14 @@ impl DBVersion {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_files_from_hash<'a, 'b, E>(
|
||||
pub async fn get_files_from_hash<'a, E>(
|
||||
algorithm: String,
|
||||
hashes: &[String],
|
||||
executor: E,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Vec<DBFile>, DatabaseError>
|
||||
where
|
||||
E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let val = redis.get_cached_keys(
|
||||
VERSION_FILES_NAMESPACE,
|
||||
|
||||
@@ -29,9 +29,7 @@ impl FileHost for MockHost {
|
||||
file_publicity: FileHostPublicity,
|
||||
file_bytes: Bytes,
|
||||
) -> Result<UploadFileData, FileHostingError> {
|
||||
let file_name = urlencoding::decode(file_name)
|
||||
.map_err(|_| FileHostingError::InvalidFilename)?;
|
||||
let path = get_file_path(&file_name, file_publicity);
|
||||
let path = get_file_path(file_name, file_publicity);
|
||||
std::fs::create_dir_all(
|
||||
path.parent().ok_or(FileHostingError::InvalidFilename)?,
|
||||
)?;
|
||||
@@ -72,6 +70,16 @@ impl FileHost for MockHost {
|
||||
file_name: file_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_file(
|
||||
&self,
|
||||
file_name: &str,
|
||||
file_publicity: FileHostPublicity,
|
||||
) -> Result<Bytes, FileHostingError> {
|
||||
let path = get_file_path(file_name, file_publicity);
|
||||
let data = std::fs::read(&path)?;
|
||||
Ok(Bytes::from(data))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_file_path(
|
||||
|
||||
@@ -45,7 +45,11 @@ pub enum FileHostPublicity {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait FileHost {
|
||||
pub trait FileHost: Send + Sync {
|
||||
/// Uploads a file at the exact storage key provided.
|
||||
///
|
||||
/// Callers must URL-decode keys derived from public URLs before passing
|
||||
/// them here, and URL-encode this key before exposing it in a public URL.
|
||||
async fn upload_file(
|
||||
&self,
|
||||
content_type: &str,
|
||||
@@ -54,17 +58,35 @@ pub trait FileHost {
|
||||
file_bytes: Bytes,
|
||||
) -> Result<UploadFileData, FileHostingError>;
|
||||
|
||||
/// Returns a private URL for the exact storage key provided.
|
||||
///
|
||||
/// Callers must URL-decode keys derived from public URLs before passing
|
||||
/// them here.
|
||||
async fn get_url_for_private_file(
|
||||
&self,
|
||||
file_name: &str,
|
||||
expiry_secs: u32,
|
||||
) -> Result<String, FileHostingError>;
|
||||
|
||||
/// Deletes the file at the exact storage key provided.
|
||||
///
|
||||
/// Callers must URL-decode keys derived from public URLs before passing
|
||||
/// them here.
|
||||
async fn delete_file(
|
||||
&self,
|
||||
file_name: &str,
|
||||
file_publicity: FileHostPublicity,
|
||||
) -> Result<DeleteFileData, FileHostingError>;
|
||||
|
||||
/// Reads the file at the exact storage key provided.
|
||||
///
|
||||
/// Callers must URL-decode keys derived from public URLs before passing
|
||||
/// them here.
|
||||
async fn read_file(
|
||||
&self,
|
||||
file_name: &str,
|
||||
file_publicity: FileHostPublicity,
|
||||
) -> Result<Bytes, FileHostingError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
|
||||
@@ -169,4 +169,28 @@ impl FileHost for S3Host {
|
||||
file_name: file_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_file(
|
||||
&self,
|
||||
file_name: &str,
|
||||
file_publicity: FileHostPublicity,
|
||||
) -> Result<Bytes, FileHostingError> {
|
||||
let bucket = self.get_bucket(file_publicity);
|
||||
|
||||
let response = bucket
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(bucket.name.as_str())
|
||||
.key(file_name)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| s3_error("reading file", e))?;
|
||||
|
||||
Ok(response
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| s3_error("reading file body", e))?
|
||||
.into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ pub struct LabrinthConfig {
|
||||
pub ro_pool: ReadOnlyPgPool,
|
||||
pub redis_pool: RedisPool,
|
||||
pub clickhouse: Client,
|
||||
pub file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
|
||||
pub file_host: web::Data<dyn file_hosting::FileHost>,
|
||||
pub scheduler: Arc<scheduler::Scheduler>,
|
||||
pub ip_salt: Pepper,
|
||||
pub search_backend: web::Data<dyn search::SearchBackend>,
|
||||
@@ -82,7 +82,7 @@ pub fn app_setup(
|
||||
redis_pool: RedisPool,
|
||||
search_backend: actix_web::web::Data<dyn search::SearchBackend>,
|
||||
clickhouse: &mut Client,
|
||||
file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
|
||||
file_host: web::Data<dyn file_hosting::FileHost>,
|
||||
stripe_client: stripe::Client,
|
||||
anrok_client: anrok::Client,
|
||||
email_queue: EmailQueue,
|
||||
@@ -339,7 +339,7 @@ pub fn app_config(
|
||||
.app_data(web::Data::new(labrinth_config.redis_pool.clone()))
|
||||
.app_data(web::Data::new(labrinth_config.pool.clone()))
|
||||
.app_data(web::Data::new(labrinth_config.ro_pool.clone()))
|
||||
.app_data(web::Data::new(labrinth_config.file_host.clone()))
|
||||
.app_data(labrinth_config.file_host.clone())
|
||||
.app_data(labrinth_config.search_backend.clone())
|
||||
.app_data(web::Data::new(labrinth_config.gotenberg_client.clone()))
|
||||
.app_data(labrinth_config.http_client.clone())
|
||||
|
||||
+33
-38
@@ -2,14 +2,14 @@
|
||||
|
||||
use actix_web::dev::Service;
|
||||
use actix_web::middleware::from_fn;
|
||||
use actix_web::{App, HttpServer};
|
||||
use actix_web::{App, HttpServer, web};
|
||||
use actix_web_prom::PrometheusMetricsBuilder;
|
||||
use clap::Parser;
|
||||
|
||||
use labrinth::background_task::BackgroundTask;
|
||||
use labrinth::database::redis::RedisPool;
|
||||
use labrinth::env::ENV;
|
||||
use labrinth::file_hosting::{FileHostKind, S3BucketConfig, S3Host};
|
||||
use labrinth::file_hosting::{FileHost, FileHostKind, S3BucketConfig, S3Host};
|
||||
use labrinth::queue::email::EmailQueue;
|
||||
use labrinth::search;
|
||||
use labrinth::util::anrok;
|
||||
@@ -111,44 +111,38 @@ async fn app() -> std::io::Result<()> {
|
||||
let redis_pool = RedisPool::new("");
|
||||
|
||||
let storage_backend = ENV.STORAGE_BACKEND;
|
||||
let file_host: Arc<dyn file_hosting::FileHost + Send + Sync> =
|
||||
match storage_backend {
|
||||
FileHostKind::S3 => {
|
||||
let not_empty = |v: &str| -> String {
|
||||
assert!(!v.is_empty(), "S3 env var is empty");
|
||||
v.to_string()
|
||||
};
|
||||
let file_host: Arc<dyn FileHost> = match storage_backend {
|
||||
FileHostKind::S3 => {
|
||||
let not_empty = |v: &str| -> String {
|
||||
assert!(!v.is_empty(), "S3 env var is empty");
|
||||
v.to_string()
|
||||
};
|
||||
|
||||
Arc::new(
|
||||
S3Host::new(
|
||||
S3BucketConfig {
|
||||
name: not_empty(&ENV.S3_PUBLIC_BUCKET_NAME),
|
||||
uses_path_style: ENV
|
||||
.S3_PUBLIC_USES_PATH_STYLE_BUCKET,
|
||||
region: not_empty(&ENV.S3_PUBLIC_REGION),
|
||||
url: not_empty(&ENV.S3_PUBLIC_URL),
|
||||
access_token: not_empty(
|
||||
&ENV.S3_PUBLIC_ACCESS_TOKEN,
|
||||
),
|
||||
secret: not_empty(&ENV.S3_PUBLIC_SECRET),
|
||||
},
|
||||
S3BucketConfig {
|
||||
name: not_empty(&ENV.S3_PRIVATE_BUCKET_NAME),
|
||||
uses_path_style: ENV
|
||||
.S3_PRIVATE_USES_PATH_STYLE_BUCKET,
|
||||
region: not_empty(&ENV.S3_PRIVATE_REGION),
|
||||
url: not_empty(&ENV.S3_PRIVATE_URL),
|
||||
access_token: not_empty(
|
||||
&ENV.S3_PRIVATE_ACCESS_TOKEN,
|
||||
),
|
||||
secret: not_empty(&ENV.S3_PRIVATE_SECRET),
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
Arc::new(
|
||||
S3Host::new(
|
||||
S3BucketConfig {
|
||||
name: not_empty(&ENV.S3_PUBLIC_BUCKET_NAME),
|
||||
uses_path_style: ENV.S3_PUBLIC_USES_PATH_STYLE_BUCKET,
|
||||
region: not_empty(&ENV.S3_PUBLIC_REGION),
|
||||
url: not_empty(&ENV.S3_PUBLIC_URL),
|
||||
access_token: not_empty(&ENV.S3_PUBLIC_ACCESS_TOKEN),
|
||||
secret: not_empty(&ENV.S3_PUBLIC_SECRET),
|
||||
},
|
||||
S3BucketConfig {
|
||||
name: not_empty(&ENV.S3_PRIVATE_BUCKET_NAME),
|
||||
uses_path_style: ENV.S3_PRIVATE_USES_PATH_STYLE_BUCKET,
|
||||
region: not_empty(&ENV.S3_PRIVATE_REGION),
|
||||
url: not_empty(&ENV.S3_PRIVATE_URL),
|
||||
access_token: not_empty(&ENV.S3_PRIVATE_ACCESS_TOKEN),
|
||||
secret: not_empty(&ENV.S3_PRIVATE_SECRET),
|
||||
},
|
||||
)
|
||||
}
|
||||
FileHostKind::Local => Arc::new(file_hosting::MockHost::new()),
|
||||
};
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
FileHostKind::Local => Arc::new(file_hosting::MockHost::new()),
|
||||
};
|
||||
let file_host = web::Data::<dyn FileHost>::from(file_host);
|
||||
|
||||
info!("Initializing clickhouse connection");
|
||||
let mut clickhouse = clickhouse::init_client().await.unwrap();
|
||||
@@ -174,6 +168,7 @@ async fn app() -> std::io::Result<()> {
|
||||
ro_pool.into_inner(),
|
||||
redis_pool,
|
||||
search_backend,
|
||||
file_host,
|
||||
clickhouse,
|
||||
stripe_client,
|
||||
anrok_client.clone(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use ariadne::ids::base62_id;
|
||||
|
||||
base62_id!(AttributionGroupId);
|
||||
base62_id!(ChargeId);
|
||||
base62_id!(CollectionId);
|
||||
base62_id!(FileId);
|
||||
|
||||
@@ -12,6 +12,7 @@ use ariadne::ids::UserId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use validator::Validate;
|
||||
|
||||
/// A project returned from the API
|
||||
@@ -645,6 +646,80 @@ impl SideTypesMigrationReviewStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
pub struct MissingAttributionFile {
|
||||
pub id: FileId,
|
||||
pub override_source: Option<OverrideSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum OverrideSource {
|
||||
Flame {
|
||||
id: u32,
|
||||
title: String,
|
||||
url: String,
|
||||
icon_url: String,
|
||||
},
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Serialize, Deserialize, Clone, PartialEq, Eq, utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(untagged)]
|
||||
pub enum AttributionLicense {
|
||||
Spdx(String),
|
||||
Custom { name: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AttributionResolutionKind {
|
||||
License {
|
||||
license: AttributionLicense,
|
||||
link_to_work: Url,
|
||||
},
|
||||
GloballyAllowed {
|
||||
link_to_work: Url,
|
||||
},
|
||||
MyProject {
|
||||
license: AttributionLicense,
|
||||
},
|
||||
SpecialPermissions {
|
||||
link_to_work: Url,
|
||||
},
|
||||
NoPermission {
|
||||
link_to_work: Option<Url>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AttributionModerationStatusKind {
|
||||
NotAllowed,
|
||||
Approved,
|
||||
BadProof,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
pub struct AttributionModerationStatus {
|
||||
#[serde(flatten)]
|
||||
pub kind: AttributionModerationStatusKind,
|
||||
#[serde(default)]
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
pub struct AttributionResolution {
|
||||
#[serde(flatten)]
|
||||
pub kind: AttributionResolutionKind,
|
||||
#[serde(default)]
|
||||
pub moderation_status: Option<AttributionModerationStatus>,
|
||||
pub notes: String,
|
||||
pub image_urls: Vec<Url>,
|
||||
}
|
||||
|
||||
/// A specific version of a project
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
pub struct Version {
|
||||
@@ -681,6 +756,9 @@ pub struct Version {
|
||||
|
||||
/// A list of files available for download for this version.
|
||||
pub files: Vec<VersionFile>,
|
||||
/// Files in this version that contain override files not yet attributed.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub files_missing_attribution: Vec<MissingAttributionFile>,
|
||||
/// A list of projects that this version depends on.
|
||||
pub dependencies: Vec<Dependency>,
|
||||
|
||||
@@ -757,6 +835,7 @@ impl From<VersionQueryResult> for Version {
|
||||
dependency_type: DependencyType::from_string(
|
||||
d.dependency_type.as_str(),
|
||||
),
|
||||
attribution: None,
|
||||
})
|
||||
.collect(),
|
||||
loaders: data.loaders.into_iter().map(Loader).collect(),
|
||||
@@ -768,6 +847,7 @@ impl From<VersionQueryResult> for Version {
|
||||
.map(|vf| (vf.field_name, vf.value.serialize_internal()))
|
||||
.collect(),
|
||||
components: data.components,
|
||||
files_missing_attribution: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -899,6 +979,20 @@ pub struct Dependency {
|
||||
pub file_name: Option<String>,
|
||||
/// The type of the dependency
|
||||
pub dependency_type: DependencyType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub attribution: Option<DependencyAttribution>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Serialize, Deserialize, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
|
||||
)]
|
||||
pub struct DependencyAttribution {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<Url>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon_url: Option<Url>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub license: Option<AttributionLicense>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
pub mod analytics;
|
||||
pub mod billing;
|
||||
pub mod email;
|
||||
pub mod file_scan;
|
||||
pub mod moderation;
|
||||
pub mod payouts;
|
||||
pub mod server_ping;
|
||||
|
||||
@@ -570,7 +570,7 @@ impl AutomatedModerationQueue {
|
||||
Vec::new()
|
||||
} else {
|
||||
let res = client
|
||||
.post(format!("{}v1/mods", ENV.FLAME_ANVIL_URL))
|
||||
.post(format!("{}/v1/mods", ENV.FLAME_ANVIL_URL))
|
||||
.json(&serde_json::json!({
|
||||
"modIds": flame_files.iter().map(|x| x.1).collect::<Vec<_>>()
|
||||
}))
|
||||
@@ -823,7 +823,7 @@ pub enum ApprovalType {
|
||||
}
|
||||
|
||||
impl ApprovalType {
|
||||
fn approved(&self) -> bool {
|
||||
pub fn approved(&self) -> bool {
|
||||
match self {
|
||||
ApprovalType::Yes => true,
|
||||
ApprovalType::WithAttributionAndSource => true,
|
||||
@@ -901,6 +901,13 @@ pub struct FlameProject {
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub links: FlameLinks,
|
||||
pub logo: FlameLogo,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FlameLogo {
|
||||
pub thumbnail_url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
use actix_web::{HttpRequest, get, patch, post, web};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::models::ids::{
|
||||
DBAttributionGroupId, DBProjectId, generate_attribution_group_id,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::{ProjectId, VersionId};
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::projects::{
|
||||
AttributionModerationStatusKind, AttributionResolution,
|
||||
AttributionResolutionKind,
|
||||
};
|
||||
use crate::models::users::User;
|
||||
use crate::queue::moderation::ApprovalType;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(list)
|
||||
.service(update_group)
|
||||
.service(assign)
|
||||
.service(split);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlameProject {
|
||||
pub id: u32,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub icon_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AttributionGroupResponse {
|
||||
id: crate::models::ids::AttributionGroupId,
|
||||
flame_project: Option<FlameProject>,
|
||||
attribution: Option<crate::models::projects::AttributionResolution>,
|
||||
attributed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
attributed_by: Option<ariadne::ids::UserId>,
|
||||
files: Vec<AttributionFileResponse>,
|
||||
versions: Vec<VersionInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct VersionInfo {
|
||||
id: VersionId,
|
||||
name: String,
|
||||
version_number: String,
|
||||
date_created: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AttributionFileResponse {
|
||||
name: String,
|
||||
sha1: String,
|
||||
versions: Vec<VersionId>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
moderation_external_license_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
moderation_external_license: Option<ModerationExternalLicenseResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct ModerationExternalLicenseResponse {
|
||||
id: i64,
|
||||
title: Option<String>,
|
||||
status: ApprovalType,
|
||||
link: Option<String>,
|
||||
exceptions: Option<String>,
|
||||
proof: Option<String>,
|
||||
flame_project_id: Option<i32>,
|
||||
inserted_at: Option<DateTime<Utc>>,
|
||||
inserted_by: Option<i64>,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
updated_by: Option<i64>,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[get("{project_id}")]
|
||||
async fn list(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
path: web::Path<ProjectId>,
|
||||
) -> Result<web::Json<Vec<AttributionGroupResponse>>, ApiError> {
|
||||
let project_id: DBProjectId = path.into_inner().into();
|
||||
let show_moderation_external_license_ids = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_READ,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.is_some_and(|(_, user)| user.role.is_mod());
|
||||
|
||||
let groups = sqlx::query!(
|
||||
r#"
|
||||
select
|
||||
g.id as "id: DBAttributionGroupId",
|
||||
g.flame_project,
|
||||
g.attribution,
|
||||
g.attributed_at,
|
||||
g.attributed_by as "attributed_by: i64"
|
||||
from project_attribution_groups g
|
||||
where g.project_id = $1
|
||||
"#,
|
||||
project_id as DBProjectId,
|
||||
)
|
||||
.fetch_all(pool.as_ref())
|
||||
.await?;
|
||||
|
||||
let group_ids: Vec<i64> = groups.iter().map(|g| g.id.0).collect();
|
||||
|
||||
let files = if group_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
sqlx::query(
|
||||
"
|
||||
select paf.group_id, paf.name, convert_from(paf.sha1, 'UTF8') as sha1, paf.moderation_external_license_id,
|
||||
coalesce(array_agg(distinct aev.id) filter (where aev.id is not null), '{}') as version_ids
|
||||
from project_attribution_files paf
|
||||
left join override_file_sources ofs on ofs.sha1 = paf.sha1
|
||||
left join files f on f.id = ofs.file_id
|
||||
left join attribution_enforced_versions aev on aev.id = f.version_id
|
||||
where paf.group_id = ANY($1)
|
||||
group by paf.group_id, paf.name, paf.sha1, paf.moderation_external_license_id
|
||||
",
|
||||
)
|
||||
.bind(&group_ids)
|
||||
.fetch_all(pool.as_ref())
|
||||
.await?
|
||||
};
|
||||
|
||||
let moderation_external_licenses = if show_moderation_external_license_ids {
|
||||
let mut ids: Vec<i64> = files
|
||||
.iter()
|
||||
.filter_map(|f| f.get("moderation_external_license_id"))
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
|
||||
if ids.is_empty() {
|
||||
std::collections::HashMap::new()
|
||||
} else {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
select
|
||||
id,
|
||||
title,
|
||||
status,
|
||||
link,
|
||||
exceptions,
|
||||
proof,
|
||||
flame_project_id,
|
||||
inserted_at,
|
||||
inserted_by,
|
||||
updated_at,
|
||||
updated_by
|
||||
from moderation_external_licenses
|
||||
where id = ANY($1)
|
||||
"#,
|
||||
&ids,
|
||||
)
|
||||
.fetch_all(pool.as_ref())
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
(
|
||||
row.id,
|
||||
ModerationExternalLicenseResponse {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
status: ApprovalType::from_string(&row.status)
|
||||
.unwrap_or(ApprovalType::Unidentified),
|
||||
link: row.link,
|
||||
exceptions: row.exceptions,
|
||||
proof: row.proof,
|
||||
flame_project_id: row.flame_project_id,
|
||||
inserted_at: row.inserted_at,
|
||||
inserted_by: row.inserted_by,
|
||||
updated_at: row.updated_at,
|
||||
updated_by: row.updated_by,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
let mut all_version_ids: Vec<i64> = files
|
||||
.iter()
|
||||
.flat_map(|f| f.get::<Vec<i64>, _>("version_ids"))
|
||||
.collect();
|
||||
all_version_ids.sort_unstable();
|
||||
all_version_ids.dedup();
|
||||
|
||||
let version_infos = if all_version_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let rows = sqlx::query!(
|
||||
"
|
||||
select id, name, version_number, date_published
|
||||
from versions
|
||||
where id = ANY($1)
|
||||
order by date_published desc
|
||||
",
|
||||
&all_version_ids,
|
||||
)
|
||||
.fetch_all(pool.as_ref())
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|v| VersionInfo {
|
||||
id: VersionId(v.id as u64),
|
||||
name: v.name,
|
||||
version_number: v.version_number,
|
||||
date_created: v.date_published,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let version_order = version_infos
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, version)| (version.id, index))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
let mut result = Vec::new();
|
||||
for group in groups {
|
||||
let group_files: Vec<AttributionFileResponse> = files
|
||||
.iter()
|
||||
.filter(|f| f.get::<i64, _>("group_id") == group.id.0)
|
||||
.map(|f| AttributionFileResponse {
|
||||
name: f.get("name"),
|
||||
sha1: f.get("sha1"),
|
||||
moderation_external_license_id:
|
||||
if show_moderation_external_license_ids {
|
||||
f.get("moderation_external_license_id")
|
||||
} else {
|
||||
None
|
||||
},
|
||||
moderation_external_license:
|
||||
if show_moderation_external_license_ids {
|
||||
f.get::<Option<i64>, _>(
|
||||
"moderation_external_license_id",
|
||||
)
|
||||
.and_then(|id| {
|
||||
moderation_external_licenses.get(&id).cloned()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
versions: {
|
||||
let mut versions: Vec<_> = f
|
||||
.get::<Vec<i64>, _>("version_ids")
|
||||
.into_iter()
|
||||
.map(|id| VersionId(id as u64))
|
||||
.collect();
|
||||
versions.sort_by_key(|id| {
|
||||
version_order.get(id).copied().unwrap_or(usize::MAX)
|
||||
});
|
||||
versions
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
result.push(AttributionGroupResponse {
|
||||
id: group.id.into(),
|
||||
flame_project: group
|
||||
.flame_project
|
||||
.and_then(|v| serde_json::from_value(v).ok()),
|
||||
attribution: group
|
||||
.attribution
|
||||
.and_then(|v| serde_json::from_value(v).ok()),
|
||||
attributed_at: group.attributed_at,
|
||||
attributed_by: group
|
||||
.attributed_by
|
||||
.map(|id| ariadne::ids::UserId(id as u64)),
|
||||
files: group_files,
|
||||
versions: version_infos.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(web::Json(result))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
struct UpdateGroupBody {
|
||||
attribution: AttributionResolution,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[patch("group/{group_id}")]
|
||||
async fn update_group(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
path: web::Path<i64>,
|
||||
web::Json(body): web::Json<UpdateGroupBody>,
|
||||
) -> Result<(), ApiError> {
|
||||
let group_id = path.into_inner();
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::VERSION_WRITE,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !can_edit_attribution_group(pool.as_ref(), group_id, &user).await? {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"This attribution group cannot be edited".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if matches!(
|
||||
body.attribution.kind,
|
||||
AttributionResolutionKind::GloballyAllowed { .. }
|
||||
) && !user.role.is_mod()
|
||||
{
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"Only moderators can set globally allowed attributions".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if body.attribution.moderation_status.is_some() && !user.role.is_mod() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"Only moderators can set attribution moderation status".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
update project_attribution_groups
|
||||
set attribution = $1, attributed_at = now(), attributed_by = $3
|
||||
where id = $2
|
||||
",
|
||||
&serde_json::to_value(&body.attribution).unwrap_or_default(),
|
||||
group_id,
|
||||
user.id.0 as i64,
|
||||
)
|
||||
.execute(pool.as_ref())
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
struct AssignBody {
|
||||
sha1: String,
|
||||
target_group_id: i64,
|
||||
project_id: ProjectId,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("assign")]
|
||||
async fn assign(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
web::Json(body): web::Json<AssignBody>,
|
||||
) -> Result<(), ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::VERSION_WRITE,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let sha1_bytes = hex_to_bytes(&body.sha1).ok_or_else(|| {
|
||||
ApiError::InvalidInput("invalid sha1 hex string".to_string())
|
||||
})?;
|
||||
let project_id: DBProjectId = body.project_id.into();
|
||||
|
||||
let source_group_id = sqlx::query_scalar!(
|
||||
"
|
||||
select paf.group_id
|
||||
from project_attribution_files paf
|
||||
inner join project_attribution_groups pag on pag.id = paf.group_id
|
||||
where paf.sha1 = $1 and pag.project_id = $2
|
||||
",
|
||||
&sha1_bytes,
|
||||
project_id as DBProjectId,
|
||||
)
|
||||
.fetch_optional(pool.as_ref())
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let target_group_exists = sqlx::query_scalar!(
|
||||
"
|
||||
select exists(
|
||||
select 1 from project_attribution_groups where id = $1 and project_id = $2
|
||||
) as \"exists!\"
|
||||
",
|
||||
body.target_group_id,
|
||||
project_id as DBProjectId,
|
||||
)
|
||||
.fetch_one(pool.as_ref())
|
||||
.await?;
|
||||
|
||||
if !target_group_exists {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
|
||||
if !can_edit_attribution_group(pool.as_ref(), source_group_id, &user)
|
||||
.await?
|
||||
|| !can_edit_attribution_group(
|
||||
pool.as_ref(),
|
||||
body.target_group_id,
|
||||
&user,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"This attribution group cannot be edited".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
update project_attribution_files
|
||||
set group_id = $1
|
||||
where sha1 = $2
|
||||
and group_id in (
|
||||
select id from project_attribution_groups where project_id = $3
|
||||
)
|
||||
",
|
||||
body.target_group_id,
|
||||
&sha1_bytes,
|
||||
project_id as DBProjectId,
|
||||
)
|
||||
.execute(pool.as_ref())
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
|
||||
cleanup_empty_groups(pool.as_ref()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
struct SplitBody {
|
||||
sha1: String,
|
||||
project_id: ProjectId,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("split")]
|
||||
async fn split(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
web::Json(body): web::Json<SplitBody>,
|
||||
) -> Result<(), ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::VERSION_WRITE,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let sha1_bytes = hex_to_bytes(&body.sha1).ok_or_else(|| {
|
||||
ApiError::InvalidInput("invalid sha1 hex string".to_string())
|
||||
})?;
|
||||
let project_id: DBProjectId = body.project_id.into();
|
||||
|
||||
let existing = sqlx::query!(
|
||||
"
|
||||
select paf.group_id, paf.name from project_attribution_files paf
|
||||
inner join project_attribution_groups pag on pag.id = paf.group_id
|
||||
where paf.sha1 = $1 and pag.project_id = $2
|
||||
",
|
||||
&sha1_bytes,
|
||||
project_id as DBProjectId,
|
||||
)
|
||||
.fetch_optional(pool.as_ref())
|
||||
.await?;
|
||||
|
||||
let Some(existing) = existing else {
|
||||
return Err(ApiError::NotFound);
|
||||
};
|
||||
|
||||
if !can_edit_attribution_group(pool.as_ref(), existing.group_id, &user)
|
||||
.await?
|
||||
{
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"This attribution group cannot be edited".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut txn = pool.begin().await?;
|
||||
|
||||
let new_group_id = generate_attribution_group_id(&mut txn).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
insert into project_attribution_groups (id, project_id)
|
||||
values ($1, $2)
|
||||
",
|
||||
new_group_id as DBAttributionGroupId,
|
||||
project_id as DBProjectId,
|
||||
)
|
||||
.execute(&mut txn)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
update project_attribution_files
|
||||
set group_id = $1
|
||||
where sha1 = $2 and group_id = $3
|
||||
",
|
||||
new_group_id as DBAttributionGroupId,
|
||||
&sha1_bytes,
|
||||
existing.group_id,
|
||||
)
|
||||
.execute(&mut txn)
|
||||
.await?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
cleanup_empty_groups(pool.as_ref()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn can_edit_attribution_group(
|
||||
pool: &PgPool,
|
||||
group_id: i64,
|
||||
user: &User,
|
||||
) -> Result<bool, ApiError> {
|
||||
if user.role.is_mod() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let attribution = sqlx::query_scalar!(
|
||||
"
|
||||
select attribution
|
||||
from project_attribution_groups
|
||||
where id = $1
|
||||
",
|
||||
group_id,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let attribution: Option<AttributionResolution> =
|
||||
attribution.and_then(|value| serde_json::from_value(value).ok());
|
||||
|
||||
Ok(!matches!(
|
||||
attribution
|
||||
.and_then(|attribution| attribution.moderation_status)
|
||||
.map(|status| status.kind),
|
||||
Some(AttributionModerationStatusKind::NotAllowed)
|
||||
))
|
||||
}
|
||||
|
||||
async fn cleanup_empty_groups(pool: &PgPool) -> Result<(), ApiError> {
|
||||
sqlx::query!(
|
||||
"
|
||||
delete from project_attribution_groups g
|
||||
where not exists (
|
||||
select 1 from project_attribution_files f where f.group_id = g.id
|
||||
)
|
||||
",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hex_to_bytes(hex: &str) -> Option<Vec<u8>> {
|
||||
if !hex.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
(0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
|
||||
.collect()
|
||||
}
|
||||
@@ -38,7 +38,6 @@ use reqwest::header::AUTHORIZATION;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
use validator::Validate;
|
||||
use zxcvbn::Score;
|
||||
@@ -83,7 +82,7 @@ impl TempUser {
|
||||
provider: AuthProvider,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
client: &PgPool,
|
||||
file_host: &Arc<dyn FileHost + Send + Sync>,
|
||||
file_host: &dyn FileHost,
|
||||
redis: &RedisPool,
|
||||
) -> Result<crate::database::models::DBUserId, AuthenticationError> {
|
||||
if let Some(email) = &self.email
|
||||
@@ -150,7 +149,7 @@ impl TempUser {
|
||||
ext,
|
||||
Some(96),
|
||||
Some(1.0),
|
||||
&**file_host,
|
||||
file_host,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1172,7 +1171,7 @@ pub async fn auth_callback(
|
||||
req: HttpRequest,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
client: Data<PgPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: Data<dyn FileHost>,
|
||||
redis: Data<RedisPool>,
|
||||
) -> Result<HttpResponse, crate::auth::templates::ErrorPage> {
|
||||
let state_string = query
|
||||
@@ -1330,7 +1329,7 @@ pub async fn auth_callback(
|
||||
provider,
|
||||
&mut transaction,
|
||||
&client,
|
||||
&file_host,
|
||||
&**file_host,
|
||||
&redis,
|
||||
)
|
||||
.await?
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod admin;
|
||||
pub mod affiliate;
|
||||
pub mod attribution;
|
||||
pub mod billing;
|
||||
pub mod delphi;
|
||||
pub mod external_notifications;
|
||||
@@ -99,5 +100,10 @@ pub fn utoipa_config(
|
||||
utoipa_actix_web::scope("/_internal/server-ping")
|
||||
.wrap(default_cors())
|
||||
.configure(server_ping::config),
|
||||
)
|
||||
.service(
|
||||
utoipa_actix_web::scope("/_internal/attribution")
|
||||
.wrap(default_cors())
|
||||
.configure(attribution::config),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::database::PgPool;
|
||||
use crate::database::models::ids::DBUserId;
|
||||
use crate::database::models::moderation_external_item::ExternalLicense;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::queue::moderation::ApprovalType;
|
||||
@@ -14,7 +16,11 @@ 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);
|
||||
.service(get_by_sha1_bulk)
|
||||
.service(lookup)
|
||||
.service(update_license)
|
||||
.service(add_file)
|
||||
.service(reassign_file);
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
@@ -43,6 +49,26 @@ pub struct LinkedFile {
|
||||
pub struct SearchRequest {
|
||||
pub title: Option<String>,
|
||||
pub flame_id: Option<i32>,
|
||||
pub flame_ids: Option<Vec<i32>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct HashLookupRequest {
|
||||
pub hashes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct ExternalLicenseLookupRequest {
|
||||
#[serde(default)]
|
||||
pub flame_ids: Vec<i32>,
|
||||
#[serde(default)]
|
||||
pub hashes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct ExternalLicenseLookupResponse {
|
||||
pub flame_ids: HashMap<i32, Vec<ExternalProject>>,
|
||||
pub hashes: HashMap<String, ExternalProject>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
@@ -55,6 +81,32 @@ pub struct UpdateLicenseRequest {
|
||||
pub flame_project_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct FileLicenseRequest {
|
||||
pub hashes: Vec<String>,
|
||||
pub license_id: LicenseId,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum LicenseId {
|
||||
Number(i64),
|
||||
String(String),
|
||||
}
|
||||
|
||||
impl LicenseId {
|
||||
fn parse(self) -> Result<i64, ApiError> {
|
||||
match self {
|
||||
LicenseId::Number(id) => Ok(id),
|
||||
LicenseId::String(id) => id.parse().map_err(|_| {
|
||||
ApiError::InvalidInput(
|
||||
"license_id must be a valid integer".to_string(),
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LicenseRow {
|
||||
id: i64,
|
||||
title: Option<String>,
|
||||
@@ -69,6 +121,38 @@ struct LicenseRow {
|
||||
updated_by: Option<i64>,
|
||||
}
|
||||
|
||||
struct LicenseHashRow {
|
||||
hash: Vec<u8>,
|
||||
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>,
|
||||
}
|
||||
|
||||
fn normalize_sha1_hashes(hashes: &[String]) -> Result<Vec<String>, ApiError> {
|
||||
hashes
|
||||
.iter()
|
||||
.map(|hash| {
|
||||
let hash = hash.trim().to_lowercase();
|
||||
if hash.len() != 40 || !hash.chars().all(|c| c.is_ascii_hexdigit())
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"hash must be a valid SHA1 hex string".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(hash)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl LicenseRow {
|
||||
fn into_external_project(
|
||||
self,
|
||||
@@ -120,12 +204,131 @@ async fn fetch_linked_files(
|
||||
.or_default()
|
||||
.push(LinkedFile {
|
||||
name: row.filename,
|
||||
sha1: hex::encode(&row.sha1),
|
||||
sha1: String::from_utf8(row.sha1)
|
||||
.unwrap_or_else(|err| hex::encode(err.into_bytes())),
|
||||
});
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
async fn fetch_by_hashes(
|
||||
pool: &PgPool,
|
||||
hashes: &[String],
|
||||
) -> Result<HashMap<String, ExternalProject>, ApiError> {
|
||||
if hashes.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let hash_bytes = hashes
|
||||
.iter()
|
||||
.map(|hash| hash.as_bytes().to_vec())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let rows = sqlx::query_as!(
|
||||
LicenseHashRow,
|
||||
r#"
|
||||
SELECT
|
||||
mef.sha1 hash,
|
||||
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 = ANY($1)
|
||||
"#,
|
||||
&hash_bytes,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let license_ids = rows.iter().map(|row| row.id).collect::<Vec<_>>();
|
||||
let files_map = fetch_linked_files(pool, &license_ids).await?;
|
||||
|
||||
let mut results = HashMap::new();
|
||||
for row in rows {
|
||||
let hash = String::from_utf8(row.hash)
|
||||
.unwrap_or_else(|err| hex::encode(err.into_bytes()));
|
||||
let linked_files = files_map.get(&row.id).cloned().unwrap_or_default();
|
||||
results.insert(
|
||||
hash,
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn fetch_by_flame_ids(
|
||||
pool: &PgPool,
|
||||
flame_ids: &[i32],
|
||||
) -> Result<HashMap<i32, Vec<ExternalProject>>, ApiError> {
|
||||
if flame_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as!(
|
||||
LicenseRow,
|
||||
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 mel.flame_project_id = ANY($1)
|
||||
ORDER BY mel.id
|
||||
"#,
|
||||
flame_ids,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let license_ids = rows.iter().map(|row| row.id).collect::<Vec<_>>();
|
||||
let files_map = fetch_linked_files(pool, &license_ids).await?;
|
||||
|
||||
let mut results: HashMap<i32, Vec<ExternalProject>> = HashMap::new();
|
||||
for row in rows {
|
||||
if let Some(flame_project_id) = row.flame_project_id {
|
||||
let linked_files =
|
||||
files_map.get(&row.id).cloned().unwrap_or_default();
|
||||
results
|
||||
.entry(flame_project_id)
|
||||
.or_default()
|
||||
.push(row.into_external_project(linked_files));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("/search")]
|
||||
async fn search(
|
||||
@@ -144,7 +347,8 @@ async fn search(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let rows = sqlx::query!(
|
||||
let rows = sqlx::query_as!(
|
||||
LicenseRow,
|
||||
r#"
|
||||
SELECT
|
||||
mel.id,
|
||||
@@ -160,11 +364,16 @@ async fn search(
|
||||
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)
|
||||
AND (
|
||||
($2::integer IS NULL AND $3::integer[] IS NULL)
|
||||
OR mel.flame_project_id = $2
|
||||
OR mel.flame_project_id = ANY($3)
|
||||
)
|
||||
ORDER BY mel.id
|
||||
"#,
|
||||
body.title,
|
||||
body.flame_id,
|
||||
body.flame_ids.as_deref(),
|
||||
)
|
||||
.fetch_all(&**pool)
|
||||
.await?;
|
||||
@@ -177,26 +386,42 @@ async fn search(
|
||||
.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)
|
||||
row.into_external_project(linked_files)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(web::Json(results))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("/lookup")]
|
||||
async fn lookup(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
body: web::Json<ExternalLicenseLookupRequest>,
|
||||
) -> Result<web::Json<ExternalLicenseLookupResponse>, ApiError> {
|
||||
check_is_moderator_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_READ,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let body = body.into_inner();
|
||||
let hashes = normalize_sha1_hashes(&body.hashes)?;
|
||||
let flame_ids = fetch_by_flame_ids(&pool, &body.flame_ids).await?;
|
||||
let hashes = fetch_by_hashes(&pool, &hashes).await?;
|
||||
|
||||
Ok(web::Json(ExternalLicenseLookupResponse {
|
||||
flame_ids,
|
||||
hashes,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[get("/by-sha1/{sha1}")]
|
||||
async fn get_by_sha1(
|
||||
@@ -215,48 +440,145 @@ async fn get_by_sha1(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let sha1 = path.into_inner().0;
|
||||
let hashes = normalize_sha1_hashes(&[path.into_inner().0])?;
|
||||
let hash = hashes.first().ok_or(ApiError::NotFound)?;
|
||||
let mut results = fetch_by_hashes(&pool, &hashes).await?;
|
||||
let result = results.remove(hash).ok_or(ApiError::NotFound)?;
|
||||
|
||||
let row = sqlx::query!(
|
||||
Ok(web::Json(result))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("/by-sha1")]
|
||||
async fn get_by_sha1_bulk(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
body: web::Json<HashLookupRequest>,
|
||||
) -> Result<web::Json<HashMap<String, ExternalProject>>, ApiError> {
|
||||
check_is_moderator_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_READ,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let hashes = normalize_sha1_hashes(&body.hashes)?;
|
||||
let results = fetch_by_hashes(&pool, &hashes).await?;
|
||||
|
||||
Ok(web::Json(results))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("/file")]
|
||||
async fn add_file(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
body: web::Json<FileLicenseRequest>,
|
||||
) -> Result<web::Json<ExternalProject>, ApiError> {
|
||||
upsert_file_license(req, pool, redis, session_queue, body).await
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("/file/reassign")]
|
||||
async fn reassign_file(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
body: web::Json<FileLicenseRequest>,
|
||||
) -> Result<web::Json<ExternalProject>, ApiError> {
|
||||
upsert_file_license(req, pool, redis, session_queue, body).await
|
||||
}
|
||||
|
||||
async fn upsert_file_license(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
body: web::Json<FileLicenseRequest>,
|
||||
) -> Result<web::Json<ExternalProject>, ApiError> {
|
||||
let user = check_is_moderator_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PROJECT_READ,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let body = body.into_inner();
|
||||
let license_id = body.license_id.parse()?;
|
||||
if body.hashes.is_empty() {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"hashes must contain at least one SHA1 hex string".to_string(),
|
||||
));
|
||||
}
|
||||
let hashes = normalize_sha1_hashes(&body.hashes)?;
|
||||
let hash_bytes = hashes
|
||||
.iter()
|
||||
.map(|hash| hash.as_bytes().to_vec())
|
||||
.collect::<Vec<_>>();
|
||||
let filenames = vec![None; hashes.len()];
|
||||
let license_ids = vec![license_id; hashes.len()];
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let license = 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
|
||||
id,
|
||||
title,
|
||||
status,
|
||||
link,
|
||||
exceptions,
|
||||
proof,
|
||||
flame_project_id,
|
||||
inserted_at,
|
||||
inserted_by,
|
||||
updated_at,
|
||||
updated_by
|
||||
FROM moderation_external_licenses
|
||||
WHERE id = $1
|
||||
"#,
|
||||
sha1.as_bytes().to_vec(),
|
||||
license_id,
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.fetch_optional(&mut transaction)
|
||||
.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();
|
||||
ExternalLicense::insert_files(
|
||||
&mut transaction,
|
||||
&hash_bytes,
|
||||
&filenames,
|
||||
&license_ids,
|
||||
DBUserId(user.id.0 as i64),
|
||||
)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
let files_map = fetch_linked_files(&pool, &[license_id]).await?;
|
||||
let linked_files = files_map.get(&license_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,
|
||||
id: license.id,
|
||||
title: license.title,
|
||||
status: license.status,
|
||||
link: license.link,
|
||||
exceptions: license.exceptions,
|
||||
proof: license.proof,
|
||||
flame_project_id: license.flame_project_id,
|
||||
inserted_at: license.inserted_at,
|
||||
inserted_by: license.inserted_by,
|
||||
updated_at: license.updated_at,
|
||||
updated_by: license.updated_by,
|
||||
}
|
||||
.into_external_project(linked_files),
|
||||
))
|
||||
|
||||
@@ -289,13 +289,13 @@ async fn get_report(
|
||||
'flag_reason', 'delphi',
|
||||
'download_url', f.url,
|
||||
-- TODO: replace with `json_array` in Postgres 16
|
||||
'issues', (
|
||||
SELECT json_agg(
|
||||
to_jsonb(dri)
|
||||
|| jsonb_build_object(
|
||||
-- TODO: replace with `json_array` in Postgres 16
|
||||
'details', (
|
||||
SELECT coalesce(jsonb_agg(
|
||||
'issues', (
|
||||
SELECT coalesce(json_agg(
|
||||
to_jsonb(dri)
|
||||
|| jsonb_build_object(
|
||||
-- TODO: replace with `json_array` in Postgres 16
|
||||
'details', (
|
||||
SELECT coalesce(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', didws.id,
|
||||
'issue_id', didws.issue_id,
|
||||
@@ -310,11 +310,11 @@ async fn get_report(
|
||||
FROM delphi_issue_details_with_statuses didws
|
||||
WHERE didws.issue_id = dri.id
|
||||
)
|
||||
)
|
||||
)
|
||||
FROM delphi_report_issues dri
|
||||
WHERE
|
||||
dri.report_id = dr.id
|
||||
)
|
||||
), '[]'::json)
|
||||
FROM delphi_report_issues dri
|
||||
WHERE
|
||||
dri.report_id = dr.id
|
||||
-- see delphi.rs todo comment
|
||||
AND dri.issue_type != '__dummy'
|
||||
)
|
||||
|
||||
@@ -20,7 +20,6 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use validator::Validate;
|
||||
|
||||
use super::version_creation::InitialVersionData;
|
||||
@@ -158,7 +157,7 @@ pub async fn project_create(
|
||||
payload: Multipart,
|
||||
client: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: Data<dyn FileHost>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
http: Data<HttpClient>,
|
||||
) -> Result<HttpResponse, CreateError> {
|
||||
|
||||
@@ -18,7 +18,6 @@ use crate::search::{SearchBackend, SearchRequest};
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
@@ -924,7 +923,7 @@ pub async fn project_icon_edit(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
payload: web::Payload,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
@@ -964,7 +963,7 @@ pub async fn delete_project_icon(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so no need to convert
|
||||
@@ -1055,7 +1054,7 @@ pub async fn add_gallery_item(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
payload: web::Payload,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
@@ -1198,7 +1197,7 @@ pub async fn delete_gallery_item(
|
||||
web::Query(item): web::Query<GalleryDeleteQuery>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so no need to convert
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::database::PgPool;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::file_hosting::FileHost;
|
||||
@@ -169,7 +167,7 @@ pub async fn message_delete(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so we don't need to convert the response
|
||||
v3::threads::message_delete(
|
||||
|
||||
@@ -11,7 +11,6 @@ use crate::queue::session::AuthQueue;
|
||||
use crate::routes::{ApiError, v2_reroute, v3};
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
@@ -299,7 +298,7 @@ pub async fn user_icon_edit(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
payload: web::Payload,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
@@ -339,7 +338,7 @@ pub async fn user_icon_delete(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so we don't need to convert to V2
|
||||
|
||||
@@ -21,7 +21,6 @@ use actix_web::{HttpRequest, HttpResponse, post, web};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn default_requested_status() -> VersionStatus {
|
||||
@@ -99,7 +98,7 @@ pub async fn version_create(
|
||||
payload: Multipart,
|
||||
client: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: Data<dyn FileHost>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
moderation_queue: Data<AutomatedModerationQueue>,
|
||||
http: Data<HttpClient>,
|
||||
@@ -327,7 +326,7 @@ pub async fn upload_file_to_version(
|
||||
payload: Multipart,
|
||||
client: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: Data<dyn FileHost>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
http: web::Data<HttpClient>,
|
||||
) -> Result<HttpResponse, CreateError> {
|
||||
|
||||
@@ -25,7 +25,6 @@ use chrono::Utc;
|
||||
use eyre::eyre;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
@@ -390,7 +389,7 @@ pub async fn collection_icon_edit(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
mut payload: web::Payload,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
@@ -423,7 +422,7 @@ pub async fn collection_icon_edit(
|
||||
collection_item.icon_url,
|
||||
collection_item.raw_icon_url,
|
||||
FileHostPublicity::Public,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -442,7 +441,7 @@ pub async fn collection_icon_edit(
|
||||
&ext.ext,
|
||||
Some(96),
|
||||
Some(1.0),
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -474,7 +473,7 @@ pub async fn delete_collection_icon(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
@@ -505,7 +504,7 @@ pub async fn delete_collection_icon(
|
||||
collection_item.icon_url,
|
||||
collection_item.raw_icon_url,
|
||||
FileHostPublicity::Public,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::threads::is_authorized_thread;
|
||||
use crate::auth::checks::{is_team_member_project, is_team_member_version};
|
||||
use crate::auth::get_user_from_headers;
|
||||
@@ -41,7 +39,7 @@ pub struct ImageUpload {
|
||||
pub async fn images_add(
|
||||
req: HttpRequest,
|
||||
web::Query(data): web::Query<ImageUpload>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
mut payload: web::Payload,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -191,7 +189,7 @@ pub async fn images_add(
|
||||
&data.ext,
|
||||
None,
|
||||
None,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{collections::HashSet, fmt::Display, sync::Arc};
|
||||
use std::{collections::HashSet, fmt::Display};
|
||||
|
||||
use super::ApiError;
|
||||
use crate::database::{PgPool, PgTransaction};
|
||||
@@ -354,7 +354,7 @@ pub async fn oauth_client_icon_edit(
|
||||
client_id: web::Path<OAuthClientId>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
mut payload: web::Payload,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
@@ -382,7 +382,7 @@ pub async fn oauth_client_icon_edit(
|
||||
client.icon_url.clone(),
|
||||
client.raw_icon_url.clone(),
|
||||
FileHostPublicity::Public,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -399,7 +399,7 @@ pub async fn oauth_client_icon_edit(
|
||||
&ext.ext,
|
||||
Some(96),
|
||||
Some(1.0),
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -424,7 +424,7 @@ pub async fn oauth_client_icon_delete(
|
||||
client_id: web::Path<OAuthClientId>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
@@ -450,7 +450,7 @@ pub async fn oauth_client_icon_delete(
|
||||
client.icon_url.clone(),
|
||||
client.raw_icon_url.clone(),
|
||||
FileHostPublicity::Public,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::ApiError;
|
||||
use crate::auth::checks::is_visible_organization;
|
||||
@@ -1158,7 +1157,7 @@ pub async fn organization_icon_edit(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
mut payload: web::Payload,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
@@ -1209,7 +1208,7 @@ pub async fn organization_icon_edit(
|
||||
organization_item.icon_url,
|
||||
organization_item.raw_icon_url,
|
||||
FileHostPublicity::Public,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1228,7 +1227,7 @@ pub async fn organization_icon_edit(
|
||||
&ext.ext,
|
||||
Some(96),
|
||||
Some(1.0),
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1264,7 +1263,7 @@ pub async fn delete_organization_icon(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
@@ -1314,7 +1313,7 @@ pub async fn delete_organization_icon(
|
||||
organization_item.icon_url,
|
||||
organization_item.raw_icon_url,
|
||||
FileHostPublicity::Public,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ use itertools::Itertools;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use validator::Validate;
|
||||
|
||||
@@ -290,7 +289,7 @@ pub async fn project_create(
|
||||
payload: Multipart,
|
||||
client: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: Data<dyn FileHost>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
http: Data<HttpClient>,
|
||||
) -> Result<HttpResponse, CreateError> {
|
||||
@@ -311,7 +310,7 @@ pub async fn project_create_internal(
|
||||
mut payload: Multipart,
|
||||
client: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: Data<dyn FileHost>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
http: Data<HttpClient>,
|
||||
) -> Result<HttpResponse, CreateError> {
|
||||
@@ -325,7 +324,7 @@ pub async fn project_create_internal(
|
||||
req,
|
||||
&mut payload,
|
||||
&mut transaction,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
&mut uploaded_files,
|
||||
&client,
|
||||
&redis,
|
||||
@@ -336,7 +335,7 @@ pub async fn project_create_internal(
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let undo_result = undo_uploads(&***file_host, &uploaded_files).await;
|
||||
let undo_result = undo_uploads(&**file_host, &uploaded_files).await;
|
||||
let rollback_result = transaction.rollback().await;
|
||||
|
||||
undo_result?;
|
||||
@@ -360,7 +359,7 @@ pub async fn project_create_with_id(
|
||||
mut payload: Multipart,
|
||||
client: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: Data<dyn FileHost>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
http: Data<HttpClient>,
|
||||
path: web::Path<(ProjectId,)>,
|
||||
@@ -374,7 +373,7 @@ pub async fn project_create_with_id(
|
||||
req,
|
||||
&mut payload,
|
||||
&mut transaction,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
&mut uploaded_files,
|
||||
&client,
|
||||
&redis,
|
||||
@@ -385,7 +384,7 @@ pub async fn project_create_with_id(
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let undo_result = undo_uploads(&***file_host, &uploaded_files).await;
|
||||
let undo_result = undo_uploads(&**file_host, &uploaded_files).await;
|
||||
let rollback_result = transaction.rollback().await;
|
||||
|
||||
undo_result?;
|
||||
@@ -907,7 +906,7 @@ async fn project_create_inner(
|
||||
let now = Utc::now();
|
||||
|
||||
let id = project_builder_actual
|
||||
.insert(&mut *transaction, http)
|
||||
.insert(&mut *transaction, redis, file_host, http)
|
||||
.await?;
|
||||
DBUser::clear_project_cache(&[current_user.id.into()], redis).await?;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::{
|
||||
},
|
||||
redis::RedisPool,
|
||||
},
|
||||
file_hosting::FileHost,
|
||||
models::{
|
||||
exp::{self, ProjectComponentKind, component::ComponentRelationError},
|
||||
ids::ProjectId,
|
||||
@@ -117,6 +118,7 @@ pub async fn create(
|
||||
req: HttpRequest,
|
||||
db: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
http: web::Data<HttpClient>,
|
||||
web::Json(create): web::Json<ProjectCreate>,
|
||||
@@ -305,13 +307,13 @@ pub async fn create(
|
||||
};
|
||||
|
||||
project_builder
|
||||
.insert(&mut txn, &http)
|
||||
.insert(&mut txn, &redis, &**file_host, &http)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert project")?;
|
||||
|
||||
if let Some(version_builder) = version_builder {
|
||||
version_builder
|
||||
.insert(&mut txn, &http)
|
||||
.insert(&mut txn, &redis, &**file_host, &http)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert initial version")?;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::any::type_name;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::auth::checks::{filter_visible_versions, is_visible_project};
|
||||
use crate::auth::{filter_visible_projects, get_user_from_headers};
|
||||
@@ -1694,7 +1693,7 @@ async fn project_icon_edit(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
payload: web::Payload,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
@@ -1717,7 +1716,7 @@ pub async fn project_icon_edit_internal(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
mut payload: web::Payload,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
@@ -1775,7 +1774,7 @@ pub async fn project_icon_edit_internal(
|
||||
project_item.inner.icon_url,
|
||||
project_item.inner.raw_icon_url,
|
||||
FileHostPublicity::Public,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1794,7 +1793,7 @@ pub async fn project_icon_edit_internal(
|
||||
&ext.ext,
|
||||
Some(96),
|
||||
Some(1.0),
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1833,7 +1832,7 @@ async fn delete_project_icon(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
delete_project_icon_internal(
|
||||
@@ -1852,7 +1851,7 @@ pub async fn delete_project_icon_internal(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
@@ -1908,7 +1907,7 @@ pub async fn delete_project_icon_internal(
|
||||
project_item.inner.icon_url,
|
||||
project_item.inner.raw_icon_url,
|
||||
FileHostPublicity::Public,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1957,7 +1956,7 @@ pub async fn add_gallery_item(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
payload: web::Payload,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
@@ -1982,7 +1981,7 @@ pub async fn add_gallery_item_internal(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
mut payload: web::Payload,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
@@ -2062,7 +2061,7 @@ pub async fn add_gallery_item_internal(
|
||||
&ext.ext,
|
||||
Some(350),
|
||||
Some(1.0),
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -2334,7 +2333,7 @@ async fn delete_gallery_item(
|
||||
web::Query(item): web::Query<GalleryDeleteQuery>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
delete_gallery_item_internal(
|
||||
@@ -2353,7 +2352,7 @@ pub async fn delete_gallery_item_internal(
|
||||
web::Query(item): web::Query<GalleryDeleteQuery>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
@@ -2429,7 +2428,7 @@ pub async fn delete_gallery_item_internal(
|
||||
Some(item.image_url),
|
||||
Some(item.raw_image_url),
|
||||
FileHostPublicity::Public,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ use bytes::BytesMut;
|
||||
use chrono::Utc;
|
||||
use futures_util::StreamExt;
|
||||
use hex::FromHex;
|
||||
use std::sync::Arc;
|
||||
|
||||
const MAX_FILE_SIZE: usize = 500 * 1024 * 1024;
|
||||
const MAX_FILE_SIZE_TEXT: &str = "500 MB";
|
||||
@@ -44,7 +43,7 @@ pub async fn shared_instance_version_create(
|
||||
payload: web::Payload,
|
||||
web::Header(ContentLength(content_length)): web::Header<ContentLength>,
|
||||
redis: Data<RedisPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: Data<dyn FileHost>,
|
||||
info: web::Path<(SharedInstanceId,)>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
@@ -63,7 +62,7 @@ pub async fn shared_instance_version_create(
|
||||
payload,
|
||||
content_length,
|
||||
&redis,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
info.into_inner().0.into(),
|
||||
&session_queue,
|
||||
&mut transaction,
|
||||
@@ -73,7 +72,7 @@ pub async fn shared_instance_version_create(
|
||||
|
||||
if result.is_err() {
|
||||
let undo_result = super::project_creation::undo_uploads(
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
&uploaded_files,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -22,7 +22,6 @@ use actix_web::web::{Data, Redirect};
|
||||
use actix_web::{HttpRequest, HttpResponse, web};
|
||||
use futures_util::future::try_join_all;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
@@ -565,7 +564,7 @@ pub async fn shared_instance_version_download(
|
||||
req: HttpRequest,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: Data<dyn FileHost>,
|
||||
info: web::Path<(SharedInstanceVersionId,)>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<Redirect, ApiError> {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::database;
|
||||
use crate::database::PgPool;
|
||||
@@ -599,7 +597,7 @@ pub async fn message_delete(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{ApiError, oauth_clients::get_user_clients};
|
||||
use crate::database::PgPool;
|
||||
@@ -671,7 +671,7 @@ pub async fn user_icon_edit(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
mut payload: web::Payload,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
@@ -698,7 +698,7 @@ pub async fn user_icon_edit(
|
||||
actual_user.avatar_url,
|
||||
actual_user.raw_avatar_url,
|
||||
FileHostPublicity::Public,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -717,7 +717,7 @@ pub async fn user_icon_edit(
|
||||
&ext.ext,
|
||||
Some(96),
|
||||
Some(1.0),
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -746,7 +746,7 @@ pub async fn user_icon_delete(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
@@ -772,7 +772,7 @@ pub async fn user_icon_delete(
|
||||
actual_user.avatar_url,
|
||||
actual_user.raw_avatar_url,
|
||||
FileHostPublicity::Public,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha1::Digest;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use validator::Validate;
|
||||
|
||||
fn default_requested_status() -> VersionStatus {
|
||||
@@ -110,7 +109,7 @@ pub async fn version_create(
|
||||
mut payload: Multipart,
|
||||
client: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: Data<dyn FileHost>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
moderation_queue: web::Data<AutomatedModerationQueue>,
|
||||
http: web::Data<HttpClient>,
|
||||
@@ -123,7 +122,7 @@ pub async fn version_create(
|
||||
&mut payload,
|
||||
&mut transaction,
|
||||
&redis,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
&mut uploaded_files,
|
||||
&client,
|
||||
&session_queue,
|
||||
@@ -134,7 +133,7 @@ pub async fn version_create(
|
||||
|
||||
if result.is_err() {
|
||||
let undo_result = super::project_creation::undo_uploads(
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
&uploaded_files,
|
||||
)
|
||||
.await;
|
||||
@@ -481,10 +480,11 @@ async fn version_create_inner(
|
||||
loaders: version_data.loaders,
|
||||
fields: version_data.fields,
|
||||
components: exp::VersionQuery::default(),
|
||||
files_missing_attribution: Vec::new(),
|
||||
};
|
||||
|
||||
let project_id = builder.project_id;
|
||||
builder.insert(transaction, http).await?;
|
||||
builder.insert(transaction, redis, file_host, http).await?;
|
||||
|
||||
for image_id in version_data.uploaded_images {
|
||||
if let Some(db_image) =
|
||||
@@ -544,7 +544,7 @@ pub async fn upload_file_to_version(
|
||||
mut payload: Multipart,
|
||||
client: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
file_host: Data<dyn FileHost>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
http: web::Data<HttpClient>,
|
||||
) -> Result<HttpResponse, CreateError> {
|
||||
@@ -559,7 +559,7 @@ pub async fn upload_file_to_version(
|
||||
client,
|
||||
&mut transaction,
|
||||
redis,
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
&mut uploaded_files,
|
||||
version_id,
|
||||
&session_queue,
|
||||
@@ -569,7 +569,7 @@ pub async fn upload_file_to_version(
|
||||
|
||||
if result.is_err() {
|
||||
let undo_result = super::project_creation::undo_uploads(
|
||||
&***file_host,
|
||||
&**file_host,
|
||||
&uploaded_files,
|
||||
)
|
||||
.await;
|
||||
@@ -780,8 +780,18 @@ async fn upload_file_to_version_inner(
|
||||
"At least one file must be specified".to_string(),
|
||||
));
|
||||
} else {
|
||||
let project_id = version.inner.project_id;
|
||||
|
||||
for file in file_builders {
|
||||
file.insert(version_id, &mut *transaction, http).await?;
|
||||
file.insert(
|
||||
version_id,
|
||||
project_id,
|
||||
&mut *transaction,
|
||||
&redis,
|
||||
file_host,
|
||||
http,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -862,8 +872,10 @@ pub async fn upload_file(
|
||||
));
|
||||
}
|
||||
|
||||
let data = data.freeze();
|
||||
|
||||
let validation_result = validate_file(
|
||||
data.clone().into(),
|
||||
data.clone(),
|
||||
file_extension.to_string(),
|
||||
loaders.clone(),
|
||||
file_type,
|
||||
@@ -940,7 +952,6 @@ pub async fn upload_file(
|
||||
}
|
||||
}
|
||||
|
||||
let data = data.freeze();
|
||||
let primary = (validation_result.is_passed()
|
||||
&& version_files.iter().all(|x| !x.primary)
|
||||
&& !ignore_primary)
|
||||
|
||||
@@ -721,6 +721,11 @@ pub async fn delete_file(
|
||||
.execute(&mut transaction)
|
||||
.await?;
|
||||
|
||||
database::models::version_item::cleanup_empty_attribution_groups(
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
delphi::send_tech_review_exit_file_deleted_message_if_exited(
|
||||
row.project_id,
|
||||
was_in_tech_review,
|
||||
|
||||
@@ -2,7 +2,8 @@ use std::collections::HashMap;
|
||||
|
||||
use super::ApiError;
|
||||
use crate::auth::checks::{
|
||||
filter_visible_versions, is_visible_project, is_visible_version,
|
||||
enrich_dependency_attributions, filter_visible_versions,
|
||||
is_visible_project, is_visible_version,
|
||||
};
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::database;
|
||||
@@ -24,6 +25,9 @@ use crate::models::projects::{
|
||||
};
|
||||
use crate::models::projects::{Loader, skip_nulls};
|
||||
use crate::models::teams::ProjectPermissions;
|
||||
use crate::queue::file_scan::{
|
||||
get_dependency_attributions, get_files_missing_attribution,
|
||||
};
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::internal::delphi;
|
||||
use crate::search::SearchBackend;
|
||||
@@ -113,8 +117,38 @@ pub async fn version_project_get_helper(
|
||||
&& is_visible_version(&version.inner, &user_option, &pool, &redis)
|
||||
.await?
|
||||
{
|
||||
return Ok(HttpResponse::Ok()
|
||||
.json(models::projects::Version::from(version)));
|
||||
let version_id = version.inner.id;
|
||||
let mut v = models::projects::Version::from(version);
|
||||
let missing = get_files_missing_attribution(&**pool, &[version_id])
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
v.files_missing_attribution = missing
|
||||
.get(&version_id)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(id, fp)| models::projects::MissingAttributionFile {
|
||||
id: models::ids::FileId(id.0 as u64),
|
||||
override_source: fp
|
||||
.as_ref()
|
||||
.map(|p| models::projects::OverrideSource::Flame {
|
||||
id: p.id,
|
||||
title: p.title.clone(),
|
||||
url: p.url.clone(),
|
||||
icon_url: p.icon_url.clone(),
|
||||
})
|
||||
.or(Some(models::projects::OverrideSource::Unknown)),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let dep_attr = get_dependency_attributions(&**pool, &[version_id])
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
enrich_dependency_attributions(&mut v, &dep_attr);
|
||||
|
||||
return Ok(HttpResponse::Ok().json(v));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +241,40 @@ pub async fn version_get_helper(
|
||||
if let Some(data) = version_data
|
||||
&& is_visible_version(&data.inner, &user_option, &pool, &redis).await?
|
||||
{
|
||||
return Ok(web::Json(models::projects::Version::from(data)));
|
||||
let version_id = data.inner.id;
|
||||
let mut version = models::projects::Version::from(data);
|
||||
let missing = get_files_missing_attribution(&**pool, &[version_id])
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
version.files_missing_attribution = missing
|
||||
.get(&version_id)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(id, fp)| models::projects::MissingAttributionFile {
|
||||
id: models::ids::FileId(id.0 as u64),
|
||||
override_source: fp
|
||||
.as_ref()
|
||||
.map(|p| models::projects::OverrideSource::Flame {
|
||||
id: p.id,
|
||||
title: p.title.clone(),
|
||||
url: p.url.clone(),
|
||||
icon_url: p.icon_url.clone(),
|
||||
})
|
||||
.or(Some(
|
||||
models::projects::OverrideSource::Unknown,
|
||||
)),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let dep_attr = get_dependency_attributions(&**pool, &[version_id])
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
enrich_dependency_attributions(&mut version, &dep_attr);
|
||||
|
||||
return Ok(web::Json(version));
|
||||
}
|
||||
|
||||
Err(ApiError::NotFound)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web;
|
||||
|
||||
use crate::env::ENV;
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::queue::email::EmailQueue;
|
||||
use crate::util::anrok;
|
||||
use crate::util::gotenberg::GotenbergClient;
|
||||
use crate::{LabrinthConfig, file_hosting};
|
||||
use crate::{clickhouse, env};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub mod api_common;
|
||||
pub mod api_v2;
|
||||
@@ -31,8 +35,8 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
|
||||
let ro_pool = db.ro_pool.clone();
|
||||
let redis_pool = db.redis_pool.clone();
|
||||
let search_backend = db.search_backend.clone();
|
||||
let file_host: Arc<dyn file_hosting::FileHost + Send + Sync> =
|
||||
Arc::new(file_hosting::MockHost::new());
|
||||
let file_host: Arc<dyn FileHost> = Arc::new(file_hosting::MockHost::new());
|
||||
let file_host = web::Data::<dyn FileHost>::from(file_host);
|
||||
let mut clickhouse = clickhouse::init_client().await.unwrap();
|
||||
|
||||
let stripe_client = stripe::Client::new(ENV.STRIPE_API_KEY.clone());
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
use std::time::Duration;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use derive_more::Deref;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
|
||||
|
||||
/// Generic HTTP client used for anywhere you need to send an HTTP request, and
|
||||
/// do not care what headers or other parameters are used.
|
||||
pub static HTTP_CLIENT: LazyLock<HttpClient> = LazyLock::new(HttpClient::new);
|
||||
|
||||
#[derive(Debug, Clone, Deref)]
|
||||
pub struct HttpClient(pub reqwest::Client);
|
||||
pub struct HttpClient(reqwest::Client);
|
||||
|
||||
impl HttpClient {
|
||||
pub fn new() -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.default_headers(HeaderMap::from_iter([(
|
||||
USER_AGENT,
|
||||
HeaderValue::from_static(concat!(
|
||||
.default_headers(reqwest::header::HeaderMap::from_iter([(
|
||||
reqwest::header::USER_AGENT,
|
||||
reqwest::header::HeaderValue::from_static(concat!(
|
||||
"Labrinth/",
|
||||
env!("COMPILATION_DATE")
|
||||
)),
|
||||
)]))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap();
|
||||
Self(client)
|
||||
|
||||
@@ -494,7 +494,8 @@ pub async fn test_patch_version() {
|
||||
project_id: Some(*beta_project_id_parsed),
|
||||
version_id: None,
|
||||
file_name: Some("dummy_file_name".to_string()),
|
||||
dependency_type: DependencyType::Required
|
||||
dependency_type: DependencyType::Required,
|
||||
attribution: None,
|
||||
}]
|
||||
);
|
||||
assert_eq!(version.loaders, vec!["forge".to_string()]);
|
||||
|
||||
@@ -14,12 +14,14 @@ import { KyrosLogsV1Module } from './kyros/logs/v1'
|
||||
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
|
||||
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
|
||||
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
|
||||
import { LabrinthAttributionInternalModule } from './labrinth/attribution/internal'
|
||||
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 { LabrinthImagesV3Module } from './labrinth/images/v3'
|
||||
import { LabrinthLimitsV3Module } from './labrinth/limits/v3'
|
||||
import { LabrinthModerationInternalModule } from './labrinth/moderation/internal'
|
||||
import { LabrinthNotificationsV2Module } from './labrinth/notifications/v2'
|
||||
@@ -76,10 +78,12 @@ export const MODULE_REGISTRY = {
|
||||
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
|
||||
labrinth_auth_internal: LabrinthAuthInternalModule,
|
||||
labrinth_auth_v2: LabrinthAuthV2Module,
|
||||
labrinth_attribution_internal: LabrinthAttributionInternalModule,
|
||||
labrinth_billing_internal: LabrinthBillingInternalModule,
|
||||
labrinth_collections: LabrinthCollectionsModule,
|
||||
labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule,
|
||||
labrinth_globals_internal: LabrinthGlobalsInternalModule,
|
||||
labrinth_images_v3: LabrinthImagesV3Module,
|
||||
labrinth_moderation_internal: LabrinthModerationInternalModule,
|
||||
labrinth_notifications_v2: LabrinthNotificationsV2Module,
|
||||
labrinth_oauth_internal: LabrinthOAuthInternalModule,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
const BASE62_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
/**
|
||||
* Decode a base62-encoded ID string into a number.
|
||||
* The backend serializes attribution group IDs as base62 strings in responses,
|
||||
* but the assign/update endpoints expect raw integer IDs in their request payloads.
|
||||
*/
|
||||
function decodeBase62Id(id: string): number {
|
||||
let value = 0
|
||||
for (const char of id) {
|
||||
const digit = BASE62_CHARS.indexOf(char)
|
||||
if (digit < 0) {
|
||||
throw new Error(`Invalid base62 character "${char}" in id "${id}"`)
|
||||
}
|
||||
value = value * 62 + digit
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new Error(`Base62 id "${id}" exceeds safe integer range`)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export class LabrinthAttributionInternalModule extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_attribution_internal'
|
||||
}
|
||||
|
||||
/**
|
||||
* List attribution groups for a project
|
||||
* GET /_internal/attribution/{project_id}
|
||||
*/
|
||||
public async listProjectAttribution(
|
||||
projectId: string,
|
||||
): Promise<Labrinth.Attribution.Internal.AttributionGroup[]> {
|
||||
return this.client.request<Labrinth.Attribution.Internal.AttributionGroup[]>(
|
||||
`/attribution/${projectId}`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an attribution group's attribution payload.
|
||||
* PATCH /_internal/attribution/group/{group_id}
|
||||
*
|
||||
* @param groupId - The base62 attribution group id (as returned from listProjectAttribution).
|
||||
*/
|
||||
public async updateGroup(
|
||||
groupId: string,
|
||||
body: Labrinth.Attribution.Internal.UpdateGroupRequest,
|
||||
): Promise<void> {
|
||||
const numericId = decodeBase62Id(groupId)
|
||||
return this.client.request<void>(`/attribution/group/${numericId}`, {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'PATCH',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign a file (by sha1) to another attribution group within the same project.
|
||||
* POST /_internal/attribution/assign
|
||||
*
|
||||
* @param body.target_group_id - The base62 id of the attribution group to assign the file to.
|
||||
*/
|
||||
public async assignFileToGroup(body: {
|
||||
sha1: string
|
||||
target_group_id: string
|
||||
project_id: string
|
||||
}): Promise<void> {
|
||||
const wireBody: Labrinth.Attribution.Internal.AssignRequest = {
|
||||
sha1: body.sha1,
|
||||
target_group_id: decodeBase62Id(body.target_group_id),
|
||||
project_id: body.project_id,
|
||||
}
|
||||
return this.client.request<void>('/attribution/assign', {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: wireBody,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a file (by sha1) out of its current attribution group into a new group.
|
||||
* POST /_internal/attribution/split
|
||||
*/
|
||||
public async splitFile(body: Labrinth.Attribution.Internal.SplitRequest): Promise<void> {
|
||||
return this.client.request<void>('/attribution/split', {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -47,4 +47,33 @@ export class LabrinthExternalProjectsInternalModule extends AbstractModule {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Backend routes don't exist yet
|
||||
public async create(
|
||||
data: Labrinth.ExternalProjects.Internal.CreateExternalLicenseRequest,
|
||||
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject> {
|
||||
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject>(
|
||||
'/moderation/external-license',
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: data,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public async addFile(
|
||||
data: Labrinth.ExternalProjects.Internal.AddFileRequest,
|
||||
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject> {
|
||||
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject>(
|
||||
'/moderation/external-license/file',
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: data,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { UploadHandle } from '../../../types/upload'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
function buildImageQueryParams(
|
||||
ext: Labrinth.Images.v3.ImageExtension,
|
||||
target: Labrinth.Images.v3.UploadImageParams,
|
||||
): Record<string, string> {
|
||||
const params: Record<string, string> = {
|
||||
ext,
|
||||
context: target.context,
|
||||
}
|
||||
switch (target.context) {
|
||||
case 'project':
|
||||
params.project_id = target.project_id
|
||||
break
|
||||
case 'version':
|
||||
params.version_id = target.version_id
|
||||
break
|
||||
case 'thread_message':
|
||||
params.thread_message_id = target.thread_message_id
|
||||
break
|
||||
case 'report':
|
||||
params.report_id = target.report_id
|
||||
break
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
export class LabrinthImagesV3Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_images_v3'
|
||||
}
|
||||
|
||||
public uploadImage(
|
||||
file: File | Blob,
|
||||
ext: Labrinth.Images.v3.ImageExtension,
|
||||
target: Labrinth.Images.v3.UploadImageParams,
|
||||
): UploadHandle<Labrinth.Images.v3.UploadedImage> {
|
||||
return this.client.upload<Labrinth.Images.v3.UploadedImage>('/image', {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
file,
|
||||
params: buildImageQueryParams(ext, target),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
export * from './attribution/internal'
|
||||
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 './images/v3'
|
||||
export * from './limits/v3'
|
||||
export * from './moderation/internal'
|
||||
export * from './notifications/v2'
|
||||
|
||||
@@ -238,6 +238,141 @@ export namespace Labrinth {
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Attribution {
|
||||
export namespace Internal {
|
||||
export type AttributionPermissionKind =
|
||||
| 'license'
|
||||
| 'my_project'
|
||||
| 'special_permissions'
|
||||
| 'globally_allowed'
|
||||
| 'no_permission'
|
||||
export type AttributionResolutionKind = AttributionPermissionKind
|
||||
|
||||
export type AttributionLicense = string | { name: string }
|
||||
|
||||
export type AttributionModerationStatusKind = 'not_allowed' | 'approved' | 'bad_proof'
|
||||
|
||||
export type AttributionModerationStatus = {
|
||||
kind: AttributionModerationStatusKind
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type AttributionResolutionBase = {
|
||||
notes: string
|
||||
image_urls: string[]
|
||||
moderation_status?: AttributionModerationStatus | null
|
||||
}
|
||||
|
||||
export type AttributionResolution =
|
||||
| (AttributionResolutionBase & {
|
||||
kind: 'license'
|
||||
license: AttributionLicense
|
||||
link_to_work: string
|
||||
})
|
||||
| (AttributionResolutionBase & {
|
||||
kind: 'my_project'
|
||||
license: AttributionLicense
|
||||
})
|
||||
| (AttributionResolutionBase & {
|
||||
kind: 'special_permissions'
|
||||
link_to_work: string
|
||||
})
|
||||
| (AttributionResolutionBase & {
|
||||
kind: 'globally_allowed'
|
||||
link_to_work: string
|
||||
})
|
||||
| (AttributionResolutionBase & {
|
||||
kind: 'no_permission'
|
||||
link_to_work?: string
|
||||
})
|
||||
|
||||
export type FlameProject = {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
icon_url: string
|
||||
}
|
||||
|
||||
export type AttributionFile = {
|
||||
name: string
|
||||
sha1: string
|
||||
versions: string[]
|
||||
moderation_external_license_id?: number
|
||||
moderation_external_license?: ExternalProjects.Internal.ExternalProject
|
||||
}
|
||||
|
||||
export type AttributionVersionInfo = {
|
||||
id: string
|
||||
name: string
|
||||
version_number: string
|
||||
date_created: string
|
||||
}
|
||||
|
||||
export type AttributionGroup = {
|
||||
id: string
|
||||
flame_project: FlameProject | null
|
||||
attribution: AttributionResolution | null
|
||||
attributed_at: string | null
|
||||
attributed_by: string | null
|
||||
files: AttributionFile[]
|
||||
versions: AttributionVersionInfo[]
|
||||
}
|
||||
|
||||
export type UpdateGroupRequest = {
|
||||
attribution: AttributionResolution
|
||||
}
|
||||
|
||||
export type AssignRequest = {
|
||||
sha1: string
|
||||
target_group_id: number
|
||||
project_id: string
|
||||
}
|
||||
|
||||
export type SplitRequest = {
|
||||
sha1: string
|
||||
project_id: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Images {
|
||||
export namespace v3 {
|
||||
/** Extensions accepted by POST /v3/image (Labrinth image pipeline). */
|
||||
export type ImageExtension = 'bmp' | 'gif' | 'jpeg' | 'jpg' | 'png' | 'webp'
|
||||
|
||||
/** `context` query values accepted by POST /v3/image. */
|
||||
export type ImageUploadContext = 'project' | 'version' | 'thread_message' | 'report'
|
||||
|
||||
export type UploadedImage = {
|
||||
id: string
|
||||
url: string
|
||||
size: number
|
||||
created: string
|
||||
owner_id: string
|
||||
} & (
|
||||
| { context: 'project'; project_id: string }
|
||||
| { context: 'version'; version_id: string }
|
||||
| { context: 'thread_message'; thread_message_id: string }
|
||||
| { context: 'report'; report_id: string }
|
||||
)
|
||||
|
||||
export type UploadedImageFor<C extends ImageUploadContext> = Extract<
|
||||
UploadedImage,
|
||||
{ context: C }
|
||||
>
|
||||
|
||||
/**
|
||||
* Target for POST /v3/image (per-context id query params, plus `context`).
|
||||
* `ext` is passed as a separate argument on the client module.
|
||||
*/
|
||||
export type UploadImageParams =
|
||||
| { context: 'project'; project_id: string }
|
||||
| { context: 'version'; version_id: string }
|
||||
| { context: 'thread_message'; thread_message_id: string }
|
||||
| { context: 'report'; report_id: string }
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Auth {
|
||||
export namespace Internal {
|
||||
export type SubscriptionStatus = {
|
||||
@@ -931,6 +1066,7 @@ export namespace Labrinth {
|
||||
date_published: string
|
||||
downloads: number
|
||||
files: VersionFile[]
|
||||
files_missing_attribution?: string[]
|
||||
environment?: Labrinth.Projects.v3.Environment
|
||||
mrpack_loaders?: string[]
|
||||
|
||||
@@ -1494,7 +1630,7 @@ export namespace Labrinth {
|
||||
inserted_by: number | null
|
||||
updated_at: string | null
|
||||
updated_by: number | null
|
||||
linked_files: LinkedFile[]
|
||||
linked_files?: LinkedFile[]
|
||||
}
|
||||
|
||||
export type SearchRequest = {
|
||||
@@ -1510,6 +1646,20 @@ export namespace Labrinth {
|
||||
proof?: string
|
||||
flame_project_id?: number
|
||||
}
|
||||
|
||||
export type CreateExternalLicenseRequest = {
|
||||
title?: string
|
||||
status: ExternalLicenseStatus
|
||||
link?: string
|
||||
proof?: string
|
||||
flame_project_id?: number
|
||||
files: LinkedFile[]
|
||||
}
|
||||
|
||||
export type AddFileRequest = {
|
||||
hashes: string[]
|
||||
license_id: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ import _ArchiveIcon from './icons/archive.svg?component'
|
||||
import _ArrowBigRightDashIcon from './icons/arrow-big-right-dash.svg?component'
|
||||
import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component'
|
||||
import _ArrowDownIcon from './icons/arrow-down.svg?component'
|
||||
import _ArrowDown10Icon from './icons/arrow-down-1-0.svg?component'
|
||||
import _ArrowDownAZIcon from './icons/arrow-down-a-z.svg?component'
|
||||
import _ArrowDownLeftIcon from './icons/arrow-down-left.svg?component'
|
||||
import _ArrowDownWideNarrowIcon from './icons/arrow-down-wide-narrow.svg?component'
|
||||
import _ArrowDownZAIcon from './icons/arrow-down-z-a.svg?component'
|
||||
import _ArrowLeftIcon from './icons/arrow-left.svg?component'
|
||||
import _ArrowLeftRightIcon from './icons/arrow-left-right.svg?component'
|
||||
@@ -55,6 +57,7 @@ import _ChevronRightIcon from './icons/chevron-right.svg?component'
|
||||
import _ChevronUpIcon from './icons/chevron-up.svg?component'
|
||||
import _CircleAlertIcon from './icons/circle-alert.svg?component'
|
||||
import _CircleArrowRightIcon from './icons/circle-arrow-right.svg?component'
|
||||
import _CircleDashedIcon from './icons/circle-dashed.svg?component'
|
||||
import _CircleUserIcon from './icons/circle-user.svg?component'
|
||||
import _ClearIcon from './icons/clear.svg?component'
|
||||
import _ClientIcon from './icons/client.svg?component'
|
||||
@@ -219,6 +222,7 @@ import _SortAscIcon from './icons/sort-asc.svg?component'
|
||||
import _SortDescIcon from './icons/sort-desc.svg?component'
|
||||
import _SparklesIcon from './icons/sparkles.svg?component'
|
||||
import _SpinnerIcon from './icons/spinner.svg?component'
|
||||
import _SplitIcon from './icons/split.svg?component'
|
||||
import _StarIcon from './icons/star.svg?component'
|
||||
import _StopCircleIcon from './icons/stop-circle.svg?component'
|
||||
import _StoreIcon from './icons/store.svg?component'
|
||||
@@ -403,8 +407,10 @@ export const ArchiveIcon = _ArchiveIcon
|
||||
export const ArrowBigRightDashIcon = _ArrowBigRightDashIcon
|
||||
export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon
|
||||
export const ArrowDownIcon = _ArrowDownIcon
|
||||
export const ArrowDown10Icon = _ArrowDown10Icon
|
||||
export const ArrowDownAZIcon = _ArrowDownAZIcon
|
||||
export const ArrowDownLeftIcon = _ArrowDownLeftIcon
|
||||
export const ArrowDownWideNarrowIcon = _ArrowDownWideNarrowIcon
|
||||
export const ArrowDownZAIcon = _ArrowDownZAIcon
|
||||
export const ArrowLeftIcon = _ArrowLeftIcon
|
||||
export const ArrowLeftRightIcon = _ArrowLeftRightIcon
|
||||
@@ -447,6 +453,7 @@ export const ChevronRightIcon = _ChevronRightIcon
|
||||
export const ChevronUpIcon = _ChevronUpIcon
|
||||
export const CircleAlertIcon = _CircleAlertIcon
|
||||
export const CircleArrowRightIcon = _CircleArrowRightIcon
|
||||
export const CircleDashedIcon = _CircleDashedIcon
|
||||
export const CircleUserIcon = _CircleUserIcon
|
||||
export const ClearIcon = _ClearIcon
|
||||
export const ClientIcon = _ClientIcon
|
||||
@@ -611,6 +618,7 @@ export const SortAscIcon = _SortAscIcon
|
||||
export const SortDescIcon = _SortDescIcon
|
||||
export const SparklesIcon = _SparklesIcon
|
||||
export const SpinnerIcon = _SpinnerIcon
|
||||
export const SplitIcon = _SplitIcon
|
||||
export const StarIcon = _StarIcon
|
||||
export const StopCircleIcon = _StopCircleIcon
|
||||
export const StoreIcon = _StoreIcon
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-arrow-down-1-0"
|
||||
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="m3 16 4 4 4-4" />
|
||||
<path d="M7 20V4" />
|
||||
<path d="M17 10V4h-2" />
|
||||
<path d="M15 10h4" />
|
||||
<rect x="15" y="14" width="4" height="6" ry="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 450 B |
@@ -0,0 +1,19 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-arrow-down-wide-narrow"
|
||||
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="m3 16 4 4 4-4" />
|
||||
<path d="M7 20V4" />
|
||||
<path d="M11 4h10" />
|
||||
<path d="M11 8h7" />
|
||||
<path d="M11 12h4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 425 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user