diff --git a/apps/frontend/src/pages/admin/emails.vue b/apps/frontend/src/pages/admin/emails.vue
index 24a788f67d..3a6e71e826 100644
--- a/apps/frontend/src/pages/admin/emails.vue
+++ b/apps/frontend/src/pages/admin/emails.vue
@@ -23,6 +23,10 @@ function copy(id: string) {
navigator.clipboard?.writeText(`/_internal/templates/email/${id}`).catch(() => {})
}
+function uncachedPreviewUrl(id: string) {
+ return `/_internal/templates/email/${id}?preview=${Date.now()}`
+}
+
const previewModal = ref<{ hide: () => void; show: () => void } | null>(null)
const previewTemplate = ref
(null)
const previewLoading = ref(false)
@@ -73,7 +77,7 @@ async function openPreview(id: string, event?: MouseEvent) {
variableValues.value = {}
try {
- const response = await fetch(`/_internal/templates/email/${id}`)
+ const response = await fetch(uncachedPreviewUrl(id), { cache: 'no-store' })
previewHtml.value = await response.text()
if (!response.ok) {
@@ -103,7 +107,7 @@ function openPopupPreview(id: string, offset = 0) {
const left = window.screenX + (window.outerWidth - width) / 2 + ((offset * 28) % 320)
const top = window.screenY + (window.outerHeight - height) / 2 + ((offset * 28) % 320)
window.open(
- `/_internal/templates/email/${id}`,
+ uncachedPreviewUrl(id),
`email-${id}`,
`popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,menubar=no,toolbar=no,location=no,status=no`,
)
diff --git a/apps/frontend/src/pages/admin/servers/notices.vue b/apps/frontend/src/pages/admin/servers/notices.vue
index 8cc36c9c6b..a3e8465714 100644
--- a/apps/frontend/src/pages/admin/servers/notices.vue
+++ b/apps/frontend/src/pages/admin/servers/notices.vue
@@ -218,7 +218,7 @@
:level="notice.level"
:message="notice.message"
:dismissable="notice.dismissable"
- :title="notice.title"
+ :title="notice.title ?? undefined"
preview
/>
@@ -260,6 +260,7 @@
+
+
+
+
diff --git a/apps/frontend/src/pages/moderation/external-projects.vue b/apps/frontend/src/pages/moderation/external-projects.vue
index 605bfc9bfc..fde7ff7ef2 100644
--- a/apps/frontend/src/pages/moderation/external-projects.vue
+++ b/apps/frontend/src/pages/moderation/external-projects.vue
@@ -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 ?? [],
}
}
diff --git a/apps/frontend/src/pages/report.vue b/apps/frontend/src/pages/report.vue
index 59af8701e6..461ac3fed5 100644
--- a/apps/frontend/src/pages/report.vue
+++ b/apps/frontend/src/pages/report.vue
@@ -284,11 +284,11 @@ import {
VersionIcon,
XCircleIcon,
} from '@modrinth/assets'
-import { defineMessage } from '@modrinth/ui'
import {
AutoLink,
Avatar,
ButtonStyled,
+ defineMessage,
defineMessages,
formatReportItemType,
injectNotificationManager,
diff --git a/apps/frontend/src/plugins/cosmetics.ts b/apps/frontend/src/plugins/cosmetics.ts
index e77aeb288e..7d821eae7f 100644
--- a/apps/frontend/src/plugins/cosmetics.ts
+++ b/apps/frontend/src/plugins/cosmetics.ts
@@ -27,10 +27,11 @@ export interface Cosmetics {
export default defineNuxtPlugin({
name: 'cosmetics',
setup() {
+ const config = useRuntimeConfig()
const cosmetics = useCookie('cosmetics', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
- secure: true,
+ secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
default: () => ({
diff --git a/apps/frontend/src/plugins/theme/theme-settings.ts b/apps/frontend/src/plugins/theme/theme-settings.ts
index 37333be09c..b8a632288a 100644
--- a/apps/frontend/src/plugins/theme/theme-settings.ts
+++ b/apps/frontend/src/plugins/theme/theme-settings.ts
@@ -8,10 +8,11 @@ interface ThemeSettings {
export function useThemeSettings(getDefaultTheme?: () => Theme) {
getDefaultTheme ??= () => 'dark'
+ const config = useRuntimeConfig()
const $settings = useCookie('color-mode', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
- secure: true,
+ secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
})
diff --git a/apps/frontend/src/templates/emails/index.ts b/apps/frontend/src/templates/emails/index.ts
index 719611b64b..015c1fdeb9 100644
--- a/apps/frontend/src/templates/emails/index.ts
+++ b/apps/frontend/src/templates/emails/index.ts
@@ -32,6 +32,10 @@ export default {
'project-invited': () => import('./project/ProjectInvited.vue'),
'project-transferred': () => import('./project/ProjectTransferred.vue'),
+ // Server
+ 'server-invited': () => import('./server/ServerInvited.vue'),
+ 'server-invited-no-account': () => import('./server/ServerInvitedNoAccount.vue'),
+
// Organizations
'organization-invited': () => import('./organization/OrganizationInvited.vue'),
} as Record Promise<{ default: Component }>>
diff --git a/apps/frontend/src/templates/emails/server/ServerInvited.vue b/apps/frontend/src/templates/emails/server/ServerInvited.vue
new file mode 100644
index 0000000000..b3876aa3e3
--- /dev/null
+++ b/apps/frontend/src/templates/emails/server/ServerInvited.vue
@@ -0,0 +1,82 @@
+
+
+
+
+ You've been invited to a server
+
+ Hi {user.name},
+
+
+ Modrinth user
+
+ {inviter.name}
+
+ has invited you to help manage
+ {server.name}
+ on Modrinth Hosting.
+
+
+
+
+ You have been invited with the {server.role} role permission.
+
+
+
+
+
+
+ https://modrinth.com/dashboard/notifications
+
+
+
+ To accept or reject this invitation, open your Modrinth notifications and review the invite.
+ If you were not expecting this invitation, contact the server owner or reach out to Modrinth
+ Support
+
+ through the Support Portal.
+
+
+
+ What does my role mean?
+
+
+
+ Editor
+
+ Manage instance content, files, backups, and other settings.
+
+
+
+
+
+ Viewer
+
+ Start, stop, and view the server without making changes.
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue b/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue
new file mode 100644
index 0000000000..07b6f7cbee
--- /dev/null
+++ b/apps/frontend/src/templates/emails/server/ServerInvitedNoAccount.vue
@@ -0,0 +1,80 @@
+
+
+
+
+ You've been invited to a server
+
+ Hi,
+
+
+ Modrinth user
+
+ {inviter.name}
+
+ has invited you to help manage
+ {server.name}
+ on Modrinth Hosting.
+
+
+
+
+ You have been invited with the {server.role} role permission.
+
+
+
+
+
+
+ {serverinvite.url}
+
+
+
+ To accept or reject this invitation, create a Modrinth account and review the invite from your
+ notifications dashboard. If you were not expecting this invitation, contact the server owner
+ or reach out to Modrinth Support
+
+ through the Support Portal.
+
+
+
+ What does my role let me do?
+
+
+
+ Editor
+
+ Manage instance content, files, backups, and other settings.
+
+
+
+
+
+ Viewer
+
+ Start, stop, and view the server without making changes.
+
+
+
+
+
+
+
diff --git a/apps/labrinth/.sqlx/query-6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57.json b/apps/labrinth/.sqlx/query-03d85f360d4c603688d0662d24caee7b59985adba006fb531beb09f195582277.json
similarity index 86%
rename from apps/labrinth/.sqlx/query-6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57.json
rename to apps/labrinth/.sqlx/query-03d85f360d4c603688d0662d24caee7b59985adba006fb531beb09f195582277.json
index 8668834ed4..d134483d44 100644
--- a/apps/labrinth/.sqlx/query-6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57.json
+++ b/apps/labrinth/.sqlx/query-03d85f360d4c603688d0662d24caee7b59985adba006fb531beb09f195582277.json
@@ -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"
}
diff --git a/apps/labrinth/.sqlx/query-0b06b60b7169a3f9ee66c7ec26f94982619e95a78ecb2d3ffa55774b098e0531.json b/apps/labrinth/.sqlx/query-0b06b60b7169a3f9ee66c7ec26f94982619e95a78ecb2d3ffa55774b098e0531.json
new file mode 100644
index 0000000000..ab2e4da34a
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-0b06b60b7169a3f9ee66c7ec26f94982619e95a78ecb2d3ffa55774b098e0531.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4.json b/apps/labrinth/.sqlx/query-0e9e528a008a9dd24acfabbffcfe8ee4fd48fbae7c6197bfbbf1923e6256658b.json
similarity index 77%
rename from apps/labrinth/.sqlx/query-99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4.json
rename to apps/labrinth/.sqlx/query-0e9e528a008a9dd24acfabbffcfe8ee4fd48fbae7c6197bfbbf1923e6256658b.json
index 890112c0a6..1fb57916d6 100644
--- a/apps/labrinth/.sqlx/query-99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4.json
+++ b/apps/labrinth/.sqlx/query-0e9e528a008a9dd24acfabbffcfe8ee4fd48fbae7c6197bfbbf1923e6256658b.json
@@ -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"
}
diff --git a/apps/labrinth/.sqlx/query-147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9.json b/apps/labrinth/.sqlx/query-147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9.json
new file mode 100644
index 0000000000..b7e45e18a1
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT id\n FROM notifications\n WHERE body @> $1::jsonb\n AND user_id = ANY($2::bigint[])\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Jsonb",
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9"
+}
diff --git a/apps/labrinth/.sqlx/query-1d27a83fb85c4640c3fc88fc6caa8209973ced0f88d8dbecdac349bbe70930a5.json b/apps/labrinth/.sqlx/query-1d27a83fb85c4640c3fc88fc6caa8209973ced0f88d8dbecdac349bbe70930a5.json
new file mode 100644
index 0000000000..928f02a117
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-1d27a83fb85c4640c3fc88fc6caa8209973ced0f88d8dbecdac349bbe70930a5.json
@@ -0,0 +1,17 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)\n values ($1, $2, $3, $4)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Text",
+ "Bytea",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "1d27a83fb85c4640c3fc88fc6caa8209973ced0f88d8dbecdac349bbe70930a5"
+}
diff --git a/apps/labrinth/.sqlx/query-1d96df0ac64801641f9af796d482d2964c1acf2d03e15b8f1397340d0c994af6.json b/apps/labrinth/.sqlx/query-1d96df0ac64801641f9af796d482d2964c1acf2d03e15b8f1397340d0c994af6.json
new file mode 100644
index 0000000000..25146596a0
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-1d96df0ac64801641f9af796d482d2964c1acf2d03e15b8f1397340d0c994af6.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-25d6977fa2db63f979fbe391f3475445fa92c9bbb5b034531f5e033a18c2a6a3.json b/apps/labrinth/.sqlx/query-25d6977fa2db63f979fbe391f3475445fa92c9bbb5b034531f5e033a18c2a6a3.json
new file mode 100644
index 0000000000..1fbd6879da
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-25d6977fa2db63f979fbe391f3475445fa92c9bbb5b034531f5e033a18c2a6a3.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-2e6bb84f45c0f8ba7bb9b09bf3def4322f727c7af8bd4be49dc4d7c487b925ed.json b/apps/labrinth/.sqlx/query-2e6bb84f45c0f8ba7bb9b09bf3def4322f727c7af8bd4be49dc4d7c487b925ed.json
new file mode 100644
index 0000000000..2c5ee6ec20
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-2e6bb84f45c0f8ba7bb9b09bf3def4322f727c7af8bd4be49dc4d7c487b925ed.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-2f56a06b78a810936a77547ab5890943d6aa3e9143a8e2617d025be960880223.json b/apps/labrinth/.sqlx/query-2f56a06b78a810936a77547ab5890943d6aa3e9143a8e2617d025be960880223.json
new file mode 100644
index 0000000000..202c1c4e6d
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-2f56a06b78a810936a77547ab5890943d6aa3e9143a8e2617d025be960880223.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-301959b1ec20a4925f86188bc5e1c0cdd11f0d15004ce9f91fbeefcc3a27f8f4.json b/apps/labrinth/.sqlx/query-301959b1ec20a4925f86188bc5e1c0cdd11f0d15004ce9f91fbeefcc3a27f8f4.json
new file mode 100644
index 0000000000..80c3a445f6
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-301959b1ec20a4925f86188bc5e1c0cdd11f0d15004ce9f91fbeefcc3a27f8f4.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-424b975139004d2854d9365ef950984dd1a65e333bb80924b5883df5e7d3cad2.json b/apps/labrinth/.sqlx/query-424b975139004d2854d9365ef950984dd1a65e333bb80924b5883df5e7d3cad2.json
new file mode 100644
index 0000000000..c427ada77e
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-424b975139004d2854d9365ef950984dd1a65e333bb80924b5883df5e7d3cad2.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-46e1f1512ff148b532fb382b51b6a5a4a81d3e8bc17886c75bc574cd5662c49f.json b/apps/labrinth/.sqlx/query-46e1f1512ff148b532fb382b51b6a5a4a81d3e8bc17886c75bc574cd5662c49f.json
new file mode 100644
index 0000000000..721cea8be6
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-46e1f1512ff148b532fb382b51b6a5a4a81d3e8bc17886c75bc574cd5662c49f.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-4fb69c674dca723b6b2cb7bea07d51feb43b2714c1cd6a2987a6fe1f10e2579a.json b/apps/labrinth/.sqlx/query-4fb69c674dca723b6b2cb7bea07d51feb43b2714c1cd6a2987a6fe1f10e2579a.json
new file mode 100644
index 0000000000..76174d70dd
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-4fb69c674dca723b6b2cb7bea07d51feb43b2714c1cd6a2987a6fe1f10e2579a.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-5f7b9d628d3ff0addc12cfaab68d7cbcca08a89aedeb15ce5926ed87ed8a12c6.json b/apps/labrinth/.sqlx/query-5f7b9d628d3ff0addc12cfaab68d7cbcca08a89aedeb15ce5926ed87ed8a12c6.json
new file mode 100644
index 0000000000..e71a423986
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-5f7b9d628d3ff0addc12cfaab68d7cbcca08a89aedeb15ce5926ed87ed8a12c6.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-5f847946ce63d3773b9a5822971ee7acaafeed018ce0540b2826bd878f213b87.json b/apps/labrinth/.sqlx/query-5f847946ce63d3773b9a5822971ee7acaafeed018ce0540b2826bd878f213b87.json
new file mode 100644
index 0000000000..a219d084e8
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-5f847946ce63d3773b9a5822971ee7acaafeed018ce0540b2826bd878f213b87.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-6320df0491556027ee64fd031797b252c2145ecf179e9ea9a8cedbdc8b7ed622.json b/apps/labrinth/.sqlx/query-6320df0491556027ee64fd031797b252c2145ecf179e9ea9a8cedbdc8b7ed622.json
new file mode 100644
index 0000000000..297814d5ad
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-6320df0491556027ee64fd031797b252c2145ecf179e9ea9a8cedbdc8b7ed622.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-64c6abc464f2df37372875817ceb67b0e33786fe3dd27c052311356d43b1d601.json b/apps/labrinth/.sqlx/query-64c6abc464f2df37372875817ceb67b0e33786fe3dd27c052311356d43b1d601.json
new file mode 100644
index 0000000000..ee0f3478d5
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-64c6abc464f2df37372875817ceb67b0e33786fe3dd27c052311356d43b1d601.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-73f7542b4b6738c4baf1e2a3513645c3bf07a960b7b9fac529f23099675ec0cc.json b/apps/labrinth/.sqlx/query-73f7542b4b6738c4baf1e2a3513645c3bf07a960b7b9fac529f23099675ec0cc.json
new file mode 100644
index 0000000000..1aa6170dcc
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-73f7542b4b6738c4baf1e2a3513645c3bf07a960b7b9fac529f23099675ec0cc.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-80751dbca09a9dcb469a30fe5b8225e520baf50750a78875f582349610439a55.json b/apps/labrinth/.sqlx/query-80751dbca09a9dcb469a30fe5b8225e520baf50750a78875f582349610439a55.json
new file mode 100644
index 0000000000..b8fc60acc8
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-80751dbca09a9dcb469a30fe5b8225e520baf50750a78875f582349610439a55.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-8820a5985291c159c98371c9650092e3eba21c81e3b3386be779978aff30451a.json b/apps/labrinth/.sqlx/query-8820a5985291c159c98371c9650092e3eba21c81e3b3386be779978aff30451a.json
deleted file mode 100644
index 18d5cf1b83..0000000000
--- a/apps/labrinth/.sqlx/query-8820a5985291c159c98371c9650092e3eba21c81e3b3386be779978aff30451a.json
+++ /dev/null
@@ -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\"\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",
- "type_info": "Jsonb"
- }
- ],
- "parameters": {
- "Left": [
- "Int8"
- ]
- },
- "nullable": [
- null
- ]
- },
- "hash": "8820a5985291c159c98371c9650092e3eba21c81e3b3386be779978aff30451a"
-}
diff --git a/apps/labrinth/.sqlx/query-8ffcb0c2bd82eaf64b143745adab359db6fcc448d6306292a7b345277a075883.json b/apps/labrinth/.sqlx/query-8ffcb0c2bd82eaf64b143745adab359db6fcc448d6306292a7b345277a075883.json
new file mode 100644
index 0000000000..3da4278f04
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-8ffcb0c2bd82eaf64b143745adab359db6fcc448d6306292a7b345277a075883.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-944286604bdef4ee40f79af358d1b68cc93bfd2067619d9cc1d03013f73e5424.json b/apps/labrinth/.sqlx/query-944286604bdef4ee40f79af358d1b68cc93bfd2067619d9cc1d03013f73e5424.json
new file mode 100644
index 0000000000..06e8fd3198
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-944286604bdef4ee40f79af358d1b68cc93bfd2067619d9cc1d03013f73e5424.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-94920d66b27503c84744d41cb2b44dd213bb461596046488eaab0b62b0fe83c5.json b/apps/labrinth/.sqlx/query-94920d66b27503c84744d41cb2b44dd213bb461596046488eaab0b62b0fe83c5.json
new file mode 100644
index 0000000000..7b1815f835
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-94920d66b27503c84744d41cb2b44dd213bb461596046488eaab0b62b0fe83c5.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-95750cd616b72347fb997c3b02a3d69f13be5993c1fd2e302761089c499fb015.json b/apps/labrinth/.sqlx/query-95750cd616b72347fb997c3b02a3d69f13be5993c1fd2e302761089c499fb015.json
new file mode 100644
index 0000000000..07c051f897
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-95750cd616b72347fb997c3b02a3d69f13be5993c1fd2e302761089c499fb015.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-968904f577c2c696c6222e19cc145bce0e845f2ab9f8629b0789a4861bddb4dc.json b/apps/labrinth/.sqlx/query-968904f577c2c696c6222e19cc145bce0e845f2ab9f8629b0789a4861bddb4dc.json
new file mode 100644
index 0000000000..1a588c429d
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-968904f577c2c696c6222e19cc145bce0e845f2ab9f8629b0789a4861bddb4dc.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-a29d71466a0842f634d0a068a45951273c087bc112a9848e9bdda37c7fe61747.json b/apps/labrinth/.sqlx/query-a29d71466a0842f634d0a068a45951273c087bc112a9848e9bdda37c7fe61747.json
new file mode 100644
index 0000000000..174fafcae6
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-a29d71466a0842f634d0a068a45951273c087bc112a9848e9bdda37c7fe61747.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT DISTINCT user_id\n FROM notifications\n WHERE user_id = ANY($1::bigint[]) AND body = $2::jsonb\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "user_id",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array",
+ "Jsonb"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "a29d71466a0842f634d0a068a45951273c087bc112a9848e9bdda37c7fe61747"
+}
diff --git a/apps/labrinth/.sqlx/query-b471add519874a6364b7a9cdbfa3a8e6f5a8a1ac9a7bacbce378f7995eb48664.json b/apps/labrinth/.sqlx/query-b471add519874a6364b7a9cdbfa3a8e6f5a8a1ac9a7bacbce378f7995eb48664.json
new file mode 100644
index 0000000000..e4cde72b9a
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-b471add519874a6364b7a9cdbfa3a8e6f5a8a1ac9a7bacbce378f7995eb48664.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-b782ed64e0df1e83623c8a8e55c00716f5338c85b6dfbb15c3465ff0cec339a7.json b/apps/labrinth/.sqlx/query-b782ed64e0df1e83623c8a8e55c00716f5338c85b6dfbb15c3465ff0cec339a7.json
new file mode 100644
index 0000000000..af1927f767
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-b782ed64e0df1e83623c8a8e55c00716f5338c85b6dfbb15c3465ff0cec339a7.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-bb7c239e2b424557f260c56c01e15ab8b0ec04d76a55a50888d28e6c5d3948bc.json b/apps/labrinth/.sqlx/query-bb7c239e2b424557f260c56c01e15ab8b0ec04d76a55a50888d28e6c5d3948bc.json
new file mode 100644
index 0000000000..316779bfde
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-bb7c239e2b424557f260c56c01e15ab8b0ec04d76a55a50888d28e6c5d3948bc.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-c39e1f0d820101ac1b19a3849254b03259f38f3de9ddca32e6189a7e015dd853.json b/apps/labrinth/.sqlx/query-c39e1f0d820101ac1b19a3849254b03259f38f3de9ddca32e6189a7e015dd853.json
new file mode 100644
index 0000000000..19a77c556c
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-c39e1f0d820101ac1b19a3849254b03259f38f3de9ddca32e6189a7e015dd853.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa.json b/apps/labrinth/.sqlx/query-ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa.json
new file mode 100644
index 0000000000..9d4ffdf994
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-d9cd479a00fa1bdef23ad3ed781e0ab992b0305f07d5194a9166e5babb1dd44a.json b/apps/labrinth/.sqlx/query-d9cd479a00fa1bdef23ad3ed781e0ab992b0305f07d5194a9166e5babb1dd44a.json
new file mode 100644
index 0000000000..fe31b3371b
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-d9cd479a00fa1bdef23ad3ed781e0ab992b0305f07d5194a9166e5babb1dd44a.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9.json b/apps/labrinth/.sqlx/query-dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9.json
new file mode 100644
index 0000000000..8565cb6fbd
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375.json b/apps/labrinth/.sqlx/query-df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375.json
new file mode 100644
index 0000000000..2456b3554f
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-e4b58fe6e5d19ded48ab9e457b4e96fb51c8f74a7532bb9ab2e7a945b95bbe78.json b/apps/labrinth/.sqlx/query-e4b58fe6e5d19ded48ab9e457b4e96fb51c8f74a7532bb9ab2e7a945b95bbe78.json
new file mode 100644
index 0000000000..91a7a9c5c8
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-e4b58fe6e5d19ded48ab9e457b4e96fb51c8f74a7532bb9ab2e7a945b95bbe78.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb.json b/apps/labrinth/.sqlx/query-e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb.json
new file mode 100644
index 0000000000..0b7cf69e9c
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-ed027c5571c34ff4fd1ac50e96f257b0055c9497d2699927b34829490e7a2529.json b/apps/labrinth/.sqlx/query-ed027c5571c34ff4fd1ac50e96f257b0055c9497d2699927b34829490e7a2529.json
new file mode 100644
index 0000000000..1b14566eb0
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-ed027c5571c34ff4fd1ac50e96f257b0055c9497d2699927b34829490e7a2529.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-f9131eb55490b96851ac3760e3199d2fd2dbb3d212cb2b8687dda8ba0fad2a80.json b/apps/labrinth/.sqlx/query-f9131eb55490b96851ac3760e3199d2fd2dbb3d212cb2b8687dda8ba0fad2a80.json
new file mode 100644
index 0000000000..0ccd35ecd7
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-f9131eb55490b96851ac3760e3199d2fd2dbb3d212cb2b8687dda8ba0fad2a80.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-f9b96c70c83f1bf0112243602843abae6ddc8851fc623ccf0a23f87567763485.json b/apps/labrinth/.sqlx/query-f9b96c70c83f1bf0112243602843abae6ddc8851fc623ccf0a23f87567763485.json
new file mode 100644
index 0000000000..17bdd2c3af
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-f9b96c70c83f1bf0112243602843abae6ddc8851fc623ccf0a23f87567763485.json
@@ -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"
+}
diff --git a/apps/labrinth/.sqlx/query-fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6.json b/apps/labrinth/.sqlx/query-fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6.json
new file mode 100644
index 0000000000..99a5a8243f
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6.json
@@ -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\"\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",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6"
+}
diff --git a/apps/labrinth/AGENTS.md~HEAD b/apps/labrinth/AGENTS.md~HEAD
new file mode 100644
index 0000000000..0b73458d53
--- /dev/null
+++ b/apps/labrinth/AGENTS.md~HEAD
@@ -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 ""`
diff --git a/apps/labrinth/migrations/20260423114534_project_attribution.sql b/apps/labrinth/migrations/20260423114534_project_attribution.sql
new file mode 100644
index 0000000000..805d85d870
--- /dev/null
+++ b/apps/labrinth/migrations/20260423114534_project_attribution.sql
@@ -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)
+);
diff --git a/apps/labrinth/migrations/20260519143157_fix_file_attribution_deletion.sql b/apps/labrinth/migrations/20260519143157_fix_file_attribution_deletion.sql
new file mode 100644
index 0000000000..473dbda6e9
--- /dev/null
+++ b/apps/labrinth/migrations/20260519143157_fix_file_attribution_deletion.sql
@@ -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;
diff --git a/apps/labrinth/migrations/20260521120000_project_attribution_external_license.sql b/apps/labrinth/migrations/20260521120000_project_attribution_external_license.sql
new file mode 100644
index 0000000000..5820106844
--- /dev/null
+++ b/apps/labrinth/migrations/20260521120000_project_attribution_external_license.sql
@@ -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;
diff --git a/apps/labrinth/migrations/20260525120000_server-invite-notification.sql b/apps/labrinth/migrations/20260525120000_server-invite-notification.sql
new file mode 100644
index 0000000000..db1fb6758c
--- /dev/null
+++ b/apps/labrinth/migrations/20260525120000_server-invite-notification.sql
@@ -0,0 +1,28 @@
+INSERT INTO notifications_types
+ (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications)
+VALUES ('server_invite', 1, FALSE, TRUE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'server_invite', FALSE);
+
+INSERT INTO notifications_templates
+ (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
+VALUES
+ (
+ 'email',
+ 'server_invite',
+ 'You''ve been invited to a server',
+ 'https://modrinth.com/_internal/templates/email/server-invited',
+ CONCAT(
+ 'Hi {user.name},',
+ CHR(10),
+ CHR(10),
+ 'Modrinth user {inviter.name} has invited you to help manage {server.name} on Modrinth Hosting with the {server.role} role.',
+ CHR(10),
+ CHR(10),
+ 'To accept or reject this invitation, open your Modrinth notifications: https://modrinth.com/dashboard/notifications',
+ CHR(10),
+ CHR(10),
+ 'If you were not expecting this invitation, contact the server owner or reach out to Modrinth Support at https://support.modrinth.com'
+ )
+ );
diff --git a/apps/labrinth/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs
index 329e071042..f8a2bc24a8 100644
--- a/apps/labrinth/src/auth/checks.rs
+++ b/apps/labrinth/src/auth/checks.rs
@@ -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::>()
+ })
+ .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)
diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs
index b44bdfe6cd..1c8c0ecefc 100644
--- a/apps/labrinth/src/background_task.rs
+++ b/apps/labrinth/src/background_task.rs
@@ -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,
+ file_host: web::Data,
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,
}
}
}
diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs
index 1ebb09b27d..b6334fad88 100644
--- a/apps/labrinth/src/database/models/ids.rs
+++ b/apps/labrinth/src/database/models/ids.rs
@@ -1,7 +1,8 @@
use super::DatabaseError;
use crate::database::PgTransaction;
use crate::models::ids::{
- AffiliateCodeId, AnalyticsEventId, CampaignDonationId, ChargeId,
+ AffiliateCodeId, AnalyticsEventId, AttributionGroupId, CampaignDonationId,
+ ChargeId,
CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId,
OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId,
OrganizationId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId,
@@ -172,6 +173,10 @@ db_id_interface!(
CollectionId,
generator: generate_collection_id @ "collections",
);
+db_id_interface!(
+ AttributionGroupId,
+ generator: generate_attribution_group_id @ "project_attribution_groups",
+);
db_id_interface!(
FileId,
generator: generate_file_id @ "files",
diff --git a/apps/labrinth/src/database/models/notification_item.rs b/apps/labrinth/src/database/models/notification_item.rs
index c25858ef29..5ad8c897b4 100644
--- a/apps/labrinth/src/database/models/notification_item.rs
+++ b/apps/labrinth/src/database/models/notification_item.rs
@@ -129,12 +129,11 @@ impl NotificationBuilder {
Ok(())
}
- pub async fn insert_many(
+ async fn insert_many_records(
&self,
- users: Vec,
+ users: &[DBUserId],
transaction: &mut PgTransaction<'_>,
- redis: &RedisPool,
- ) -> Result<(), DatabaseError> {
+ ) -> Result, DatabaseError> {
let notification_ids =
generate_many_notification_ids(users.len(), &mut *transaction)
.await?;
@@ -163,6 +162,20 @@ impl NotificationBuilder {
.execute(&mut *transaction)
.await?;
+ Ok(notification_ids)
+ }
+
+ pub async fn insert_many(
+ &self,
+ users: Vec,
+ transaction: &mut PgTransaction<'_>,
+ redis: &RedisPool,
+ ) -> Result<(), DatabaseError> {
+ let notification_ids =
+ self.insert_many_records(&users, transaction).await?;
+
+ let users_raw_ids = users.iter().map(|x| x.0).collect::>();
+
let notification_types = notification_ids
.iter()
.map(|_| self.body.notification_type().as_str())
@@ -181,6 +194,19 @@ impl NotificationBuilder {
Ok(())
}
+ /// Like [`insert_many`], but skips queuing deliveries so the caller can
+ /// manually send the notifications.
+ pub async fn insert_many_without_delivery(
+ &self,
+ users: Vec,
+ transaction: &mut PgTransaction<'_>,
+ redis: &RedisPool,
+ ) -> Result<(), DatabaseError> {
+ self.insert_many_records(&users, transaction).await?;
+ DBNotification::clear_user_notifications_cache(&users, redis).await?;
+ Ok(())
+ }
+
pub async fn insert_many_deliveries(
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
@@ -571,6 +597,38 @@ impl DBNotification {
Ok(Some(()))
}
+ pub async fn remove_many_matching_body(
+ body_filter: &serde_json::Value,
+ users: &[DBUserId],
+ transaction: &mut PgTransaction<'_>,
+ redis: &RedisPool,
+ ) -> Result {
+ let user_ids = users.iter().map(|x| x.0).collect::>();
+
+ let ids = sqlx::query!(
+ "
+ SELECT id
+ FROM notifications
+ WHERE body @> $1::jsonb
+ AND user_id = ANY($2::bigint[])
+ ",
+ body_filter,
+ &user_ids
+ )
+ .fetch(&mut *transaction)
+ .map_ok(|x| DBNotificationId(x.id))
+ .try_collect::>()
+ .await?;
+
+ if ids.is_empty() {
+ return Ok(0);
+ }
+
+ Self::remove_many(&ids, transaction, redis).await?;
+
+ Ok(ids.len())
+ }
+
pub async fn clear_user_notifications_cache(
user_ids: impl IntoIterator- ,
redis: &RedisPool,
diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs
index 904f799e62..9fa9091a9b 100644
--- a/apps/labrinth/src/database/models/project_item.rs
+++ b/apps/labrinth/src/database/models/project_item.rs
@@ -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 {
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(
diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs
index 6ffaf90c7f..7f8458d211 100644
--- a/apps/labrinth/src/database/models/version_item.rs
+++ b/apps/labrinth/src/database/models/version_item.rs
@@ -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 {
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 {
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, 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,
diff --git a/apps/labrinth/src/file_hosting/mock.rs b/apps/labrinth/src/file_hosting/mock.rs
index 3e414bd393..8f18da5334 100644
--- a/apps/labrinth/src/file_hosting/mock.rs
+++ b/apps/labrinth/src/file_hosting/mock.rs
@@ -29,9 +29,7 @@ impl FileHost for MockHost {
file_publicity: FileHostPublicity,
file_bytes: Bytes,
) -> Result {
- 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 {
+ let path = get_file_path(file_name, file_publicity);
+ let data = std::fs::read(&path)?;
+ Ok(Bytes::from(data))
+ }
}
fn get_file_path(
diff --git a/apps/labrinth/src/file_hosting/mod.rs b/apps/labrinth/src/file_hosting/mod.rs
index 667f4cb21e..29fd25d8cf 100644
--- a/apps/labrinth/src/file_hosting/mod.rs
+++ b/apps/labrinth/src/file_hosting/mod.rs
@@ -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;
+ /// 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;
+ /// 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;
+
+ /// 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;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
diff --git a/apps/labrinth/src/file_hosting/s3_host.rs b/apps/labrinth/src/file_hosting/s3_host.rs
index 56bd0ef45c..558e4d5086 100644
--- a/apps/labrinth/src/file_hosting/s3_host.rs
+++ b/apps/labrinth/src/file_hosting/s3_host.rs
@@ -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 {
+ 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())
+ }
}
diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs
index a97ec86bdc..d9ed7c370f 100644
--- a/apps/labrinth/src/lib.rs
+++ b/apps/labrinth/src/lib.rs
@@ -58,7 +58,7 @@ pub struct LabrinthConfig {
pub ro_pool: ReadOnlyPgPool,
pub redis_pool: RedisPool,
pub clickhouse: Client,
- pub file_host: Arc,
+ pub file_host: web::Data,
pub scheduler: Arc,
pub ip_salt: Pepper,
pub search_backend: web::Data,
@@ -84,7 +84,7 @@ pub fn app_setup(
redis_pool: RedisPool,
search_backend: actix_web::web::Data,
clickhouse: &mut Client,
- file_host: Arc,
+ file_host: web::Data,
stripe_client: stripe::Client,
anrok_client: anrok::Client,
email_queue: EmailQueue,
@@ -344,7 +344,7 @@ pub fn app_config(
.app_data(web::Data::new(labrinth_config.redis_pool.clone()))
.app_data(web::Data::new(labrinth_config.pool.clone()))
.app_data(web::Data::new(labrinth_config.ro_pool.clone()))
- .app_data(web::Data::new(labrinth_config.file_host.clone()))
+ .app_data(labrinth_config.file_host.clone())
.app_data(labrinth_config.search_backend.clone())
.app_data(web::Data::new(labrinth_config.gotenberg_client.clone()))
.app_data(labrinth_config.http_client.clone())
diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs
index f24a2fb79d..feba395e1a 100644
--- a/apps/labrinth/src/main.rs
+++ b/apps/labrinth/src/main.rs
@@ -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 =
- 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(),
+ let file_host: Arc = 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),
+ },
)
- }
- FileHostKind::Local => Arc::new(file_hosting::MockHost::new()),
- };
+ .unwrap(),
+ )
+ }
+ FileHostKind::Local => Arc::new(file_hosting::MockHost::new()),
+ };
+ let file_host = web::Data::::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(),
diff --git a/apps/labrinth/src/models/v2/notifications.rs b/apps/labrinth/src/models/v2/notifications.rs
index 66252e5dbb..0f88942394 100644
--- a/apps/labrinth/src/models/v2/notifications.rs
+++ b/apps/labrinth/src/models/v2/notifications.rs
@@ -11,6 +11,7 @@ use crate::models::{
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
+use uuid::Uuid;
#[derive(Serialize, Deserialize)]
pub struct LegacyNotification {
@@ -66,6 +67,12 @@ pub enum LegacyNotificationBody {
team_id: TeamId,
role: String,
},
+ ServerInvite {
+ server_id: Uuid,
+ server_name: String,
+ invited_by: UserId,
+ role: String,
+ },
StatusChange {
project_id: ProjectId,
old_status: ProjectStatus,
@@ -166,6 +173,9 @@ impl LegacyNotification {
NotificationBody::OrganizationInvite { .. } => {
Some("organization_invite".to_string())
}
+ NotificationBody::ServerInvite { .. } => {
+ Some("server_invite".to_string())
+ }
NotificationBody::StatusChange { .. } => {
Some("status_change".to_string())
}
@@ -269,6 +279,17 @@ impl LegacyNotification {
team_id,
role,
},
+ NotificationBody::ServerInvite {
+ server_id,
+ server_name,
+ invited_by,
+ role,
+ } => LegacyNotificationBody::ServerInvite {
+ server_id,
+ server_name,
+ invited_by,
+ role,
+ },
NotificationBody::StatusChange {
project_id,
old_status,
diff --git a/apps/labrinth/src/models/v3/ids.rs b/apps/labrinth/src/models/v3/ids.rs
index d7919fe681..5d23815f4f 100644
--- a/apps/labrinth/src/models/v3/ids.rs
+++ b/apps/labrinth/src/models/v3/ids.rs
@@ -1,5 +1,6 @@
use ariadne::ids::base62_id;
+base62_id!(AttributionGroupId);
base62_id!(ChargeId);
base62_id!(CampaignDonationId);
base62_id!(CollectionId);
diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs
index dc9ea448a6..30a32ffc44 100644
--- a/apps/labrinth/src/models/v3/notifications.rs
+++ b/apps/labrinth/src/models/v3/notifications.rs
@@ -12,6 +12,7 @@ use crate::routes::ApiError;
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
+use uuid::Uuid;
#[derive(Serialize, Deserialize)]
pub struct Notification {
@@ -34,6 +35,7 @@ pub enum NotificationType {
ProjectUpdate,
TeamInvite,
OrganizationInvite,
+ ServerInvite,
StatusChange,
ModeratorMessage,
LegacyMarkdown,
@@ -67,6 +69,7 @@ impl NotificationType {
NotificationType::ProjectUpdate => "project_update",
NotificationType::TeamInvite => "team_invite",
NotificationType::OrganizationInvite => "organization_invite",
+ NotificationType::ServerInvite => "server_invite",
NotificationType::StatusChange => "status_change",
NotificationType::ModeratorMessage => "moderator_message",
NotificationType::LegacyMarkdown => "legacy_markdown",
@@ -104,6 +107,7 @@ impl NotificationType {
"project_update" => NotificationType::ProjectUpdate,
"team_invite" => NotificationType::TeamInvite,
"organization_invite" => NotificationType::OrganizationInvite,
+ "server_invite" => NotificationType::ServerInvite,
"status_change" => NotificationType::StatusChange,
"moderator_message" => NotificationType::ModeratorMessage,
"legacy_markdown" => NotificationType::LegacyMarkdown,
@@ -156,6 +160,12 @@ pub enum NotificationBody {
team_id: TeamId,
role: String,
},
+ ServerInvite {
+ server_id: Uuid,
+ server_name: String,
+ invited_by: UserId,
+ role: String,
+ },
StatusChange {
project_id: ProjectId,
old_status: ProjectStatus,
@@ -267,6 +277,9 @@ impl NotificationBody {
NotificationBody::OrganizationInvite { .. } => {
NotificationType::OrganizationInvite
}
+ NotificationBody::ServerInvite { .. } => {
+ NotificationType::ServerInvite
+ }
NotificationBody::StatusChange { .. } => {
NotificationType::StatusChange
}
@@ -418,6 +431,34 @@ impl From for Notification {
},
],
),
+ NotificationBody::ServerInvite {
+ server_id: _,
+ server_name,
+ role,
+ ..
+ } => (
+ "You have been invited to join a server!".to_string(),
+ format!(
+ "An invite has been sent for you to be {role} of {server_name}"
+ ),
+ "#".to_string(),
+ vec![
+ NotificationAction {
+ name: "Accept".to_string(),
+ action_route: (
+ "POST".to_string(),
+ format!(""),
+ ),
+ },
+ NotificationAction {
+ name: "Deny".to_string(),
+ action_route: (
+ "POST".to_string(),
+ format!(""),
+ ),
+ },
+ ],
+ ),
NotificationBody::StatusChange {
old_status,
new_status,
diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs
index 92abe3fddb..37df786b9c 100644
--- a/apps/labrinth/src/models/v3/projects.rs
+++ b/apps/labrinth/src/models/v3/projects.rs
@@ -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,
+}
+
+#[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,
+ },
+}
+
+#[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,
+ pub notes: String,
+ pub image_urls: Vec,
+}
+
/// 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,
+ /// Files in this version that contain override files not yet attributed.
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub files_missing_attribution: Vec,
/// A list of projects that this version depends on.
pub dependencies: Vec,
@@ -757,6 +835,7 @@ impl From 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 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,
/// The type of the dependency
pub dependency_type: DependencyType,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub attribution: Option,
+}
+
+#[derive(
+ Serialize, Deserialize, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
+)]
+pub struct DependencyAttribution {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub link: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub icon_url: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub license: Option,
}
#[derive(
diff --git a/apps/labrinth/src/queue/email/templates.rs b/apps/labrinth/src/queue/email/templates.rs
index e1d3b97d0c..5638947da5 100644
--- a/apps/labrinth/src/queue/email/templates.rs
+++ b/apps/labrinth/src/queue/email/templates.rs
@@ -61,6 +61,10 @@ const ORGINVITE_INVITER_NAME: &str = "organizationinvite.inviter.name";
const ORGINVITE_ORG_NAME: &str = "organizationinvite.organization.name";
const ORGINVITE_ROLE_NAME: &str = "organizationinvite.role.name";
+const SERVERINVITE_INVITER_NAME: &str = "inviter.name";
+const SERVERINVITE_SERVER_NAME: &str = "server.name";
+const SERVERINVITE_ROLE_NAME: &str = "server.role";
+
const STATUSCHANGE_PROJECT_NAME: &str = "statuschange.project.name";
const STATUSCHANGE_OLD_STATUS: &str = "statuschange.old.status";
const STATUSCHANGE_NEW_STATUS: &str = "statuschange.new.status";
@@ -735,6 +739,27 @@ async fn collect_template_variables(
title: title.to_string(),
}),
+ NotificationBody::ServerInvite {
+ server_name,
+ invited_by,
+ role,
+ ..
+ } => {
+ let inviter = DBUser::get_id(
+ DBUserId(invited_by.0 as i64),
+ &mut *exec,
+ redis,
+ )
+ .await?
+ .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?;
+
+ map.insert(SERVERINVITE_INVITER_NAME, inviter.username);
+ map.insert(SERVERINVITE_SERVER_NAME, server_name.clone());
+ map.insert(SERVERINVITE_ROLE_NAME, role.clone());
+
+ Ok(EmailTemplate::Static(map))
+ }
+
NotificationBody::ProjectUpdate { .. }
| NotificationBody::ModeratorMessage { .. }
| NotificationBody::LegacyMarkdown { .. }
diff --git a/apps/labrinth/src/queue/file_scan.rs b/apps/labrinth/src/queue/file_scan.rs
new file mode 100644
index 0000000000..2e2611c529
--- /dev/null
+++ b/apps/labrinth/src/queue/file_scan.rs
@@ -0,0 +1,1025 @@
+use std::collections::HashMap;
+use std::io::{Cursor, Read};
+
+use chrono::Utc;
+use eyre::{Result, eyre};
+use hex::ToHex;
+use sha1::Digest;
+use tokio::task::spawn_blocking;
+use tracing::{Instrument, info, info_span, warn};
+use zip::ZipArchive;
+
+use crate::database::models::ids::{
+ DBAttributionGroupId, DBProjectId, DBVersionId,
+ generate_attribution_group_id,
+};
+use crate::database::models::moderation_external_item::ExternalLicense;
+use crate::database::models::{DBFileId, DBUserId, DBVersion};
+use crate::database::{PgPool, PgTransaction, redis::RedisPool};
+use crate::env::ENV;
+use crate::file_hosting::{FileHost, FileHostPublicity};
+use crate::models::ids::FileId;
+use crate::models::projects::{
+ AttributionResolution, AttributionResolutionKind,
+};
+use crate::queue::moderation::{
+ ApprovalType, FingerprintResponse, FlameProject, FlameResponse,
+};
+use crate::routes::internal::attribution::FlameProject as AttributionFlameProject;
+use crate::util::error::Context;
+use crate::util::http::HTTP_CLIENT;
+
+/// Attribution enforcement is version-scoped, not file-hash-scoped.
+///
+/// Versions listed in `version_attribution_exemptions` are legacy public
+/// versions that predate this attribution system. They are not scanned for
+/// attribution requirements and must not cause missing-attribution withholding.
+/// A later non-exempt version can still contain the same override SHA1 and
+/// create attribution groups/files for that SHA1. Because of that, reverse
+/// lookups from override SHA1s to versions must go through the
+/// `attribution_enforced_versions` view so grandfathered versions are ignored
+/// without making the SHA1 itself exempt.
+pub async fn scan_all_files(
+ db: &PgPool,
+ redis: &RedisPool,
+ file_host: &dyn FileHost,
+) -> Result<()> {
+ let mut txn = db.begin().await.wrap_err("beginning transaction")?;
+
+ let files_to_scan = sqlx::query!(
+ r#"
+ select
+ fa.file_id as "file_id: DBFileId",
+ f.url,
+ v.mod_id as "project_id: DBProjectId"
+ from file_scans fa
+ inner join files f on f.id = fa.file_id
+ inner join versions v on v.id = f.version_id
+ where fa.attributions_scanned_at is null
+ "#
+ )
+ .fetch_all(&mut txn)
+ .await
+ .wrap_err("fetching files to scan")?;
+
+ info!("Found {} files to scan", files_to_scan.len());
+
+ let mut scanned_ids = Vec::new();
+
+ for row in files_to_scan {
+ let human_file_id = FileId::from(row.file_id);
+ let span = info_span!("scan", file_id = %human_file_id);
+ async {
+ info!("Scanning file");
+
+ let file_id = row.file_id;
+
+ let overrides = extract_override_files_from_storage(
+ file_host, file_id, &row.url,
+ )
+ .await
+ .wrap_err_with(|| {
+ eyre!("extracting overrides for file {file_id:?}")
+ })?;
+
+ if overrides.is_empty() {
+ info!("Found no overrides");
+ } else {
+ info!("Found {} overrides", overrides.len());
+
+ let resolved = resolve_overrides(&overrides, redis, &mut txn)
+ .await
+ .wrap_err_with(|| {
+ eyre!("resolving overrides for file {file_id:?}")
+ })?;
+ info!("Resolved: {resolved:#?}");
+
+ persist_attribution_results(
+ row.project_id,
+ file_id,
+ &overrides,
+ &resolved,
+ &mut txn,
+ )
+ .await
+ .wrap_err_with(|| {
+ eyre!("persisting attribution results for file {file_id:?}")
+ })?;
+ }
+
+ scanned_ids.push(file_id.0);
+ eyre::Ok(())
+ }
+ .instrument(span)
+ .await?;
+ }
+
+ if !scanned_ids.is_empty() {
+ let now = Utc::now();
+ sqlx::query!(
+ "
+ update file_scans
+ set attributions_scanned_at = now
+ from unnest($1::bigint[], $2::timestamptz[]) as u(id, now)
+ where file_scans.file_id = u.id
+ ",
+ &scanned_ids,
+ &vec![now; scanned_ids.len()],
+ )
+ .execute(&mut txn)
+ .await
+ .wrap_err("marking files as scanned")?;
+ }
+
+ info!("Marked {} files as scanned", scanned_ids.len());
+
+ txn.commit().await.wrap_err("committing transaction")?;
+
+ Ok(())
+}
+
+pub async fn scan_file(
+ txn: &mut PgTransaction<'_>,
+ redis: &RedisPool,
+ file_host: &dyn FileHost,
+ project_id: DBProjectId,
+ file_id: DBFileId,
+ file_url: &str,
+) -> Result<()> {
+ let overrides =
+ extract_override_files_from_storage(file_host, file_id, file_url)
+ .await
+ .wrap_err_with(|| {
+ eyre!("extracting overrides for file {file_id:?}")
+ })?;
+
+ if !overrides.is_empty() {
+ let resolved = resolve_overrides(&overrides, redis, txn)
+ .await
+ .wrap_err_with(|| {
+ eyre!("resolving overrides for file {file_id:?}")
+ })?;
+
+ persist_attribution_results(
+ project_id, file_id, &overrides, &resolved, txn,
+ )
+ .await
+ .wrap_err_with(|| {
+ eyre!("persisting attribution results for file {file_id:?}")
+ })?;
+ }
+
+ sqlx::query!(
+ "
+ insert into file_scans (file_id, attributions_scanned_at)
+ values ($1, now())
+ on conflict (file_id) do update set attributions_scanned_at = now()
+ ",
+ file_id.0,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("marking file as scanned")?;
+
+ Ok(())
+}
+
+pub async fn scan_override_files(
+ file_host: &dyn FileHost,
+ file_id: DBFileId,
+ file_url: &str,
+) -> Result> {
+ extract_override_files_from_storage(file_host, file_id, file_url)
+ .await
+ .wrap_err_with(|| eyre!("extracting overrides for file {file_id:?}"))
+}
+
+async fn extract_override_files_from_storage(
+ file_host: &dyn FileHost,
+ file_id: DBFileId,
+ file_url: &str,
+) -> Result> {
+ let key = file_url
+ .strip_prefix(&ENV.CDN_URL)
+ .unwrap_or(file_url)
+ .trim_start_matches('/');
+ let key = urlencoding::decode(key).wrap_err("decoding file URL path")?;
+
+ let file_data = file_host
+ .read_file(&key, FileHostPublicity::Public)
+ .await
+ .wrap_err_with(|| {
+ eyre!("reading file {file_id:?} from storage at {key}")
+ })?;
+
+ spawn_blocking(move || extract_override_files(&file_data))
+ .await
+ .wrap_err("extracting override files")?
+ .wrap_err("extracting override files")
+}
+
+#[derive(Debug)]
+pub struct OverrideFile {
+ pub path: String,
+ pub sha1: String,
+ pub murmur2: u32,
+}
+
+#[derive(Debug)]
+pub enum OverrideResolution {
+ OnModrinth,
+ ExternalLicense {
+ id: i64,
+ status: ApprovalType,
+ link: Option,
+ flame_project: Option,
+ },
+ Flame(AttributionFlameProject),
+ Unknown,
+}
+
+const OVERRIDE_PREFIXES: &[&str] = &[
+ "overrides/mods",
+ "client-overrides/mods",
+ "server-overrides/mods",
+ "overrides/shaderpacks",
+ "client-overrides/shaderpacks",
+ "overrides/resourcepacks",
+ "client-overrides/resourcepacks",
+];
+
+fn extract_override_files(data: &[u8]) -> Result> {
+ let reader = Cursor::new(data);
+ let mut zip =
+ ZipArchive::new(reader).wrap_err("creating zip archive reader")?;
+
+ let mut files = Vec::new();
+
+ for i in 0..zip.len() {
+ let mut file = zip
+ .by_index(i)
+ .wrap_err_with(|| eyre!("reading file {i}"))?;
+ let name = file.name().to_string();
+
+ if file.is_dir() {
+ continue;
+ }
+
+ if !OVERRIDE_PREFIXES
+ .iter()
+ .any(|prefix| name.starts_with(prefix))
+ {
+ continue;
+ }
+
+ if name.matches('/').count() > 2
+ || name.ends_with(".txt")
+ || name.ends_with(".rpo")
+ {
+ continue;
+ }
+
+ let mut contents = Vec::new();
+ file.read_to_end(&mut contents)?;
+
+ let sha1 = sha1::Sha1::digest(&contents).encode_hex::();
+ let murmur = hash_flame_murmur32(contents);
+
+ files.push(OverrideFile {
+ sha1,
+ murmur2: murmur,
+ path: name,
+ });
+ }
+
+ Ok(files)
+}
+
+async fn persist_attribution_results(
+ project_id: DBProjectId,
+ file_id: DBFileId,
+ overrides: &[OverrideFile],
+ resolved: &HashMap,
+ txn: &mut PgTransaction<'_>,
+) -> Result<()> {
+ let all_sha1s: Vec> = overrides
+ .iter()
+ .map(|f| f.sha1.as_bytes().to_vec())
+ .collect();
+
+ let already_persisted: Vec> = sqlx::query_scalar!(
+ "
+ select paf.sha1 from project_attribution_files paf
+ inner join project_attribution_groups pag on pag.id = paf.group_id
+ where pag.project_id = $1 and paf.sha1 = ANY($2)
+ ",
+ project_id as DBProjectId,
+ &all_sha1s,
+ )
+ .fetch_all(&mut *txn)
+ .await
+ .wrap_err("checking existing attribution files")?;
+
+ let mut flame_groups: HashMap<
+ u32,
+ (Vec<&OverrideFile>, Option<&OverrideResolution>),
+ > = HashMap::new();
+ let mut external_license_files: Vec<(
+ &OverrideFile,
+ i64,
+ ApprovalType,
+ Option,
+ Option,
+ )> = Vec::new();
+ let mut unknown_files: Vec<&OverrideFile> = Vec::new();
+
+ for file in overrides {
+ if already_persisted
+ .iter()
+ .any(|s| s.as_slice() == file.sha1.as_bytes())
+ {
+ continue;
+ }
+
+ match resolved.get(&file.sha1) {
+ Some(OverrideResolution::OnModrinth) => continue,
+ Some(OverrideResolution::ExternalLicense {
+ id,
+ status,
+ link,
+ flame_project,
+ }) => {
+ external_license_files.push((
+ file,
+ *id,
+ *status,
+ link.clone(),
+ flame_project.clone(),
+ ));
+ }
+ Some(res @ OverrideResolution::Flame(flame_project)) => {
+ let entry = flame_groups.entry(flame_project.id).or_default();
+ entry.0.push(file);
+ if entry.1.is_none() {
+ entry.1 = Some(res);
+ }
+ }
+ Some(OverrideResolution::Unknown) | None => {
+ unknown_files.push(file);
+ }
+ }
+ }
+
+ let existing_flame_groups = sqlx::query!(
+ r#"
+ select id as "id: DBAttributionGroupId", flame_project
+ from project_attribution_groups
+ where project_id = $1 and flame_project is not null
+ "#,
+ project_id as DBProjectId,
+ )
+ .fetch_all(&mut *txn)
+ .await
+ .wrap_err("fetching existing flame attribution groups")?;
+
+ let mut existing_flame_group_ids = HashMap::new();
+ for group in existing_flame_groups {
+ if let Some(flame_project) = group.flame_project.and_then(|fp| {
+ serde_json::from_value::(fp).ok()
+ }) {
+ existing_flame_group_ids.insert(flame_project.id, group.id);
+ }
+ }
+
+ for (file, external_license_id, status, link, flame_project) in
+ external_license_files
+ {
+ if let Some(group_id) = flame_project
+ .as_ref()
+ .and_then(|fp| existing_flame_group_ids.get(&fp.id))
+ {
+ sqlx::query!(
+ "
+ insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)
+ values ($1, $2, $3, $4)
+ ",
+ *group_id as DBAttributionGroupId,
+ &file.path,
+ &file.sha1.as_bytes().to_vec() as &[u8],
+ external_license_id,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting external license attribution file into existing flame group")?;
+
+ continue;
+ }
+
+ let attribution = default_external_license_attribution(status, link);
+ let flame_project =
+ flame_project.and_then(|fp| serde_json::to_value(fp).ok());
+ let group_id = generate_attribution_group_id(&mut *txn).await?;
+ sqlx::query!(
+ "
+ insert into project_attribution_groups (id, project_id, attribution, flame_project)
+ values ($1, $2, $3, $4)
+ ",
+ group_id as DBAttributionGroupId,
+ project_id as DBProjectId,
+ attribution,
+ flame_project,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting external license attribution group")?;
+
+ sqlx::query!(
+ "
+ insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)
+ values ($1, $2, $3, $4)
+ ",
+ group_id as DBAttributionGroupId,
+ &file.path,
+ &file.sha1.as_bytes().to_vec() as &[u8],
+ external_license_id,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting external license attribution file")?;
+ }
+
+ for (flame_project_id, (files, resolution)) in &flame_groups {
+ let group_id = if let Some(group_id) =
+ existing_flame_group_ids.get(flame_project_id)
+ {
+ *group_id
+ } else {
+ let fp = resolution
+ .and_then(|r| {
+ if let OverrideResolution::Flame(flame_project) = r {
+ Some(serde_json::to_value(flame_project).ok())
+ } else {
+ None
+ }
+ })
+ .flatten();
+
+ let id = generate_attribution_group_id(&mut *txn).await?;
+ sqlx::query!(
+ "
+ insert into project_attribution_groups (id, project_id, flame_project)
+ values ($1, $2, $3)
+ ",
+ id as DBAttributionGroupId,
+ project_id as DBProjectId,
+ fp,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting attribution group")?;
+ existing_flame_group_ids.insert(*flame_project_id, id);
+ id
+ };
+
+ let names: Vec = files.iter().map(|f| f.path.clone()).collect();
+ let sha1s: Vec> =
+ files.iter().map(|f| f.sha1.as_bytes().to_vec()).collect();
+
+ sqlx::query!(
+ "
+ insert into project_attribution_files (group_id, name, sha1)
+ select $1, unnest($2::text[]), unnest($3::bytea[])
+ ",
+ group_id as DBAttributionGroupId,
+ &names,
+ &sha1s,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting attribution files")?;
+ }
+
+ for file in &unknown_files {
+ let group_id = generate_attribution_group_id(&mut *txn).await?;
+ sqlx::query!(
+ "
+ insert into project_attribution_groups (id, project_id)
+ values ($1, $2)
+ ",
+ group_id as DBAttributionGroupId,
+ project_id as DBProjectId,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting unknown attribution group")?;
+
+ sqlx::query!(
+ "
+ insert into project_attribution_files (group_id, name, sha1)
+ values ($1, $2, $3)
+ ",
+ group_id as DBAttributionGroupId,
+ &file.path,
+ &file.sha1.as_bytes().to_vec() as &[u8],
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting unknown attribution file")?;
+ }
+
+ if !all_sha1s.is_empty() {
+ sqlx::query!(
+ "
+ insert into override_file_sources (sha1, file_id)
+ select unnest($1::bytea[]), $2
+ on conflict do nothing
+ ",
+ &all_sha1s,
+ file_id as DBFileId,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting override file sources")?;
+ }
+
+ Ok(())
+}
+
+fn default_external_license_attribution(
+ status: ApprovalType,
+ link: Option,
+) -> Option {
+ match status {
+ ApprovalType::Yes
+ | ApprovalType::WithAttributionAndSource
+ | ApprovalType::WithAttribution => link
+ .and_then(|link| url::Url::parse(&link).ok())
+ .and_then(|link_to_work| {
+ serde_json::to_value(AttributionResolution {
+ kind: AttributionResolutionKind::GloballyAllowed {
+ link_to_work,
+ },
+ moderation_status: None,
+ notes: String::new(),
+ image_urls: Vec::new(),
+ })
+ .ok()
+ }),
+ ApprovalType::No => {
+ let link_to_work =
+ link.and_then(|link| url::Url::parse(&link).ok());
+
+ serde_json::to_value(AttributionResolution {
+ kind: AttributionResolutionKind::NoPermission { link_to_work },
+ moderation_status: None,
+ notes: String::new(),
+ image_urls: Vec::new(),
+ })
+ .ok()
+ }
+ ApprovalType::PermanentNo | ApprovalType::Unidentified => None,
+ }
+}
+
+async fn resolve_overrides(
+ overrides: &[OverrideFile],
+ redis: &RedisPool,
+ txn: &mut PgTransaction<'_>,
+) -> Result> {
+ let mut results: HashMap = HashMap::new();
+ let mut remaining: Vec = (0..overrides.len()).collect();
+
+ if overrides.is_empty() {
+ return Ok(results);
+ }
+
+ let hashes: Vec =
+ overrides.iter().map(|x| x.sha1.clone()).collect();
+ let files = DBVersion::get_files_from_hash(
+ "sha1".to_string(),
+ &hashes,
+ &mut *txn,
+ redis,
+ )
+ .await
+ .wrap_err("fetching files on platform by hash")?;
+
+ let version_ids: Vec<_> = files.iter().map(|x| x.version_id).collect();
+ let versions_data = DBVersion::get_many(&version_ids, &mut *txn, redis)
+ .await
+ .wrap_err("fetching versions")?;
+
+ for file in &files {
+ if !versions_data.iter().any(|v| v.inner.id == file.version_id) {
+ continue;
+ }
+
+ if let Some(hash) = file.hashes.get("sha1")
+ && let Some(pos) =
+ remaining.iter().position(|i| overrides[*i].sha1 == *hash)
+ {
+ let idx = remaining.remove(pos);
+ results.insert(
+ overrides[idx].sha1.clone(),
+ OverrideResolution::OnModrinth,
+ );
+ }
+ }
+
+ if remaining.is_empty() {
+ return Ok(results);
+ }
+
+ let rows = sqlx::query!(
+ "
+ SELECT encode(mef.sha1, 'escape') sha1, mel.id, mel.status status, mel.link
+ FROM moderation_external_files mef
+ INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id
+ WHERE mef.sha1 = ANY($1)
+ ",
+ &remaining
+ .iter()
+ .map(|i| overrides[*i].sha1.as_bytes().to_vec())
+ .collect::>()
+ )
+ .fetch_all(&mut *txn)
+ .await
+ .wrap_err("fetching external file licenses")?;
+
+ let mut direct_external_licenses = HashMap::new();
+ for row in rows {
+ if let Some(sha1) = row.sha1 {
+ direct_external_licenses.insert(
+ sha1,
+ (
+ row.id,
+ ApprovalType::from_string(&row.status)
+ .unwrap_or(ApprovalType::Unidentified),
+ row.link,
+ ),
+ );
+ }
+ }
+
+ let fingerprints: Vec =
+ remaining.iter().map(|i| overrides[*i].murmur2).collect();
+ let res = HTTP_CLIENT
+ .post(format!("{}/v1/fingerprints", ENV.FLAME_ANVIL_URL))
+ .json(&serde_json::json!({
+ "fingerprints": fingerprints
+ }))
+ .send()
+ .await;
+
+ if let Err(e) = &res {
+ warn!("Flame fingerprint request failed: {e}");
+ }
+
+ if let Ok(res) = res {
+ let body = res
+ .text()
+ .await
+ .wrap_err("reading Flame fingerprint response")?;
+
+ let flame_files: Vec<_> =
+ serde_json::from_str::>(&body)
+ .ok()
+ .map(|x| {
+ x.data
+ .exact_matches
+ .into_iter()
+ .map(|m| m.file)
+ .collect::>()
+ })
+ .unwrap_or_default();
+
+ let mut flame_matches: Vec<(String, u32)> = Vec::new();
+ for flame_file in &flame_files {
+ if let Some(hash) = flame_file
+ .hashes
+ .iter()
+ .find(|x| x.algo == 1)
+ .map(|x| x.value.clone())
+ {
+ flame_matches.push((hash, flame_file.mod_id));
+ }
+ }
+
+ let project_license_rows = sqlx::query!(
+ "
+ SELECT mel.id, mel.flame_project_id, mel.status status, mel.link
+ FROM moderation_external_licenses mel
+ WHERE mel.flame_project_id = ANY($1)
+ ",
+ &flame_matches.iter().map(|x| x.1 as i32).collect::>()
+ )
+ .fetch_all(&mut *txn)
+ .await
+ .wrap_err("fetching Flame project licenses")?;
+
+ let mut project_external_licenses = HashMap::new();
+ for row in project_license_rows {
+ if let Some(flame_project_id) = row.flame_project_id {
+ project_external_licenses.insert(
+ flame_project_id as u32,
+ (
+ row.id,
+ ApprovalType::from_string(&row.status)
+ .unwrap_or(ApprovalType::Unidentified),
+ row.link,
+ ),
+ );
+ }
+ }
+
+ let flame_projects_res = HTTP_CLIENT
+ .post(format!("{}/v1/mods", ENV.FLAME_ANVIL_URL))
+ .json(&serde_json::json!({
+ "modIds": flame_matches.iter().map(|x| x.1).collect::>()
+ }))
+ .send()
+ .await;
+
+ let flame_projects = match flame_projects_res {
+ Ok(res) => res
+ .text()
+ .await
+ .ok()
+ .and_then(|t| {
+ serde_json::from_str::>>(&t)
+ .ok()
+ })
+ .map(|x| x.data)
+ .unwrap_or_default(),
+ Err(e) => {
+ warn!("Flame projects request failed: {e}");
+ Vec::new()
+ }
+ };
+
+ let mut insert_hashes = Vec::new();
+ let mut insert_filenames = Vec::new();
+ let mut insert_ids = Vec::new();
+
+ for (sha1, flame_project_id) in &flame_matches {
+ if let Some(remaining_pos) =
+ remaining.iter().position(|i| overrides[*i].sha1 == *sha1)
+ {
+ let idx = remaining.remove(remaining_pos);
+ let project =
+ flame_projects.iter().find(|p| p.id == *flame_project_id);
+ let flame_project = AttributionFlameProject {
+ id: *flame_project_id,
+ title: project.map(|p| p.name.clone()).unwrap_or_else(
+ || format!("Flame project {flame_project_id}"),
+ ),
+ url: project
+ .map(|p| p.links.website_url.clone())
+ .unwrap_or_default(),
+ icon_url: project
+ .map(|p| p.logo.thumbnail_url.clone())
+ .unwrap_or_default(),
+ };
+
+ if let Some((id, status, link)) =
+ direct_external_licenses.remove(&overrides[idx].sha1)
+ {
+ results.insert(
+ overrides[idx].sha1.clone(),
+ OverrideResolution::ExternalLicense {
+ id,
+ status,
+ link,
+ flame_project: Some(flame_project),
+ },
+ );
+ } else if let Some((id, status, link)) =
+ project_external_licenses.get(flame_project_id)
+ {
+ results.insert(
+ overrides[idx].sha1.clone(),
+ OverrideResolution::ExternalLicense {
+ id: *id,
+ status: *status,
+ link: link.clone(),
+ flame_project: Some(flame_project),
+ },
+ );
+
+ insert_hashes.push(overrides[idx].sha1.as_bytes().to_vec());
+ insert_filenames.push(Some(overrides[idx].path.clone()));
+ insert_ids.push(*id);
+ } else {
+ results.insert(
+ overrides[idx].sha1.clone(),
+ OverrideResolution::Flame(flame_project),
+ );
+ }
+ }
+ }
+
+ if !insert_hashes.is_empty() {
+ ExternalLicense::insert_files(
+ &mut *txn,
+ &insert_hashes,
+ &insert_filenames,
+ &insert_ids,
+ DBUserId(0),
+ )
+ .await
+ .wrap_err("inserting external license files")?;
+ }
+ }
+
+ remaining.retain(|idx| {
+ if let Some((id, status, link)) =
+ direct_external_licenses.remove(&overrides[*idx].sha1)
+ {
+ results.insert(
+ overrides[*idx].sha1.clone(),
+ OverrideResolution::ExternalLicense {
+ id,
+ status,
+ link,
+ flame_project: None,
+ },
+ );
+ false
+ } else {
+ true
+ }
+ });
+
+ for idx in remaining {
+ results
+ .insert(overrides[idx].sha1.clone(), OverrideResolution::Unknown);
+ }
+
+ Ok(results)
+}
+
+fn hash_flame_murmur32(input: Vec) -> u32 {
+ murmur2::murmur2(
+ &input
+ .into_iter()
+ .filter(|x| *x != 9 && *x != 10 && *x != 13 && *x != 32)
+ .collect::>(),
+ 1,
+ )
+}
+
+pub async fn get_files_missing_attribution<'a, E>(
+ exec: E,
+ version_ids: &[DBVersionId],
+) -> Result<
+ std::collections::HashMap<
+ DBVersionId,
+ Vec<(DBFileId, Option)>,
+ >,
+>
+where
+ E: sqlx::Executor<'a, Database = sqlx::Postgres>,
+{
+ if version_ids.is_empty() {
+ return Ok(std::collections::HashMap::new());
+ }
+
+ let rows = sqlx::query!(
+ r#"
+ select distinct f.version_id as "version_id: DBVersionId", f.id as "file_id: DBFileId",
+ pag.flame_project
+ from files f
+ inner join attribution_enforced_versions aev on aev.id = f.version_id
+ inner join override_file_sources ofs on ofs.file_id = f.id
+ inner join project_attribution_files paf on paf.sha1 = ofs.sha1
+ inner join project_attribution_groups pag on pag.id = paf.group_id
+ where f.version_id = ANY($1)
+ and (
+ pag.attribution is null
+ or pag.attribution->>'kind' = 'no_permission'
+ or (
+ pag.attribution->'moderation_status' is not null
+ and pag.attribution->'moderation_status'->>'kind' != 'approved'
+ )
+ )
+ "#,
+ &version_ids.iter().map(|v| v.0).collect::>(),
+ )
+ .fetch_all(exec)
+ .await
+ .wrap_err("fetching files missing attribution")?;
+
+ let mut result = std::collections::HashMap::new();
+ for row in rows {
+ let flame_project = row
+ .flame_project
+ .and_then(|v| serde_json::from_value(v).ok());
+ result
+ .entry(row.version_id)
+ .or_insert_with(Vec::new)
+ .push((row.file_id, flame_project));
+ }
+
+ Ok(result)
+}
+
+pub struct DependencyAttributionData {
+ pub link: Option,
+ pub icon_url: Option,
+ pub license: Option,
+}
+
+pub async fn get_dependency_attributions<'a, E>(
+ exec: E,
+ version_ids: &[DBVersionId],
+) -> Result>
+where
+ E: sqlx::Executor<'a, Database = sqlx::Postgres>,
+{
+ if version_ids.is_empty() {
+ return Ok(HashMap::new());
+ }
+
+ let version_ids_vec: Vec<_> = version_ids.iter().map(|v| v.0).collect();
+
+ let rows = sqlx::query!(
+ r#"
+ select
+ d.dependent_id as "version_id: DBVersionId",
+ d.dependency_file_name as "file_name!",
+ pag.attribution,
+ pag.flame_project,
+ pag.project_id as "project_id: DBProjectId"
+ from dependencies d
+ inner join files f on f.version_id = d.dependent_id
+ inner join attribution_enforced_versions aev on aev.id = f.version_id
+ inner join override_file_sources ofs on ofs.file_id = f.id
+ inner join project_attribution_files paf on paf.sha1 = ofs.sha1
+ inner join project_attribution_groups pag on pag.id = paf.group_id
+ where d.dependent_id = ANY($1)
+ and d.dependency_file_name is not null
+ and pag.attribution is not null
+ and pag.attribution->>'kind' not in ('no_permission')
+ and (
+ pag.attribution->'moderation_status' is null
+ or pag.attribution->'moderation_status'->>'kind' = 'approved'
+ )
+ and split_part(paf.name, '/', -1) = d.dependency_file_name
+ "#,
+ &version_ids_vec,
+ )
+ .fetch_all(exec)
+ .await
+ .wrap_err("fetching dependency attributions")?;
+
+ let mut result = HashMap::new();
+ for row in rows {
+ let file_name = row.file_name;
+
+ let attribution: Option =
+ row.attribution.and_then(|v| serde_json::from_value(v).ok());
+
+ let flame_project: Option = row
+ .flame_project
+ .and_then(|v| serde_json::from_value(v).ok());
+
+ let link = attribution
+ .as_ref()
+ .and_then(|a| match &a.kind {
+ AttributionResolutionKind::License { link_to_work, .. } => {
+ Some(link_to_work.to_string())
+ }
+ AttributionResolutionKind::GloballyAllowed { link_to_work } => {
+ Some(link_to_work.to_string())
+ }
+ AttributionResolutionKind::SpecialPermissions {
+ link_to_work,
+ } => Some(link_to_work.to_string()),
+ _ => None,
+ })
+ .or(flame_project.as_ref().map(|fp| fp.url.clone()));
+
+ let icon_url = flame_project.as_ref().map(|fp| fp.icon_url.clone());
+
+ let license = attribution
+ .as_ref()
+ .and_then(|a| match &a.kind {
+ AttributionResolutionKind::License { license, .. } => {
+ Some(serde_json::to_value(license).ok())
+ }
+ _ => None,
+ })
+ .flatten();
+
+ result.insert(
+ (row.version_id, file_name),
+ DependencyAttributionData {
+ link,
+ icon_url,
+ license,
+ },
+ );
+ }
+
+ Ok(result)
+}
diff --git a/apps/labrinth/src/queue/mod.rs b/apps/labrinth/src/queue/mod.rs
index 666670dc0b..0d7dcb273b 100644
--- a/apps/labrinth/src/queue/mod.rs
+++ b/apps/labrinth/src/queue/mod.rs
@@ -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;
diff --git a/apps/labrinth/src/queue/moderation.rs b/apps/labrinth/src/queue/moderation.rs
index 7d852f4eb3..1914f80359 100644
--- a/apps/labrinth/src/queue/moderation.rs
+++ b/apps/labrinth/src/queue/moderation.rs
@@ -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::>()
}))
@@ -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)]
diff --git a/apps/labrinth/src/routes/internal/attribution.rs b/apps/labrinth/src/routes/internal/attribution.rs
new file mode 100644
index 0000000000..c433142ce8
--- /dev/null
+++ b/apps/labrinth/src/routes/internal/attribution.rs
@@ -0,0 +1,614 @@
+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,
+ attribution: Option,
+ attributed_at: Option>,
+ attributed_by: Option,
+ files: Vec,
+ versions: Vec,
+}
+
+#[derive(Clone, Serialize)]
+struct VersionInfo {
+ id: VersionId,
+ name: String,
+ version_number: String,
+ date_created: chrono::DateTime,
+}
+
+#[derive(Serialize)]
+struct AttributionFileResponse {
+ name: String,
+ sha1: String,
+ versions: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ moderation_external_license_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ moderation_external_license: Option,
+}
+
+#[derive(Clone, Serialize)]
+struct ModerationExternalLicenseResponse {
+ id: i64,
+ title: Option,
+ status: ApprovalType,
+ link: Option,
+ exceptions: Option,
+ proof: Option,
+ flame_project_id: Option,
+ inserted_at: Option>,
+ inserted_by: Option,
+ updated_at: Option>,
+ updated_by: Option,
+}
+
+#[utoipa::path]
+#[get("/{project_id}")]
+async fn list(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ path: web::Path,
+) -> Result>, 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 = 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 = 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 = files
+ .iter()
+ .flat_map(|f| f.get::, _>("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::>();
+
+ let mut result = Vec::new();
+ for group in groups {
+ let group_files: Vec = files
+ .iter()
+ .filter(|f| f.get::("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::